summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/aws_logo.svg38
-rw-r--r--app/assets/images/experienced.svg1
-rw-r--r--app/assets/images/learn-gitlab-avatar.jpgbin0 -> 4237 bytes
-rw-r--r--app/assets/images/novice.svg1
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue262
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue216
-rw-r--r--app/assets/javascripts/alert_management/components/alert_sidebar.vue61
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue51
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue278
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue34
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue189
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue29
-rw-r--r--app/assets/javascripts/alert_management/components/system_notes/system_note.vue46
-rw-r--r--app/assets/javascripts/alert_management/constants.js57
-rw-r--r--app/assets/javascripts/alert_management/details.js4
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql16
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql (renamed from app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql)8
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql (renamed from app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql)6
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql15
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql8
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql1
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/details.query.graphql2
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql11
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql32
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql11
-rw-r--r--app/assets/javascripts/alert_management/services/index.js7
-rw-r--r--app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue74
-rw-r--r--app/assets/javascripts/alerts_service_settings/index.js12
-rw-r--r--app/assets/javascripts/api.js39
-rw-r--r--app/assets/javascripts/authentication/mount_2fa.js14
-rw-r--r--app/assets/javascripts/authentication/u2f/authenticate.js (renamed from app/assets/javascripts/u2f/authenticate.js)10
-rw-r--r--app/assets/javascripts/authentication/u2f/error.js (renamed from app/assets/javascripts/u2f/error.js)0
-rw-r--r--app/assets/javascripts/authentication/u2f/index.js (renamed from app/assets/javascripts/shared/sessions/u2f.js)8
-rw-r--r--app/assets/javascripts/authentication/u2f/register.js (renamed from app/assets/javascripts/u2f/register.js)2
-rw-r--r--app/assets/javascripts/authentication/u2f/util.js (renamed from app/assets/javascripts/u2f/util.js)0
-rw-r--r--app/assets/javascripts/avatar_picker.js16
-rw-r--r--app/assets/javascripts/badges/components/badge.vue9
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue2
-rw-r--r--app/assets/javascripts/batch_comments/components/diff_file_drafts.vue41
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue113
-rw-r--r--app/assets/javascripts/batch_comments/components/drafts_count.vue15
-rw-r--r--app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue32
-rw-r--r--app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue45
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue111
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue143
-rw-r--r--app/assets/javascripts/batch_comments/components/publish_button.vue55
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue70
-rw-r--r--app/assets/javascripts/batch_comments/constants.js3
-rw-r--r--app/assets/javascripts/batch_comments/index.js24
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js54
-rw-r--r--app/assets/javascripts/batch_comments/services/drafts_service.js33
-rw-r--r--app/assets/javascripts/batch_comments/stores/index.js14
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js151
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js87
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js12
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js23
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js81
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js9
-rw-r--r--app/assets/javascripts/batch_comments/utils.js35
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js3
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue1
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue6
-rw-r--r--app/assets/javascripts/blob/components/blob_header_default_actions.vue7
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue254
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue82
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js7
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue291
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js2
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue8
-rw-r--r--app/assets/javascripts/boards/index.js12
-rw-r--r--app/assets/javascripts/boards/models/issue.js39
-rw-r--r--app/assets/javascripts/boards/models/list.js66
-rw-r--r--app/assets/javascripts/boards/stores/actions.js6
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js144
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js4
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/boards/toggle_epics_swimlanes.js1
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_key_field.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue100
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js6
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js26
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js6
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue130
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue402
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue4
-rw-r--r--app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue6
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue5
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue2
-rw-r--r--app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue65
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue200
-rw-r--r--app/assets/javascripts/clusters_list/constants.js3
-rw-r--r--app/assets/javascripts/clusters_list/index.js4
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js49
-rw-r--r--app/assets/javascripts/clusters_list/store/state.js5
-rw-r--r--app/assets/javascripts/code_navigation/components/doc_line.vue22
-rw-r--r--app/assets/javascripts/code_navigation/components/popover.vue5
-rw-r--r--app/assets/javascripts/commit/image_file.js1
-rw-r--r--app/assets/javascripts/commons/bootstrap.js7
-rw-r--r--app/assets/javascripts/commons/jquery.js2
-rw-r--r--app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_note_pin.vue4
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue158
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue40
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue3
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue70
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue42
-rw-r--r--app/assets/javascripts/design_management/components/design_presentation.vue10
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue178
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue2
-rw-r--r--app/assets/javascripts/design_management/constants.js2
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql1
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql9
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql17
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue114
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue2
-rw-r--r--app/assets/javascripts/design_management/router/index.js15
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js4
-rw-r--r--app/assets/javascripts/design_management/utils/design_management_utils.js5
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js12
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue24
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue38
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue8
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue6
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue2
-rw-r--r--app/assets/javascripts/diffs/constants.js19
-rw-r--r--app/assets/javascripts/diffs/mixins/draft_comments.js17
-rw-r--r--app/assets/javascripts/diffs/store/actions.js24
-rw-r--r--app/assets/javascripts/diffs/store/utils.js2
-rw-r--r--app/assets/javascripts/diffs/utils/uuids.js79
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue14
-rw-r--r--app/assets/javascripts/error_tracking/components/constants.js4
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue51
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue21
-rw-r--r--app/assets/javascripts/error_tracking/store/list/actions.js14
-rw-r--r--app/assets/javascripts/error_tracking/utils.js26
-rw-r--r--app/assets/javascripts/file_pickers.js21
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js32
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service_error.js7
-rw-r--r--app/assets/javascripts/frequent_items/index.js76
-rw-r--r--app/assets/javascripts/gl_dropdown.js2
-rw-r--r--app/assets/javascripts/global_search_input.js (renamed from app/assets/javascripts/search_autocomplete.js)291
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql2
-rw-r--r--app/assets/javascripts/group.js70
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue19
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue4
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue8
-rw-r--r--app/assets/javascripts/header.js2
-rw-r--r--app/assets/javascripts/ide/commit_icon.js11
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue7
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue51
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue6
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue4
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue23
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue9
-rw-r--r--app/assets/javascripts/ide/components/ide_sidebar_nav.vue83
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_status_list.vue12
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/description.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue4
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/item.vue3
-rw-r--r--app/assets/javascripts/ide/components/mr_file_icon.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue11
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue1
-rw-r--r--app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue92
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue35
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue8
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue48
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue89
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue14
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue13
-rw-r--r--app/assets/javascripts/ide/components/terminal/empty_state.vue71
-rw-r--r--app/assets/javascripts/ide/components/terminal/session.vue53
-rw-r--r--app/assets/javascripts/ide/components/terminal/terminal.vue117
-rw-r--r--app/assets/javascripts/ide/components/terminal/terminal_controls.vue27
-rw-r--r--app/assets/javascripts/ide/components/terminal/view.vue41
-rw-r--r--app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue76
-rw-r--r--app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue22
-rw-r--r--app/assets/javascripts/ide/constants.js8
-rw-r--r--app/assets/javascripts/ide/ide_router.js153
-rw-r--r--app/assets/javascripts/ide/index.js3
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js35
-rw-r--r--app/assets/javascripts/ide/lib/create_diff.js85
-rw-r--r--app/assets/javascripts/ide/lib/create_file_diff.js112
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js9
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js9
-rw-r--r--app/assets/javascripts/ide/lib/editor.js32
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js22
-rw-r--r--app/assets/javascripts/ide/lib/editorconfig/parser.js55
-rw-r--r--app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js33
-rw-r--r--app/assets/javascripts/ide/lib/files.js5
-rw-r--r--app/assets/javascripts/ide/lib/languages/README.md21
-rw-r--r--app/assets/javascripts/ide/lib/mirror.js154
-rw-r--r--app/assets/javascripts/ide/services/terminals.js15
-rw-r--r--app/assets/javascripts/ide/stores/actions.js42
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js23
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js3
-rw-r--r--app/assets/javascripts/ide/stores/extend.js14
-rw-r--r--app/assets/javascripts/ide/stores/getters.js20
-rw-r--r--app/assets/javascripts/ide/stores/index.js35
-rw-r--r--app/assets/javascripts/ide/stores/modules/branches/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js8
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pane/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pane/getters.js7
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/actions.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/index.js10
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/mutations.js7
-rw-r--r--app/assets/javascripts/ide/stores/modules/router/state.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js98
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/index.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js118
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js64
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js14
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/constants.js9
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/getters.js19
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/index.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/messages.js55
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js11
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/mutations.js64
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/state.js13
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/utils.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js41
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/index.js10
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js22
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal_sync/state.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js12
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js8
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js5
-rw-r--r--app/assets/javascripts/ide/stores/plugins/terminal.js25
-rw-r--r--app/assets/javascripts/ide/stores/plugins/terminal_sync.js49
-rw-r--r--app/assets/javascripts/ide/stores/state.js3
-rw-r--r--app/assets/javascripts/ide/stores/utils.js44
-rw-r--r--app/assets/javascripts/ide/sync_router_and_store.js55
-rw-r--r--app/assets/javascripts/ide/utils.js61
-rw-r--r--app/assets/javascripts/import_projects/components/bitbucket_status_table.vue74
-rw-r--r--app/assets/javascripts/import_projects/components/import_projects_table.vue65
-rw-r--r--app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue30
-rw-r--r--app/assets/javascripts/import_projects/components/provider_repo_table_row.vue6
-rw-r--r--app/assets/javascripts/import_projects/index.js52
-rw-r--r--app/assets/javascripts/import_projects/store/actions.js66
-rw-r--r--app/assets/javascripts/import_projects/store/getters.js2
-rw-r--r--app/assets/javascripts/import_projects/store/index.js4
-rw-r--r--app/assets/javascripts/import_projects/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/import_projects/store/mutations.js10
-rw-r--r--app/assets/javascripts/import_projects/store/state.js1
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_toggle.vue17
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue172
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue8
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue103
-rw-r--r--app/assets/javascripts/integrations/edit/index.js3
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js11
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js5
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/item.vue2
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue6
-rw-r--r--app/assets/javascripts/issuables_list/components/issuables_list_app.vue2
-rw-r--r--app/assets/javascripts/issue.js26
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue79
-rw-r--r--app/assets/javascripts/issue_show/components/pinned_links.vue39
-rw-r--r--app/assets/javascripts/issue_show/constants.js17
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_app.vue108
-rw-r--r--app/assets/javascripts/jira_import/index.js1
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql12
-rw-r--r--app/assets/javascripts/jira_import/utils/cache_update.js37
-rw-r--r--app/assets/javascripts/jira_import/utils/jira_import_utils.js (renamed from app/assets/javascripts/jira_import/utils.js)11
-rw-r--r--app/assets/javascripts/jobs/components/artifacts_block.vue32
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue2
-rw-r--r--app/assets/javascripts/jobs/components/stuck_block.vue12
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue2
-rw-r--r--app/assets/javascripts/lazy_loader.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js14
-rw-r--r--app/assets/javascripts/lib/utils/constants.js8
-rw-r--r--app/assets/javascripts/lib/utils/datetime_range.js15
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js13
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js9
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js17
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue4
-rw-r--r--app/assets/javascripts/logs/constants.js8
-rw-r--r--app/assets/javascripts/logs/logs_tracking_helper.js18
-rw-r--r--app/assets/javascripts/logs/stores/actions.js23
-rw-r--r--app/assets/javascripts/logs/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/main.js16
-rw-r--r--app/assets/javascripts/merge_request_tabs.js50
-rw-r--r--app/assets/javascripts/milestone_select.js9
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue13
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue9
-rw-r--r--app/assets/javascripts/monitoring/components/charts/options.js29
-rw-r--r--app/assets/javascripts/monitoring/components/charts/stacked_column.vue68
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue60
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue407
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue369
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue43
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue11
-rw-r--r--app/assets/javascripts/monitoring/components/links_section.vue32
-rw-r--r--app/assets/javascripts/monitoring/components/variables_section.vue16
-rw-r--r--app/assets/javascripts/monitoring/constants.js23
-rw-r--r--app/assets/javascripts/monitoring/format_date.js39
-rw-r--r--app/assets/javascripts/monitoring/monitoring_app.js59
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js32
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js13
-rw-r--r--app/assets/javascripts/monitoring/pages/dashboard_page.vue18
-rw-r--r--app/assets/javascripts/monitoring/router/constants.js3
-rw-r--r--app/assets/javascripts/monitoring/router/index.js15
-rw-r--r--app/assets/javascripts/monitoring/router/routes.js18
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js16
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js22
-rw-r--r--app/assets/javascripts/monitoring/stores/index.js12
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js26
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js17
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js110
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js2
-rw-r--r--app/assets/javascripts/monitoring/utils.js16
-rw-r--r--app/assets/javascripts/mr_notes/index.js2
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js2
-rw-r--r--app/assets/javascripts/namespace_storage_limit_alert.js20
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue11
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue1
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_form.vue68
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_utils.js57
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue65
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue36
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue70
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue5
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue98
-rw-r--r--app/assets/javascripts/notes/mixins/description_version_history.js2
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js98
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js4
-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/stores/actions.js53
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js5
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js36
-rw-r--r--app/assets/javascripts/onboarding_issues/index.js120
-rw-r--r--app/assets/javascripts/operation_settings/components/external_dashboard.vue72
-rw-r--r--app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue60
-rw-r--r--app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue48
-rw-r--r--app/assets/javascripts/operation_settings/components/metrics_settings.vue53
-rw-r--r--app/assets/javascripts/operation_settings/index.js4
-rw-r--r--app/assets/javascripts/operation_settings/store/actions.js18
-rw-r--r--app/assets/javascripts/operation_settings/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/operation_settings/store/mutations.js5
-rw-r--r--app/assets/javascripts/operation_settings/store/state.js9
-rw-r--r--app/assets/javascripts/pager.js5
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js7
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue2
-rw-r--r--app/assets/javascripts/pages/groups/clusters/index.js (renamed from app/assets/javascripts/pages/groups/index.js)0
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js7
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js3
-rw-r--r--app/assets/javascripts/pages/ide/index.js3
-rw-r--r--app/assets/javascripts/pages/import/bitbucket/status/index.js19
-rw-r--r--app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue30
-rw-r--r--app/assets/javascripts/pages/import/bitbucket_server/status/index.js20
-rw-r--r--app/assets/javascripts/pages/import/fogbugz/status/index.js7
-rw-r--r--app/assets/javascripts/pages/import/gitlab/status/index.js7
-rw-r--r--app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js4
-rw-r--r--app/assets/javascripts/pages/omniauth_callbacks/index.js4
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js14
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue177
-rw-r--r--app/assets/javascripts/pages/projects/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js39
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/index/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue98
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/wikis/index.js42
-rw-r--r--app/assets/javascripts/pages/sessions/index.js4
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js10
-rw-r--r--app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js10
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue (renamed from app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue)0
-rw-r--r--app/assets/javascripts/pages/shared/wikis/index.js41
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js (renamed from app/assets/javascripts/pages/projects/wikis/wikis.js)0
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue11
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue12
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js15
-rw-r--r--app/assets/javascripts/pipelines/components/dag/constants.js10
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue136
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_graph.vue299
-rw-r--r--app/assets/javascripts/pipelines/components/dag/drawing_utils.js134
-rw-r--r--app/assets/javascripts/pipelines/components/dag/interactions.js134
-rw-r--r--app/assets/javascripts/pipelines/components/dag/parsing_utils.js164
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue24
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_artifacts.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue84
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue104
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue64
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue13
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js25
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js13
-rw-r--r--app/assets/javascripts/pipelines/utils.js8
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue4
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js5
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue160
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue31
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue70
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg27
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg73
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg54
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg95
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/index.js14
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue70
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue67
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/details_header.vue30
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue33
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue34
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue210
-rw-r--r--app/assets/javascripts/registry/explorer/components/image_list.vue124
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue (renamed from app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue)2
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue (renamed from app/assets/javascripts/registry/explorer/components/group_empty_state.vue)0
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list.vue52
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue136
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue (renamed from app/assets/javascripts/registry/explorer/components/project_empty_state.vue)7
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue138
-rw-r--r--app/assets/javascripts/registry/explorer/components/project_policy_alert.vue68
-rw-r--r--app/assets/javascripts/registry/explorer/constants.js130
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js60
-rw-r--r--app/assets/javascripts/registry/explorer/constants/expiration_policies.js11
-rw-r--r--app/assets/javascripts/registry/explorer/constants/index.js4
-rw-r--r--app/assets/javascripts/registry/explorer/constants/list.js48
-rw-r--r--app/assets/javascripts/registry/explorer/constants/quick_start.js9
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue401
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue52
-rw-r--r--app/assets/javascripts/registry/explorer/router.js4
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js2
-rw-r--r--app/assets/javascripts/registry/explorer/stores/getters.js6
-rw-r--r--app/assets/javascripts/registry/explorer/stores/index.js4
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutations.js8
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue65
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue4
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue187
-rw-r--r--app/assets/javascripts/releases/components/release_block_author.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_metadata.vue4
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestone_info.vue2
-rw-r--r--app/assets/javascripts/releases/constants.js9
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/actions.js30
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutation_types.js1
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutations.js7
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue40
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue5
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue2
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue2
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue7
-rw-r--r--app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql2
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue6
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js1
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue60
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue10
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue32
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_view.vue2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue3
-rw-r--r--app/assets/javascripts/snippets/components/snippet_title.vue2
-rw-r--r--app/assets/javascripts/snippets/constants.js3
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue45
-rw-r--r--app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue27
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js2
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue6
-rw-r--r--app/assets/javascripts/static_site_editor/services/parse_source_file.js55
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js13
-rw-r--r--app/assets/javascripts/user_callout.js13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js65
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue107
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue253
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue114
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue84
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue119
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue72
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js42
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/url_sync.vue25
-rw-r--r--app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js14
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js4
-rw-r--r--app/assets/stylesheets/application_dark.scss3
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss4
-rw-r--r--app/assets/stylesheets/components/avatar.scss11
-rw-r--r--app/assets/stylesheets/components/dashboard_skeleton.scss6
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss59
-rw-r--r--app/assets/stylesheets/components/popover.scss2
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss27
-rw-r--r--app/assets/stylesheets/components/rich_content_editor.scss22
-rw-r--r--app/assets/stylesheets/framework/animations.scss9
-rw-r--r--app/assets/stylesheets/framework/badges.scss10
-rw-r--r--app/assets/stylesheets/framework/blank.scss14
-rw-r--r--app/assets/stylesheets/framework/broadcast_messages.scss5
-rw-r--r--app/assets/stylesheets/framework/common.scss46
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss2
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss5
-rw-r--r--app/assets/stylesheets/framework/files.scss2
-rw-r--r--app/assets/stylesheets/framework/filters.scss17
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss49
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss2
-rw-r--r--app/assets/stylesheets/framework/selects.scss41
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss12
-rw-r--r--app/assets/stylesheets/framework/tables.scss2
-rw-r--r--app/assets/stylesheets/framework/typography.scss18
-rw-r--r--app/assets/stylesheets/framework/variables.scss244
-rw-r--r--app/assets/stylesheets/framework/variables_overrides.scss23
-rw-r--r--app/assets/stylesheets/notify.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss326
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss67
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/README.md14
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss50
-rw-r--r--app/assets/stylesheets/pages/alert_management/details.scss33
-rw-r--r--app/assets/stylesheets/pages/alert_management/list.scss93
-rw-r--r--app/assets/stylesheets/pages/boards.scss29
-rw-r--r--app/assets/stylesheets/pages/diff.scss2
-rw-r--r--app/assets/stylesheets/pages/experience_level.scss29
-rw-r--r--app/assets/stylesheets/pages/groups.scss32
-rw-r--r--app/assets/stylesheets/pages/issuable.scss5
-rw-r--r--app/assets/stylesheets/pages/issues.scss66
-rw-r--r--app/assets/stylesheets/pages/labels.scss4
-rw-r--r--app/assets/stylesheets/pages/login.scss13
-rw-r--r--app/assets/stylesheets/pages/note_form.scss4
-rw-r--r--app/assets/stylesheets/pages/notes.scss6
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss5
-rw-r--r--app/assets/stylesheets/pages/profiles/preferences.scss14
-rw-r--r--app/assets/stylesheets/pages/projects.scss2
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss8
-rw-r--r--app/assets/stylesheets/pages/storage_quota.scss17
-rw-r--r--app/assets/stylesheets/themes/_dark.scss134
-rw-r--r--app/assets/stylesheets/utilities.scss70
-rw-r--r--app/channels/application_cable/channel.rb11
-rw-r--r--app/channels/application_cable/connection.rb8
-rw-r--r--app/channels/application_cable/logging.rb17
-rw-r--r--app/controllers/admin/application_settings_controller.rb5
-rw-r--r--app/controllers/admin/runners_controller.rb2
-rw-r--r--app/controllers/admin/users_controller.rb3
-rw-r--r--app/controllers/application_controller.rb1
-rw-r--r--app/controllers/clusters/base_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb1
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb3
-rw-r--r--app/controllers/concerns/find_snippet.rb24
-rw-r--r--app/controllers/concerns/integrations_actions.rb6
-rw-r--r--app/controllers/concerns/issuable_actions.rb29
-rw-r--r--app/controllers/concerns/issuable_collections.rb3
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb4
-rw-r--r--app/controllers/concerns/known_sign_in.rb2
-rw-r--r--app/controllers/concerns/milestone_actions.rb8
-rw-r--r--app/controllers/concerns/notes_actions.rb6
-rw-r--r--app/controllers/concerns/preview_markdown.rb2
-rw-r--r--app/controllers/concerns/service_params.rb3
-rw-r--r--app/controllers/concerns/snippet_authorizations.rb23
-rw-r--r--app/controllers/concerns/snippets_actions.rb61
-rw-r--r--app/controllers/concerns/wiki_actions.rb232
-rw-r--r--app/controllers/concerns/workhorse_import_export_upload.rb33
-rw-r--r--app/controllers/dashboard/milestones_controller.rb32
-rw-r--r--app/controllers/dashboard/todos_controller.rb10
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/group_links_controller.rb2
-rw-r--r--app/controllers/groups/imports_controller.rb19
-rw-r--r--app/controllers/groups/milestones_controller.rb71
-rw-r--r--app/controllers/groups_controller.rb15
-rw-r--r--app/controllers/ide_controller.rb6
-rw-r--r--app/controllers/import/base_controller.rb76
-rw-r--r--app/controllers/import/bitbucket_controller.rb53
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb77
-rw-r--r--app/controllers/import/fogbugz_controller.rb33
-rw-r--r--app/controllers/import/github_controller.rb2
-rw-r--r--app/controllers/import/gitlab_controller.rb29
-rw-r--r--app/controllers/import/gitlab_groups_controller.rb65
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb27
-rw-r--r--app/controllers/projects/alert_management_controller.rb4
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb16
-rw-r--r--app/controllers/projects/badges_controller.rb11
-rw-r--r--app/controllers/projects/blame_controller.rb3
-rw-r--r--app/controllers/projects/blob_controller.rb6
-rw-r--r--app/controllers/projects/branches_controller.rb14
-rw-r--r--app/controllers/projects/ci/daily_build_group_report_results_controller.rb11
-rw-r--r--app/controllers/projects/discussions_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb2
-rw-r--r--app/controllers/projects/graphs_controller.rb10
-rw-r--r--app/controllers/projects/group_links_controller.rb7
-rw-r--r--app/controllers/projects/import/jira_controller.rb54
-rw-r--r--app/controllers/projects/issues_controller.rb1
-rw-r--r--app/controllers/projects/jobs_controller.rb40
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb11
-rw-r--r--app/controllers/projects/merge_requests/drafts_controller.rb129
-rw-r--r--app/controllers/projects/merge_requests_controller.rb3
-rw-r--r--app/controllers/projects/milestones_controller.rb4
-rw-r--r--app/controllers/projects/pipelines_controller.rb16
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/releases_controller.rb1
-rw-r--r--app/controllers/projects/services_controller.rb15
-rw-r--r--app/controllers/projects/settings/operations_controller.rb2
-rw-r--r--app/controllers/projects/snippets/application_controller.rb19
-rw-r--r--app/controllers/projects/snippets_controller.rb86
-rw-r--r--app/controllers/projects/tags_controller.rb18
-rw-r--r--app/controllers/projects/web_ide_terminals_controller.rb98
-rw-r--r--app/controllers/projects/wikis_controller.rb199
-rw-r--r--app/controllers/projects_controller.rb21
-rw-r--r--app/controllers/registrations/experience_levels_controller.rb44
-rw-r--r--app/controllers/registrations_controller.rb4
-rw-r--r--app/controllers/repositories/git_http_controller.rb2
-rw-r--r--app/controllers/search_controller.rb31
-rw-r--r--app/controllers/snippets/application_controller.rb22
-rw-r--r--app/controllers/snippets_controller.rb92
-rw-r--r--app/finders/admin/runners_finder.rb71
-rw-r--r--app/finders/ci/daily_build_group_report_results_finder.rb20
-rw-r--r--app/finders/ci/runners_finder.rb92
-rw-r--r--app/finders/events_finder.rb5
-rw-r--r--app/finders/issuable_finder.rb2
-rw-r--r--app/finders/issuable_finder/params.rb10
-rw-r--r--app/finders/labels_finder.rb9
-rw-r--r--app/finders/milestones_finder.rb6
-rw-r--r--app/finders/notes_finder.rb2
-rw-r--r--app/finders/resource_label_event_finder.rb41
-rw-r--r--app/finders/resource_milestone_event_finder.rb69
-rw-r--r--app/finders/uploader_finder.rb34
-rw-r--r--app/finders/users_finder.rb19
-rw-r--r--app/graphql/mutations/alert_management/alerts/set_assignees.rb56
-rw-r--r--app/graphql/mutations/alert_management/base.rb4
-rw-r--r--app/graphql/mutations/alert_management/update_alert_status.rb2
-rw-r--r--app/graphql/mutations/base_mutation.rb2
-rw-r--r--app/graphql/mutations/branches/create.rb2
-rw-r--r--app/graphql/mutations/commits/create.rb60
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_issuable.rb16
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_project.rb15
-rw-r--r--app/graphql/mutations/container_expiration_policies/update.rb62
-rw-r--r--app/graphql/mutations/discussions/toggle_resolve.rb73
-rw-r--r--app/graphql/mutations/issues/set_confidential.rb2
-rw-r--r--app/graphql/mutations/issues/set_due_date.rb2
-rw-r--r--app/graphql/mutations/issues/update.rb2
-rw-r--r--app/graphql/mutations/jira_import/import_users.rb44
-rw-r--r--app/graphql/mutations/jira_import/start.rb16
-rw-r--r--app/graphql/mutations/merge_requests/create.rb63
-rw-r--r--app/graphql/mutations/merge_requests/set_assignees.rb2
-rw-r--r--app/graphql/mutations/merge_requests/set_labels.rb7
-rw-r--r--app/graphql/mutations/merge_requests/set_locked.rb2
-rw-r--r--app/graphql/mutations/merge_requests/set_milestone.rb2
-rw-r--r--app/graphql/mutations/merge_requests/set_subscription.rb2
-rw-r--r--app/graphql/mutations/merge_requests/set_wip.rb2
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/base.rb18
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/delete.rb32
-rw-r--r--app/graphql/mutations/snippets/create.rb4
-rw-r--r--app/graphql/mutations/todos/mark_all_done.rb4
-rw-r--r--app/graphql/mutations/todos/mark_done.rb2
-rw-r--r--app/graphql/mutations/todos/restore.rb6
-rw-r--r--app/graphql/mutations/todos/restore_many.rb2
-rw-r--r--app/graphql/resolvers/alert_management/alert_resolver.rb42
-rw-r--r--app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb4
-rw-r--r--app/graphql/resolvers/alert_management_alert_resolver.rb31
-rw-r--r--app/graphql/resolvers/assigned_merge_requests_resolver.rb9
-rw-r--r--app/graphql/resolvers/authored_merge_requests_resolver.rb9
-rw-r--r--app/graphql/resolvers/base_resolver.rb33
-rw-r--r--app/graphql/resolvers/concerns/looks_ahead.rb52
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb67
-rw-r--r--app/graphql/resolvers/concerns/resolves_project.rb15
-rw-r--r--app/graphql/resolvers/full_path_resolver.rb7
-rw-r--r--app/graphql/resolvers/merge_request_resolver.rb18
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb52
-rw-r--r--app/graphql/resolvers/project_members_resolver.rb21
-rw-r--r--app/graphql/resolvers/project_pipeline_resolver.rb17
-rw-r--r--app/graphql/resolvers/projects/jira_imports_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects/jira_projects_resolver.rb76
-rw-r--r--app/graphql/resolvers/user_merge_requests_resolver.rb68
-rw-r--r--app/graphql/resolvers/user_resolver.rb43
-rw-r--r--app/graphql/resolvers/users_resolver.rb57
-rw-r--r--app/graphql/types/access_level_enum.rb15
-rw-r--r--app/graphql/types/access_level_type.rb17
-rw-r--r--app/graphql/types/alert_management/alert_sort_enum.rb12
-rw-r--r--app/graphql/types/alert_management/alert_type.rb11
-rw-r--r--app/graphql/types/base_object.rb4
-rw-r--r--app/graphql/types/board_type.rb2
-rw-r--r--app/graphql/types/ci/pipeline_type.rb2
-rw-r--r--app/graphql/types/commit_action_mode_enum.rb14
-rw-r--r--app/graphql/types/commit_action_type.rb22
-rw-r--r--app/graphql/types/commit_encoding_enum.rb10
-rw-r--r--app/graphql/types/container_expiration_policy_cadence_enum.rb17
-rw-r--r--app/graphql/types/container_expiration_policy_keep_enum.rb18
-rw-r--r--app/graphql/types/container_expiration_policy_older_than_enum.rb16
-rw-r--r--app/graphql/types/container_expiration_policy_type.rb21
-rw-r--r--app/graphql/types/evidence_type.rb21
-rw-r--r--app/graphql/types/group_member_type.rb17
-rw-r--r--app/graphql/types/group_type.rb39
-rw-r--r--app/graphql/types/jira_import_type.rb6
-rw-r--r--app/graphql/types/jira_user_type.rb19
-rw-r--r--app/graphql/types/member_interface.rb22
-rw-r--r--app/graphql/types/merge_request_type.rb12
-rw-r--r--app/graphql/types/metrics/dashboard_type.rb3
-rw-r--r--app/graphql/types/milestone_type.rb12
-rw-r--r--app/graphql/types/mutation_type.rb7
-rw-r--r--app/graphql/types/notes/discussion_type.rb2
-rw-r--r--app/graphql/types/notes/note_type.rb12
-rw-r--r--app/graphql/types/notes/noteable_type.rb2
-rw-r--r--app/graphql/types/permission_types/ci/pipeline.rb3
-rw-r--r--app/graphql/types/permission_types/merge_request.rb12
-rw-r--r--app/graphql/types/project_member_type.rb25
-rw-r--r--app/graphql/types/project_type.rb63
-rw-r--r--app/graphql/types/projects/service_type.rb2
-rw-r--r--app/graphql/types/projects/services/jira_project_type.rb21
-rw-r--r--app/graphql/types/projects/services/jira_service_type.rb11
-rw-r--r--app/graphql/types/query_type.rb14
-rw-r--r--app/graphql/types/release_assets_type.rb20
-rw-r--r--app/graphql/types/release_link_type.rb20
-rw-r--r--app/graphql/types/release_link_type_enum.rb12
-rw-r--r--app/graphql/types/release_source_type.rb14
-rw-r--r--app/graphql/types/release_type.rb4
-rw-r--r--app/graphql/types/resolvable_interface.rb28
-rw-r--r--app/graphql/types/snippet_type.rb10
-rw-r--r--app/graphql/types/snippets/file_input_action_enum.rb15
-rw-r--r--app/graphql/types/snippets/file_input_type.rb26
-rw-r--r--app/graphql/types/user_state_enum.rb12
-rw-r--r--app/graphql/types/user_type.rb22
-rw-r--r--app/helpers/active_sessions_helper.rb2
-rw-r--r--app/helpers/application_helper.rb3
-rw-r--r--app/helpers/application_settings_helper.rb14
-rw-r--r--app/helpers/auto_devops_helper.rb2
-rw-r--r--app/helpers/clusters_helper.rb24
-rw-r--r--app/helpers/environments_helper.rb15
-rw-r--r--app/helpers/events_helper.rb2
-rw-r--r--app/helpers/gitlab_routing_helper.rb29
-rw-r--r--app/helpers/issuables_helper.rb5
-rw-r--r--app/helpers/issues_helper.rb39
-rw-r--r--app/helpers/markup_helper.rb6
-rw-r--r--app/helpers/namespaces_helper.rb41
-rw-r--r--app/helpers/notes_helper.rb23
-rw-r--r--app/helpers/notifications_helper.rb2
-rw-r--r--app/helpers/numbers_helper.rb5
-rw-r--r--app/helpers/page_layout_helper.rb10
-rw-r--r--app/helpers/projects/alert_management_helper.rb2
-rw-r--r--app/helpers/projects_helper.rb51
-rw-r--r--app/helpers/search_helper.rb107
-rw-r--r--app/helpers/services_helper.rb22
-rw-r--r--app/helpers/subscribable_banner_helper.rb9
-rw-r--r--app/helpers/timeboxes_helper.rb (renamed from app/helpers/milestones_helper.rb)39
-rw-r--r--app/helpers/timeboxes_routing_helper.rb (renamed from app/helpers/milestones_routing_helper.rb)4
-rw-r--r--app/helpers/todos_helper.rb14
-rw-r--r--app/helpers/visibility_level_helper.rb11
-rw-r--r--app/helpers/wiki_helper.rb51
-rw-r--r--app/mailers/emails/issues.rb2
-rw-r--r--app/mailers/emails/profile.rb11
-rw-r--r--app/mailers/emails/reviews.rb33
-rw-r--r--app/mailers/notify.rb3
-rw-r--r--app/mailers/previews/notify_preview.rb2
-rw-r--r--app/models/active_session.rb2
-rw-r--r--app/models/alert_management.rb7
-rw-r--r--app/models/alert_management/alert.rb48
-rw-r--r--app/models/alert_management/alert_assignee.rb11
-rw-r--r--app/models/alert_management/alert_user_mention.rb8
-rw-r--r--app/models/application_record.rb4
-rw-r--r--app/models/application_setting.rb29
-rw-r--r--app/models/application_setting_implementation.rb33
-rw-r--r--app/models/audit_event.rb3
-rw-r--r--app/models/badge.rb2
-rw-r--r--app/models/blob.rb2
-rw-r--r--app/models/blob_viewer/go_mod.rb43
-rw-r--r--app/models/blob_viewer/metrics_dashboard_yml.rb44
-rw-r--r--app/models/board_group_recent_visit.rb2
-rw-r--r--app/models/board_project_recent_visit.rb2
-rw-r--r--app/models/chat_team.rb2
-rw-r--r--app/models/ci/bridge.rb3
-rw-r--r--app/models/ci/build.rb49
-rw-r--r--app/models/ci/build_dependencies.rb2
-rw-r--r--app/models/ci/build_report_result.rb45
-rw-r--r--app/models/ci/build_runner_session.rb15
-rw-r--r--app/models/ci/daily_build_group_report_result.rb2
-rw-r--r--app/models/ci/freeze_period.rb2
-rw-r--r--app/models/ci/group.rb2
-rw-r--r--app/models/ci/instance_variable.rb53
-rw-r--r--app/models/ci/job_artifact.rb29
-rw-r--r--app/models/ci/pipeline.rb50
-rw-r--r--app/models/ci/pipeline_enums.rb5
-rw-r--r--app/models/ci/processable.rb4
-rw-r--r--app/models/ci/ref.rb71
-rw-r--r--app/models/ci/runner.rb32
-rw-r--r--app/models/clusters/applications/cert_manager.rb6
-rw-r--r--app/models/clusters/applications/crossplane.rb3
-rw-r--r--app/models/clusters/applications/elastic_stack.rb12
-rw-r--r--app/models/clusters/applications/fluentd.rb3
-rw-r--r--app/models/clusters/applications/helm.rb6
-rw-r--r--app/models/clusters/applications/ingress.rb3
-rw-r--r--app/models/clusters/applications/jupyter.rb3
-rw-r--r--app/models/clusters/applications/knative.rb6
-rw-r--r--app/models/clusters/applications/prometheus.rb11
-rw-r--r--app/models/clusters/applications/runner.rb5
-rw-r--r--app/models/clusters/cluster.rb22
-rw-r--r--app/models/clusters/concerns/application_core.rb2
-rw-r--r--app/models/clusters/concerns/application_data.rb5
-rw-r--r--app/models/clusters/concerns/application_status.rb22
-rw-r--r--app/models/commit_status.rb25
-rw-r--r--app/models/concerns/cacheable_attributes.rb2
-rw-r--r--app/models/concerns/ci/contextable.rb2
-rw-r--r--app/models/concerns/each_batch.rb2
-rw-r--r--app/models/concerns/featurable.rb99
-rw-r--r--app/models/concerns/has_status.rb4
-rw-r--r--app/models/concerns/import_state/sidekiq_job_tracker.rb7
-rw-r--r--app/models/concerns/integration.rb19
-rw-r--r--app/models/concerns/issuable.rb30
-rw-r--r--app/models/concerns/limitable.rb29
-rw-r--r--app/models/concerns/mentionable.rb4
-rw-r--r--app/models/concerns/milestoneish.rb20
-rw-r--r--app/models/concerns/noteable.rb2
-rw-r--r--app/models/concerns/prometheus_adapter.rb2
-rw-r--r--app/models/concerns/relative_positioning.rb2
-rw-r--r--app/models/concerns/resolvable_discussion.rb7
-rw-r--r--app/models/concerns/resolvable_note.rb4
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb2
-rw-r--r--app/models/concerns/timebox.rb51
-rw-r--r--app/models/concerns/token_authenticatable.rb5
-rw-r--r--app/models/concerns/update_highest_role.rb4
-rw-r--r--app/models/container_expiration_policy.rb6
-rw-r--r--app/models/container_repository.rb14
-rw-r--r--app/models/dashboard_group_milestone.rb29
-rw-r--r--app/models/dashboard_milestone.rb19
-rw-r--r--app/models/data_list.rb25
-rw-r--r--app/models/deployment.rb2
-rw-r--r--app/models/design_management/design.rb68
-rw-r--r--app/models/design_management/version.rb2
-rw-r--r--app/models/diff_note.rb2
-rw-r--r--app/models/discussion.rb1
-rw-r--r--app/models/draft_note.rb122
-rw-r--r--app/models/environment.rb2
-rw-r--r--app/models/event.rb194
-rw-r--r--app/models/global_milestone.rb108
-rw-r--r--app/models/group.rb59
-rw-r--r--app/models/group_deploy_key.rb11
-rw-r--r--app/models/group_group_link.rb1
-rw-r--r--app/models/group_import_state.rb7
-rw-r--r--app/models/group_milestone.rb49
-rw-r--r--app/models/internal_id.rb24
-rw-r--r--app/models/issue.rb27
-rw-r--r--app/models/issue/metrics.rb4
-rw-r--r--app/models/iteration.rb25
-rw-r--r--app/models/jira_import_state.rb27
-rw-r--r--app/models/label.rb4
-rw-r--r--app/models/lfs_object.rb4
-rw-r--r--app/models/license_template.rb2
-rw-r--r--app/models/member.rb2
-rw-r--r--app/models/members/group_member.rb11
-rw-r--r--app/models/members/project_member.rb2
-rw-r--r--app/models/merge_request.rb32
-rw-r--r--app/models/merge_request_context_commit.rb2
-rw-r--r--app/models/merge_request_context_commit_diff_file.rb2
-rw-r--r--app/models/merge_request_diff.rb4
-rw-r--r--app/models/merge_request_diff_commit.rb2
-rw-r--r--app/models/metrics/dashboard/annotation.rb13
-rw-r--r--app/models/milestone.rb49
-rw-r--r--app/models/namespace.rb8
-rw-r--r--app/models/note.rb25
-rw-r--r--app/models/pages_domain.rb8
-rw-r--r--app/models/pages_domain_acme_order.rb2
-rw-r--r--app/models/performance_monitoring/prometheus_dashboard.rb43
-rw-r--r--app/models/performance_monitoring/prometheus_metric.rb26
-rw-r--r--app/models/performance_monitoring/prometheus_panel.rb25
-rw-r--r--app/models/performance_monitoring/prometheus_panel_group.rb21
-rw-r--r--app/models/project.rb132
-rw-r--r--app/models/project_ci_cd_setting.rb13
-rw-r--r--app/models/project_feature.rb106
-rw-r--r--app/models/project_group_link.rb6
-rw-r--r--app/models/project_import_state.rb6
-rw-r--r--app/models/project_metrics_setting.rb7
-rw-r--r--app/models/project_repository_storage_move.rb22
-rw-r--r--app/models/project_services/alerts_service.rb2
-rw-r--r--app/models/project_services/chat_message/alert_message.rb74
-rw-r--r--app/models/project_services/chat_message/merge_message.rb2
-rw-r--r--app/models/project_services/chat_message/pipeline_message.rb6
-rw-r--r--app/models/project_services/hipchat_service.rb4
-rw-r--r--app/models/project_services/jira_service.rb23
-rw-r--r--app/models/project_services/pipelines_email_service.rb6
-rw-r--r--app/models/project_services/prometheus_service.rb43
-rw-r--r--app/models/project_services/slack_service.rb16
-rw-r--r--app/models/project_setting.rb4
-rw-r--r--app/models/prometheus_alert_event.rb6
-rw-r--r--app/models/push_event.rb4
-rw-r--r--app/models/releases/evidence.rb55
-rw-r--r--app/models/releases/link.rb7
-rw-r--r--app/models/remote_mirror.rb12
-rw-r--r--app/models/repository.rb8
-rw-r--r--app/models/repository_language.rb2
-rw-r--r--app/models/resource_label_event.rb8
-rw-r--r--app/models/resource_milestone_event.rb10
-rw-r--r--app/models/review.rb30
-rw-r--r--app/models/route.rb2
-rw-r--r--app/models/self_managed_prometheus_alert_event.rb6
-rw-r--r--app/models/service.rb26
-rw-r--r--app/models/service_list.rb27
-rw-r--r--app/models/snippet.rb2
-rw-r--r--app/models/snippet_input_action.rb62
-rw-r--r--app/models/snippet_input_action_collection.rb25
-rw-r--r--app/models/ssh_host_key.rb2
-rw-r--r--app/models/storage/legacy_project.rb2
-rw-r--r--app/models/terraform/state.rb2
-rw-r--r--app/models/todo.rb18
-rw-r--r--app/models/uploads/base.rb2
-rw-r--r--app/models/user.rb61
-rw-r--r--app/models/user_interacted_project.rb14
-rw-r--r--app/models/user_preference.rb2
-rw-r--r--app/models/web_ide_terminal.rb51
-rw-r--r--app/models/wiki.rb2
-rw-r--r--app/models/wiki_directory.rb2
-rw-r--r--app/models/wiki_page.rb8
-rw-r--r--app/models/wiki_page/meta.rb2
-rw-r--r--app/models/wiki_page/slug.rb4
-rw-r--r--app/policies/ci/build_policy.rb22
-rw-r--r--app/policies/container_expiration_policy_policy.rb5
-rw-r--r--app/policies/draft_note_policy.rb13
-rw-r--r--app/policies/project_policy.rb35
-rw-r--r--app/policies/releases/link_policy.rb7
-rw-r--r--app/policies/releases/source_policy.rb13
-rw-r--r--app/presenters/gitlab/blame_presenter.rb82
-rw-r--r--app/presenters/projects/prometheus/alert_presenter.rb8
-rw-r--r--app/presenters/release_presenter.rb2
-rw-r--r--app/presenters/snippet_presenter.rb8
-rw-r--r--app/serializers/build_artifact_entity.rb34
-rw-r--r--app/serializers/ci/dag_job_entity.rb1
-rw-r--r--app/serializers/ci/dag_pipeline_entity.rb6
-rw-r--r--app/serializers/ci/daily_build_group_report_result_entity.rb13
-rw-r--r--app/serializers/ci/daily_build_group_report_result_serializer.rb28
-rw-r--r--app/serializers/cluster_entity.rb1
-rw-r--r--app/serializers/cluster_serializer.rb1
-rw-r--r--app/serializers/container_repository_entity.rb2
-rw-r--r--app/serializers/diff_file_base_entity.rb6
-rw-r--r--app/serializers/diff_file_metadata_entity.rb5
-rw-r--r--app/serializers/diffs_entity.rb14
-rw-r--r--app/serializers/draft_note_entity.rb39
-rw-r--r--app/serializers/draft_note_serializer.rb4
-rw-r--r--app/serializers/import/base_provider_repo_entity.rb8
-rw-r--r--app/serializers/import/bitbucket_provider_repo_entity.rb15
-rw-r--r--app/serializers/import/bitbucket_server_provider_repo_entity.rb7
-rw-r--r--app/serializers/import/fogbugz_provider_repo_entity.rb17
-rw-r--r--app/serializers/import/githubish_provider_repo_entity.rb (renamed from app/serializers/provider_repo_entity.rb)12
-rw-r--r--app/serializers/import/gitlab_provider_repo_entity.rb19
-rw-r--r--app/serializers/import/provider_repo_serializer.rb23
-rw-r--r--app/serializers/merge_request_noteable_entity.rb2
-rw-r--r--app/serializers/paginated_diff_entity.rb10
-rw-r--r--app/serializers/pipeline_details_entity.rb3
-rw-r--r--app/serializers/pipeline_serializer.rb36
-rw-r--r--app/serializers/provider_repo_serializer.rb5
-rw-r--r--app/serializers/service_field_entity.rb24
-rw-r--r--app/serializers/service_field_serializer.rb5
-rw-r--r--app/serializers/web_ide_terminal_entity.rb12
-rw-r--r--app/serializers/web_ide_terminal_serializer.rb11
-rw-r--r--app/services/admin/propagate_integration_service.rb142
-rw-r--r--app/services/alert_management/alerts/update_service.rb98
-rw-r--r--app/services/alert_management/create_alert_issue_service.rb3
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb6
-rw-r--r--app/services/auto_merge/base_service.rb54
-rw-r--r--app/services/award_emojis/destroy_service.rb2
-rw-r--r--app/services/ci/authorize_job_artifact_service.rb53
-rw-r--r--app/services/ci/build_report_result_service.rb36
-rw-r--r--app/services/ci/create_cross_project_pipeline_service.rb1
-rw-r--r--app/services/ci/create_web_ide_terminal_service.rb123
-rw-r--r--app/services/ci/extract_sections_from_build_trace_service.rb2
-rw-r--r--app/services/ci/process_pipeline_service.rb2
-rw-r--r--app/services/ci/update_ci_ref_status_service.rb1
-rw-r--r--app/services/ci/web_ide_config_service.rb59
-rw-r--r--app/services/clusters/applications/prometheus_config_service.rb12
-rw-r--r--app/services/clusters/parse_cluster_applications_artifact_service.rb27
-rw-r--r--app/services/commits/create_service.rb2
-rw-r--r--app/services/concerns/exclusive_lease_guard.rb2
-rw-r--r--app/services/concerns/integrations/project_test_data.rb62
-rw-r--r--app/services/concerns/measurable.rb2
-rw-r--r--app/services/concerns/spam_check_methods.rb11
-rw-r--r--app/services/container_expiration_policies/update_service.rb38
-rw-r--r--app/services/container_expiration_policy_service.rb7
-rw-r--r--app/services/design_management/delete_designs_service.rb5
-rw-r--r--app/services/design_management/save_designs_service.rb17
-rw-r--r--app/services/discussions/resolve_service.rb55
-rw-r--r--app/services/draft_notes/base_service.rb21
-rw-r--r--app/services/draft_notes/create_service.rb56
-rw-r--r--app/services/draft_notes/destroy_service.rb23
-rw-r--r--app/services/draft_notes/publish_service.rb67
-rw-r--r--app/services/event_create_service.rb132
-rw-r--r--app/services/git/wiki_push_service/change.rb6
-rw-r--r--app/services/groups/destroy_service.rb2
-rw-r--r--app/services/groups/group_links/create_service.rb2
-rw-r--r--app/services/groups/group_links/destroy_service.rb6
-rw-r--r--app/services/groups/import_export/export_service.rb29
-rw-r--r--app/services/groups/import_export/import_service.rb23
-rw-r--r--app/services/groups/transfer_service.rb10
-rw-r--r--app/services/import/github_service.rb29
-rw-r--r--app/services/integrations/test/base_service.rb36
-rw-r--r--app/services/integrations/test/project_service.rb47
-rw-r--r--app/services/issuable/bulk_update_service.rb13
-rw-r--r--app/services/issuable/clone/attributes_rewriter.rb2
-rw-r--r--app/services/issuable_base_service.rb12
-rw-r--r--app/services/issues/create_service.rb7
-rw-r--r--app/services/issues/import_csv_service.rb2
-rw-r--r--app/services/issues/update_service.rb6
-rw-r--r--app/services/jira/requests/base.rb52
-rw-r--r--app/services/jira/requests/projects.rb32
-rw-r--r--app/services/jira_import/start_import_service.rb4
-rw-r--r--app/services/jira_import/users_importer.rb43
-rw-r--r--app/services/jira_import/users_mapper.rb31
-rw-r--r--app/services/keys/create_service.rb8
-rw-r--r--app/services/labels/available_labels_service.rb6
-rw-r--r--app/services/labels/create_service.rb2
-rw-r--r--app/services/labels/promote_service.rb2
-rw-r--r--app/services/merge_requests/merge_service.rb4
-rw-r--r--app/services/merge_requests/update_service.rb4
-rw-r--r--app/services/metrics/dashboard/base_service.rb3
-rw-r--r--app/services/metrics/dashboard/self_monitoring_dashboard_service.rb4
-rw-r--r--app/services/metrics/dashboard/system_dashboard_service.rb4
-rw-r--r--app/services/milestones/promote_service.rb2
-rw-r--r--app/services/namespaces/check_storage_size_service.rb5
-rw-r--r--app/services/notes/create_service.rb8
-rw-r--r--app/services/notes/post_process_service.rb2
-rw-r--r--app/services/notification_recipients/build_service.rb6
-rw-r--r--app/services/notification_recipients/builder/new_review.rb43
-rw-r--r--app/services/notification_service.rb29
-rw-r--r--app/services/pages/delete_service.rb2
-rw-r--r--app/services/projects/after_import_service.rb8
-rw-r--r--app/services/projects/alerting/notify_service.rb33
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb12
-rw-r--r--app/services/projects/create_service.rb24
-rw-r--r--app/services/projects/destroy_service.rb2
-rw-r--r--app/services/projects/detect_repository_languages_service.rb2
-rw-r--r--app/services/projects/group_links/create_service.rb1
-rw-r--r--app/services/projects/group_links/destroy_service.rb4
-rw-r--r--app/services/projects/group_links/update_service.rb29
-rw-r--r--app/services/projects/hashed_storage/base_attachment_service.rb2
-rw-r--r--app/services/projects/import_export/export_service.rb14
-rw-r--r--app/services/projects/import_service.rb2
-rw-r--r--app/services/projects/lfs_pointers/lfs_link_service.rb2
-rw-r--r--app/services/projects/lsif_data_service.rb101
-rw-r--r--app/services/projects/move_deploy_keys_projects_service.rb2
-rw-r--r--app/services/projects/move_lfs_objects_projects_service.rb2
-rw-r--r--app/services/projects/move_notification_settings_service.rb2
-rw-r--r--app/services/projects/move_project_group_links_service.rb2
-rw-r--r--app/services/projects/move_project_members_service.rb2
-rw-r--r--app/services/projects/operations/update_service.rb4
-rw-r--r--app/services/projects/prometheus/alerts/create_events_service.rb8
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb35
-rw-r--r--app/services/projects/propagate_service_template.rb48
-rw-r--r--app/services/projects/update_remote_mirror_service.rb6
-rw-r--r--app/services/projects/update_repository_storage_service.rb7
-rw-r--r--app/services/projects/update_service.rb13
-rw-r--r--app/services/projects/update_statistics_service.rb2
-rw-r--r--app/services/prometheus/create_default_alerts_service.rb11
-rw-r--r--app/services/prometheus/proxy_service.rb4
-rw-r--r--app/services/prometheus/proxy_variable_substitution_service.rb24
-rw-r--r--app/services/protected_branches/legacy_api_update_service.rb4
-rw-r--r--app/services/releases/create_evidence_service.rb25
-rw-r--r--app/services/releases/create_service.rb33
-rw-r--r--app/services/resource_events/change_labels_service.rb2
-rw-r--r--app/services/resource_events/change_state_service.rb36
-rw-r--r--app/services/resource_events/merge_into_notes_service.rb5
-rw-r--r--app/services/resource_events/synthetic_state_notes_builder_service.rb20
-rw-r--r--app/services/search_service.rb22
-rw-r--r--app/services/service_response.rb6
-rw-r--r--app/services/snippets/base_service.rb33
-rw-r--r--app/services/snippets/bulk_destroy_service.rb6
-rw-r--r--app/services/snippets/create_service.rb31
-rw-r--r--app/services/snippets/update_service.rb41
-rw-r--r--app/services/spam/akismet_service.rb4
-rw-r--r--app/services/spam/spam_action_service.rb21
-rw-r--r--app/services/spam/spam_constants.rb22
-rw-r--r--app/services/spam/spam_verdict_service.rb76
-rw-r--r--app/services/submit_usage_ping_service.rb2
-rw-r--r--app/services/suggestions/apply_service.rb110
-rw-r--r--app/services/suggestions/create_service.rb2
-rw-r--r--app/services/system_notes/issuables_service.rb14
-rw-r--r--app/services/test_hooks/base_service.rb28
-rw-r--r--app/services/test_hooks/project_service.rb72
-rw-r--r--app/services/test_hooks/system_service.rb25
-rw-r--r--app/services/todo_service.rb98
-rw-r--r--app/services/user_project_access_changed_service.rb3
-rw-r--r--app/services/users/build_service.rb3
-rw-r--r--app/services/users/destroy_service.rb2
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb5
-rw-r--r--app/services/web_hook_service.rb2
-rw-r--r--app/services/wiki_pages/create_service.rb2
-rw-r--r--app/services/wiki_pages/destroy_service.rb2
-rw-r--r--app/services/wiki_pages/update_service.rb2
-rw-r--r--app/uploaders/file_mover.rb2
-rw-r--r--app/validators/json_schema_validator.rb38
-rw-r--r--app/validators/json_schemas/build_report_result_data.json12
-rw-r--r--app/validators/json_schemas/build_report_result_data_tests.json13
-rw-r--r--app/validators/json_schemas/daily_build_group_report_result_data.json8
-rw-r--r--app/views/admin/appearances/_form.html.haml10
-rw-r--r--app/views/admin/appearances/_system_header_footer_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml5
-rw-r--r--app/views/admin/application_settings/_issue_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml4
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml8
-rw-r--r--app/views/admin/application_settings/_spam.html.haml9
-rw-r--r--app/views/admin/application_settings/ci/_header.html.haml20
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml11
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml4
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml4
-rw-r--r--app/views/admin/groups/_group.html.haml43
-rw-r--r--app/views/admin/groups/show.html.haml2
-rw-r--r--app/views/admin/hook_logs/_index.html.haml2
-rw-r--r--app/views/admin/hooks/edit.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml2
-rw-r--r--app/views/admin/sessions/_two_factor_u2f.html.haml17
-rw-r--r--app/views/admin/sessions/two_factor.html.haml2
-rw-r--r--app/views/admin/users/_admin_notes.html.haml7
-rw-r--r--app/views/admin/users/_form.html.haml2
-rw-r--r--app/views/admin/users/_user_detail.html.haml4
-rw-r--r--app/views/admin/users/_user_detail_note.html.haml7
-rw-r--r--app/views/admin/users/_user_listing_note.html.haml3
-rw-r--r--app/views/admin/users/show.html.haml8
-rw-r--r--app/views/ci/variables/_index.html.haml13
-rw-r--r--app/views/clusters/clusters/_banner.html.haml2
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml2
-rw-r--r--app/views/clusters/clusters/_gitlab_integration_form.html.haml2
-rw-r--r--app/views/clusters/clusters/_sidebar.html.haml2
-rw-r--r--app/views/clusters/clusters/index.html.haml2
-rw-r--r--app/views/clusters/clusters/show.html.haml2
-rw-r--r--app/views/dashboard/milestones/_milestone.html.haml1
-rw-r--r--app/views/dashboard/milestones/show.html.haml5
-rw-r--r--app/views/devise/confirmations/almost_there.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/doorkeeper/applications/index.html.haml4
-rw-r--r--app/views/events/event/_note.html.haml6
-rw-r--r--app/views/groups/_flash_messages.html.haml1
-rw-r--r--app/views/groups/_home_panel.html.haml6
-rw-r--r--app/views/groups/_import_group_pane.html.haml52
-rw-r--r--app/views/groups/_new_group_fields.html.haml22
-rw-r--r--app/views/groups/imports/show.html.haml10
-rw-r--r--app/views/groups/milestones/_form.html.haml4
-rw-r--r--app/views/groups/milestones/_milestone.html.haml2
-rw-r--r--app/views/groups/milestones/index.html.haml5
-rw-r--r--app/views/groups/new.html.haml73
-rw-r--r--app/views/groups/registry/repositories/index.html.haml4
-rw-r--r--app/views/groups/settings/_export.html.haml2
-rw-r--r--app/views/groups/settings/_general.html.haml2
-rw-r--r--app/views/groups/show.html.haml4
-rw-r--r--app/views/groups/sidebar/_packages.html.haml6
-rw-r--r--app/views/import/_githubish_status.html.haml5
-rw-r--r--app/views/import/bitbucket/status.html.haml167
-rw-r--r--app/views/import/bitbucket_server/new.html.haml6
-rw-r--r--app/views/import/bitbucket_server/status.html.haml165
-rw-r--r--app/views/import/fogbugz/status.html.haml99
-rw-r--r--app/views/import/gitlab/status.html.haml95
-rw-r--r--app/views/import/phabricator/new.html.haml6
-rw-r--r--app/views/layouts/_head.html.haml10
-rw-r--r--app/views/layouts/_page.html.haml4
-rw-r--r--app/views/layouts/_search.html.haml32
-rw-r--r--app/views/layouts/group.html.haml1
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml4
-rw-r--r--app/views/layouts/header/_default.html.haml15
-rw-r--r--app/views/layouts/header/_logo_with_title.html.haml2
-rw-r--r--app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml4
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml35
-rw-r--r--app/views/layouts/project.html.haml3
-rw-r--r--app/views/layouts/terms.html.haml4
-rw-r--r--app/views/notify/merged_merge_request_email.html.haml2
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml2
-rw-r--r--app/views/notify/new_issue_email.html.haml2
-rw-r--r--app/views/notify/new_review_email.html.haml16
-rw-r--r--app/views/notify/new_review_email.text.erb13
-rw-r--r--app/views/notify/unknown_sign_in_email.html.haml66
-rw-r--r--app/views/profiles/_event_table.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml8
-rw-r--r--app/views/profiles/active_sessions/index.html.haml2
-rw-r--r--app/views/profiles/audit_log.html.haml2
-rw-r--r--app/views/profiles/chat_names/index.html.haml2
-rw-r--r--app/views/profiles/emails/index.html.haml6
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml4
-rw-r--r--app/views/profiles/keys/index.html.haml4
-rw-r--r--app/views/profiles/notifications/show.html.haml4
-rw-r--r--app/views/profiles/passwords/edit.html.haml4
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml8
-rw-r--r--app/views/profiles/preferences/_sourcegraph.html.haml2
-rw-r--r--app/views/profiles/preferences/show.html.haml10
-rw-r--r--app/views/profiles/show.html.haml10
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml12
-rw-r--r--app/views/projects/_export.html.haml2
-rw-r--r--app/views/projects/_find_file_link.html.haml3
-rw-r--r--app/views/projects/_flash_messages.html.haml3
-rw-r--r--app/views/projects/_home_panel.html.haml10
-rw-r--r--app/views/projects/_import_project_pane.html.haml6
-rw-r--r--app/views/projects/_merge_request_merge_checks_settings.html.haml7
-rw-r--r--app/views/projects/_merge_request_merge_suggestions_settings.html.haml4
-rw-r--r--app/views/projects/_new_project_fields.html.haml2
-rw-r--r--app/views/projects/_wiki.html.haml2
-rw-r--r--app/views/projects/blame/_blame_group.html.haml26
-rw-r--r--app/views/projects/blame/show.html.haml47
-rw-r--r--app/views/projects/blob/_editor.html.haml2
-rw-r--r--app/views/projects/blob/_header_content.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml11
-rw-r--r--app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml4
-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.haml8
-rw-r--r--app/views/projects/branches/_panel.html.haml2
-rw-r--r--app/views/projects/branches/index.html.haml2
-rw-r--r--app/views/projects/buttons/_fork.html.haml2
-rw-r--r--app/views/projects/buttons/_star.html.haml4
-rw-r--r--app/views/projects/cleanup/_show.html.haml8
-rw-r--r--app/views/projects/commit/_commit_box.html.haml4
-rw-r--r--app/views/projects/commits/_commit.html.haml2
-rw-r--r--app/views/projects/diffs/_file.html.haml2
-rw-r--r--app/views/projects/diffs/_stats.html.haml4
-rw-r--r--app/views/projects/empty.html.haml6
-rw-r--r--app/views/projects/environments/_form.html.haml4
-rw-r--r--app/views/projects/environments/empty_metrics.html.haml2
-rw-r--r--app/views/projects/environments/index.html.haml2
-rw-r--r--app/views/projects/environments/metrics.html.haml2
-rw-r--r--app/views/projects/environments/show.html.haml4
-rw-r--r--app/views/projects/forks/_fork_button.html.haml2
-rw-r--r--app/views/projects/forks/new.html.haml4
-rw-r--r--app/views/projects/graphs/charts.html.haml2
-rw-r--r--app/views/projects/hook_logs/_index.html.haml4
-rw-r--r--app/views/projects/hook_logs/show.html.haml2
-rw-r--r--app/views/projects/hooks/edit.html.haml2
-rw-r--r--app/views/projects/import/jira/show.html.haml39
-rw-r--r--app/views/projects/issues/_issue.html.haml1
-rw-r--r--app/views/projects/issues/_issue_estimate.html.haml7
-rw-r--r--app/views/projects/issues/_new_branch.html.haml2
-rw-r--r--app/views/projects/issues/import_csv/_button.html.haml28
-rw-r--r--app/views/projects/issues/index.html.haml9
-rw-r--r--app/views/projects/issues/show.html.haml1
-rw-r--r--app/views/projects/logs/empty_logs.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/views/projects/milestones/_form.html.haml4
-rw-r--r--app/views/projects/milestones/_milestone.html.haml1
-rw-r--r--app/views/projects/new.html.haml14
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml2
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml12
-rw-r--r--app/views/projects/pipelines/charts.html.haml2
-rw-r--r--app/views/projects/pipelines/index.html.haml1
-rw-r--r--app/views/projects/project_templates/_built_in_templates.html.haml2
-rw-r--r--app/views/projects/protected_branches/show.html.haml2
-rw-r--r--app/views/projects/protected_tags/show.html.haml2
-rw-r--r--app/views/projects/registry/repositories/_tag.html.haml33
-rw-r--r--app/views/projects/registry/repositories/index.html.haml3
-rw-r--r--app/views/projects/services/_form.html.haml3
-rw-r--r--app/views/projects/services/alerts/_help.html.haml5
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml2
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/integrations/show.html.haml6
-rw-r--r--app/views/projects/settings/operations/_external_dashboard.html.haml3
-rw-r--r--app/views/projects/settings/operations/_incidents.html.haml2
-rw-r--r--app/views/projects/settings/operations/_metrics_dashboard.html.haml5
-rw-r--r--app/views/projects/settings/operations/show.html.haml2
-rw-r--r--app/views/projects/snippets/_actions.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml6
-rw-r--r--app/views/projects/tags/new.html.haml4
-rw-r--r--app/views/projects/tags/releases/edit.html.haml4
-rw-r--r--app/views/projects/triggers/_index.html.haml2
-rw-r--r--app/views/projects/triggers/edit.html.haml2
-rw-r--r--app/views/projects/wikis/_main_links.html.haml9
-rw-r--r--app/views/projects/wikis/_wiki_page.html.haml1
-rw-r--r--app/views/projects/wikis/git_access.html.haml10
-rw-r--r--app/views/registrations/experience_levels/show.html.haml28
-rw-r--r--app/views/shared/_broadcast_message.html.haml4
-rw-r--r--app/views/shared/_choose_avatar_button.html.haml5
-rw-r--r--app/views/shared/_custom_attributes.html.haml12
-rw-r--r--app/views/shared/_field.html.haml5
-rw-r--r--app/views/shared/_file_highlight.html.haml2
-rw-r--r--app/views/shared/_file_picker_button.html.haml6
-rw-r--r--app/views/shared/_group_form.html.haml4
-rw-r--r--app/views/shared/_md_preview.html.haml (renamed from app/views/projects/_md_preview.html.haml)2
-rw-r--r--app/views/shared/_namespace_storage_limit_alert.html.haml26
-rw-r--r--app/views/shared/_new_merge_request_checkbox.html.haml2
-rw-r--r--app/views/shared/_promo.html.haml6
-rw-r--r--app/views/shared/_service_settings.html.haml10
-rw-r--r--app/views/shared/_visibility_level.html.haml2
-rw-r--r--app/views/shared/_visibility_radios.html.haml7
-rw-r--r--app/views/shared/_zen.html.haml (renamed from app/views/projects/_zen.html.haml)4
-rw-r--r--app/views/shared/access_tokens/_created_container.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml2
-rw-r--r--app/views/shared/blob/_markdown_buttons.html.haml (renamed from app/views/projects/blob/_markdown_buttons.html.haml)4
-rw-r--r--app/views/shared/boards/_show.html.haml29
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_index.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml4
-rw-r--r--app/views/shared/deploy_tokens/_index.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_new_deploy_token.html.haml2
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml4
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml19
-rw-r--r--app/views/shared/file_hooks/_index.html.haml2
-rw-r--r--app/views/shared/form_elements/_description.html.haml4
-rw-r--r--app/views/shared/groups/_group.html.haml37
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml3
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml15
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml8
-rw-r--r--app/views/shared/milestones/_deprecation_message.html.haml4
-rw-r--r--app/views/shared/milestones/_form_dates.html.haml12
-rw-r--r--app/views/shared/milestones/_header.html.haml6
-rw-r--r--app/views/shared/milestones/_milestone.html.haml92
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml56
-rw-r--r--app/views/shared/milestones/_top.html.haml27
-rw-r--r--app/views/shared/notes/_edit_form.html.haml4
-rw-r--r--app/views/shared/notes/_form.html.haml4
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml4
-rw-r--r--app/views/shared/notifications/_new_button.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml12
-rw-r--r--app/views/shared/snippets/_form.html.haml8
-rw-r--r--app/views/shared/snippets/_header.html.haml6
-rw-r--r--app/views/shared/tokens/_scopes_list.html.haml2
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml4
-rw-r--r--app/views/shared/web_hooks/_title_and_docs.html.haml13
-rw-r--r--app/views/shared/wikis/_form.html.haml (renamed from app/views/projects/wikis/_form.html.haml)23
-rw-r--r--app/views/shared/wikis/_main_links.html.haml9
-rw-r--r--app/views/shared/wikis/_pages_wiki_page.html.haml (renamed from app/views/projects/wikis/_pages_wiki_page.html.haml)2
-rw-r--r--app/views/shared/wikis/_sidebar.html.haml (renamed from app/views/projects/wikis/_sidebar.html.haml)6
-rw-r--r--app/views/shared/wikis/_sidebar_wiki_page.html.haml (renamed from app/views/projects/wikis/_sidebar_wiki_page.html.haml)2
-rw-r--r--app/views/shared/wikis/_wiki_directory.html.haml (renamed from app/views/projects/wikis/_wiki_directory.html.haml)0
-rw-r--r--app/views/shared/wikis/_wiki_page.html.haml1
-rw-r--r--app/views/shared/wikis/edit.html.haml (renamed from app/views/projects/wikis/edit.html.haml)14
-rw-r--r--app/views/shared/wikis/empty.html.haml (renamed from app/views/projects/wikis/empty.html.haml)0
-rw-r--r--app/views/shared/wikis/history.html.haml (renamed from app/views/projects/wikis/history.html.haml)7
-rw-r--r--app/views/shared/wikis/pages.html.haml (renamed from app/views/projects/wikis/pages.html.haml)10
-rw-r--r--app/views/shared/wikis/show.html.haml (renamed from app/views/projects/wikis/show.html.haml)12
-rw-r--r--app/views/snippets/_actions.html.haml2
-rw-r--r--app/views/u2f/_authenticate.html.haml19
-rw-r--r--app/views/u2f/_register.html.haml2
-rw-r--r--app/views/users/_deletion_guidance.html.haml2
-rw-r--r--app/views/users/terms/index.html.haml6
-rw-r--r--app/workers/all_queues.yml259
-rw-r--r--app/workers/authorized_keys_worker.rb1
-rw-r--r--app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb1
-rw-r--r--app/workers/authorized_projects_worker.rb2
-rw-r--r--app/workers/background_migration_worker.rb1
-rw-r--r--app/workers/build_finished_worker.rb1
-rw-r--r--app/workers/ci/build_report_result_worker.rb16
-rw-r--r--app/workers/cleanup_container_repository_worker.rb1
-rw-r--r--app/workers/cluster_install_app_worker.rb1
-rw-r--r--app/workers/cluster_patch_app_worker.rb1
-rw-r--r--app/workers/cluster_update_app_worker.rb1
-rw-r--r--app/workers/cluster_upgrade_app_worker.rb1
-rw-r--r--app/workers/cluster_wait_for_app_installation_worker.rb1
-rw-r--r--app/workers/cluster_wait_for_app_update_worker.rb2
-rw-r--r--app/workers/cluster_wait_for_ingress_ip_address_worker.rb1
-rw-r--r--app/workers/clusters/applications/activate_service_worker.rb2
-rw-r--r--app/workers/clusters/applications/check_prometheus_health_worker.rb30
-rw-r--r--app/workers/clusters/applications/deactivate_service_worker.rb2
-rw-r--r--app/workers/clusters/applications/uninstall_worker.rb1
-rw-r--r--app/workers/clusters/applications/wait_for_uninstall_app_worker.rb1
-rw-r--r--app/workers/concerns/application_worker.rb27
-rw-r--r--app/workers/concerns/gitlab/github_import/queue.rb11
-rw-r--r--app/workers/concerns/gitlab/jira_import/import_worker.rb2
-rw-r--r--app/workers/concerns/project_import_options.rb2
-rw-r--r--app/workers/concerns/reactive_cacheable_worker.rb1
-rw-r--r--app/workers/concerns/worker_attributes.rb22
-rw-r--r--app/workers/container_expiration_policy_worker.rb2
-rw-r--r--app/workers/create_commit_signature_worker.rb4
-rw-r--r--app/workers/create_evidence_worker.rb9
-rw-r--r--app/workers/create_pipeline_worker.rb1
-rw-r--r--app/workers/delete_stored_files_worker.rb1
-rw-r--r--app/workers/delete_user_worker.rb3
-rw-r--r--app/workers/email_receiver_worker.rb2
-rw-r--r--app/workers/expire_build_instance_artifacts_worker.rb2
-rw-r--r--app/workers/export_csv_worker.rb1
-rw-r--r--app/workers/file_hook_worker.rb1
-rw-r--r--app/workers/git_garbage_collect_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb1
-rw-r--r--app/workers/gitlab/import/stuck_import_job.rb82
-rw-r--r--app/workers/gitlab/import/stuck_project_import_jobs_worker.rb22
-rw-r--r--app/workers/gitlab/jira_import/import_issue_worker.rb6
-rw-r--r--app/workers/gitlab/jira_import/stage/start_import_worker.rb1
-rw-r--r--app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb21
-rw-r--r--app/workers/gitlab/phabricator_import/base_worker.rb2
-rw-r--r--app/workers/gitlab_shell_worker.rb1
-rw-r--r--app/workers/group_export_worker.rb1
-rw-r--r--app/workers/group_import_worker.rb9
-rw-r--r--app/workers/hashed_storage/project_migrate_worker.rb1
-rw-r--r--app/workers/hashed_storage/project_rollback_worker.rb1
-rw-r--r--app/workers/incident_management/process_prometheus_alert_worker.rb16
-rw-r--r--app/workers/irker_worker.rb117
-rw-r--r--app/workers/mail_scheduler/notification_service_worker.rb1
-rw-r--r--app/workers/merge_worker.rb1
-rw-r--r--app/workers/metrics/dashboard/prune_old_annotations_worker.rb23
-rw-r--r--app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb23
-rw-r--r--app/workers/new_note_worker.rb9
-rw-r--r--app/workers/object_storage/background_move_worker.rb1
-rw-r--r--app/workers/object_storage/migrate_uploads_worker.rb1
-rw-r--r--app/workers/pages_worker.rb1
-rw-r--r--app/workers/personal_access_tokens/expiring_worker.rb4
-rw-r--r--app/workers/pipeline_notification_worker.rb5
-rw-r--r--app/workers/pipeline_process_worker.rb1
-rw-r--r--app/workers/pipeline_update_ci_ref_status_worker.rb1
-rw-r--r--app/workers/post_receive.rb1
-rw-r--r--app/workers/process_commit_worker.rb2
-rw-r--r--app/workers/project_cache_worker.rb4
-rw-r--r--app/workers/project_export_worker.rb1
-rw-r--r--app/workers/propagate_integration_worker.rb16
-rw-r--r--app/workers/rebase_worker.rb1
-rw-r--r--app/workers/remove_expired_group_links_worker.rb6
-rw-r--r--app/workers/repository_check/batch_worker.rb2
-rw-r--r--app/workers/repository_import_worker.rb7
-rw-r--r--app/workers/repository_remove_remote_worker.rb1
-rw-r--r--app/workers/repository_update_remote_mirror_worker.rb1
-rw-r--r--app/workers/stuck_import_jobs_worker.rb70
-rw-r--r--app/workers/todos_destroyer/entity_leave_worker.rb2
-rw-r--r--app/workers/update_external_pull_requests_worker.rb1
-rw-r--r--app/workers/update_merge_requests_worker.rb1
-rw-r--r--app/workers/web_hook_worker.rb1
1461 files changed, 26389 insertions, 8940 deletions
diff --git a/app/assets/images/aws_logo.svg b/app/assets/images/aws_logo.svg
new file mode 100644
index 00000000000..e028fd1b1c0
--- /dev/null
+++ b/app/assets/images/aws_logo.svg
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 304 182" style="enable-background:new 0 0 304 182;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#252F3E;}
+ .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FF9900;}
+</style>
+<g>
+ <path class="st0" d="M86.4,66.4c0,3.7,0.4,6.7,1.1,8.9c0.8,2.2,1.8,4.6,3.2,7.2c0.5,0.8,0.7,1.6,0.7,2.3c0,1-0.6,2-1.9,3l-6.3,4.2
+ c-0.9,0.6-1.8,0.9-2.6,0.9c-1,0-2-0.5-3-1.4C76.2,90,75,88.4,74,86.8c-1-1.7-2-3.6-3.1-5.9c-7.8,9.2-17.6,13.8-29.4,13.8
+ c-8.4,0-15.1-2.4-20-7.2c-4.9-4.8-7.4-11.2-7.4-19.2c0-8.5,3-15.4,9.1-20.6c6.1-5.2,14.2-7.8,24.5-7.8c3.4,0,6.9,0.3,10.6,0.8
+ c3.7,0.5,7.5,1.3,11.5,2.2v-7.3c0-7.6-1.6-12.9-4.7-16c-3.2-3.1-8.6-4.6-16.3-4.6c-3.5,0-7.1,0.4-10.8,1.3c-3.7,0.9-7.3,2-10.8,3.4
+ c-1.6,0.7-2.8,1.1-3.5,1.3c-0.7,0.2-1.2,0.3-1.6,0.3c-1.4,0-2.1-1-2.1-3.1v-4.9c0-1.6,0.2-2.8,0.7-3.5c0.5-0.7,1.4-1.4,2.8-2.1
+ c3.5-1.8,7.7-3.3,12.6-4.5c4.9-1.3,10.1-1.9,15.6-1.9c11.9,0,20.6,2.7,26.2,8.1c5.5,5.4,8.3,13.6,8.3,24.6V66.4z M45.8,81.6
+ c3.3,0,6.7-0.6,10.3-1.8c3.6-1.2,6.8-3.4,9.5-6.4c1.6-1.9,2.8-4,3.4-6.4c0.6-2.4,1-5.3,1-8.7v-4.2c-2.9-0.7-6-1.3-9.2-1.7
+ c-3.2-0.4-6.3-0.6-9.4-0.6c-6.7,0-11.6,1.3-14.9,4c-3.3,2.7-4.9,6.5-4.9,11.5c0,4.7,1.2,8.2,3.7,10.6
+ C37.7,80.4,41.2,81.6,45.8,81.6z M126.1,92.4c-1.8,0-3-0.3-3.8-1c-0.8-0.6-1.5-2-2.1-3.9L96.7,10.2c-0.6-2-0.9-3.3-0.9-4
+ c0-1.6,0.8-2.5,2.4-2.5h9.8c1.9,0,3.2,0.3,3.9,1c0.8,0.6,1.4,2,2,3.9l16.8,66.2l15.6-66.2c0.5-2,1.1-3.3,1.9-3.9c0.8-0.6,2.2-1,4-1
+ h8c1.9,0,3.2,0.3,4,1c0.8,0.6,1.5,2,1.9,3.9l15.8,67l17.3-67c0.6-2,1.3-3.3,2-3.9c0.8-0.6,2.1-1,3.9-1h9.3c1.6,0,2.5,0.8,2.5,2.5
+ c0,0.5-0.1,1-0.2,1.6c-0.1,0.6-0.3,1.4-0.7,2.5l-24.1,77.3c-0.6,2-1.3,3.3-2.1,3.9c-0.8,0.6-2.1,1-3.8,1h-8.6c-1.9,0-3.2-0.3-4-1
+ c-0.8-0.7-1.5-2-1.9-4L156,23l-15.4,64.4c-0.5,2-1.1,3.3-1.9,4c-0.8,0.7-2.2,1-4,1H126.1z M254.6,95.1c-5.2,0-10.4-0.6-15.4-1.8
+ c-5-1.2-8.9-2.5-11.5-4c-1.6-0.9-2.7-1.9-3.1-2.8c-0.4-0.9-0.6-1.9-0.6-2.8v-5.1c0-2.1,0.8-3.1,2.3-3.1c0.6,0,1.2,0.1,1.8,0.3
+ c0.6,0.2,1.5,0.6,2.5,1c3.4,1.5,7.1,2.7,11,3.5c4,0.8,7.9,1.2,11.9,1.2c6.3,0,11.2-1.1,14.6-3.3c3.4-2.2,5.2-5.4,5.2-9.5
+ c0-2.8-0.9-5.1-2.7-7c-1.8-1.9-5.2-3.6-10.1-5.2L246,52c-7.3-2.3-12.7-5.7-16-10.2c-3.3-4.4-5-9.3-5-14.5c0-4.2,0.9-7.9,2.7-11.1
+ c1.8-3.2,4.2-6,7.2-8.2c3-2.3,6.4-4,10.4-5.2c4-1.2,8.2-1.7,12.6-1.7c2.2,0,4.5,0.1,6.7,0.4c2.3,0.3,4.4,0.7,6.5,1.1
+ c2,0.5,3.9,1,5.7,1.6c1.8,0.6,3.2,1.2,4.2,1.8c1.4,0.8,2.4,1.6,3,2.5c0.6,0.8,0.9,1.9,0.9,3.3v4.7c0,2.1-0.8,3.2-2.3,3.2
+ c-0.8,0-2.1-0.4-3.8-1.2c-5.7-2.6-12.1-3.9-19.2-3.9c-5.7,0-10.2,0.9-13.3,2.8c-3.1,1.9-4.7,4.8-4.7,8.9c0,2.8,1,5.2,3,7.1
+ c2,1.9,5.7,3.8,11,5.5l14.2,4.5c7.2,2.3,12.4,5.5,15.5,9.6c3.1,4.1,4.6,8.8,4.6,14c0,4.3-0.9,8.2-2.6,11.6
+ c-1.8,3.4-4.2,6.4-7.3,8.8c-3.1,2.5-6.8,4.3-11.1,5.6C264.4,94.4,259.7,95.1,254.6,95.1z"/>
+ <g>
+ <path class="st1" d="M273.5,143.7c-32.9,24.3-80.7,37.2-121.8,37.2c-57.6,0-109.5-21.3-148.7-56.7c-3.1-2.8-0.3-6.6,3.4-4.4
+ c42.4,24.6,94.7,39.5,148.8,39.5c36.5,0,76.6-7.6,113.5-23.2C274.2,133.6,278.9,139.7,273.5,143.7z"/>
+ <path class="st1" d="M287.2,128.1c-4.2-5.4-27.8-2.6-38.5-1.3c-3.2,0.4-3.7-2.4-0.8-4.5c18.8-13.2,49.7-9.4,53.3-5
+ c3.6,4.5-1,35.4-18.6,50.2c-2.7,2.3-5.3,1.1-4.1-1.9C282.5,155.7,291.4,133.4,287.2,128.1z"/>
+ </g>
+</g>
+</svg>
diff --git a/app/assets/images/experienced.svg b/app/assets/images/experienced.svg
new file mode 100644
index 00000000000..1c93cfcf1ee
--- /dev/null
+++ b/app/assets/images/experienced.svg
@@ -0,0 +1 @@
+<svg height="82" viewBox="0 0 78 82" width="78" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><path d="m2.12 42c-.08 1-.12 2-.12 3 0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3-1.53 19.03-17.46 34-36.88 34s-35.35-14.97-36.88-34z" fill="#000" fill-opacity=".03"/><path d="m39 78c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z" fill="#eee"/><path d="m44 31-2.5-3-2.5 3-2.5-3-2.5 3-2.5-3-2.5 3h-2.72c2.65-4.2 7.36-7 12.72-7s10.07 2.8 12.72 7h-2.72l-2.5-3z" fill="#e1dbf2"/><path d="m39 57c-9.4 0-17-7.6-17-17s7.6-17 17-17 17 7.6 17 17-7.6 17-17 17zm0-4c7.18 0 13-5.82 13-13s-5.82-13-13-13-13 5.82-13 13 5.82 13 13 13z" fill="#e1dbf2"/></g><g fill="#e2ba3e" stroke="#fff" transform="translate(14 18)"><path d="m8.5 12.75-4.99617464 2.6266445.95418445-5.56332227-4.0419902-3.93996668 5.58589307-.81167778 2.49808732-5.06167777 2.4980873 5.06167777 5.5858931.81167778-4.0419902 3.93996668.9541844 5.56332227z"/><path d="m24.5 12.75-4.9961746 2.6266445.9541844-5.56332227-4.0419902-3.93996668 5.5858931-.81167778 2.4980873-5.06167777 2.4980873 5.06167777 5.5858931.81167778-4.0419902 3.93996668.9541844 5.56332227z"/><path d="m40.5 12.75-4.9961746 2.6266445.9541844-5.56332227-4.0419902-3.93996668 5.5858931-.81167778 2.4980873-5.06167777 2.4980873 5.06167777 5.5858931.81167778-4.0419902 3.93996668.9541844 5.56332227z"/></g><path d="m35 45h8c0 2.2-1.8 4-4 4s-4-1.8-4-4zm-1.5-2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z" fill="#6b4fbb" fill-rule="nonzero"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/learn-gitlab-avatar.jpg b/app/assets/images/learn-gitlab-avatar.jpg
new file mode 100644
index 00000000000..65ec29444cb
--- /dev/null
+++ b/app/assets/images/learn-gitlab-avatar.jpg
Binary files differ
diff --git a/app/assets/images/novice.svg b/app/assets/images/novice.svg
new file mode 100644
index 00000000000..c6744fa4550
--- /dev/null
+++ b/app/assets/images/novice.svg
@@ -0,0 +1 @@
+<svg width="78" height="82" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M2.12 42C2.04 43 2 44 2 45c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 61.03 58.42 76 39 76S3.65 61.03 2.12 42z" fill-opacity=".03" fill="#000" fill-rule="nonzero"/><path d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z" fill="#EEE" fill-rule="nonzero"/><path d="M44 31l-2.5-3-2.5 3-2.5-3-2.5 3-2.5-3-2.5 3h-2.72c2.65-4.2 7.36-7 12.72-7s10.07 2.8 12.72 7H49l-2.5-3-2.5 3z" fill="#E1DBF2" fill-rule="nonzero"/><path d="M39 57c-9.4 0-17-7.6-17-17s7.6-17 17-17 17 7.6 17 17-7.6 17-17 17zm0-4c7.18 0 13-5.82 13-13s-5.82-13-13-13-13 5.82-13 13 5.82 13 13 13z" fill="#E1DBF2" fill-rule="nonzero"/><g transform="translate(20 13)"><path d="M5.731 15.24V8.736H33.75v6.504c0 4.851-6.272 8.784-14.01 8.784-7.737 0-14.009-3.933-14.009-8.784z" fill="#0B2630"/><path d="M.75 7.662L18.824.182a2.4 2.4 0 0 1 1.835 0l18.072 7.48a1.2 1.2 0 0 1 0 2.218l-18.072 7.48a2.4 2.4 0 0 1-1.835 0L.75 9.88a1.2 1.2 0 0 1 0-2.218z" fill="#29424E"/><path d="M19.295 9.771a1.194 1.194 0 0 1-.66-1.557c.248-.612.948-.907 1.562-.659l11.516 4.657v11.766c0 .66-.538 1.195-1.2 1.195-.663 0-1.2-.535-1.2-1.195V13.822l-10.018-4.05z" fill="#FFCF00" fill-rule="nonzero"/><path d="M32.613 23.373v3.807h-4.2v-3.807c0-.711.353-1.34.894-1.72h2.411c.541.38.895 1.009.895 1.72z" fill="#FCA326"/><path fill="#FFCF00" d="M28.413 23.592H32.613V27.18H28.413z"/></g><path d="M35 45h8c0 2.2-1.8 4-4 4s-4-1.8-4-4zm-1.5-2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z" fill="#6B4FBB" fill-rule="nonzero"/></g></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
index 89db7db77d5..ed6b4b7fdb2 100644
--- a/app/assets/javascripts/alert_management/components/alert_details.vue
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -2,31 +2,32 @@
import * as Sentry from '@sentry/browser';
import {
GlAlert,
+ GlBadge,
GlIcon,
GlLoadingIcon,
- GlDropdown,
- GlDropdownItem,
GlSprintf,
GlTabs,
GlTab,
GlButton,
GlTable,
} from '@gitlab/ui';
-import createFlash from '~/flash';
import { s__ } from '~/locale';
import query from '../graphql/queries/details.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { ALERTS_SEVERITY_LABELS } from '../constants';
-import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
+import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
+import initUserPopovers from '~/user_popovers';
+import { ALERTS_SEVERITY_LABELS, trackAlertsDetailsViewsOptions } from '../constants';
+import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql';
+import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
+import Tracking from '~/tracking';
+import { toggleContainerClasses } from '~/lib/utils/dom_utils';
+import SystemNote from './system_notes/system_note.vue';
+import AlertSidebar from './alert_sidebar.vue';
+
+const containerEl = document.querySelector('.page-with-contextual-sidebar');
export default {
- statuses: {
- TRIGGERED: s__('AlertManagement|Triggered'),
- ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
- RESOLVED: s__('AlertManagement|Resolved'),
- },
i18n: {
errorMsg: s__(
'AlertManagement|There was an error displaying the alert. Please refresh the page to try again.',
@@ -38,19 +39,19 @@ export default {
},
severityLabels: ALERTS_SEVERITY_LABELS,
components: {
+ GlBadge,
GlAlert,
GlIcon,
GlLoadingIcon,
GlSprintf,
- GlDropdown,
- GlDropdownItem,
GlTab,
GlTabs,
GlButton,
GlTable,
TimeAgoTooltip,
+ AlertSidebar,
+ SystemNote,
},
- mixins: [glFeatureFlagsMixin()],
props: {
alertId: {
type: String,
@@ -60,7 +61,7 @@ export default {
type: String,
required: true,
},
- newIssuePath: {
+ projectIssuesPath: {
type: String,
required: true,
},
@@ -85,7 +86,15 @@ export default {
},
},
data() {
- return { alert: null, errored: false, isErrorDismissed: false };
+ return {
+ alert: null,
+ errored: false,
+ isErrorDismissed: false,
+ createIssueError: '',
+ issueCreationInProgress: false,
+ sidebarCollapsed: false,
+ sidebarErrorMessage: '',
+ };
},
computed: {
loading() {
@@ -100,38 +109,92 @@ export default {
return this.errored && !this.isErrorDismissed;
},
},
+ mounted() {
+ this.trackPageViews();
+ toggleContainerClasses(containerEl, {
+ 'issuable-bulk-update-sidebar': true,
+ 'right-sidebar-expanded': true,
+ });
+ },
+ updated() {
+ this.$nextTick(() => {
+ highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ });
+ },
methods: {
dismissError() {
this.isErrorDismissed = true;
+ this.sidebarErrorMessage = '';
},
- updateAlertStatus(status) {
+ toggleSidebar() {
+ this.sidebarCollapsed = !this.sidebarCollapsed;
+ toggleContainerClasses(containerEl, {
+ 'right-sidebar-collapsed': this.sidebarCollapsed,
+ 'right-sidebar-expanded': !this.sidebarCollapsed,
+ });
+ },
+ handleAlertSidebarError(errorMessage) {
+ this.errored = true;
+ this.sidebarErrorMessage = errorMessage;
+ },
+ createIssue() {
+ this.issueCreationInProgress = true;
+
this.$apollo
.mutate({
- mutation: updateAlertStatus,
+ mutation: createIssueQuery,
variables: {
- iid: this.alertId,
- status: status.toUpperCase(),
+ iid: this.alert.iid,
projectPath: this.projectPath,
},
})
- .catch(() => {
- createFlash(
- s__(
- 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
- ),
- );
+ .then(({ data: { createAlertIssue: { errors, issue } } }) => {
+ if (errors?.length) {
+ [this.createIssueError] = errors;
+ this.issueCreationInProgress = false;
+ } else if (issue) {
+ visitUrl(this.issuePath(issue.iid));
+ }
+ })
+ .catch(error => {
+ this.createIssueError = error;
+ this.issueCreationInProgress = false;
});
},
+ issuePath(issueId) {
+ return joinPaths(this.projectIssuesPath, issueId);
+ },
+ trackPageViews() {
+ const { category, action } = trackAlertsDetailsViewsOptions;
+ Tracking.event(category, action);
+ },
+ alertRefresh() {
+ this.$apollo.queries.alert.refetch();
+ },
},
};
</script>
+
<template>
<div>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
- {{ $options.i18n.errorMsg }}
+ {{ sidebarErrorMessage || $options.i18n.errorMsg }}
+ </gl-alert>
+ <gl-alert
+ v-if="createIssueError"
+ variant="danger"
+ data-testid="issueCreationError"
+ @dismiss="createIssueError = null"
+ >
+ {{ createIssueError }}
</gl-alert>
<div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div>
- <div v-if="alert" class="alert-management-details gl-relative">
+ <div
+ v-if="alert"
+ class="alert-management-details gl-relative"
+ :class="{ 'pr-sm-8': sidebarCollapsed }"
+ >
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid flex-column flex-sm-row"
>
@@ -142,32 +205,50 @@ export default {
<div
class="gl-display-inline-flex gl-align-items-center gl-justify-content-space-between"
>
- <gl-icon
- class="gl-mr-3 align-middle"
- :size="12"
- :name="`severity-${alert.severity.toLowerCase()}`"
- :class="`icon-${alert.severity.toLowerCase()}`"
- />
- <strong>{{ $options.severityLabels[alert.severity] }}</strong>
+ <gl-badge class="gl-mr-3">
+ <strong>{{ s__('AlertManagement|Alert') }}</strong>
+ </gl-badge>
</div>
- <span class="mx-2">&bull;</span>
- <gl-sprintf :message="reportedAtMessage">
- <template #when>
- <time-ago-tooltip :time="alert.createdAt" class="gl-ml-3" />
- </template>
- <template #tool>{{ alert.monitoringTool }}</template>
- </gl-sprintf>
+ <span>
+ <gl-sprintf :message="reportedAtMessage">
+ <template #when>
+ <time-ago-tooltip :time="alert.createdAt" />
+ </template>
+ <template #tool>{{ alert.monitoringTool }}</template>
+ </gl-sprintf>
+ </span>
</div>
<gl-button
- v-if="glFeatures.createIssueFromAlertEnabled"
- class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-create-issue-button"
+ v-if="alert.issueIid"
+ class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-issue-button"
+ data-testid="viewIssueBtn"
+ :href="issuePath(alert.issueIid)"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('AlertManagement|View issue') }}
+ </gl-button>
+ <gl-button
+ v-else
+ class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-issue-button"
data-testid="createIssueBtn"
- :href="newIssuePath"
+ :loading="issueCreationInProgress"
category="primary"
variant="success"
+ @click="createIssue()"
>
{{ s__('AlertManagement|Create issue') }}
</gl-button>
+ <gl-button
+ :aria-label="__('Toggle sidebar')"
+ category="primary"
+ variant="default"
+ class="d-sm-none gl-absolute toggle-sidebar-mobile-button"
+ type="button"
+ @click="toggleSidebar"
+ >
+ <i class="fa fa-angle-double-left"></i>
+ </gl-button>
</div>
<div
v-if="alert"
@@ -175,44 +256,57 @@ export default {
>
<h2 data-testid="title">{{ alert.title }}</h2>
</div>
- <gl-dropdown :text="$options.statuses[alert.status]" class="gl-absolute gl-right-0" right>
- <gl-dropdown-item
- v-for="(label, field) in $options.statuses"
- :key="field"
- data-testid="statusDropdownItem"
- class="gl-vertical-align-middle"
- @click="updateAlertStatus(label)"
- >
- <span class="d-flex">
- <gl-icon
- class="flex-shrink-0 append-right-4"
- :class="{ invisible: label.toUpperCase() !== alert.status }"
- name="mobile-issue-close"
- />
- {{ label }}
- </span>
- </gl-dropdown-item>
- </gl-dropdown>
<gl-tabs v-if="alert" data-testid="alertDetailsTabs">
<gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle">
- <ul class="pl-4 mb-n1">
- <li v-if="alert.startedAt" class="my-2">
- <strong class="bold">{{ s__('AlertManagement|Start time') }}:</strong>
+ <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex">
+ <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
+ {{ s__('AlertManagement|Severity') }}:
+ </div>
+ <div class="gl-pl-2" data-testid="severity">
+ <span>
+ <gl-icon
+ class="gl-vertical-align-middle"
+ :size="12"
+ :name="`severity-${alert.severity.toLowerCase()}`"
+ :class="`icon-${alert.severity.toLowerCase()}`"
+ />
+ </span>
+ {{ $options.severityLabels[alert.severity] }}
+ </div>
+ </div>
+ <div v-if="alert.startedAt" class="gl-my-5 gl-display-flex">
+ <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
+ {{ s__('AlertManagement|Start time') }}:
+ </div>
+ <div class="gl-pl-2">
<time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
- </li>
- <li v-if="alert.eventCount" class="my-2">
- <strong class="bold">{{ s__('AlertManagement|Events') }}:</strong>
- <span data-testid="eventCount">{{ alert.eventCount }}</span>
- </li>
- <li v-if="alert.monitoringTool" class="my-2">
- <strong class="bold">{{ s__('AlertManagement|Tool') }}:</strong>
- <span data-testid="monitoringTool">{{ alert.monitoringTool }}</span>
- </li>
- <li v-if="alert.service" class="my-2">
- <strong class="bold">{{ s__('AlertManagement|Service') }}:</strong>
- <span data-testid="service">{{ alert.service }}</span>
- </li>
- </ul>
+ </div>
+ </div>
+ <div v-if="alert.eventCount" class="gl-my-5 gl-display-flex">
+ <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
+ {{ s__('AlertManagement|Events') }}:
+ </div>
+ <div class="gl-pl-2" data-testid="eventCount">{{ alert.eventCount }}</div>
+ </div>
+ <div v-if="alert.monitoringTool" class="gl-my-5 gl-display-flex">
+ <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
+ {{ s__('AlertManagement|Tool') }}:
+ </div>
+ <div class="gl-pl-2" data-testid="monitoringTool">{{ alert.monitoringTool }}</div>
+ </div>
+ <div v-if="alert.service" class="gl-my-5 gl-display-flex">
+ <div class="bold gl-w-13 gl-text-right gl-pr-3">
+ {{ s__('AlertManagement|Service') }}:
+ </div>
+ <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div>
+ </div>
+ <template>
+ <div v-if="alert.notes.nodes" class="issuable-discussion py-5">
+ <ul class="notes main-notes-list timeline">
+ <system-note v-for="note in alert.notes.nodes" :key="note.id" :note="note" />
+ </ul>
+ </div>
+ </template>
</gl-tab>
<gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle">
<gl-table
@@ -231,6 +325,14 @@ export default {
</gl-table>
</gl-tab>
</gl-tabs>
+ <alert-sidebar
+ :project-path="projectPath"
+ :alert="alert"
+ :sidebar-collapsed="sidebarCollapsed"
+ @alert-refresh="alertRefresh"
+ @toggle-sidebar="toggleSidebar"
+ @alert-sidebar-error="handleAlertSidebarError"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue
index 74fc19ff3d4..37901c21f9b 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_list.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue
@@ -10,23 +10,41 @@ import {
GlDropdownItem,
GlTabs,
GlTab,
+ GlBadge,
+ GlPagination,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import { fetchPolicies } from '~/lib/graphql';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import getAlerts from '../graphql/queries/getAlerts.query.graphql';
-import { ALERTS_STATUS, ALERTS_STATUS_TABS, ALERTS_SEVERITY_LABELS } from '../constants';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import getAlerts from '../graphql/queries/get_alerts.query.graphql';
+import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
+import {
+ ALERTS_STATUS_TABS,
+ ALERTS_SEVERITY_LABELS,
+ DEFAULT_PAGE_SIZE,
+ trackAlertListViewsOptions,
+ trackAlertStatusUpdateOptions,
+} from '../constants';
import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { convertToSnakeCase } from '~/lib/utils/text_utility';
+import Tracking from '~/tracking';
-const tdClass = 'table-col d-flex d-md-table-cell align-items-center';
+const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center';
+const thClass = 'gl-hover-bg-blue-50';
const bodyTrClass =
- 'gl-border-1 gl-border-t-solid gl-border-gray-100 hover-bg-blue-50 hover-gl-cursor-pointer hover-gl-border-b-solid hover-gl-border-blue-200';
+ 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200';
+
+const initialPaginationState = {
+ currentPage: 1,
+ prevPageCursor: '',
+ nextPageCursor: '',
+ firstPageSize: DEFAULT_PAGE_SIZE,
+ lastPageSize: null,
+};
export default {
- bodyTrClass,
i18n: {
noAlertsMsg: s__(
"AlertManagement|No alerts available to display. If you think you're seeing this message in error, refresh the page.",
@@ -40,40 +58,54 @@ export default {
key: 'severity',
label: s__('AlertManagement|Severity'),
tdClass: `${tdClass} rounded-top text-capitalize`,
+ thClass,
+ sortable: true,
},
{
key: 'startedAt',
label: s__('AlertManagement|Start time'),
+ thClass: `${thClass} js-started-at`,
tdClass,
+ sortable: true,
},
{
key: 'endedAt',
label: s__('AlertManagement|End time'),
+ thClass,
tdClass,
+ sortable: true,
},
{
key: 'title',
label: s__('AlertManagement|Alert'),
- thClass: 'w-30p',
+ thClass: `${thClass} w-30p gl-pointer-events-none`,
tdClass,
+ sortable: false,
},
{
key: 'eventCount',
label: s__('AlertManagement|Events'),
- thClass: 'text-right event-count',
- tdClass: `${tdClass} text-md-right event-count`,
+ thClass: `${thClass} text-right gl-pr-9 w-3rem`,
+ tdClass: `${tdClass} text-md-right`,
+ sortable: true,
+ },
+ {
+ key: 'assignees',
+ label: s__('AlertManagement|Assignees'),
+ tdClass,
},
{
key: 'status',
- thClass: 'w-15p',
+ thClass: `${thClass} w-15p`,
label: s__('AlertManagement|Status'),
tdClass: `${tdClass} rounded-bottom`,
+ sortable: true,
},
],
statuses: {
- [ALERTS_STATUS.TRIGGERED]: s__('AlertManagement|Triggered'),
- [ALERTS_STATUS.ACKNOWLEDGED]: s__('AlertManagement|Acknowledged'),
- [ALERTS_STATUS.RESOLVED]: s__('AlertManagement|Resolved'),
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
},
severityLabels: ALERTS_SEVERITY_LABELS,
statusTabs: ALERTS_STATUS_TABS,
@@ -89,8 +121,9 @@ export default {
GlIcon,
GlTabs,
GlTab,
+ GlBadge,
+ GlPagination,
},
- mixins: [glFeatureFlagsMixin()],
props: {
projectPath: {
type: String,
@@ -115,33 +148,63 @@ export default {
},
apollo: {
alerts: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getAlerts,
variables() {
return {
projectPath: this.projectPath,
statuses: this.statusFilter,
+ sort: this.sort,
+ firstPageSize: this.pagination.firstPageSize,
+ lastPageSize: this.pagination.lastPageSize,
+ prevPageCursor: this.pagination.prevPageCursor,
+ nextPageCursor: this.pagination.nextPageCursor,
};
},
update(data) {
- return data.project.alertManagementAlerts.nodes;
+ const { alertManagementAlerts: { nodes: list = [], pageInfo = {} } = {} } =
+ data.project || {};
+
+ return {
+ list,
+ pageInfo,
+ };
},
error() {
this.errored = true;
},
},
+ alertsCount: {
+ query: getAlertsCountByStatus,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data.project?.alertManagementAlertStatusCounts;
+ },
+ },
},
data() {
return {
- alerts: null,
errored: false,
isAlertDismissed: false,
isErrorAlertDismissed: false,
- statusFilter: this.$options.statusTabs[4].filters,
+ sort: 'STARTED_AT_DESC',
+ statusFilter: [],
+ filteredByStatus: '',
+ pagination: initialPaginationState,
+ sortBy: 'startedAt',
+ sortDesc: true,
+ sortDirection: 'desc',
};
},
computed: {
showNoAlertsMsg() {
- return !this.errored && !this.loading && !this.alerts?.length && !this.isAlertDismissed;
+ return (
+ !this.errored && !this.loading && this.alertsCount?.all === 0 && !this.isAlertDismissed
+ );
},
showErrorMsg() {
return this.errored && !this.isErrorAlertDismissed;
@@ -149,12 +212,43 @@ export default {
loading() {
return this.$apollo.queries.alerts.loading;
},
+ hasAlerts() {
+ return this.alerts?.list?.length;
+ },
+ tbodyTrClass() {
+ return !this.loading && this.hasAlerts ? bodyTrClass : '';
+ },
+ showPaginationControls() {
+ return Boolean(this.prevPage || this.nextPage);
+ },
+ alertsForCurrentTab() {
+ return this.alertsCount ? this.alertsCount[this.filteredByStatus.toLowerCase()] : 0;
+ },
+ prevPage() {
+ return Math.max(this.pagination.currentPage - 1, 0);
+ },
+ nextPage() {
+ const nextPage = this.pagination.currentPage + 1;
+ return nextPage > Math.ceil(this.alertsForCurrentTab / DEFAULT_PAGE_SIZE) ? null : nextPage;
+ },
+ },
+ mounted() {
+ this.trackPageViews();
},
methods: {
filterAlertsByStatus(tabIndex) {
- this.statusFilter = this.$options.statusTabs[tabIndex].filters;
+ this.resetPagination();
+ const { filters, status } = this.$options.statusTabs[tabIndex];
+ this.statusFilter = filters;
+ this.filteredByStatus = status;
+ },
+ fetchSortedData({ sortBy, sortDesc }) {
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
+
+ this.resetPagination();
+ this.sort = `${sortingColumn}_${sortingDirection}`;
},
- capitalizeFirstCharacter,
updateAlertStatus(status, iid) {
this.$apollo
.mutate({
@@ -166,7 +260,10 @@ export default {
},
})
.then(() => {
+ this.trackStatusUpdate(status);
this.$apollo.queries.alerts.refetch();
+ this.$apollo.queries.alertsCount.refetch();
+ this.resetPagination();
})
.catch(() => {
createFlash(
@@ -179,6 +276,42 @@ export default {
navigateToAlertDetails({ iid }) {
return visitUrl(joinPaths(window.location.pathname, iid, 'details'));
},
+ trackPageViews() {
+ const { category, action } = trackAlertListViewsOptions;
+ Tracking.event(category, action);
+ },
+ trackStatusUpdate(status) {
+ const { category, action, label } = trackAlertStatusUpdateOptions;
+ Tracking.event(category, action, { label, property: status });
+ },
+ getAssignees(assignees) {
+ // TODO: Update to show list of assignee(s) after https://gitlab.com/gitlab-org/gitlab/-/issues/218405
+ return assignees.nodes?.length > 0
+ ? assignees.nodes[0]?.username
+ : s__('AlertManagement|Unassigned');
+ },
+ handlePageChange(page) {
+ const { startCursor, endCursor } = this.alerts.pageInfo;
+
+ if (page > this.pagination.currentPage) {
+ this.pagination = {
+ ...initialPaginationState,
+ nextPageCursor: endCursor,
+ currentPage: page,
+ };
+ } else {
+ this.pagination = {
+ lastPageSize: DEFAULT_PAGE_SIZE,
+ firstPageSize: null,
+ prevPageCursor: startCursor,
+ nextPageCursor: '',
+ currentPage: page,
+ };
+ }
+ },
+ resetPagination() {
+ this.pagination = initialPaginationState;
+ },
},
};
</script>
@@ -192,10 +325,13 @@ export default {
{{ $options.i18n.errorMsg }}
</gl-alert>
- <gl-tabs v-if="glFeatures.alertListStatusFilteringEnabled" @input="filterAlertsByStatus">
+ <gl-tabs @input="filterAlertsByStatus">
<gl-tab v-for="tab in $options.statusTabs" :key="tab.status">
<template slot="title">
<span>{{ tab.title }}</span>
+ <gl-badge v-if="alertsCount" pill size="sm" class="gl-tab-counter-badge">
+ {{ alertsCount[tab.status.toLowerCase()] }}
+ </gl-badge>
</template>
</gl-tab>
</gl-tabs>
@@ -205,13 +341,19 @@ export default {
</h4>
<gl-table
class="alert-management-table mt-3"
- :items="alerts"
+ :items="alerts ? alerts.list : []"
:fields="$options.fields"
:show-empty="true"
:busy="loading"
stacked="md"
- :tbody-tr-class="$options.bodyTrClass"
+ :tbody-tr-class="tbodyTrClass"
+ :no-local-sorting="true"
+ :sort-direction="sortDirection"
+ :sort-desc.sync="sortDesc"
+ :sort-by.sync="sortBy"
+ sort-icon-left
@row-clicked="navigateToAlertDetails"
+ @sort-changed="fetchSortedData"
>
<template #cell(severity)="{ item }">
<div
@@ -236,16 +378,22 @@ export default {
<time-ago v-if="item.endedAt" :time="item.endedAt" />
</template>
+ <template #cell(eventCount)="{ item }">
+ {{ item.eventCount }}
+ </template>
+
<template #cell(title)="{ item }">
<div class="gl-max-w-full text-truncate">{{ item.title }}</div>
</template>
+ <template #cell(assignees)="{ item }">
+ <div class="gl-max-w-full text-truncate" data-testid="assigneesField">
+ {{ getAssignees(item.assignees) }}
+ </div>
+ </template>
+
<template #cell(status)="{ item }">
- <gl-dropdown
- :text="capitalizeFirstCharacter(item.status.toLowerCase())"
- class="w-100"
- right
- >
+ <gl-dropdown :text="$options.statuses[item.status]" class="w-100" right>
<gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
@@ -271,6 +419,16 @@ export default {
<gl-loading-icon size="lg" color="dark" class="mt-3" />
</template>
</gl-table>
+
+ <gl-pagination
+ v-if="showPaginationControls"
+ :value="pagination.currentPage"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination prepend-top-default"
+ @input="handlePageChange"
+ />
</div>
<gl-empty-state
v-else
diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue
new file mode 100644
index 00000000000..dcd22e2062e
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue
@@ -0,0 +1,61 @@
+<script>
+import SidebarHeader from './sidebar/sidebar_header.vue';
+import SidebarTodo from './sidebar/sidebar_todo.vue';
+import SidebarStatus from './sidebar/sidebar_status.vue';
+import SidebarAssignees from './sidebar/sidebar_assignees.vue';
+
+export default {
+ components: {
+ SidebarAssignees,
+ SidebarHeader,
+ SidebarTodo,
+ SidebarStatus,
+ },
+ props: {
+ sidebarCollapsed: {
+ type: Boolean,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alert: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ sidebarCollapsedClass() {
+ return this.sidebarCollapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded';
+ },
+ },
+};
+</script>
+
+<template>
+ <aside :class="sidebarCollapsedClass" class="right-sidebar alert-sidebar">
+ <div class="issuable-sidebar js-issuable-update">
+ <sidebar-header
+ :sidebar-collapsed="sidebarCollapsed"
+ @toggle-sidebar="$emit('toggle-sidebar')"
+ />
+ <sidebar-todo v-if="sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" />
+ <sidebar-status
+ :project-path="projectPath"
+ :alert="alert"
+ @toggle-sidebar="$emit('toggle-sidebar')"
+ @alert-sidebar-error="$emit('alert-sidebar-error', $event)"
+ />
+ <sidebar-assignees
+ :project-path="projectPath"
+ :alert="alert"
+ :sidebar-collapsed="sidebarCollapsed"
+ @alert-refresh="$emit('alert-refresh')"
+ @toggle-sidebar="$emit('toggle-sidebar')"
+ @alert-sidebar-error="$emit('alert-sidebar-error', $event)"
+ />
+ <div class="block"></div>
+ </div>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
new file mode 100644
index 00000000000..df07038151e
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdownItem,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ isActive(name) {
+ return this.alert.assignees.nodes.some(({ username }) => username === name);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-item
+ :key="user.username"
+ data-testid="assigneeDropdownItem"
+ class="assignee-dropdown-item gl-vertical-align-middle"
+ :active="active"
+ active-class="is-active"
+ @click="$emit('update-alert-assignees', user.username)"
+ >
+ <span class="gl-relative mr-2">
+ <img
+ :alt="user.username"
+ :src="user.avatar_url"
+ :width="32"
+ class="avatar avatar-inline gl-m-0 s32"
+ data-qa-selector="avatar_image"
+ />
+ </span>
+ <span class="d-flex gl-flex-direction-column gl-overflow-hidden">
+ <strong class="dropdown-menu-user-full-name">
+ {{ user.name }}
+ </strong>
+ <span class="dropdown-menu-user-username"> {{ user.username }}</span>
+ </span>
+ </gl-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
new file mode 100644
index 00000000000..453a3901665
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
@@ -0,0 +1,278 @@
+<script>
+import {
+ GlIcon,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownHeader,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlTooltip,
+ GlButton,
+ GlSprintf,
+} from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { s__ } from '~/locale';
+import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.graphql';
+import SidebarAssignee from './sidebar_assignee.vue';
+import { debounce } from 'lodash';
+
+const DATA_REFETCH_DELAY = 250;
+
+export default {
+ FETCH_USERS_ERROR: s__(
+ 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.',
+ ),
+ UPDATE_ALERT_ASSIGNEES_ERROR: s__(
+ 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.',
+ ),
+ components: {
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownHeader,
+ GlLoadingIcon,
+ GlTooltip,
+ GlButton,
+ GlSprintf,
+ SidebarAssignee,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alert: {
+ type: Object,
+ required: true,
+ },
+ isEditable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ sidebarCollapsed: {
+ type: Boolean,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ isDropdownShowing: false,
+ isDropdownSearching: false,
+ isUpdating: false,
+ search: '',
+ users: [],
+ };
+ },
+ computed: {
+ currentUser() {
+ return gon?.current_username;
+ },
+ userName() {
+ return this.alert?.assignees?.nodes[0]?.username;
+ },
+ assignedUser() {
+ return this.userName || s__('AlertManagement|None');
+ },
+ sortedUsers() {
+ return this.users
+ .map(user => ({ ...user, active: this.isActive(user.username) }))
+ .sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); // eslint-disable-line no-nested-ternary
+ },
+ dropdownClass() {
+ return this.isDropdownShowing ? 'show' : 'gl-display-none';
+ },
+ userListValid() {
+ return !this.isDropdownSearching && this.users.length > 0;
+ },
+ userListEmpty() {
+ return !this.isDropdownSearching && this.users.length === 0;
+ },
+ },
+ watch: {
+ search: debounce(function debouncedUserSearch() {
+ this.updateAssigneesDropdown();
+ }, DATA_REFETCH_DELAY),
+ },
+ mounted() {
+ this.updateAssigneesDropdown();
+ },
+ methods: {
+ hideDropdown() {
+ this.isDropdownShowing = false;
+ },
+ toggleFormDropdown() {
+ this.isDropdownShowing = !this.isDropdownShowing;
+ const { dropdown } = this.$refs.dropdown.$refs;
+ if (dropdown && this.isDropdownShowing) {
+ dropdown.show();
+ }
+ },
+ isActive(name) {
+ return this.alert.assignees.nodes.some(({ username }) => username === name);
+ },
+ buildUrl(urlRoot, url) {
+ let newUrl;
+ if (urlRoot != null) {
+ newUrl = urlRoot.replace(/\/$/, '') + url;
+ }
+ return newUrl;
+ },
+ updateAssigneesDropdown() {
+ this.isDropdownSearching = true;
+ return axios
+ .get(this.buildUrl(gon.relative_url_root, '/autocomplete/users.json'), {
+ params: {
+ search: this.search,
+ per_page: 20,
+ active: true,
+ current_user: true,
+ project_id: gon?.current_project_id,
+ },
+ })
+ .then(({ data }) => {
+ this.users = data;
+ })
+ .catch(() => {
+ this.$emit('alert-sidebar-error', this.$options.FETCH_USERS_ERROR);
+ })
+ .finally(() => {
+ this.isDropdownSearching = false;
+ });
+ },
+ updateAlertAssignees(assignees) {
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation: alertSetAssignees,
+ variables: {
+ iid: this.alert.iid,
+ assigneeUsernames: [this.isActive(assignees) ? '' : assignees],
+ projectPath: this.projectPath,
+ },
+ })
+ .then(() => {
+ this.hideDropdown();
+ this.$emit('alert-refresh');
+ })
+ .catch(() => {
+ this.$emit('alert-sidebar-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR);
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block alert-status">
+ <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
+ <gl-icon name="user" :size="14" />
+ <gl-loading-icon v-if="isUpdating" />
+ </div>
+ <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
+ <gl-sprintf :message="s__('AlertManagement|Alert assignee(s): %{assignees}')">
+ <template #assignees>
+ {{ assignedUser }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+
+ <div class="hide-collapsed">
+ <p class="title gl-display-flex gl-justify-content-space-between">
+ {{ s__('AlertManagement|Assignee') }}
+ <a
+ v-if="isEditable"
+ ref="editButton"
+ class="btn-link"
+ href="#"
+ @click="toggleFormDropdown"
+ @keydown.esc="hideDropdown"
+ >
+ {{ s__('AlertManagement|Edit') }}
+ </a>
+ </p>
+
+ <div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
+ <gl-dropdown
+ ref="dropdown"
+ :text="assignedUser"
+ class="w-100"
+ toggle-class="dropdown-menu-toggle"
+ variant="outline-default"
+ @keydown.esc.native="hideDropdown"
+ @hide="hideDropdown"
+ >
+ <div class="dropdown-title">
+ <span class="alert-title">{{ s__('AlertManagement|Assign To') }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ class="dropdown-title-button dropdown-menu-close"
+ icon="close"
+ @click="hideDropdown"
+ />
+ </div>
+ <div class="dropdown-input">
+ <input
+ v-model.trim="search"
+ class="dropdown-input-field"
+ type="search"
+ :placeholder="__('Search users')"
+ />
+ <gl-icon name="search" class="dropdown-input-search ic-search" data-hidden="true" />
+ </div>
+ <div class="dropdown-content dropdown-body">
+ <template v-if="userListValid">
+ <gl-dropdown-item
+ :active="!userName"
+ active-class="is-active"
+ @click="updateAlertAssignees('')"
+ >
+ {{ s__('AlertManagement|Unassigned') }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+
+ <gl-dropdown-header class="mt-0">
+ {{ s__('AlertManagement|Assignee') }}
+ </gl-dropdown-header>
+ <sidebar-assignee
+ v-for="user in sortedUsers"
+ :key="user.username"
+ :user="user"
+ :active="user.active"
+ @update-alert-assignees="updateAlertAssignees"
+ />
+ </template>
+ <gl-dropdown-item v-else-if="userListEmpty">
+ {{ s__('AlertManagement|No Matching Results') }}
+ </gl-dropdown-item>
+ <gl-loading-icon v-else />
+ </div>
+ </gl-dropdown>
+ </div>
+
+ <gl-loading-icon v-if="isUpdating" :inline="true" />
+ <p v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
+ <span v-if="userName" class="gl-text-gray-700" data-testid="assigned-users">{{
+ assignedUser
+ }}</span>
+ <span v-else class="gl-display-flex gl-align-items-center">
+ {{ s__('AlertManagement|None -') }}
+ <gl-button
+ class="gl-pl-2"
+ href="#"
+ variant="link"
+ data-testid="unassigned-users"
+ @click="updateAlertAssignees(currentUser)"
+ >
+ {{ s__('AlertManagement| assign yourself') }}
+ </gl-button>
+ </span>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue
new file mode 100644
index 00000000000..047793d8cee
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue
@@ -0,0 +1,34 @@
+<script>
+import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
+import SidebarTodo from './sidebar_todo.vue';
+
+export default {
+ components: {
+ ToggleSidebar,
+ SidebarTodo,
+ },
+ props: {
+ sidebarCollapsed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block d-flex justify-content-between">
+ <span class="issuable-header-text hide-collapsed">
+ {{ __('Quick actions') }}
+ </span>
+ <toggle-sidebar
+ :collapsed="sidebarCollapsed"
+ css-classes="ml-auto"
+ @toggle="$emit('toggle-sidebar')"
+ />
+ <!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 -->
+ <template v-if="false">
+ <sidebar-todo v-if="!sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue
new file mode 100644
index 00000000000..89dbbedd9c1
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue
@@ -0,0 +1,189 @@
+<script>
+import {
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlTooltip,
+ GlButton,
+ GlSprintf,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import { trackAlertStatusUpdateOptions } from '../../constants';
+import updateAlertStatus from '../../graphql/mutations/update_alert_status.graphql';
+
+export default {
+ statuses: {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+ },
+ components: {
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlTooltip,
+ GlButton,
+ GlSprintf,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alert: {
+ type: Object,
+ required: true,
+ },
+ isEditable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ isDropdownShowing: false,
+ isUpdating: false,
+ };
+ },
+ computed: {
+ dropdownClass() {
+ return this.isDropdownShowing ? 'show' : 'gl-display-none';
+ },
+ },
+ methods: {
+ hideDropdown() {
+ this.isDropdownShowing = false;
+ },
+ toggleFormDropdown() {
+ this.isDropdownShowing = !this.isDropdownShowing;
+ const { dropdown } = this.$refs.dropdown.$refs;
+ if (dropdown && this.isDropdownShowing) {
+ dropdown.show();
+ }
+ },
+ isSelected(status) {
+ return this.alert.status === status;
+ },
+ updateAlertStatus(status) {
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation: updateAlertStatus,
+ variables: {
+ iid: this.alert.iid,
+ status: status.toUpperCase(),
+ projectPath: this.projectPath,
+ },
+ })
+ .then(() => {
+ this.trackStatusUpdate(status);
+ this.hideDropdown();
+ })
+ .catch(() => {
+ this.$emit(
+ 'alert-sidebar-error',
+ s__(
+ 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
+ ),
+ );
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ trackStatusUpdate(status) {
+ const { category, action, label } = trackAlertStatusUpdateOptions;
+ Tracking.event(category, action, { label, property: status });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block alert-status">
+ <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
+ <gl-icon name="status" :size="14" />
+ <gl-loading-icon v-if="isUpdating" />
+ </div>
+ <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
+ <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')">
+ <template #status>
+ {{ alert.status.toLowerCase() }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+
+ <div class="hide-collapsed">
+ <p class="title gl-display-flex justify-content-between">
+ {{ s__('AlertManagement|Status') }}
+ <a
+ v-if="isEditable"
+ ref="editButton"
+ class="btn-link"
+ href="#"
+ @click="toggleFormDropdown"
+ @keydown.esc="hideDropdown"
+ >
+ {{ s__('AlertManagement|Edit') }}
+ </a>
+ </p>
+
+ <div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
+ <gl-dropdown
+ ref="dropdown"
+ :text="$options.statuses[alert.status]"
+ class="w-100"
+ toggle-class="dropdown-menu-toggle"
+ variant="outline-default"
+ @keydown.esc.native="hideDropdown"
+ @hide="hideDropdown"
+ >
+ <div class="dropdown-title">
+ <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ class="dropdown-title-button dropdown-menu-close"
+ icon="close"
+ @click="hideDropdown"
+ />
+ </div>
+ <div class="dropdown-content dropdown-body">
+ <gl-dropdown-item
+ v-for="(label, field) in $options.statuses"
+ :key="field"
+ data-testid="statusDropdownItem"
+ class="gl-vertical-align-middle"
+ :active="label.toUpperCase() === alert.status"
+ :active-class="'is-active'"
+ @click="updateAlertStatus(label)"
+ >
+ {{ label }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+ </div>
+
+ <gl-loading-icon v-if="isUpdating" :inline="true" />
+ <p
+ v-else-if="!isDropdownShowing"
+ class="value gl-m-0"
+ :class="{ 'no-value': !$options.statuses[alert.status] }"
+ >
+ <span
+ v-if="$options.statuses[alert.status]"
+ class="gl-text-gray-700"
+ data-testid="status"
+ >{{ $options.statuses[alert.status] }}</span
+ >
+ <span v-else>
+ {{ s__('AlertManagement|None') }}
+ </span>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue
new file mode 100644
index 00000000000..87090165f82
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue
@@ -0,0 +1,29 @@
+<script>
+import Todo from '~/sidebar/components/todo_toggle/todo.vue';
+
+export default {
+ components: {
+ Todo,
+ },
+ props: {
+ sidebarCollapsed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 -->
+<template>
+ <div v-if="false" :class="{ 'block todo': sidebarCollapsed }">
+ <todo
+ :collapsed="sidebarCollapsed"
+ :issuable-id="1"
+ :is-todo="false"
+ :is-action-active="false"
+ issuable-type="alert"
+ @toggleTodo="() => {}"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
new file mode 100644
index 00000000000..9042d51aecf
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
@@ -0,0 +1,46 @@
+<script>
+import NoteHeader from '~/notes/components/note_header.vue';
+import { spriteIcon } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ NoteHeader,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ noteAnchorId() {
+ return `note_${this.note?.id?.split('/').pop()}`;
+ },
+ noteAuthor() {
+ const {
+ author,
+ author: { id },
+ } = this.note;
+ return { ...author, id: id?.split('/').pop() };
+ },
+ iconHtml() {
+ return spriteIcon('user');
+ },
+ },
+};
+</script>
+
+<template>
+ <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon" v-html="iconHtml"></div>
+ <div class="timeline-content">
+ <div class="note-header">
+ <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
+ <span v-html="note.bodyHtml"></span>
+ </note-header>
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js
index 9df01d9d0b5..b9670466c0f 100644
--- a/app/assets/javascripts/alert_management/constants.js
+++ b/app/assets/javascripts/alert_management/constants.js
@@ -9,38 +9,59 @@ export const ALERTS_SEVERITY_LABELS = {
UNKNOWN: s__('AlertManagement|Unknown'),
};
-export const ALERTS_STATUS = {
- OPEN: 'OPEN',
- TRIGGERED: 'TRIGGERED',
- ACKNOWLEDGED: 'ACKNOWLEDGED',
- RESOLVED: 'RESOLVED',
- ALL: 'ALL',
-};
-
export const ALERTS_STATUS_TABS = [
{
title: s__('AlertManagement|Open'),
- status: ALERTS_STATUS.OPEN,
- filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED],
+ status: 'OPEN',
+ filters: ['TRIGGERED', 'ACKNOWLEDGED'],
},
{
title: s__('AlertManagement|Triggered'),
- status: ALERTS_STATUS.TRIGGERED,
- filters: [ALERTS_STATUS.TRIGGERED],
+ status: 'TRIGGERED',
+ filters: 'TRIGGERED',
},
{
title: s__('AlertManagement|Acknowledged'),
- status: ALERTS_STATUS.ACKNOWLEDGED,
- filters: [ALERTS_STATUS.ACKNOWLEDGED],
+ status: 'ACKNOWLEDGED',
+ filters: 'ACKNOWLEDGED',
},
{
title: s__('AlertManagement|Resolved'),
- status: ALERTS_STATUS.RESOLVED,
- filters: [ALERTS_STATUS.RESOLVED],
+ status: 'RESOLVED',
+ filters: 'RESOLVED',
},
{
title: s__('AlertManagement|All alerts'),
- status: ALERTS_STATUS.ALL,
- filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED, ALERTS_STATUS.RESOLVED],
+ status: 'ALL',
+ filters: ['TRIGGERED', 'ACKNOWLEDGED', 'RESOLVED'],
},
];
+
+/* eslint-disable @gitlab/require-i18n-strings */
+
+/**
+ * Tracks snowplow event when user views alerts list
+ */
+export const trackAlertListViewsOptions = {
+ category: 'Alert Management',
+ action: 'view_alerts_list',
+};
+
+/**
+ * Tracks snowplow event when user views alert details
+ */
+export const trackAlertsDetailsViewsOptions = {
+ category: 'Alert Management',
+ action: 'view_alert_details',
+};
+
+/**
+ * Tracks snowplow event when alert status is updated
+ */
+export const trackAlertStatusUpdateOptions = {
+ category: 'Alert Management',
+ action: 'update_alert_status',
+ label: 'Status',
+};
+
+export const DEFAULT_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js
index d3523e0a29d..aa8a839ea3f 100644
--- a/app/assets/javascripts/alert_management/details.js
+++ b/app/assets/javascripts/alert_management/details.js
@@ -8,7 +8,7 @@ Vue.use(VueApollo);
export default selector => {
const domEl = document.querySelector(selector);
- const { alertId, projectPath, newIssuePath } = domEl.dataset;
+ const { alertId, projectPath, projectIssuesPath } = domEl.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
@@ -39,7 +39,7 @@ export default selector => {
props: {
alertId,
projectPath,
- newIssuePath,
+ projectIssuesPath,
},
});
},
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql
new file mode 100644
index 00000000000..c72300e9757
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql
@@ -0,0 +1,16 @@
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+
+fragment AlertNote on Note {
+ id
+ author {
+ id
+ state
+ ...Author
+ }
+ body
+ bodyHtml
+ createdAt
+ discussion {
+ id
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql
index df802616e97..cbe7e169be3 100644
--- a/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql
+++ b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql
@@ -1,4 +1,5 @@
-#import "./listItem.fragment.graphql"
+#import "./list_item.fragment.graphql"
+#import "./alert_note.fragment.graphql"
fragment AlertDetailItem on AlertManagementAlert {
...AlertListItem
@@ -8,4 +9,9 @@ fragment AlertDetailItem on AlertManagementAlert {
description
updatedAt
details
+ notes {
+ nodes {
+ ...AlertNote
+ }
+ }
}
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql
index fffe07b0cfd..746c4435f38 100644
--- a/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql
+++ b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql
@@ -6,4 +6,10 @@ fragment AlertListItem on AlertManagementAlert {
startedAt
endedAt
eventCount
+ issueIid
+ assignees {
+ nodes {
+ username
+ }
+ }
}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql
new file mode 100644
index 00000000000..efeaf8fa372
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql
@@ -0,0 +1,15 @@
+mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) {
+ alertSetAssignees(
+ input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
+ ) {
+ errors
+ alert {
+ iid
+ assignees {
+ nodes {
+ username
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql
new file mode 100644
index 00000000000..664596ab88f
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql
@@ -0,0 +1,8 @@
+mutation ($projectPath: ID!, $iid: String!) {
+ createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) {
+ errors
+ issue {
+ iid
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
index 009ae0b2930..09151f233f5 100644
--- a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
+++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
@@ -4,6 +4,7 @@ mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) {
alert {
iid,
status,
+ endedAt
}
}
}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
index 7c77715fad2..c02b8accdd1 100644
--- a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
+++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
@@ -1,4 +1,4 @@
-#import "../fragments/detailItem.fragment.graphql"
+#import "../fragments/detail_item.fragment.graphql"
query alertDetails($fullPath: ID!, $alertId: String) {
project(fullPath: $fullPath) {
diff --git a/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql
deleted file mode 100644
index 54b66389d5b..00000000000
--- a/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql
+++ /dev/null
@@ -1,11 +0,0 @@
-#import "../fragments/listItem.fragment.graphql"
-
-query getAlerts($projectPath: ID!, $statuses: [AlertManagementStatus!]) {
- project(fullPath: $projectPath) {
- alertManagementAlerts(statuses: $statuses) {
- nodes {
- ...AlertListItem
- }
- }
- }
-}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql
new file mode 100644
index 00000000000..1d3c3c83cc1
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql
@@ -0,0 +1,32 @@
+#import "../fragments/list_item.fragment.graphql"
+
+query getAlerts(
+ $projectPath: ID!,
+ $statuses: [AlertManagementStatus!],
+ $sort: AlertManagementAlertSort,
+ $firstPageSize: Int,
+ $lastPageSize: Int,
+ $prevPageCursor: String = ""
+ $nextPageCursor: String = ""
+) {
+ project(fullPath: $projectPath, ) {
+ alertManagementAlerts(
+ statuses: $statuses,
+ sort: $sort,
+ first: $firstPageSize
+ last: $lastPageSize,
+ after: $nextPageCursor,
+ before: $prevPageCursor
+ ) {
+ nodes {
+ ...AlertListItem
+ },
+ pageInfo {
+ hasNextPage
+ endCursor
+ hasPreviousPage
+ startCursor
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
new file mode 100644
index 00000000000..1143050200c
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
@@ -0,0 +1,11 @@
+query getAlertsCount($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ alertManagementAlertStatusCounts {
+ all
+ open
+ acknowledged
+ resolved
+ triggered
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/services/index.js b/app/assets/javascripts/alert_management/services/index.js
deleted file mode 100644
index 787603d3e7a..00000000000
--- a/app/assets/javascripts/alert_management/services/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-export default {
- getAlertManagementList({ endpoint }) {
- return axios.get(endpoint);
- },
-};
diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
index 410c5c00e8a..ac30b086875 100644
--- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
+++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
@@ -1,26 +1,37 @@
<script>
import {
- GlDeprecatedButton,
+ GlButton,
GlFormGroup,
GlFormInput,
+ GlLink,
GlModal,
GlModalDirective,
+ GlSprintf,
} from '@gitlab/ui';
-import { escape } from 'lodash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import axios from '~/lib/utils/axios_utils';
-import { s__, __, sprintf } from '~/locale';
+import { s__, __ } from '~/locale';
import createFlash from '~/flash';
export default {
+ i18n: {
+ usageSection: s__(
+ 'AlertService|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.',
+ ),
+ setupSection: s__(
+ "AlertService|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
+ ),
+ },
COPY_TO_CLIPBOARD: __('Copy'),
RESET_KEY: __('Reset key'),
components: {
- GlDeprecatedButton,
+ GlButton,
GlFormGroup,
GlFormInput,
+ GlLink,
GlModal,
+ GlSprintf,
ClipboardButton,
ToggleButton,
},
@@ -28,6 +39,14 @@ export default {
'gl-modal': GlModalDirective,
},
props: {
+ alertsSetupUrl: {
+ type: String,
+ required: true,
+ },
+ alertsUsageUrl: {
+ type: String,
+ required: true,
+ },
initialAuthorizationKey: {
type: String,
required: false,
@@ -41,11 +60,6 @@ export default {
type: String,
required: true,
},
- learnMoreUrl: {
- type: String,
- required: false,
- default: '',
- },
initialActivated: {
type: Boolean,
required: true,
@@ -59,27 +73,17 @@ export default {
};
},
computed: {
- learnMoreDescription() {
- return sprintf(
- s__(
- 'AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts.',
- ),
+ sections() {
+ return [
{
- linkStart: `<a href="${escape(
- this.learnMoreUrl,
- )}" target="_blank" rel="noopener noreferrer">`,
- linkEnd: '</a>',
+ text: this.$options.i18n.usageSection,
+ url: this.alertsUsageUrl,
},
- false,
- );
- },
- sectionDescription() {
- const desc = s__(
- 'AlertService|Each alert source must be authorized using the following URL and authorization key.',
- );
- const learnMoreDesc = this.learnMoreDescription ? ` ${this.learnMoreDescription}` : '';
-
- return `${desc}${learnMoreDesc}`;
+ {
+ text: this.$options.i18n.setupSection,
+ url: this.alertsSetupUrl,
+ },
+ ];
},
},
watch: {
@@ -126,7 +130,15 @@ export default {
<template>
<div>
- <p v-html="sectionDescription"></p>
+ <div data-testid="description">
+ <p v-for="section in sections" :key="section.text">
+ <gl-sprintf :message="section.text">
+ <template #link="{ content }">
+ <gl-link :href="section.url" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
<gl-form-group :label="__('Active')" label-for="activated" label-class="label-bold">
<toggle-button
id="activated"
@@ -155,9 +167,7 @@ export default {
<clipboard-button :text="authorizationKey" :title="$options.COPY_TO_CLIPBOARD" />
</span>
</div>
- <gl-deprecated-button v-gl-modal.authKeyModal class="mt-2">{{
- $options.RESET_KEY
- }}</gl-deprecated-button>
+ <gl-button v-gl-modal.authKeyModal class="mt-2">{{ $options.RESET_KEY }}</gl-button>
<gl-modal
modal-id="authKeyModal"
:title="$options.RESET_KEY"
diff --git a/app/assets/javascripts/alerts_service_settings/index.js b/app/assets/javascripts/alerts_service_settings/index.js
index d49725c6a4d..c26adf24a7f 100644
--- a/app/assets/javascripts/alerts_service_settings/index.js
+++ b/app/assets/javascripts/alerts_service_settings/index.js
@@ -7,7 +7,14 @@ export default el => {
return null;
}
- const { activated: activatedStr, formPath, authorizationKey, url, learnMoreUrl } = el.dataset;
+ const {
+ activated: activatedStr,
+ alertsSetupUrl,
+ alertsUsageUrl,
+ formPath,
+ authorizationKey,
+ url,
+ } = el.dataset;
const activated = parseBoolean(activatedStr);
return new Vue({
@@ -15,9 +22,10 @@ export default el => {
render(createElement) {
return createElement(AlertsServiceForm, {
props: {
+ alertsSetupUrl,
+ alertsUsageUrl,
initialActivated: activated,
formPath,
- learnMoreUrl,
initialAuthorizationKey: authorizationKey,
url,
},
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index e527659a939..94d155840ea 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -38,6 +38,7 @@ const Api = {
userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
applySuggestionPath: '/api/:version/suggestions/:id/apply',
+ applySuggestionBatchPath: '/api/:version/suggestions/batch_apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
@@ -51,8 +52,10 @@ const Api = {
pipelinesPath: '/api/:version/projects/:id/pipelines/',
environmentsPath: '/api/:version/projects/:id/environments',
rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw',
+ issuePath: '/api/:version/projects/:id/issues/:issue_iid',
+ tagsPath: '/api/:version/projects/:id/repository/tags',
- group(groupId, callback) {
+ group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
return axios.get(url).then(({ data }) => {
callback(data);
@@ -321,6 +324,12 @@ const Api = {
return axios.put(url);
},
+ applySuggestionBatch(ids) {
+ const url = Api.buildUrl(Api.applySuggestionBatchPath);
+
+ return axios.put(url, { ids });
+ },
+
commitPipelines(projectId, sha) {
const encodedProjectId = projectId
.split('/')
@@ -540,6 +549,34 @@ const Api = {
return axios.get(url, { params });
},
+ updateIssue(project, issue, data = {}) {
+ const url = Api.buildUrl(Api.issuePath)
+ .replace(':id', encodeURIComponent(project))
+ .replace(':issue_iid', encodeURIComponent(issue));
+
+ return axios.put(url, data);
+ },
+
+ updateMergeRequest(project, mergeRequest, data = {}) {
+ const url = Api.buildUrl(Api.projectMergeRequestPath)
+ .replace(':id', encodeURIComponent(project))
+ .replace(':mrid', encodeURIComponent(mergeRequest));
+
+ return axios.put(url, data);
+ },
+
+ tags(id, query = '', options = {}) {
+ const url = Api.buildUrl(this.tagsPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url, {
+ params: {
+ search: query,
+ per_page: DEFAULT_PER_PAGE,
+ ...options,
+ },
+ });
+ },
+
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js
new file mode 100644
index 00000000000..9917151ac81
--- /dev/null
+++ b/app/assets/javascripts/authentication/mount_2fa.js
@@ -0,0 +1,14 @@
+import $ from 'jquery';
+import initU2F from './u2f';
+import U2FRegister from './u2f/register';
+
+export const mount2faAuthentication = () => {
+ // Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692)
+ initU2F();
+};
+
+export const mount2faRegistration = () => {
+ // Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692)
+ const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f);
+ u2fRegister.start();
+};
diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/authentication/u2f/authenticate.js
index 6244df1180e..201cd5c2e61 100644
--- a/app/assets/javascripts/u2f/authenticate.js
+++ b/app/assets/javascripts/authentication/u2f/authenticate.js
@@ -40,10 +40,10 @@ export default class U2FAuthenticate {
this.signRequests = u2fParams.sign_requests.map(request => omit(request, 'challenge'));
this.templates = {
- setup: '#js-authenticate-u2f-setup',
- inProgress: '#js-authenticate-u2f-in-progress',
- error: '#js-authenticate-u2f-error',
- authenticated: '#js-authenticate-u2f-authenticated',
+ setup: '#js-authenticate-token-2fa-setup',
+ inProgress: '#js-authenticate-token-2fa-in-progress',
+ error: '#js-authenticate-token-2fa-error',
+ authenticated: '#js-authenticate-token-2fa-authenticated',
};
}
@@ -88,7 +88,7 @@ export default class U2FAuthenticate {
error_message: error.message(),
error_code: error.errorCode,
});
- return this.container.find('#js-u2f-try-again').on('click', this.renderInProgress);
+ return this.container.find('#js-token-2fa-try-again').on('click', this.renderInProgress);
}
renderAuthenticated(deviceResponse) {
diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/authentication/u2f/error.js
index ca0fc0700ad..ca0fc0700ad 100644
--- a/app/assets/javascripts/u2f/error.js
+++ b/app/assets/javascripts/authentication/u2f/error.js
diff --git a/app/assets/javascripts/shared/sessions/u2f.js b/app/assets/javascripts/authentication/u2f/index.js
index 6ae9faf1dde..f129acca1c3 100644
--- a/app/assets/javascripts/shared/sessions/u2f.js
+++ b/app/assets/javascripts/authentication/u2f/index.js
@@ -1,17 +1,17 @@
import $ from 'jquery';
-import U2FAuthenticate from '../../u2f/authenticate';
+import U2FAuthenticate from './authenticate';
export default () => {
if (!gon.u2f) return;
const u2fAuthenticate = new U2FAuthenticate(
- $('#js-authenticate-u2f'),
- '#js-login-u2f-form',
+ $('#js-authenticate-token-2fa'),
+ '#js-login-token-2fa-form',
gon.u2f,
document.querySelector('#js-login-2fa-device'),
document.querySelector('.js-2fa-form'),
);
u2fAuthenticate.start();
- // needed in rspec
+ // needed in rspec (FakeU2fDevice)
gl.u2fAuthenticate = u2fAuthenticate;
};
diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/authentication/u2f/register.js
index f5a422727ad..52c0ce1fc04 100644
--- a/app/assets/javascripts/u2f/register.js
+++ b/app/assets/javascripts/authentication/u2f/register.js
@@ -78,7 +78,7 @@ export default class U2FRegister {
error_message: error.message(),
error_code: error.errorCode,
});
- return this.container.find('#js-u2f-try-again').on('click', this.renderSetup);
+ return this.container.find('#js-token-2fa-try-again').on('click', this.renderSetup);
}
renderRegistered(deviceResponse) {
diff --git a/app/assets/javascripts/u2f/util.js b/app/assets/javascripts/authentication/u2f/util.js
index b706481c02f..b706481c02f 100644
--- a/app/assets/javascripts/u2f/util.js
+++ b/app/assets/javascripts/authentication/u2f/util.js
diff --git a/app/assets/javascripts/avatar_picker.js b/app/assets/javascripts/avatar_picker.js
deleted file mode 100644
index d38e0b4abaa..00000000000
--- a/app/assets/javascripts/avatar_picker.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import $ from 'jquery';
-
-export default function initAvatarPicker() {
- $('.js-choose-avatar-button').on('click', function onClickAvatar() {
- const form = $(this).closest('form');
- return form.find('.js-avatar-input').click();
- });
-
- $('.js-avatar-input').on('change', function onChangeAvatarInput() {
- const form = $(this).closest('form');
- const filename = $(this)
- .val()
- .replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape
- return form.find('.js-avatar-filename').text(filename);
- });
-}
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index f9dd153eba0..3242993b06a 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -84,15 +84,10 @@ export default {
<div v-show="hasError" class="btn-group">
<div class="btn btn-default btn-sm disabled">
- <icon
- :size="16"
- class="prepend-left-8 append-right-8"
- name="doc-image"
- aria-hidden="true"
- />
+ <icon :size="16" class="gl-ml-3 gl-mr-3" name="doc-image" aria-hidden="true" />
</div>
<div class="btn btn-default btn-sm disabled">
- <span class="prepend-left-8 append-right-8">{{ s__('Badges|No badge image') }}</span>
+ <span class="gl-ml-3 gl-mr-3">{{ s__('Badges|No badge image') }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
index bb363b8d85e..bad14666bb2 100644
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -54,7 +54,7 @@ export default {
<div v-if="canEditBadge" class="table-action-buttons">
<button
:disabled="badge.isDeleting"
- class="btn btn-default append-right-8"
+ class="btn btn-default gl-mr-3"
type="button"
@click="editBadge(badge)"
>
diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
new file mode 100644
index 00000000000..570954c7200
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
@@ -0,0 +1,41 @@
+<script>
+import { mapGetters } from 'vuex';
+import imageDiff from '~/diffs/mixins/image_diff';
+import DraftNote from './draft_note.vue';
+
+export default {
+ components: {
+ DraftNote,
+ },
+ mixins: [imageDiff],
+ props: {
+ fileHash: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters('batchComments', ['draftsForFile']),
+ drafts() {
+ return this.draftsForFile(this.fileHash);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-for="(draft, index) in drafts"
+ :key="draft.id"
+ class="discussion-notes diff-discussions position-relative"
+ >
+ <div class="notes">
+ <span class="d-block btn-transparent badge badge-pill is-draft js-diff-notes-index">
+ {{ toggleText(draft, index) }}
+ </span>
+ <draft-note :draft="draft" />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
new file mode 100644
index 00000000000..963d104b6b3
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -0,0 +1,113 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import NoteableNote from '~/notes/components/noteable_note.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import PublishButton from './publish_button.vue';
+
+export default {
+ components: {
+ NoteableNote,
+ PublishButton,
+ LoadingButton,
+ },
+ props: {
+ draft: {
+ type: Object,
+ required: true,
+ },
+ diffFile: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ isEditingDraft: false,
+ };
+ },
+ computed: {
+ ...mapState('batchComments', ['isPublishing']),
+ ...mapGetters('batchComments', ['isPublishingDraft']),
+ draftCommands() {
+ return this.draft.references.commands;
+ },
+ },
+ mounted() {
+ if (window.location.hash && window.location.hash === `#note_${this.draft.id}`) {
+ this.scrollToDraft(this.draft);
+ }
+ },
+ methods: {
+ ...mapActions('batchComments', [
+ 'deleteDraft',
+ 'updateDraft',
+ 'publishSingleDraft',
+ 'scrollToDraft',
+ 'toggleResolveDiscussion',
+ ]),
+ update(data) {
+ this.updateDraft(data);
+ },
+ publishNow() {
+ this.publishSingleDraft(this.draft.id);
+ },
+ handleEditing() {
+ this.isEditingDraft = true;
+ },
+ handleNotEditing() {
+ this.isEditingDraft = false;
+ },
+ },
+};
+</script>
+<template>
+ <article class="draft-note-component note-wrapper">
+ <ul class="notes draft-notes">
+ <noteable-note
+ :note="draft"
+ :diff-lines="diffFile.highlighted_diff_lines"
+ :line="line"
+ class="draft-note"
+ @handleEdit="handleEditing"
+ @cancelForm="handleNotEditing"
+ @updateSuccess="handleNotEditing"
+ @handleDeleteNote="deleteDraft"
+ @handleUpdateNote="update"
+ @toggleResolveStatus="toggleResolveDiscussion(draft.id)"
+ >
+ <strong slot="note-header-info" class="badge draft-pending-label append-right-4">
+ {{ __('Pending') }}
+ </strong>
+ </noteable-note>
+ </ul>
+
+ <template v-if="!isEditingDraft">
+ <div
+ v-if="draftCommands"
+ class="referenced-commands draft-note-commands"
+ v-html="draftCommands"
+ ></div>
+
+ <p class="draft-note-actions d-flex">
+ <publish-button
+ :show-count="true"
+ :should-publish="false"
+ class="btn btn-success btn-inverted gl-mr-3"
+ />
+ <loading-button
+ ref="publishNowButton"
+ :loading="isPublishingDraft(draft.id) || isPublishing"
+ :label="__('Add comment now')"
+ container-class="btn btn-inverted"
+ @click="publishNow"
+ />
+ </p>
+ </template>
+ </article>
+</template>
diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue
new file mode 100644
index 00000000000..f1180760c4d
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue
@@ -0,0 +1,15 @@
+<script>
+import { mapGetters } from 'vuex';
+
+export default {
+ computed: {
+ ...mapGetters('batchComments', ['draftsCount']),
+ },
+};
+</script>
+<template>
+ <span class="drafts-count-component">
+ <span class="drafts-count-number">{{ draftsCount }}</span>
+ <span class="sr-only"> {{ n__('draft', 'drafts', draftsCount) }} </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue
new file mode 100644
index 00000000000..385725cd109
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue
@@ -0,0 +1,32 @@
+<script>
+import DraftNote from './draft_note.vue';
+
+export default {
+ components: {
+ DraftNote,
+ },
+ props: {
+ draft: {
+ type: Object,
+ required: true,
+ },
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="notes_holder js-temp-notes-holder">
+ <td class="notes-content" colspan="4">
+ <div class="content"><draft-note :draft="draft" :diff-file="diffFile" :line="line" /></div>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
new file mode 100644
index 00000000000..68fd20e56bc
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
@@ -0,0 +1,45 @@
+<script>
+import { mapGetters } from 'vuex';
+import DraftNote from './draft_note.vue';
+
+export default {
+ components: {
+ DraftNote,
+ },
+ props: {
+ line: {
+ type: Object,
+ required: true,
+ },
+ diffFileContentSha: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters('batchComments', ['draftForLine']),
+ className() {
+ return this.leftDraft > 0 || this.rightDraft > 0 ? '' : 'js-temp-notes-holder';
+ },
+ leftDraft() {
+ return this.draftForLine(this.diffFileContentSha, this.line, 'left');
+ },
+ rightDraft() {
+ return this.draftForLine(this.diffFileContentSha, this.line, 'right');
+ },
+ },
+};
+</script>
+
+<template>
+ <tr :class="className" class="notes_holder">
+ <td class="notes_line old"></td>
+ <td class="notes-content parallel old" colspan="2">
+ <div v-if="leftDraft.isDraft" class="content"><draft-note :draft="leftDraft" /></div>
+ </td>
+ <td class="notes_line new"></td>
+ <td class="notes-content parallel new" colspan="2">
+ <div v-if="rightDraft.isDraft" class="content"><draft-note :draft="rightDraft" /></div>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
new file mode 100644
index 00000000000..195e1b7ec5c
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -0,0 +1,111 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { sprintf, n__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import DraftsCount from './drafts_count.vue';
+import PublishButton from './publish_button.vue';
+import PreviewItem from './preview_item.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ Icon,
+ DraftsCount,
+ PublishButton,
+ PreviewItem,
+ },
+ computed: {
+ ...mapGetters(['isNotesFetched']),
+ ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
+ ...mapState('batchComments', ['showPreviewDropdown']),
+ dropdownTitle() {
+ return sprintf(
+ n__('%{count} pending comment', '%{count} pending comments', this.draftsCount),
+ { count: this.draftsCount },
+ );
+ },
+ },
+ watch: {
+ showPreviewDropdown() {
+ if (this.showPreviewDropdown && this.$refs.dropdown) {
+ this.$nextTick(() => this.$refs.dropdown.focus());
+ }
+ },
+ },
+ mounted() {
+ document.addEventListener('click', this.onClickDocument);
+ },
+ beforeDestroy() {
+ document.removeEventListener('click', this.onClickDocument);
+ },
+ methods: {
+ ...mapActions('batchComments', ['toggleReviewDropdown']),
+ isLast(index) {
+ return index === this.sortedDrafts.length - 1;
+ },
+ onClickDocument({ target }) {
+ if (
+ this.showPreviewDropdown &&
+ !target.closest('.review-preview-dropdown, .js-publish-draft-button')
+ ) {
+ this.toggleReviewDropdown();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="dropdown float-right review-preview-dropdown"
+ :class="{
+ show: showPreviewDropdown,
+ }"
+ >
+ <button
+ ref="dropdown"
+ type="button"
+ class="btn btn-success review-preview-dropdown-toggle qa-review-preview-toggle"
+ @click="toggleReviewDropdown"
+ >
+ {{ __('Finish review') }}
+ <drafts-count />
+ <icon name="angle-up" />
+ </button>
+ <div
+ class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top"
+ :class="{
+ show: showPreviewDropdown,
+ }"
+ >
+ <div class="dropdown-title">
+ {{ dropdownTitle }}
+ <button
+ :aria-label="__('Close')"
+ type="button"
+ class="dropdown-title-button dropdown-menu-close"
+ @click="toggleReviewDropdown"
+ >
+ <icon name="close" />
+ </button>
+ </div>
+ <div class="dropdown-content">
+ <ul v-if="isNotesFetched">
+ <li v-for="(draft, index) in sortedDrafts" :key="draft.id">
+ <preview-item :draft="draft" :is-last="isLast(index)" />
+ </li>
+ </ul>
+ <gl-loading-icon v-else size="lg" class="prepend-top-default append-bottom-default" />
+ </div>
+ <div class="dropdown-footer">
+ <publish-button
+ :show-count="false"
+ :should-publish="true"
+ :label="__('Submit review')"
+ class="float-right gl-mr-3"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
new file mode 100644
index 00000000000..22495eb4d7d
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -0,0 +1,143 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
+import { sprintf, __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import resolvedStatusMixin from '../mixins/resolved_status';
+import { GlSprintf } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ getStartLineNumber,
+ getEndLineNumber,
+ getLineClasses,
+} from '~/notes/components/multiline_comment_utils';
+
+export default {
+ components: {
+ Icon,
+ GlSprintf,
+ },
+ mixins: [resolvedStatusMixin, glFeatureFlagsMixin()],
+ props: {
+ draft: {
+ type: Object,
+ required: true,
+ },
+ isLast: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters('diffs', ['getDiffFileByHash']),
+ ...mapGetters(['getDiscussion']),
+ iconName() {
+ return this.isDiffDiscussion || this.draft.line_code ? 'doc-text' : 'comment';
+ },
+ discussion() {
+ return this.getDiscussion(this.draft.discussion_id);
+ },
+ isDiffDiscussion() {
+ return this.discussion && this.discussion.diff_discussion;
+ },
+ titleText() {
+ const file = this.discussion ? this.discussion.diff_file : this.draft;
+
+ if (file) {
+ return file.file_path;
+ }
+
+ return sprintf(__("%{authorsName}'s thread"), {
+ authorsName: this.discussion.notes.find(note => !note.system).author.name,
+ });
+ },
+ linePosition() {
+ if (this.draft.position && this.draft.position.position_type === IMAGE_DIFF_POSITION_TYPE) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${this.draft.position.x}x ${this.draft.position.y}y`;
+ }
+
+ const position = this.discussion ? this.discussion.position : this.draft.position;
+
+ return position?.new_line || position?.old_line;
+ },
+ content() {
+ const el = document.createElement('div');
+ el.innerHTML = this.draft.note_html;
+
+ return el.textContent;
+ },
+ showLinePosition() {
+ return this.draft.file_hash || this.isDiffDiscussion;
+ },
+ startLineNumber() {
+ return getStartLineNumber(this.draft.position?.line_range);
+ },
+ endLineNumber() {
+ return getEndLineNumber(this.draft.position?.line_range);
+ },
+ },
+ methods: {
+ ...mapActions('batchComments', ['scrollToDraft']),
+ getLineClasses(lineNumber) {
+ return getLineClasses(lineNumber);
+ },
+ },
+ showStaysResolved: false,
+};
+</script>
+
+<template>
+ <button
+ type="button"
+ class="review-preview-item menu-item"
+ :class="[
+ componentClasses,
+ {
+ 'is-last': isLast,
+ },
+ ]"
+ @click="scrollToDraft(draft)"
+ >
+ <span class="review-preview-item-header">
+ <icon class="flex-shrink-0" :name="iconName" />
+ <span
+ class="bold text-nowrap"
+ :class="{ 'gl-align-items-center': glFeatures.multilineComments }"
+ >
+ <span class="review-preview-item-header-text block-truncated">
+ {{ titleText }}
+ </span>
+ <template v-if="showLinePosition">
+ <template v-if="!glFeatures.multilineComments"
+ >:{{ linePosition }}</template
+ >
+ <template v-else-if="startLineNumber === endLineNumber">
+ :<span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
+ </template>
+ <gl-sprintf v-else :message="__(':%{startLine} to %{endLine}')">
+ <template #startLine>
+ <span class="gl-mr-2" :class="getLineClasses(startLineNumber)">{{
+ startLineNumber
+ }}</span>
+ </template>
+ <template #endLine>
+ <span class="gl-ml-2" :class="getLineClasses(endLineNumber)">{{
+ endLineNumber
+ }}</span>
+ </template>
+ </gl-sprintf>
+ </template>
+ </span>
+ </span>
+ <span class="review-preview-item-content">
+ <p>{{ content }}</p>
+ </span>
+ <span
+ v-if="draft.discussion_id && resolvedStatusMessage"
+ class="review-preview-item-footer draft-note-resolution p-0"
+ >
+ <icon class="gl-mr-3" name="status_success" /> {{ resolvedStatusMessage }}
+ </span>
+ </button>
+</template>
diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue
new file mode 100644
index 00000000000..f4dc0f04dc3
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/publish_button.vue
@@ -0,0 +1,55 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { __ } from '~/locale';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import DraftsCount from './drafts_count.vue';
+
+export default {
+ components: {
+ LoadingButton,
+ DraftsCount,
+ },
+ props: {
+ showCount: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: __('Finish review'),
+ },
+ shouldPublish: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState('batchComments', ['isPublishing']),
+ },
+ methods: {
+ ...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']),
+ onClick() {
+ if (this.shouldPublish) {
+ this.publishReview();
+ } else {
+ this.toggleReviewDropdown();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <loading-button
+ :loading="isPublishing"
+ container-class="btn btn-success js-publish-draft-button qa-submit-review"
+ @click="onClick"
+ >
+ <span>
+ {{ label }}
+ <drafts-count v-if="showCount" />
+ </span>
+ </loading-button>
+</template>
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
new file mode 100644
index 00000000000..b0e8b806701
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -0,0 +1,70 @@
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { GlModal, GlModalDirective } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import PreviewDropdown from './preview_dropdown.vue';
+
+export default {
+ components: {
+ LoadingButton,
+ GlModal,
+ PreviewDropdown,
+ },
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
+ computed: {
+ ...mapGetters(['isNotesFetched']),
+ ...mapState('batchComments', ['isDiscarding']),
+ ...mapGetters('batchComments', ['draftsCount']),
+ },
+ watch: {
+ isNotesFetched() {
+ if (this.isNotesFetched) {
+ this.expandAllDiscussions();
+ }
+ },
+ },
+ methods: {
+ ...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']),
+ },
+ modalId: 'discard-draft-review',
+ text: sprintf(
+ s__(
+ `BatchComments|You're about to discard your review which will delete all of your pending comments.
+ The deleted comments %{strong_start}cannot%{strong_end} be restored.`,
+ ),
+ {
+ strong_start: '<strong>',
+ strong_end: '</strong>',
+ },
+ false,
+ ),
+};
+</script>
+<template>
+ <div v-show="draftsCount > 0">
+ <nav class="review-bar-component">
+ <div class="review-bar-content qa-review-bar">
+ <preview-dropdown />
+ <loading-button
+ v-gl-modal="$options.modalId"
+ :loading="isDiscarding"
+ :label="__('Discard review')"
+ class="qa-discard-review float-right"
+ />
+ </div>
+ </nav>
+ <gl-modal
+ :title="s__('BatchComments|Discard review?')"
+ :ok-title="s__('BatchComments|Delete all pending comments')"
+ :modal-id="$options.modalId"
+ title-tag="h4"
+ ok-variant="danger qa-modal-delete-pending-comments"
+ @ok="discardReview"
+ >
+ <p v-html="$options.text"></p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/batch_comments/constants.js b/app/assets/javascripts/batch_comments/constants.js
new file mode 100644
index 00000000000..b309c339fc8
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/constants.js
@@ -0,0 +1,3 @@
+export const CHANGES_TAB = 'diffs';
+export const DISCUSSION_TAB = 'notes';
+export const SHOW_TAB = 'show';
diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js
new file mode 100644
index 00000000000..e06285c0b37
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import { mapActions } from 'vuex';
+import store from '~/mr_notes/stores';
+import ReviewBar from './components/review_bar.vue';
+
+// eslint-disable-next-line import/prefer-default-export
+export const initReviewBar = () => {
+ const el = document.getElementById('js-review-bar');
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ store,
+ mounted() {
+ this.fetchDrafts();
+ },
+ methods: {
+ ...mapActions('batchComments', ['fetchDrafts']),
+ },
+ render(createElement) {
+ return createElement(ReviewBar);
+ },
+ });
+};
diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
index 3bbbaa86b51..2517fb198f0 100644
--- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js
+++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
@@ -1,9 +1,58 @@
-import { sprintf, __ } from '~/locale';
+import { mapGetters } from 'vuex';
+import { sprintf, s__, __ } from '~/locale';
export default {
+ props: {
+ discussionId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ resolveDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isDraft: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
computed: {
+ ...mapGetters(['isDiscussionResolved']),
+ resolvedStatusMessage() {
+ let message;
+ const discussionResolved = this.isDiscussionResolved(
+ this.draft ? this.draft.discussion_id : this.discussionId,
+ );
+ const discussionToBeResolved = this.draft
+ ? this.draft.resolve_discussion
+ : this.resolveDiscussion;
+
+ if (discussionToBeResolved && discussionResolved && !this.$options.showStaysResolved) {
+ return undefined;
+ }
+
+ if (discussionToBeResolved) {
+ message = discussionResolved
+ ? s__('MergeRequests|Thread stays resolved')
+ : s__('MergeRequests|Thread will be resolved');
+ } else if (discussionResolved) {
+ message = s__('MergeRequests|Thread will be unresolved');
+ } else if (this.$options.showStaysResolved) {
+ message = s__('MergeRequests|Thread stays unresolved');
+ }
+
+ return message;
+ },
+ componentClasses() {
+ return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion';
+ },
resolveButtonTitle() {
- let title = __('Mark comment as resolved');
+ if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
+
+ let title = __('Mark as resolved');
if (this.resolvedBy) {
title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
@@ -12,4 +61,5 @@ export default {
return title;
},
},
+ showStaysResolved: true,
};
diff --git a/app/assets/javascripts/batch_comments/services/drafts_service.js b/app/assets/javascripts/batch_comments/services/drafts_service.js
new file mode 100644
index 00000000000..36d2f8df612
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/services/drafts_service.js
@@ -0,0 +1,33 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ createNewDraft(endpoint, data) {
+ const postData = { ...data, draft_note: data.note };
+ delete postData.note;
+
+ return axios.post(endpoint, postData);
+ },
+ deleteDraft(endpoint, draftId) {
+ return axios.delete(`${endpoint}/${draftId}`);
+ },
+ publishDraft(endpoint, draftId) {
+ return axios.post(endpoint, { id: draftId });
+ },
+ addDraftToDiscussion(endpoint, data) {
+ return axios.post(endpoint, data);
+ },
+ fetchDrafts(endpoint) {
+ return axios.get(endpoint);
+ },
+ publish(endpoint) {
+ return axios.post(endpoint);
+ },
+ discard(endpoint) {
+ return axios.delete(endpoint);
+ },
+ update(endpoint, { draftId, note, resolveDiscussion, position }) {
+ return axios.put(`${endpoint}/${draftId}`, {
+ draft_note: { note, resolve_discussion: resolveDiscussion, position },
+ });
+ },
+};
diff --git a/app/assets/javascripts/batch_comments/stores/index.js b/app/assets/javascripts/batch_comments/stores/index.js
new file mode 100644
index 00000000000..08dc9ea70f8
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/stores/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import batchComments from './modules/batch_comments';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ modules: {
+ batchComments: batchComments(),
+ },
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
new file mode 100644
index 00000000000..1ef012696c5
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -0,0 +1,151 @@
+import flash from '~/flash';
+import { __ } from '~/locale';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import service from '../../../services/drafts_service';
+import * as types from './mutation_types';
+import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
+
+export const saveDraft = ({ dispatch }, draft) =>
+ dispatch('saveNote', { ...draft, isDraft: true }, { root: true });
+
+export const addDraftToDiscussion = ({ commit }, { endpoint, data }) =>
+ service
+ .addDraftToDiscussion(endpoint, data)
+ .then(res => res.data)
+ .then(res => {
+ commit(types.ADD_NEW_DRAFT, res);
+ return res;
+ })
+ .catch(() => {
+ flash(__('An error occurred adding a draft to the thread.'));
+ });
+
+export const createNewDraft = ({ commit }, { endpoint, data }) =>
+ service
+ .createNewDraft(endpoint, data)
+ .then(res => res.data)
+ .then(res => {
+ commit(types.ADD_NEW_DRAFT, res);
+ return res;
+ })
+ .catch(() => {
+ flash(__('An error occurred adding a new draft.'));
+ });
+
+export const deleteDraft = ({ commit, getters }, draft) =>
+ service
+ .deleteDraft(getters.getNotesData.draftsPath, draft.id)
+ .then(() => {
+ commit(types.DELETE_DRAFT, draft.id);
+ })
+ .catch(() => flash(__('An error occurred while deleting the comment')));
+
+export const fetchDrafts = ({ commit, getters }) =>
+ service
+ .fetchDrafts(getters.getNotesData.draftsPath)
+ .then(res => res.data)
+ .then(data => commit(types.SET_BATCH_COMMENTS_DRAFTS, data))
+ .catch(() => flash(__('An error occurred while fetching pending comments')));
+
+export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => {
+ commit(types.REQUEST_PUBLISH_DRAFT, draftId);
+
+ service
+ .publishDraft(getters.getNotesData.draftsPublishPath, draftId)
+ .then(() => dispatch('updateDiscussionsAfterPublish'))
+ .then(() => commit(types.RECEIVE_PUBLISH_DRAFT_SUCCESS, draftId))
+ .catch(() => commit(types.RECEIVE_PUBLISH_DRAFT_ERROR, draftId));
+};
+
+export const publishReview = ({ commit, dispatch, getters }) => {
+ commit(types.REQUEST_PUBLISH_REVIEW);
+
+ return service
+ .publish(getters.getNotesData.draftsPublishPath)
+ .then(() => dispatch('updateDiscussionsAfterPublish'))
+ .then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS))
+ .catch(() => commit(types.RECEIVE_PUBLISH_REVIEW_ERROR));
+};
+
+export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters }) =>
+ dispatch('fetchDiscussions', { path: getters.getNotesData.discussionsPath }, { root: true }).then(
+ () =>
+ dispatch('diffs/assignDiscussionsToDiff', rootGetters.discussionsStructuredByLineCode, {
+ root: true,
+ }),
+ );
+
+export const discardReview = ({ commit, getters }) => {
+ commit(types.REQUEST_DISCARD_REVIEW);
+
+ return service
+ .discard(getters.getNotesData.draftsDiscardPath)
+ .then(() => commit(types.RECEIVE_DISCARD_REVIEW_SUCCESS))
+ .catch(() => commit(types.RECEIVE_DISCARD_REVIEW_ERROR));
+};
+
+export const updateDraft = (
+ { commit, getters },
+ { note, noteText, resolveDiscussion, position, callback },
+) =>
+ service
+ .update(getters.getNotesData.draftsPath, {
+ draftId: note.id,
+ note: noteText,
+ resolveDiscussion,
+ position: JSON.stringify(position),
+ })
+ .then(res => res.data)
+ .then(data => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data))
+ .then(callback)
+ .catch(() => flash(__('An error occurred while updating the comment')));
+
+export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
+ const discussion = draft.discussion_id && rootGetters.getDiscussion(draft.discussion_id);
+ const tab =
+ draft.file_hash || (discussion && discussion.diff_discussion) ? CHANGES_TAB : SHOW_TAB;
+ const tabEl = tab === CHANGES_TAB ? CHANGES_TAB : DISCUSSION_TAB;
+ const draftID = `note_${draft.id}`;
+ const el = document.querySelector(`#${tabEl} #${draftID}`);
+
+ dispatch('closeReviewDropdown');
+
+ window.location.hash = draftID;
+
+ if (window.mrTabs.currentAction !== tab) {
+ window.mrTabs.tabShown(tab);
+ }
+
+ if (discussion) {
+ dispatch('expandDiscussion', { discussionId: discussion.id }, { root: true });
+ }
+
+ if (el) {
+ setTimeout(() => scrollToElement(el.closest('.draft-note-component')));
+ }
+};
+
+export const toggleReviewDropdown = ({ dispatch, state }) => {
+ if (state.showPreviewDropdown) {
+ dispatch('closeReviewDropdown');
+ } else {
+ dispatch('openReviewDropdown');
+ }
+};
+
+export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN);
+export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN);
+
+export const expandAllDiscussions = ({ dispatch, state }) =>
+ state.drafts
+ .filter(draft => draft.discussion_id)
+ .forEach(draft => {
+ dispatch('expandDiscussion', { discussionId: draft.discussion_id }, { root: true });
+ });
+
+export const toggleResolveDiscussion = ({ commit }, draftId) => {
+ commit(types.TOGGLE_RESOLVE_DISCUSSION, draftId);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js
new file mode 100644
index 00000000000..43f43c983aa
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js
@@ -0,0 +1,87 @@
+import { parallelLineKey, showDraftOnSide } from '../../../utils';
+
+export const draftsCount = state => state.drafts.length;
+
+export const getNotesData = (state, getters, rootState, rootGetters) => rootGetters.getNotesData;
+
+export const hasDrafts = state => state.drafts.length > 0;
+
+export const draftsPerDiscussionId = state =>
+ state.drafts.reduce((acc, draft) => {
+ if (draft.discussion_id) {
+ acc[draft.discussion_id] = draft;
+ }
+
+ return acc;
+ }, {});
+
+export const draftsPerFileHashAndLine = state =>
+ state.drafts.reduce((acc, draft) => {
+ if (draft.file_hash) {
+ if (!acc[draft.file_hash]) {
+ acc[draft.file_hash] = {};
+ }
+
+ acc[draft.file_hash][draft.line_code] = draft;
+ }
+
+ return acc;
+ }, {});
+
+export const shouldRenderDraftRow = (state, getters) => (diffFileSha, line) =>
+ Boolean(
+ diffFileSha in getters.draftsPerFileHashAndLine &&
+ getters.draftsPerFileHashAndLine[diffFileSha][line.line_code],
+ );
+
+export const shouldRenderParallelDraftRow = (state, getters) => (diffFileSha, line) => {
+ const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha];
+ const [lkey, rkey] = [parallelLineKey(line, 'left'), parallelLineKey(line, 'right')];
+
+ return draftsForFile ? Boolean(draftsForFile[lkey] || draftsForFile[rkey]) : false;
+};
+
+export const hasParallelDraftLeft = (state, getters) => (diffFileSha, line) => {
+ const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha];
+ const lkey = parallelLineKey(line, 'left');
+
+ return draftsForFile ? Boolean(draftsForFile[lkey]) : false;
+};
+
+export const hasParallelDraftRight = (state, getters) => (diffFileSha, line) => {
+ const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha];
+ const rkey = parallelLineKey(line, 'left');
+
+ return draftsForFile ? Boolean(draftsForFile[rkey]) : false;
+};
+
+export const shouldRenderDraftRowInDiscussion = (state, getters) => discussionId =>
+ typeof getters.draftsPerDiscussionId[discussionId] !== 'undefined';
+
+export const draftForDiscussion = (state, getters) => discussionId =>
+ getters.draftsPerDiscussionId[discussionId] || {};
+
+export const draftForLine = (state, getters) => (diffFileSha, line, side = null) => {
+ const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha];
+
+ const key = side !== null ? parallelLineKey(line, side) : line.line_code;
+
+ if (draftsForFile) {
+ const draft = draftsForFile[key];
+ if (draft && showDraftOnSide(line, side)) {
+ return draft;
+ }
+ }
+ return {};
+};
+
+export const draftsForFile = state => diffFileSha =>
+ state.drafts.filter(draft => draft.file_hash === diffFileSha);
+
+export const isPublishingDraft = state => draftId =>
+ state.currentlyPublishingDrafts.indexOf(draftId) !== -1;
+
+export const sortedDrafts = state => [...state.drafts].sort((a, b) => a.id > b.id);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js
new file mode 100644
index 00000000000..81dab0566c1
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js
@@ -0,0 +1,12 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+import * as getters from './getters';
+
+export default () => ({
+ namespaced: true,
+ state: state(),
+ mutations,
+ actions,
+ getters,
+});
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
new file mode 100644
index 00000000000..c8f0658c21c
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
@@ -0,0 +1,23 @@
+export const ENABLE_BATCH_COMMENTS = 'ENABLE_BATCH_COMMENTS';
+export const ADD_NEW_DRAFT = 'ADD_NEW_DRAFT';
+export const DELETE_DRAFT = 'DELETE_DRAFT';
+export const SET_BATCH_COMMENTS_DRAFTS = 'SET_BATCH_COMMENTS_DRAFTS';
+
+export const REQUEST_PUBLISH_DRAFT = 'REQUEST_PUBLISH_DRAFT';
+export const RECEIVE_PUBLISH_DRAFT_SUCCESS = 'RECEIVE_PUBLISH_DRAFT_SUCCESS';
+export const RECEIVE_PUBLISH_DRAFT_ERROR = 'RECEIVE_PUBLISH_DRAFT_ERROR';
+
+export const REQUEST_PUBLISH_REVIEW = 'REQUEST_PUBLISH_REVIEW';
+export const RECEIVE_PUBLISH_REVIEW_SUCCESS = 'RECEIVE_PUBLISH_REVIEW_SUCCESS';
+export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR';
+
+export const REQUEST_DISCARD_REVIEW = 'REQUEST_DISCARD_REVIEW';
+export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS';
+export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR';
+
+export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
+
+export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN';
+export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN';
+
+export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION';
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
new file mode 100644
index 00000000000..81ceef7b160
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
@@ -0,0 +1,81 @@
+import * as types from './mutation_types';
+
+const processDraft = draft => ({
+ ...draft,
+ isDraft: true,
+});
+
+export default {
+ [types.ADD_NEW_DRAFT](state, draft) {
+ state.drafts.push(processDraft(draft));
+ },
+
+ [types.DELETE_DRAFT](state, draftId) {
+ state.drafts = state.drafts.filter(draft => draft.id !== draftId);
+ },
+
+ [types.SET_BATCH_COMMENTS_DRAFTS](state, drafts) {
+ state.drafts = drafts.map(processDraft);
+ },
+
+ [types.REQUEST_PUBLISH_DRAFT](state, draftId) {
+ state.currentlyPublishingDrafts.push(draftId);
+ },
+ [types.RECEIVE_PUBLISH_DRAFT_SUCCESS](state, draftId) {
+ state.currentlyPublishingDrafts = state.currentlyPublishingDrafts.filter(
+ publishingDraftId => publishingDraftId !== draftId,
+ );
+ state.drafts = state.drafts.filter(d => d.id !== draftId);
+ },
+ [types.RECEIVE_PUBLISH_DRAFT_ERROR](state, draftId) {
+ state.currentlyPublishingDrafts = state.currentlyPublishingDrafts.filter(
+ publishingDraftId => publishingDraftId !== draftId,
+ );
+ },
+
+ [types.REQUEST_PUBLISH_REVIEW](state) {
+ state.isPublishing = true;
+ },
+ [types.RECEIVE_PUBLISH_REVIEW_SUCCESS](state) {
+ state.isPublishing = false;
+ state.drafts = [];
+ },
+ [types.RECEIVE_PUBLISH_REVIEW_ERROR](state) {
+ state.isPublishing = false;
+ },
+ [types.REQUEST_DISCARD_REVIEW](state) {
+ state.isDiscarding = true;
+ },
+ [types.RECEIVE_DISCARD_REVIEW_SUCCESS](state) {
+ state.isDiscarding = false;
+ state.drafts = [];
+ },
+ [types.RECEIVE_DISCARD_REVIEW_ERROR](state) {
+ state.isDiscarding = false;
+ },
+ [types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, data) {
+ const index = state.drafts.findIndex(draft => draft.id === data.id);
+
+ if (index >= 0) {
+ state.drafts.splice(index, 1, processDraft(data));
+ }
+ },
+ [types.OPEN_REVIEW_DROPDOWN](state) {
+ state.showPreviewDropdown = true;
+ },
+ [types.CLOSE_REVIEW_DROPDOWN](state) {
+ state.showPreviewDropdown = false;
+ },
+ [types.TOGGLE_RESOLVE_DISCUSSION](state, draftId) {
+ state.drafts = state.drafts.map(draft => {
+ if (draft.id === draftId) {
+ return {
+ ...draft,
+ resolve_discussion: !draft.resolve_discussion,
+ };
+ }
+
+ return draft;
+ });
+ },
+};
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
new file mode 100644
index 00000000000..80c710deab0
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
@@ -0,0 +1,9 @@
+export default () => ({
+ withBatchComments: true,
+ isDraftsFetched: false,
+ drafts: [],
+ isPublishing: false,
+ currentlyPublishingDrafts: [],
+ isDiscarding: false,
+ showPreviewDropdown: false,
+});
diff --git a/app/assets/javascripts/batch_comments/utils.js b/app/assets/javascripts/batch_comments/utils.js
new file mode 100644
index 00000000000..cf4f7af0ebb
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/utils.js
@@ -0,0 +1,35 @@
+import { getFormData } from '~/diffs/store/utils';
+
+export const getDraftReplyFormData = data => ({
+ endpoint: data.notesData.draftsPath,
+ data,
+});
+
+export const getDraftFormData = params => ({
+ endpoint: params.notesData.draftsPath,
+ data: getFormData(params),
+});
+
+export const parallelLineKey = (line, side) => (line[side] ? line[side].line_code : '');
+
+export const showDraftOnSide = (line, side) => {
+ // inline mode
+ if (side === null) {
+ return true;
+ }
+
+ // parallel
+ if (side === 'left' || side === 'right') {
+ const otherSide = side === 'left' ? 'right' : 'left';
+ const thisCode = (line[side] && line[side].line_code) || '';
+ const otherCode = (line[otherSide] && line[otherSide].line_code) || '';
+
+ // either the lineCodes are different
+ // or if they're the same, only show on the left side
+ if (thisCode !== otherCode || (side === 'left' && thisCode === otherCode)) {
+ return true;
+ }
+ }
+
+ return false;
+};
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index 057cdb6cc4c..e4c69a114e0 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -25,9 +25,10 @@ function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then(mermaid => {
let theme = 'neutral';
+ const ideDarkThemes = ['dark', 'solarized-dark'];
if (
- window.gon?.user_color_scheme === 'dark' &&
+ ideDarkThemes.includes(window.gon?.user_color_scheme) &&
// if on the Web IDE page
document.querySelector('.ide')
) {
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index 4f433bd8dfd..eb7f45cba6f 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -66,6 +66,7 @@ export default {
ref="contentViewer"
:content="content"
:type="activeViewer.fileType"
+ data-qa-selector="file_content"
/>
</template>
</div>
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index e5e01caa9a5..76c5779f3ae 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -30,6 +30,11 @@ export default {
required: false,
default: SIMPLE_BLOB_VIEWER,
},
+ hasRenderError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -75,6 +80,7 @@ export default {
v-if="showDefaultActions"
:raw-path="blob.rawPath"
:active-viewer="viewer"
+ :has-render-error="hasRenderError"
@copy="proxyCopyRequest"
/>
</div>
diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
index 63ae70a37f4..62fef108b47 100644
--- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue
+++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
@@ -27,6 +27,11 @@ export default {
default: SIMPLE_BLOB_VIEWER,
required: false,
},
+ hasRenderError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
downloadUrl() {
@@ -44,11 +49,13 @@ export default {
<template>
<gl-button-group>
<gl-deprecated-button
+ v-if="!hasRenderError"
v-gl-tooltip.hover
:aria-label="$options.BTN_COPY_CONTENTS_TITLE"
:title="$options.BTN_COPY_CONTENTS_TITLE"
:disabled="copyDisabled"
data-clipboard-target="#blob-code-content"
+ data-testid="copyContentsButton"
>
<gl-icon name="copy-to-clipboard" :size="14" />
</gl-deprecated-button>
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index e9be7fbcf9b..601b694db87 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -32,7 +32,7 @@ export default {
<file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" />
<strong
class="file-title-name mr-1 js-blob-header-filepath"
- data-qa-selector="file_title_name"
+ data-qa-selector="file_title_content"
>{{ blob.path }}</strong
>
</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index fb854616a04..0ed7579e8e1 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -1,33 +1,22 @@
<script>
-import $ from 'jquery';
import Sortable from 'sortablejs';
-import { GlButtonGroup, GlDeprecatedButton, GlLabel, GlTooltip, GlIcon } from '@gitlab/ui';
import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
-import { s__, __, sprintf } from '~/locale';
import Tooltip from '~/vue_shared/directives/tooltip';
import EmptyComponent from '~/vue_shared/components/empty_component';
-import AccessorUtilities from '../../lib/utils/accessor';
import BoardBlankState from './board_blank_state.vue';
-import BoardDelete from './board_delete';
+import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import BoardList from './board_list.vue';
-import IssueCount from './issue_count.vue';
import boardsStore from '../stores/boards_store';
+import eventHub from '../eventhub';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
import { ListType } from '../constants';
-import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
components: {
BoardPromotionState: EmptyComponent,
BoardBlankState,
- BoardDelete,
+ BoardListHeader,
BoardList,
- GlButtonGroup,
- IssueCount,
- GlDeprecatedButton,
- GlLabel,
- GlTooltip,
- GlIcon,
},
directives: {
Tooltip,
@@ -70,42 +59,9 @@ export default {
return {
detailIssue: boardsStore.detail,
filter: boardsStore.filter,
- weightFeatureAvailable: false,
};
},
computed: {
- isLoggedIn() {
- return Boolean(gon.current_user_id);
- },
- showListHeaderButton() {
- return (
- !this.disabled &&
- this.list.type !== ListType.closed &&
- this.list.type !== ListType.blank &&
- this.list.type !== ListType.promotion
- );
- },
- issuesTooltip() {
- const { issuesSize } = this.list;
-
- return sprintf(__('%{issuesSize} issues'), { issuesSize });
- },
- // Only needed to make karma pass.
- weightCountToolTip() {}, // eslint-disable-line vue/return-in-computed-property
- caretTooltip() {
- return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
- },
- isNewIssueShown() {
- return this.list.type === ListType.backlog || this.showListHeaderButton;
- },
- isSettingsShown() {
- return (
- this.list.type !== ListType.backlog &&
- this.showListHeaderButton &&
- this.list.isExpanded &&
- this.isWipLimitsOn
- );
- },
showBoardListAndBoardInfo() {
return this.list.type !== ListType.blank && this.list.type !== ListType.promotion;
},
@@ -151,41 +107,9 @@ export default {
Sortable.create(this.$el.parentNode, sortableOptions);
},
- created() {
- if (
- this.list.isExpandable &&
- AccessorUtilities.isLocalStorageAccessSafe() &&
- !this.isLoggedIn
- ) {
- const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false';
-
- this.list.isExpanded = !isCollapsed;
- }
- },
methods: {
- showScopedLabels(label) {
- return boardsStore.scopedLabels.enabled && isScopedLabel(label);
- },
-
- showNewIssueForm() {
- this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
- },
- toggleExpanded() {
- if (this.list.isExpandable) {
- this.list.isExpanded = !this.list.isExpanded;
-
- if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) {
- localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
- }
-
- if (this.isLoggedIn) {
- this.list.update();
- }
-
- // When expanding/collapsing, the tooltip on the caret button sometimes stays open.
- // Close all tooltips manually to prevent dangling tooltips.
- $('.tooltip').tooltip('hide');
- }
+ showListNewIssueForm(listId) {
+ eventHub.$emit('showForm', listId);
},
},
};
@@ -200,166 +124,18 @@ export default {
'board-type-assignee': list.type === 'assignee',
}"
:data-id="list.id"
- class="board h-100 px-2 align-top ws-normal"
+ class="board gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
data-qa-selector="board_list"
>
- <div class="board-inner d-flex flex-column position-relative h-100 rounded">
- <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 }"
- class="board-header"
- data-qa-selector="board_list_header"
- >
- <h3
- :class="{
- 'user-can-drag': !disabled && !list.preset,
- 'border-bottom-0': !list.isExpanded,
- }"
- class="board-title m-0 d-flex js-board-handle"
- >
- <div
- v-if="list.isExpandable"
- v-tooltip=""
- :aria-label="caretTooltip"
- :title="caretTooltip"
- aria-hidden="true"
- class="board-title-caret no-drag"
- data-placement="bottom"
- @click="toggleExpanded"
- >
- <i
- :class="{ 'fa-caret-right': list.isExpanded, 'fa-caret-down': !list.isExpanded }"
- class="fa fa-fw"
- ></i>
- </div>
- <!-- The following is only true in EE and if it is a milestone -->
- <span
- v-if="list.type === 'milestone' && list.milestone"
- aria-hidden="true"
- class="append-right-5 milestone-icon"
- >
- <gl-icon name="timer" />
- </span>
-
- <a
- v-if="list.type === 'assignee'"
- :href="list.assignee.path"
- class="user-avatar-link js-no-trigger"
- >
- <img
- :alt="list.assignee.name"
- :src="list.assignee.avatar"
- class="avatar s20 has-tooltip"
- height="20"
- width="20"
- />
- </a>
- <div class="board-title-text">
- <span
- v-if="list.type !== 'label'"
- :class="{
- 'has-tooltip': !['backlog', 'closed'].includes(list.type),
- 'd-block': list.type === 'milestone',
- }"
- :title="(list.label && list.label.description) || list.title || ''"
- class="board-title-main-text block-truncated"
- data-container="body"
- >
- {{ list.title }}
- </span>
- <span
- v-if="list.type === 'assignee'"
- :title="(list.assignee && list.assignee.username) || ''"
- class="board-title-sub-text prepend-left-5 has-tooltip"
- >
- @{{ list.assignee.username }}
- </span>
- <gl-label
- v-if="list.type === 'label'"
- :background-color="list.label.color"
- :description="list.label.description"
- :scoped="showScopedLabels(list.label)"
- :size="!list.isExpanded ? 'sm' : ''"
- :title="list.label.title"
- tooltip-placement="bottom"
- />
- </div>
- <board-delete
- v-if="canAdminList && !list.preset && list.id"
- :list="list"
- inline-template="true"
- >
- <button
- :class="{ 'd-none': !list.isExpanded }"
- :aria-label="__(`Delete list`)"
- class="board-delete no-drag p-0 border-0 has-tooltip float-right"
- data-placement="bottom"
- title="Delete list"
- type="button"
- @click.stop="deleteBoard"
- >
- <i aria-hidden="true" data-hidden="true" class="fa fa-trash"></i>
- </button>
- </board-delete>
- <div
- v-if="showBoardListAndBoardInfo"
- class="issue-count-badge pr-0 no-drag text-secondary"
- >
- <span class="d-inline-flex">
- <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltip" />
- <span ref="issueCount" class="issue-count-badge-count">
- <gl-icon class="mr-1" name="issues" />
- <issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" />
- </span>
- <!-- The following is only true in EE. -->
- <template v-if="weightFeatureAvailable">
- <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
- <span ref="weightTooltip" class="d-inline-flex ml-2">
- <gl-icon class="mr-1" name="weight" />
- {{ list.totalWeight }}
- </span>
- </template>
- </span>
- </div>
- <gl-button-group
- v-if="isNewIssueShown || isSettingsShown"
- class="board-list-button-group pl-2"
- >
- <gl-deprecated-button
- v-if="isNewIssueShown"
- ref="newIssueBtn"
- :class="{
- 'd-none': !list.isExpanded,
- 'rounded-right': isNewIssueShown && !isSettingsShown,
- }"
- :aria-label="__(`New issue`)"
- class="issue-count-badge-add-button no-drag"
- type="button"
- @click="showNewIssueForm"
- >
- <i aria-hidden="true" data-hidden="true" class="fa fa-plus"></i>
- </gl-deprecated-button>
- <gl-tooltip :target="() => $refs.newIssueBtn">{{ __('New Issue') }}</gl-tooltip>
-
- <gl-deprecated-button
- v-if="isSettingsShown"
- ref="settingsBtn"
- :aria-label="__(`List settings`)"
- class="no-drag rounded-right js-board-settings-button"
- title="List settings"
- type="button"
- @click="openSidebarSettings"
- >
- <gl-icon name="settings" />
- </gl-deprecated-button>
- <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
- </gl-button-group>
- </h3>
- </header>
+ <div
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
+ >
+ <board-list-header
+ :can-admin-list="canAdminList"
+ :list="list"
+ :disabled="disabled"
+ :board-id="boardId"
+ />
<board-list
v-if="showBoardListAndBoardInfo"
ref="board-list"
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
new file mode 100644
index 00000000000..f0497ea0b64
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -0,0 +1,82 @@
+<script>
+import { mapState } from 'vuex';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import BoardColumn from 'ee_else_ce/boards/components/board_column.vue';
+import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
+
+export default {
+ components: {
+ BoardColumn,
+ EpicsSwimlanes,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ lists: {
+ type: Array,
+ required: true,
+ },
+ canAdminList: {
+ type: Boolean,
+ required: true,
+ },
+ groupId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ issueLinkBase: {
+ type: String,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ boardId: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['isShowingEpicsSwimlanes']),
+ isSwimlanesOn() {
+ return this.glFeatures.boardsWithSwimlanes && this.isShowingEpicsSwimlanes;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-if="!isSwimlanesOn"
+ class="boards-list w-100 py-3 px-2 text-nowrap"
+ data-qa-selector="boards_list"
+ >
+ <board-column
+ v-for="list in lists"
+ :key="list.id"
+ ref="board"
+ :can-admin-list="canAdminList"
+ :group-id="groupId"
+ :list="list"
+ :disabled="disabled"
+ :issue-link-base="issueLinkBase"
+ :root-path="rootPath"
+ :board-id="boardId"
+ />
+ </div>
+ <epics-swimlanes
+ v-else
+ ref="swimlanes"
+ :lists="lists"
+ :can-admin-list="canAdminList"
+ :disabled="disabled"
+ :board-id="boardId"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js
index cc15dc82db9..b74234a2e3c 100644
--- a/app/assets/javascripts/boards/components/board_delete.js
+++ b/app/assets/javascripts/boards/components/board_delete.js
@@ -1,8 +1,15 @@
import $ from 'jquery';
import Vue from 'vue';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
export default Vue.extend({
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
list: {
type: Object,
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index c4e2c398d45..4270ad5783d 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -104,7 +104,7 @@ export default {
},
},
created() {
- eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
@@ -381,7 +381,7 @@ export default {
this.$refs.list.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
- eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
new file mode 100644
index 00000000000..eb12617a66e
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -0,0 +1,291 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlLabel,
+ GlTooltip,
+ GlIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
+import { s__, __, sprintf } from '~/locale';
+import AccessorUtilities from '../../lib/utils/accessor';
+import BoardDelete from './board_delete';
+import IssueCount from './issue_count.vue';
+import boardsStore from '../stores/boards_store';
+import eventHub from '../eventhub';
+import { ListType } from '../constants';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ BoardDelete,
+ GlButtonGroup,
+ GlButton,
+ GlLabel,
+ GlTooltip,
+ GlIcon,
+ IssueCount,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [isWipLimitsOn],
+ props: {
+ list: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ boardId: {
+ type: String,
+ required: true,
+ },
+ canAdminList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isSwimlanesHeader: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ weightFeatureAvailable: false,
+ };
+ },
+ computed: {
+ isLoggedIn() {
+ return Boolean(gon.current_user_id);
+ },
+ listType() {
+ return this.list.type;
+ },
+ listAssignee() {
+ return this.list?.assignee?.username || '';
+ },
+ listTitle() {
+ return this.list?.label?.description || this.list.title || '';
+ },
+ showListHeaderButton() {
+ return (
+ !this.disabled &&
+ this.listType !== ListType.closed &&
+ this.listType !== ListType.blank &&
+ this.listType !== ListType.promotion
+ );
+ },
+ issuesTooltip() {
+ const { issuesSize } = this.list;
+
+ return sprintf(__('%{issuesSize} issues'), { issuesSize });
+ },
+ chevronTooltip() {
+ return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
+ },
+ chevronIcon() {
+ return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
+ },
+ isNewIssueShown() {
+ return this.listType === ListType.backlog || this.showListHeaderButton;
+ },
+ isSettingsShown() {
+ return (
+ this.listType !== ListType.backlog &&
+ this.showListHeaderButton &&
+ this.list.isExpanded &&
+ this.isWipLimitsOn
+ );
+ },
+ showBoardListAndBoardInfo() {
+ return this.listType !== ListType.blank && this.listType !== ListType.promotion;
+ },
+ uniqueKey() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `boards.${this.boardId}.${this.listType}.${this.list.id}`;
+ },
+ },
+ methods: {
+ showScopedLabels(label) {
+ return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ },
+
+ showNewIssueForm() {
+ eventHub.$emit(`toggle-issue-form-${this.list.id}`);
+ },
+ toggleExpanded() {
+ if (this.list.isExpandable) {
+ this.list.isExpanded = !this.list.isExpanded;
+
+ if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) {
+ localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
+ }
+
+ if (this.isLoggedIn) {
+ this.list.update();
+ }
+
+ // When expanding/collapsing, the tooltip on the caret button sometimes stays open.
+ // Close all tooltips manually to prevent dangling tooltips.
+ this.$root.$emit('bv::hide::tooltip');
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <header
+ :class="{
+ 'has-border': list.label && list.label.color,
+ 'gl-relative': list.isExpanded,
+ 'gl-h-full': !list.isExpanded,
+ 'board-inner gl-rounded-base gl-border-b-0': isSwimlanesHeader,
+ }"
+ :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
+ class="board-header gl-relative"
+ data-qa-selector="board_list_header"
+ data-testid="board-list-header"
+ >
+ <h3
+ :class="{
+ 'user-can-drag': !disabled && !list.preset,
+ 'gl-border-b-0': !list.isExpanded,
+ }"
+ class="board-title gl-m-0 gl-display-flex js-board-handle"
+ >
+ <gl-button
+ v-if="list.isExpandable"
+ v-gl-tooltip.hover
+ :aria-label="chevronTooltip"
+ :title="chevronTooltip"
+ :icon="chevronIcon"
+ class="board-title-caret no-drag"
+ variant="link"
+ @click="toggleExpanded"
+ />
+ <!-- The following is only true in EE and if it is a milestone -->
+ <span
+ v-if="list.type === 'milestone' && list.milestone"
+ aria-hidden="true"
+ class="gl-mr-2 milestone-icon"
+ >
+ <gl-icon name="timer" />
+ </span>
+
+ <a
+ v-if="list.type === 'assignee'"
+ :href="list.assignee.path"
+ class="user-avatar-link js-no-trigger"
+ >
+ <img
+ v-gl-tooltip.hover.bottom
+ :title="listAssignee"
+ :alt="list.assignee.name"
+ :src="list.assignee.avatar"
+ class="avatar s20"
+ height="20"
+ width="20"
+ />
+ </a>
+ <div class="board-title-text">
+ <span
+ v-if="list.type !== 'label'"
+ v-gl-tooltip.hover
+ :class="{
+ 'gl-display-inline-block': list.type === 'milestone',
+ }"
+ :title="listTitle"
+ class="board-title-main-text block-truncated"
+ >
+ {{ list.title }}
+ </span>
+ <span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2">
+ @{{ list.assignee.username }}
+ </span>
+ <gl-label
+ v-if="list.type === 'label'"
+ v-gl-tooltip.hover.bottom
+ :background-color="list.label.color"
+ :description="list.label.description"
+ :scoped="showScopedLabels(list.label)"
+ :size="!list.isExpanded ? 'sm' : ''"
+ :title="list.label.title"
+ />
+ </div>
+ <board-delete
+ v-if="canAdminList && !list.preset && list.id"
+ :list="list"
+ inline-template="true"
+ >
+ <gl-button
+ v-gl-tooltip.hover.bottom
+ :class="{ 'gl-display-none': !list.isExpanded }"
+ :aria-label="__('Delete list')"
+ class="board-delete no-drag gl-pr-0 gl-shadow-none gl-mr-3"
+ :title="__('Delete list')"
+ icon="remove"
+ size="small"
+ @click.stop="deleteBoard"
+ />
+ </board-delete>
+ <div
+ v-if="showBoardListAndBoardInfo"
+ class="issue-count-badge gl-pr-0 no-drag text-secondary"
+ >
+ <span class="gl-display-inline-flex">
+ <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltip" />
+ <span ref="issueCount" class="issue-count-badge-count">
+ <gl-icon class="gl-mr-2" name="issues" />
+ <issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" />
+ </span>
+ <!-- The following is only true in EE. -->
+ <template v-if="weightFeatureAvailable">
+ <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
+ <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
+ <gl-icon class="gl-mr-2" name="weight" />
+ {{ list.totalWeight }}
+ </span>
+ </template>
+ </span>
+ </div>
+ <gl-button-group
+ v-if="isNewIssueShown || isSettingsShown"
+ class="board-list-button-group pl-2"
+ >
+ <gl-button
+ v-if="isNewIssueShown"
+ ref="newIssueBtn"
+ v-gl-tooltip.hover
+ :class="{
+ 'gl-display-none': !list.isExpanded,
+ }"
+ :aria-label="__('New issue')"
+ :title="__('New issue')"
+ class="issue-count-badge-add-button no-drag"
+ icon="plus"
+ @click="showNewIssueForm"
+ />
+
+ <gl-button
+ v-if="isSettingsShown"
+ ref="settingsBtn"
+ v-gl-tooltip.hover
+ :aria-label="__('List settings')"
+ class="no-drag js-board-settings-button"
+ :title="__('List settings')"
+ icon="settings"
+ @click="openSidebarSettings"
+ />
+ <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
+ </gl-button-group>
+ </h3>
+ </header>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index deebe122109..c72fb7b30f9 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -92,7 +92,7 @@ export default {
},
cancel() {
this.title = '';
- eventHub.$emit(`hide-issue-form-${this.list.id}`);
+ eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index c8953158811..056a7b48212 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -54,7 +54,7 @@ export default Vue.extend({
return this.issue.milestone ? this.issue.milestone.title : __('No milestone');
},
canRemove() {
- return !this.list.preset;
+ return !this.list?.preset;
},
hasLabels() {
return this.issue.labels && this.issue.labels.length;
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index a589fb325b2..f2e198eaedb 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -147,7 +147,7 @@ export default {
<template>
<div>
<div class="d-flex board-card-header" dir="auto">
- <h4 class="board-card-title append-bottom-0 prepend-top-0">
+ <h4 class="board-card-title gl-mb-0 gl-mt-0">
<icon
v-if="issue.blocked"
v-gl-tooltip
@@ -169,7 +169,7 @@ export default {
}}</a>
</h4>
</div>
- <div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap">
+ <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 d-flex flex-wrap">
<template v-for="label in orderedLabels">
<gl-label
:key="label.id"
@@ -188,7 +188,7 @@ export default {
>
<span
v-if="issue.referencePath"
- class="board-card-number overflow-hidden d-flex append-right-8 prepend-top-8"
+ class="board-card-number overflow-hidden d-flex gl-mr-3 gl-mt-3"
>
<tooltip-on-truncate
v-if="issueReferencePath"
@@ -199,7 +199,7 @@ export default {
>
#{{ issue.iid }}
</span>
- <span class="board-info-items prepend-top-8 d-inline-block">
+ <span class="board-info-items gl-mt-3 d-inline-block">
<issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" />
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
<issue-card-weight
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 9ff7575ae09..a882cd1cdfa 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,12 +1,15 @@
import $ from 'jquery';
import Vue from 'vue';
+import { mapActions } from 'vuex';
import 'ee_else_ce/boards/models/issue';
import 'ee_else_ce/boards/models/list';
+import BoardContent from '~/boards/components/board_content.vue';
import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
import toggleLabels from 'ee_else_ce/boards/toggle_labels';
+import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
import {
setPromotionState,
setWeigthFetchingState,
@@ -76,6 +79,7 @@ export default () => {
issueBoardsApp = new Vue({
el: $boardApp,
components: {
+ BoardContent,
Board: () =>
window?.gon?.features?.sfcIssueBoards
? import('ee_else_ce/boards/components/board_column.vue')
@@ -114,14 +118,16 @@ export default () => {
},
},
created() {
- boardsStore.setEndpoints({
+ const endpoints = {
boardsEndpoint: this.boardsEndpoint,
recentBoardsEndpoint: this.recentBoardsEndpoint,
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
fullPath: $boardApp.dataset.fullPath,
- });
+ };
+ this.setEndpoints(endpoints);
+ boardsStore.setEndpoints(endpoints);
boardsStore.rootPath = this.boardsEndpoint;
eventHub.$on('updateTokens', this.updateTokens);
@@ -192,6 +198,7 @@ export default () => {
}
},
methods: {
+ ...mapActions(['setEndpoints']),
updateTokens() {
this.filterManager.updateTokens();
},
@@ -371,5 +378,6 @@ export default () => {
toggleFocusMode(ModalStore, boardsStore);
toggleLabels();
+ toggleEpicsSwimlanes();
mountMultipleBoardsSwitcher();
};
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index 878f49cc6be..98eac35b2ed 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -30,56 +30,43 @@ class ListIssue {
}
addLabel(label) {
- if (!this.findLabel(label)) {
- this.labels.push(new ListLabel(label));
- }
+ boardsStore.addIssueLabel(this, label);
}
findLabel(findLabel) {
- return this.labels.find(label => label.id === findLabel.id);
+ return boardsStore.findIssueLabel(this, findLabel);
}
removeLabel(removeLabel) {
- if (removeLabel) {
- this.labels = this.labels.filter(label => removeLabel.id !== label.id);
- }
+ boardsStore.removeIssueLabel(this, removeLabel);
}
removeLabels(labels) {
- labels.forEach(this.removeLabel.bind(this));
+ boardsStore.removeIssueLabels(this, labels);
}
addAssignee(assignee) {
- if (!this.findAssignee(assignee)) {
- this.assignees.push(new ListAssignee(assignee));
- }
+ boardsStore.addIssueAssignee(this, assignee);
}
findAssignee(findAssignee) {
- return this.assignees.find(assignee => assignee.id === findAssignee.id);
+ return boardsStore.findIssueAssignee(this, findAssignee);
}
removeAssignee(removeAssignee) {
- if (removeAssignee) {
- this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
- }
+ boardsStore.removeIssueAssignee(this, removeAssignee);
}
removeAllAssignees() {
- this.assignees = [];
+ boardsStore.removeAllIssueAssignees(this);
}
addMilestone(milestone) {
- const miletoneId = this.milestone ? this.milestone.id : null;
- if (IS_EE && milestone.id !== miletoneId) {
- this.milestone = new ListMilestone(milestone);
- }
+ boardsStore.addIssueMilestone(this, milestone);
}
removeMilestone(removeMilestone) {
- if (IS_EE && removeMilestone && removeMilestone.id === this.milestone.id) {
- this.milestone = {};
- }
+ boardsStore.removeIssueMilestone(this, removeMilestone);
}
getLists() {
@@ -87,15 +74,15 @@ class ListIssue {
}
updateData(newData) {
- Object.assign(this, newData);
+ boardsStore.updateIssueData(this, newData);
}
setFetchingState(key, value) {
- this.isFetching[key] = value;
+ boardsStore.setIssueFetchingState(this, key, value);
}
setLoadingState(key, value) {
- this.isLoading[key] = value;
+ boardsStore.setIssueLoadingState(this, key, value);
}
update() {
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 31c372b7a75..0bd606c6297 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return */
+/* eslint-disable no-underscore-dangle, class-methods-use-this */
import ListIssue from 'ee_else_ce/boards/models/issue';
import { __ } from '~/locale';
@@ -8,8 +8,6 @@ import flash from '~/flash';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
-const PER_PAGE = 20;
-
const TYPES = {
backlog: {
isPreset: true,
@@ -83,30 +81,15 @@ class List {
}
destroy() {
- const index = boardsStore.state.lists.indexOf(this);
- boardsStore.state.lists.splice(index, 1);
- boardsStore.updateNewListDropdown(this.id);
-
- boardsStore.destroyList(this.id).catch(() => {
- // TODO: handle request error
- });
+ boardsStore.destroy(this);
}
update() {
- const collapsed = !this.isExpanded;
- return boardsStore.updateList(this.id, this.position, collapsed).catch(() => {
- // TODO: handle request error
- });
+ return boardsStore.updateListFunc(this);
}
nextPage() {
- if (this.issuesSize > this.issues.length) {
- if (this.issues.length / PER_PAGE >= 1) {
- this.page += 1;
- }
-
- return this.getIssues(false);
- }
+ return boardsStore.goToNextPage(this);
}
getIssues(emptyIssues = true) {
@@ -114,13 +97,7 @@ class List {
}
newIssue(issue) {
- this.addIssue(issue, null, 0);
- this.issuesSize += 1;
-
- return boardsStore
- .newIssue(this.id, issue)
- .then(res => res.data)
- .then(data => this.onNewIssueResponse(issue, data));
+ return boardsStore.newListIssue(this, issue);
}
createIssues(data) {
@@ -138,12 +115,7 @@ class List {
}
moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
- this.issues.splice(oldIndex, 1);
- this.issues.splice(newIndex, 0, issue);
-
- boardsStore.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
- // TODO: handle request error
- });
+ boardsStore.moveListIssues(this, issue, oldIndex, newIndex, moveBeforeId, moveAfterId);
}
moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
@@ -182,35 +154,15 @@ class List {
}
findIssue(id) {
- return this.issues.find(issue => issue.id === id);
+ return boardsStore.findListIssue(this, id);
}
removeMultipleIssues(removeIssues) {
- const ids = removeIssues.map(issue => issue.id);
-
- this.issues = this.issues.filter(issue => {
- const matchesRemove = ids.includes(issue.id);
-
- if (matchesRemove) {
- this.issuesSize -= 1;
- issue.removeLabel(this.label);
- }
-
- return !matchesRemove;
- });
+ return boardsStore.removeListMultipleIssues(this, removeIssues);
}
removeIssue(removeIssue) {
- this.issues = this.issues.filter(issue => {
- const matchesRemove = removeIssue.id === issue.id;
-
- if (matchesRemove) {
- this.issuesSize -= 1;
- issue.removeLabel(this.label);
- }
-
- return !matchesRemove;
- });
+ return boardsStore.removeListIssues(this, removeIssue);
}
getTypeInfo(type) {
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 34598d66f45..08fedb14dff 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,11 +1,13 @@
+import * as types from './mutation_types';
+
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
throw new Error('Not implemented!');
};
export default {
- setEndpoints: () => {
- notImplemented();
+ setEndpoints: ({ commit }, endpoints) => {
+ commit(types.SET_ENDPOINTS, endpoints);
},
fetchLists: () => {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index fdbd7e89bfb..a930f39189e 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-shadow, no-param-reassign */
+/* eslint-disable no-shadow, no-param-reassign,consistent-return */
/* global List */
import $ from 'jquery';
@@ -22,6 +22,7 @@ import ListLabel from '../models/label';
import ListAssignee from '../models/assignee';
import ListMilestone from '../models/milestone';
+const PER_PAGE = 20;
const boardsStore = {
disabled: false,
timeTracking: {
@@ -42,6 +43,7 @@ const boardsStore = {
},
detail: {
issue: {},
+ list: {},
},
moving: {
issue: {},
@@ -73,6 +75,7 @@ const boardsStore = {
this.filter.path = getUrlParamsArray().join('&');
this.detail = {
issue: {},
+ list: {},
};
},
showPage(page) {
@@ -133,6 +136,21 @@ const boardsStore = {
path: '',
});
},
+
+ findIssueLabel(issue, findLabel) {
+ return issue.labels.find(label => label.id === findLabel.id);
+ },
+
+ goToNextPage(list) {
+ if (list.issuesSize > list.issues.length) {
+ if (list.issues.length / PER_PAGE >= 1) {
+ list.page += 1;
+ }
+
+ return list.getIssues(false);
+ }
+ },
+
addListIssue(list, issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
@@ -177,6 +195,10 @@ const boardsStore = {
}
}
},
+ findListIssue(list, id) {
+ return list.issues.find(issue => issue.id === id);
+ },
+
welcomeIsHidden() {
return parseBoolean(Cookies.get('issue_board_welcome_hidden'));
},
@@ -243,6 +265,33 @@ const boardsStore = {
}
},
+ removeListIssues(list, removeIssue) {
+ list.issues = list.issues.filter(issue => {
+ const matchesRemove = removeIssue.id === issue.id;
+
+ if (matchesRemove) {
+ list.issuesSize -= 1;
+ issue.removeLabel(list.label);
+ }
+
+ return !matchesRemove;
+ });
+ },
+ removeListMultipleIssues(list, removeIssues) {
+ const ids = removeIssues.map(issue => issue.id);
+
+ list.issues = list.issues.filter(issue => {
+ const matchesRemove = ids.includes(issue.id);
+
+ if (matchesRemove) {
+ list.issuesSize -= 1;
+ issue.removeLabel(list.label);
+ }
+
+ return !matchesRemove;
+ });
+ },
+
startMoving(list, issue) {
Object.assign(this.moving, { list, issue });
},
@@ -516,9 +565,25 @@ const boardsStore = {
});
},
+ updateListFunc(list) {
+ const collapsed = !list.isExpanded;
+ return this.updateList(list.id, list.position, collapsed).catch(() => {
+ // TODO: handle request error
+ });
+ },
+
destroyList(id) {
return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`);
},
+ destroy(list) {
+ const index = this.state.lists.indexOf(list);
+ this.state.lists.splice(index, 1);
+ this.updateNewListDropdown(list.id);
+
+ this.destroyList(list.id).catch(() => {
+ // TODO: handle request error
+ });
+ },
saveList(list) {
const entity = list.label || list.assignee || list.milestone;
@@ -591,6 +656,15 @@ const boardsStore = {
});
},
+ moveListIssues(list, issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
+ list.issues.splice(oldIndex, 1);
+ list.issues.splice(newIndex, 0, issue);
+
+ this.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
+ // TODO: handle request error
+ });
+ },
+
moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) {
return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), {
from_list_id: fromListId,
@@ -607,6 +681,15 @@ const boardsStore = {
});
},
+ newListIssue(list, issue) {
+ list.addIssue(issue, null, 0);
+ list.issuesSize += 1;
+
+ return this.newIssue(list.id, issue)
+ .then(res => res.data)
+ .then(data => list.onNewIssueResponse(issue, data));
+ },
+
getBacklog(data) {
return axios.get(
mergeUrlParams(
@@ -615,6 +698,21 @@ const boardsStore = {
),
);
},
+ removeIssueLabel(issue, removeLabel) {
+ if (removeLabel) {
+ issue.labels = issue.labels.filter(label => removeLabel.id !== label.id);
+ }
+ },
+
+ addIssueAssignee(issue, assignee) {
+ if (!issue.findAssignee(assignee)) {
+ issue.assignees.push(new ListAssignee(assignee));
+ }
+ },
+
+ removeIssueLabels(issue, labels) {
+ labels.forEach(issue.removeLabel.bind(issue));
+ },
bulkUpdate(issueIds, extraData = {}) {
const data = {
@@ -682,10 +780,49 @@ const boardsStore = {
...this.multiSelect.list.slice(index + 1),
];
},
+ removeIssueAssignee(issue, removeAssignee) {
+ if (removeAssignee) {
+ issue.assignees = issue.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ },
+
+ findIssueAssignee(issue, findAssignee) {
+ return issue.assignees.find(assignee => assignee.id === findAssignee.id);
+ },
clearMultiSelect() {
this.multiSelect.list = [];
},
+
+ removeAllIssueAssignees(issue) {
+ issue.assignees = [];
+ },
+
+ addIssueMilestone(issue, milestone) {
+ const miletoneId = issue.milestone ? issue.milestone.id : null;
+ if (IS_EE && milestone.id !== miletoneId) {
+ issue.milestone = new ListMilestone(milestone);
+ }
+ },
+
+ setIssueLoadingState(issue, key, value) {
+ issue.isLoading[key] = value;
+ },
+
+ updateIssueData(issue, newData) {
+ Object.assign(issue, newData);
+ },
+
+ setIssueFetchingState(issue, key, value) {
+ issue.isFetching[key] = value;
+ },
+
+ removeIssueMilestone(issue, removeMilestone) {
+ if (IS_EE && removeMilestone && removeMilestone.id === issue.milestone.id) {
+ issue.milestone = {};
+ }
+ },
+
refreshIssueData(issue, obj) {
issue.id = obj.id;
issue.iid = obj.iid;
@@ -718,6 +855,11 @@ const boardsStore = {
issue.assignees = obj.assignees.map(a => new ListAssignee(a));
}
},
+ addIssueLabel(issue, label) {
+ if (!issue.findLabel(label)) {
+ issue.labels.push(new ListLabel(label));
+ }
+ },
updateIssue(issue) {
const data = {
issue: {
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 7a287400265..e4459cdcc07 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -6,8 +6,8 @@ const notImplemented = () => {
};
export default {
- [mutationTypes.SET_ENDPOINTS]: () => {
- notImplemented();
+ [mutationTypes.SET_ENDPOINTS]: (state, endpoints) => {
+ state.endpoints = endpoints;
},
[mutationTypes.REQUEST_ADD_LIST]: () => {
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 10aac2f649e..aca93c4d7c6 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,6 +1,7 @@
import { inactiveListId } from '~/boards/constants';
export default () => ({
+ endpoints: {},
isShowingLabels: true,
activeListId: inactiveListId,
});
diff --git a/app/assets/javascripts/boards/toggle_epics_swimlanes.js b/app/assets/javascripts/boards/toggle_epics_swimlanes.js
new file mode 100644
index 00000000000..2d1ec238274
--- /dev/null
+++ b/app/assets/javascripts/boards/toggle_epics_swimlanes.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
index f5c2cc57f3f..c15d638d92b 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
@@ -154,7 +154,7 @@ export default {
v-for="(result, i) in results"
:key="i"
role="option"
- :class="{ 'gl-bg-gray-100': i === arrowCounter }"
+ :class="{ 'gl-bg-gray-50': i === arrowCounter }"
:aria-selected="i === arrowCounter"
>
<gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index f6ade0867cd..6531b945212 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -1,19 +1,29 @@
<script>
import {
+ GlAlert,
+ GlButton,
+ GlCollapse,
GlDeprecatedButton,
- GlModal,
- GlFormSelect,
+ GlFormCheckbox,
GlFormGroup,
GlFormInput,
+ GlFormSelect,
GlFormTextarea,
- GlFormCheckbox,
- GlLink,
GlIcon,
+ GlLink,
+ GlModal,
+ GlSprintf,
} from '@gitlab/ui';
+import Cookies from 'js-cookie';
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
+import {
+ AWS_TOKEN_CONSTANTS,
+ ADD_CI_VARIABLE_MODAL_ID,
+ AWS_TIP_DISMISSED_COOKIE_NAME,
+ AWS_TIP_MESSAGE,
+} from '../constants';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
import CiKeyField from './ci_key_field.vue';
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
@@ -23,19 +33,29 @@ export default {
components: {
CiEnvironmentsDropdown,
CiKeyField,
+ GlAlert,
+ GlButton,
+ GlCollapse,
GlDeprecatedButton,
- GlModal,
- GlFormSelect,
+ GlFormCheckbox,
GlFormGroup,
GlFormInput,
+ GlFormSelect,
GlFormTextarea,
- GlFormCheckbox,
- GlLink,
GlIcon,
+ GlLink,
+ GlModal,
+ GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
tokens: awsTokens,
tokenList: awsTokenList,
+ awsTipMessage: AWS_TIP_MESSAGE,
+ data() {
+ return {
+ isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
+ };
+ },
computed: {
...mapState([
'projectId',
@@ -47,7 +67,16 @@ export default {
'maskableRegex',
'selectedEnvironment',
'isProtectedByDefault',
+ 'awsLogoSvgPath',
+ 'awsTipDeployLink',
+ 'awsTipCommandsLink',
+ 'awsTipLearnLink',
+ 'protectedEnvironmentVariablesLink',
+ 'maskedEnvironmentVariablesLink',
]),
+ isTipVisible() {
+ return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variableData.key);
+ },
canSubmit() {
return (
this.variableValidationState &&
@@ -126,6 +155,10 @@ export default {
'setSelectedEnvironment',
'setVariableProtected',
]),
+ dismissTip() {
+ Cookies.set(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
+ this.isTipDismissed = true;
+ },
deleteVarAndClose() {
this.deleteVariable(this.variableBeingEdited);
this.hideModal();
@@ -232,10 +265,10 @@ export default {
<gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
<gl-form-checkbox v-model="variableData.protected" class="mb-0">
{{ __('Protect variable') }}
- <gl-link href="/help/ci/variables/README#protected-environment-variables">
+ <gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
<gl-icon name="question" :size="12" />
</gl-link>
- <p class="prepend-top-4 text-secondary">
+ <p class="gl-mt-2 text-secondary">
{{ __('Export variable to pipelines running on protected branches and tags only.') }}
</p>
</gl-form-checkbox>
@@ -246,10 +279,10 @@ export default {
data-qa-selector="ci_variable_masked_checkbox"
>
{{ __('Mask variable') }}
- <gl-link href="/help/ci/variables/README#masked-variables">
+ <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">
<gl-icon name="question" :size="12" />
</gl-link>
- <p class="prepend-top-4 append-bottom-0 text-secondary">
+ <p class="gl-mt-2 gl-mb-0 text-secondary">
{{ __('Variable will be masked in job logs.') }}
<span
:class="{
@@ -258,13 +291,52 @@ export default {
>
{{ __('Requires values to meet regular expression requirements.') }}</span
>
- <gl-link href="/help/ci/variables/README#masked-variables">{{
+ <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{
__('More information')
}}</gl-link>
</p>
</gl-form-checkbox>
</gl-form-group>
</form>
+ <gl-collapse :visible="isTipVisible">
+ <gl-alert
+ :title="__('Deploying to AWS is easy with GitLab')"
+ variant="tip"
+ data-testid="aws-guidance-tip"
+ @dismiss="dismissTip"
+ >
+ <div class="gl-display-flex gl-flex-direction-row">
+ <div>
+ <p>
+ <gl-sprintf :message="$options.awsTipMessage">
+ <template #deployLink="{ content }">
+ <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #commandsLink="{ content }">
+ <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ <gl-button
+ :href="awsTipLearnLink"
+ target="_blank"
+ category="secondary"
+ variant="info"
+ class="gl-overflow-wrap-break"
+ >{{ __('Learn more about deploying to AWS') }}</gl-button
+ >
+ </p>
+ </div>
+ <img
+ class="gl-mt-3"
+ :alt="__('Amazon Web Services Logo')"
+ :src="awsLogoSvgPath"
+ height="32"
+ />
+ </div>
+ </gl-alert>
+ </gl-collapse>
<template #modal-footer>
<gl-deprecated-button @click="hideModal">{{ __('Cancel') }}</gl-deprecated-button>
<gl-deprecated-button
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index 7eb791f97e4..7b703c5ede1 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -170,7 +170,7 @@ export default {
v-if="tableIsNotEmpty"
ref="secret-value-reveal-button"
data-qa-selector="reveal_ci_variable_value_button"
- class="append-right-8"
+ class="gl-mr-3"
@click="toggleValues(!valuesHidden)"
>{{ valuesButtonText }}</gl-deprecated-button
>
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index a4db6481720..ef304c7ccee 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -15,7 +15,13 @@ export const types = {
allEnvironmentsType: '*',
};
+export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed';
+export const AWS_TIP_MESSAGE = __(
+ '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.',
+);
+
// AWS TOKEN CONSTANTS
export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID';
export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION';
export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY';
+export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY];
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index 2b4a56a4e6d..a28b52d6b57 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -3,9 +3,21 @@ import CiVariableSettings from './components/ci_variable_settings.vue';
import createStore from './store';
import { parseBoolean } from '~/lib/utils/common_utils';
-export default () => {
- const el = document.getElementById('js-ci-project-variables');
- const { endpoint, projectId, group, maskableRegex, protectedByDefault } = el.dataset;
+export default (containerId = 'js-ci-project-variables') => {
+ const containerEl = document.getElementById(containerId);
+ const {
+ endpoint,
+ projectId,
+ group,
+ maskableRegex,
+ protectedByDefault,
+ awsLogoSvgPath,
+ awsTipDeployLink,
+ awsTipCommandsLink,
+ awsTipLearnLink,
+ protectedEnvironmentVariablesLink,
+ maskedEnvironmentVariablesLink,
+ } = containerEl.dataset;
const isGroup = parseBoolean(group);
const isProtectedByDefault = parseBoolean(protectedByDefault);
@@ -15,10 +27,16 @@ export default () => {
isGroup,
maskableRegex,
isProtectedByDefault,
+ awsLogoSvgPath,
+ awsTipDeployLink,
+ awsTipCommandsLink,
+ awsTipLearnLink,
+ protectedEnvironmentVariablesLink,
+ maskedEnvironmentVariablesLink,
});
return new Vue({
- el,
+ el: containerEl,
store,
render(createElement) {
return createElement(CiVariableSettings);
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index d8bfbdb458c..f15efb2fdeb 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -468,6 +468,11 @@ export default class Clusters {
return;
}
+ if (appId === KNATIVE && !params.hostname && !params.pages_domain_id) {
+ reject(s__('ClusterIntegration|You must specify a domain before you can install Knative.'));
+ return;
+ }
+
resolve();
});
}
@@ -520,6 +525,7 @@ export default class Clusters {
this.store.updateAppProperty(appId, 'isEditingDomain', true);
this.store.updateAppProperty(appId, 'hostname', domain);
this.store.updateAppProperty(appId, 'pagesDomain', domainId ? { id: domainId, domain } : null);
+ this.store.updateAppProperty(appId, 'validationError', null);
}
setCrossplaneProviderStack(data) {
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 53bc079a4e1..ba6de41e025 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -1,23 +1,24 @@
<script>
-/* eslint-disable vue/require-default-prop */
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlLink, GlModalDirective } from '@gitlab/ui';
+import { GlLink, GlModalDirective, GlSprintf } from '@gitlab/ui';
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 UninstallApplicationButton from './uninstall_application_button.vue';
import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue';
+import UpdateApplicationConfirmationModal from './update_application_confirmation_modal.vue';
-import { APPLICATION_STATUS } from '../constants';
+import { APPLICATION_STATUS, ELASTIC_STACK } from '../constants';
export default {
components: {
loadingButton,
identicon,
GlLink,
+ GlSprintf,
UninstallApplicationButton,
UninstallApplicationConfirmationModal,
+ UpdateApplicationConfirmationModal,
},
directives: {
GlModalDirective,
@@ -34,15 +35,17 @@ export default {
titleLink: {
type: String,
required: false,
+ default: '',
},
manageLink: {
type: String,
required: false,
+ default: '',
},
logoUrl: {
type: String,
required: false,
- default: null,
+ default: '',
},
disabled: {
type: Boolean,
@@ -57,14 +60,17 @@ export default {
status: {
type: String,
required: false,
+ default: '',
},
statusReason: {
type: String,
required: false,
+ default: '',
},
requestReason: {
type: String,
required: false,
+ default: '',
},
installed: {
type: Boolean,
@@ -76,17 +82,15 @@ export default {
required: false,
default: false,
},
- installedVia: {
- type: String,
- required: false,
- },
version: {
type: String,
required: false,
+ default: '',
},
chartRepo: {
type: String,
required: false,
+ default: '',
},
updateAvailable: {
type: Boolean,
@@ -204,15 +208,6 @@ export default {
return sprintf(errorDescription, { title: this.title });
},
- versionLabel() {
- if (this.updateFailed) {
- return __('Update failed');
- } else if (this.isUpdating) {
- return __('Updating');
- }
-
- return this.updateSuccessful ? __('Updated to') : __('Updated');
- },
updateFailureDescription() {
return s__('ClusterIntegration|Update failed. Please check the logs and try again.');
},
@@ -233,6 +228,17 @@ export default {
return label;
},
+ updatingNeedsConfirmation() {
+ if (this.version) {
+ const majorVersion = parseInt(this.version.split('.')[0], 10);
+
+ if (!Number.isNaN(majorVersion)) {
+ return this.id === ELASTIC_STACK && majorVersion < 3;
+ }
+ }
+
+ return false;
+ },
isUpdating() {
// 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;
@@ -248,6 +254,12 @@ export default {
title: this.title,
});
},
+ updateModalId() {
+ return `update-${this.id}`;
+ },
+ uninstallModalId() {
+ return `uninstall-${this.id}`;
+ },
},
watch: {
updateSuccessful(updateSuccessful) {
@@ -263,12 +275,16 @@ export default {
},
methods: {
installClicked() {
+ if (this.disabled || this.installButtonDisabled) return;
+
eventHub.$emit('installApplication', {
id: this.id,
params: this.installApplicationRequestParams,
});
},
- updateClicked() {
+ updateConfirmed() {
+ if (this.isUpdating) return;
+
eventHub.$emit('updateApplication', {
id: this.id,
params: this.installApplicationRequestParams,
@@ -294,7 +310,7 @@ export default {
:data-qa-selector="id"
>
<div class="gl-responsive-table-row-layout" role="row">
- <div class="table-section append-right-8 section-align-top" role="gridcell">
+ <div class="table-section gl-mr-3 section-align-top" role="gridcell">
<img
v-if="hasLogo"
:src="logoUrl"
@@ -315,14 +331,12 @@ export default {
>
<span v-else class="js-cluster-application-title">{{ title }}</span>
</strong>
- <span
- v-if="installedVia"
- class="js-cluster-application-installed-via"
- v-html="installedVia"
- ></span>
- <slot name="description"></slot>
+ <slot name="installedVia"></slot>
+ <div>
+ <slot name="description"></slot>
+ </div>
<div v-if="hasError" class="cluster-application-error text-danger prepend-top-10">
- <p class="js-cluster-application-general-error-message append-bottom-0">
+ <p class="js-cluster-application-general-error-message gl-mb-0">
{{ generalErrorDescription }}
</p>
<ul v-if="statusReason || requestReason">
@@ -340,14 +354,20 @@ export default {
v-if="shouldShowUpdateDetails"
class="form-text text-muted label p-0 js-cluster-application-update-details"
>
- {{ versionLabel }}
- <gl-link
- v-if="updateSuccessful"
- :href="chartRepo"
- target="_blank"
- class="js-cluster-application-update-version"
- >chart v{{ version }}</gl-link
- >
+ <template v-if="updateFailed">{{ __('Update failed') }}</template>
+ <template v-else-if="isUpdating">{{ __('Updating') }}</template>
+ <template v-else>
+ <gl-sprintf :message="__('Updated to %{linkStart}chart v%{linkEnd}')">
+ <template #link="{ content }">
+ <gl-link
+ :href="chartRepo"
+ target="_blank"
+ class="js-cluster-application-update-version"
+ >{{ content }}{{ version }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </template>
</div>
<div
@@ -356,14 +376,36 @@ export default {
>
{{ updateFailureDescription }}
</div>
- <loading-button
- v-if="updateAvailable || updateFailed || isUpdating"
- class="btn btn-primary js-cluster-application-update-button mt-2"
- :loading="isUpdating"
- :disabled="isUpdating"
- :label="updateButtonLabel"
- @click="updateClicked"
- />
+ <template v-if="updateAvailable || updateFailed || isUpdating">
+ <template v-if="updatingNeedsConfirmation">
+ <loading-button
+ v-gl-modal-directive="updateModalId"
+ class="btn btn-primary js-cluster-application-update-button mt-2"
+ :loading="isUpdating"
+ :disabled="isUpdating"
+ :label="updateButtonLabel"
+ data-qa-selector="update_button_with_confirmation"
+ :data-qa-application="id"
+ />
+
+ <update-application-confirmation-modal
+ :application="id"
+ :application-title="title"
+ @confirm="updateConfirmed()"
+ />
+ </template>
+
+ <loading-button
+ v-else
+ class="btn btn-primary js-cluster-application-update-button mt-2"
+ :loading="isUpdating"
+ :disabled="isUpdating"
+ :label="updateButtonLabel"
+ data-qa-selector="update_button"
+ :data-qa-application="id"
+ @click="updateConfirmed"
+ />
+ </template>
</div>
</div>
<div
@@ -389,7 +431,7 @@ export default {
/>
<uninstall-application-button
v-if="displayUninstallButton"
- v-gl-modal-directive="'uninstall-' + id"
+ v-gl-modal-directive="uninstallModalId"
:status="status"
data-qa-selector="uninstall_button"
:data-qa-application="id"
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index f11502a7dde..214906021ad 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -1,21 +1,16 @@
<script>
-import { escape } from 'lodash';
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 { GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui';
import gitlabLogo from 'images/cluster_app_logos/gitlab.png';
import helmLogo from 'images/cluster_app_logos/helm.png';
-import jeagerLogo from 'images/cluster_app_logos/jeager.png';
import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png';
import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png';
import certManagerLogo from 'images/cluster_app_logos/cert_manager.png';
import crossplaneLogo from 'images/cluster_app_logos/crossplane.png';
import knativeLogo from 'images/cluster_app_logos/knative.png';
-import meltanoLogo from 'images/cluster_app_logos/meltano.png';
import prometheusLogo from 'images/cluster_app_logos/prometheus.png';
import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png';
import fluentdLogo from 'images/cluster_app_logos/fluentd.png';
-import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import KnativeDomainEditor from './knative_domain_editor.vue';
@@ -30,6 +25,8 @@ export default {
applicationRow,
clipboardButton,
GlLoadingIcon,
+ GlSprintf,
+ GlLink,
KnativeDomainEditor,
CrossplaneProviderStack,
IngressModsecuritySettings,
@@ -92,25 +89,7 @@ export default {
default: false,
},
},
- data: () => ({
- elasticsearchLogo,
- gitlabLogo,
- helmLogo,
- jeagerLogo,
- jupyterhubLogo,
- kubernetesLogo,
- certManagerLogo,
- crossplaneLogo,
- knativeLogo,
- meltanoLogo,
- prometheusLogo,
- elasticStackLogo,
- fluentdLogo,
- }),
computed: {
- isProjectCluster() {
- return this.type === CLUSTER_TYPE.PROJECT;
- },
managedAppsLocalTillerEnabled() {
return Boolean(gon.features?.managedAppsLocalTiller);
},
@@ -133,84 +112,12 @@ export default {
certManagerInstalled() {
return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED;
},
- crossplaneInstalled() {
- return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED;
- },
- ingressDescription() {
- return sprintf(
- escape(
- s__(
- `ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}.`,
- ),
- ),
- {
- pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb"
- target="_blank" rel="noopener noreferrer">
- ${escape(s__('ClusterIntegration|pricing'))}</a>`,
- },
- false,
- );
- },
- certManagerDescription() {
- return sprintf(
- escape(
- s__(
- `ClusterIntegration|Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates.
- Installing Cert-Manager on your cluster will issue a certificate by %{letsEncrypt} and ensure that certificates
- are valid and up-to-date.`,
- ),
- ),
- {
- letsEncrypt: `<a href="https://letsencrypt.org/"
- target="_blank" rel="noopener noreferrer">
- ${escape(s__("ClusterIntegration|Let's Encrypt"))}</a>`,
- },
- false,
- );
- },
- crossplaneDescription() {
- return sprintf(
- escape(
- s__(
- `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{kubectl} or %{gitlabIntegrationLink}.
-Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`,
- ),
- ),
- {
- gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane"
- target="_blank" rel="noopener noreferrer">
- ${escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`,
- kubectl: `<code>kubectl</code>`,
- },
- false,
- );
- },
-
- prometheusDescription() {
- return sprintf(
- escape(
- s__(
- `ClusterIntegration|Prometheus is an open-source monitoring system
- with %{gitlabIntegrationLink} to monitor deployed applications.`,
- ),
- ),
- {
- gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html"
- target="_blank" rel="noopener noreferrer">
- ${escape(s__('ClusterIntegration|GitLab Integration'))}</a>`,
- },
- false,
- );
- },
jupyterInstalled() {
return this.applications.jupyter.status === APPLICATION_STATUS.INSTALLED;
},
jupyterHostname() {
return this.applications.jupyter.hostname;
},
- elasticStackInstalled() {
- return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED;
- },
knative() {
return this.applications.knative;
},
@@ -220,29 +127,10 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
cloudRun() {
return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative;
},
- installedVia() {
- if (this.cloudRun) {
- return sprintf(
- escape(s__(`ClusterIntegration|installed via %{installed_via}`)),
- {
- installed_via: `<a href="${
- this.cloudRunHelpPath
- }" target="_blank" rel="noopener noreferrer">${escape(
- s__('ClusterIntegration|Cloud Run'),
- )}</a>`,
- },
- false,
- );
- }
- return null;
- },
ingress() {
return this.applications.ingress;
},
},
- created() {
- this.helmInstallIllustration = helmInstallIllustration;
- },
methods: {
saveKnativeDomain() {
eventHub.$emit('saveKnativeDomain', {
@@ -267,24 +155,37 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
});
},
},
+ logos: {
+ gitlabLogo,
+ helmLogo,
+ jupyterhubLogo,
+ kubernetesLogo,
+ certManagerLogo,
+ crossplaneLogo,
+ knativeLogo,
+ prometheusLogo,
+ elasticStackLogo,
+ fluentdLogo,
+ },
+ helmInstallIllustration,
};
</script>
<template>
<section id="cluster-applications">
- <p class="append-bottom-0">
+ <p class="gl-mb-0">
{{
s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster.
Helm Tiller is required to install any of the following applications.`)
}}
- <a :href="helpPath">{{ __('More information') }}</a>
+ <gl-link :href="helpPath">{{ __('More information') }}</gl-link>
</p>
<div class="cluster-application-list prepend-top-10">
<application-row
v-if="!managedAppsLocalTillerEnabled"
id="helm"
- :logo-url="helmLogo"
+ :logo-url="$options.logos.helmLogo"
:title="applications.helm.title"
:status="applications.helm.status"
:status-reason="applications.helm.statusReason"
@@ -298,17 +199,17 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
class="rounded-top"
title-link="https://docs.helm.sh/"
>
- <div slot="description">
+ <template #description>
{{
s__(`ClusterIntegration|Helm streamlines installing
and managing Kubernetes applications.
Tiller runs inside of your Kubernetes Cluster,
and manages releases of your charts.`)
}}
- </div>
+ </template>
</application-row>
<div v-show="!helmInstalled" class="cluster-application-warning">
- <div class="svg-container" v-html="helmInstallIllustration"></div>
+ <div class="svg-container" v-html="$options.helmInstallIllustration"></div>
{{
s__(`ClusterIntegration|You must first install Helm Tiller before
installing the applications below`)
@@ -316,7 +217,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
</div>
<application-row
:id="ingressId"
- :logo-url="kubernetesLogo"
+ :logo-url="$options.logos.kubernetesLogo"
:title="applications.ingress.title"
:status="applications.ingress.status"
:status-reason="applications.ingress.statusReason"
@@ -335,7 +236,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:updateable="false"
title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
>
- <div slot="description">
+ <template #description>
<p>
{{
s__(`ClusterIntegration|Ingress gives you a way to route
@@ -352,27 +253,29 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
<template v-if="ingressInstalled">
<div class="form-group">
<label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label>
- <div v-if="ingressExternalEndpoint" class="input-group">
- <input
- id="ingress-endpoint"
- :value="ingressExternalEndpoint"
- type="text"
- class="form-control js-endpoint"
- readonly
- />
- <span class="input-group-append">
- <clipboard-button
- :text="ingressExternalEndpoint"
- :title="s__('ClusterIntegration|Copy Ingress Endpoint')"
- class="input-group-text js-clipboard-btn"
+ <div class="input-group">
+ <template v-if="ingressExternalEndpoint">
+ <input
+ id="ingress-endpoint"
+ :value="ingressExternalEndpoint"
+ type="text"
+ class="form-control js-endpoint"
+ readonly
/>
- </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-ingress-ip-loading-icon"
- />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="ingressExternalEndpoint"
+ :title="s__('ClusterIntegration|Copy Ingress Endpoint')"
+ class="input-group-text js-clipboard-btn"
+ />
+ </span>
+ </template>
+ <template v-else>
+ <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"
+ />
+ </template>
</div>
<p class="form-text text-muted">
{{
@@ -380,9 +283,9 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
generated endpoint in order to access
your application after it has been deployed.`)
}}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
+ <gl-link :href="ingressDnsHelpPath" target="_blank">
{{ __('More information') }}
- </a>
+ </gl-link>
</p>
</div>
@@ -392,21 +295,35 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
the process of being assigned. Please check your Kubernetes
cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
}}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
+ <gl-link :href="ingressDnsHelpPath" target="_blank">
{{ __('More information') }}
- </a>
+ </gl-link>
</p>
</template>
- <template v-if="!ingressInstalled">
+ <template v-else>
<div class="bs-callout bs-callout-info">
- <strong v-html="ingressDescription"></strong>
+ <strong data-testid="ingressCostWarning">
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{linkStart}pricing%{linkEnd}.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link href="https://cloud.google.com/compute/pricing#lb" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </strong>
</div>
</template>
- </div>
+ </template>
</application-row>
<application-row
id="cert_manager"
- :logo-url="certManagerLogo"
+ :logo-url="$options.logos.certManagerLogo"
:title="applications.cert_manager.title"
:status="applications.cert_manager.status"
:status-reason="applications.cert_manager.statusReason"
@@ -421,40 +338,50 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:disabled="!helmInstalled"
title-link="https://cert-manager.readthedocs.io/en/latest/#"
>
- <template>
- <div slot="description">
- <p v-html="certManagerDescription"></p>
- <div class="form-group">
- <label for="cert-manager-issuer-email">
- {{ s__('ClusterIntegration|Issuer Email') }}
- </label>
- <div class="input-group">
- <input
- v-model="applications.cert_manager.email"
- :readonly="certManagerInstalled"
- type="text"
- class="form-control js-email"
- />
- </div>
- <p class="form-text text-muted">
- {{
- s__(`ClusterIntegration|Issuers represent a certificate authority.
- 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
- >
- </p>
+ <template #description>
+ <p data-testid="certManagerDescription">
+ <gl-sprintf
+ :message="
+ s__(`ClusterIntegration|Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates.
+ Installing Cert-Manager on your cluster will issue a certificate by %{linkStart}Let's Encrypt%{linkEnd} and ensure that certificates
+ are valid and up-to-date.`)
+ "
+ >
+ <template #link="{ content }">
+ <gl-link href="https://letsencrypt.org/" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div class="form-group">
+ <label for="cert-manager-issuer-email">
+ {{ s__('ClusterIntegration|Issuer Email') }}
+ </label>
+ <div class="input-group">
+ <input
+ id="cert-manager-issuer-email"
+ v-model="applications.cert_manager.email"
+ :readonly="certManagerInstalled"
+ type="text"
+ class="form-control js-email"
+ />
</div>
+ <p class="form-text text-muted">
+ {{
+ s__(`ClusterIntegration|Issuers represent a certificate authority.
+ You must provide an email address for your Issuer.`)
+ }}
+ <gl-link
+ href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email"
+ target="_blank"
+ >{{ __('More information') }}</gl-link
+ >
+ </p>
</div>
</template>
</application-row>
<application-row
id="prometheus"
- :logo-url="prometheusLogo"
+ :logo-url="$options.logos.prometheusLogo"
:title="applications.prometheus.title"
:manage-link="managePrometheusPath"
:status="applications.prometheus.status"
@@ -469,11 +396,28 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:disabled="!helmInstalled"
title-link="https://prometheus.io/docs/introduction/overview/"
>
- <div slot="description" v-html="prometheusDescription"></div>
+ <template #description>
+ <span data-testid="prometheusDescription">
+ <gl-sprintf
+ :message="
+ s__(`ClusterIntegration|Prometheus is an open-source monitoring system
+ with %{linkStart}GitLab Integration%{linkEnd} to monitor deployed applications.`)
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html"
+ target="_blank"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
</application-row>
<application-row
id="runner"
- :logo-url="gitlabLogo"
+ :logo-url="$options.logos.gitlabLogo"
:title="applications.runner.title"
:status="applications.runner.status"
:status-reason="applications.runner.statusReason"
@@ -492,18 +436,18 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:disabled="!helmInstalled"
title-link="https://docs.gitlab.com/runner/"
>
- <div slot="description">
+ <template #description>
{{
s__(`ClusterIntegration|GitLab Runner connects to the
repository and executes CI/CD jobs,
pushing results back and deploying
applications to production.`)
}}
- </div>
+ </template>
</application-row>
<application-row
id="crossplane"
- :logo-url="crossplaneLogo"
+ :logo-url="$options.logos.crossplaneLogo"
:title="applications.crossplane.title"
:status="applications.crossplane.status"
:status-reason="applications.crossplane.statusReason"
@@ -518,19 +462,37 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:disabled="!helmInstalled"
title-link="https://crossplane.io"
>
- <template>
- <div slot="description">
- <p v-html="crossplaneDescription"></p>
- <div class="form-group">
- <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" />
- </div>
+ <template #description>
+ <p data-testid="crossplaneDescription">
+ <gl-sprintf
+ :message="
+ s__(
+ `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{codeStart}kubectl%{codeEnd} or %{linkStart}GitLab Integration%{linkEnd}.
+ Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`,
+ )
+ "
+ >
+ <template #code="{content}">
+ <code>{{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link
+ href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane"
+ target="_blank"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ <div class="form-group">
+ <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" />
</div>
</template>
</application-row>
<application-row
id="jupyter"
- :logo-url="jupyterhubLogo"
+ :logo-url="$options.logos.jupyterhubLogo"
:title="applications.jupyter.title"
:status="applications.jupyter.status"
:status-reason="applications.jupyter.statusReason"
@@ -545,7 +507,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:disabled="!helmInstalled"
title-link="https://jupyterhub.readthedocs.io/en/stable/"
>
- <div slot="description">
+ <template #description>
<p>
{{
s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
@@ -562,6 +524,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
<div class="input-group">
<input
+ id="jupyter-hostname"
v-model="applications.jupyter.hostname"
:readonly="jupyterInstalled"
type="text"
@@ -581,17 +544,17 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
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">
+ <gl-link :href="ingressDnsHelpPath" target="_blank">
{{ __('More information') }}
- </a>
+ </gl-link>
</p>
</div>
</template>
- </div>
+ </template>
</application-row>
<application-row
id="knative"
- :logo-url="knativeLogo"
+ :logo-url="$options.logos.knativeLogo"
:title="applications.knative.title"
:status="applications.knative.status"
:status-reason="applications.knative.statusReason"
@@ -603,7 +566,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
hostname: applications.knative.hostname,
pages_domain_id: applications.knative.pagesDomain && applications.knative.pagesDomain.id,
}"
- :installed-via="installedVia"
:uninstallable="applications.knative.uninstallable"
:uninstall-successful="applications.knative.uninstallSuccessful"
:uninstall-failed="applications.knative.uninstallFailed"
@@ -612,19 +574,14 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
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>
+ <template #description>
+ <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info">
+ {{
+ s__(`ClusterIntegration|You must have an RBAC-enabled cluster
+ to install Knative.`)
+ }}
+ <gl-link :href="helpPath" target="_blank">{{ __('More information') }}</gl-link>
+ </p>
<p>
{{
s__(`ClusterIntegration|Knative extends Kubernetes to provide
@@ -641,11 +598,22 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
@save="saveKnativeDomain"
@set="setKnativeDomain"
/>
- </div>
+ </template>
+ <template v-if="cloudRun" #installedVia>
+ <span data-testid="installedVia">
+ <gl-sprintf
+ :message="s__('ClusterIntegration|installed via %{linkStart}Cloud Run%{linkEnd}')"
+ >
+ <template #link="{ content }">
+ <gl-link :href="cloudRunHelpPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
</application-row>
<application-row
id="elastic_stack"
- :logo-url="elasticStackLogo"
+ :logo-url="$options.logos.elasticStackLogo"
:title="applications.elastic_stack.title"
:status="applications.elastic_stack.status"
:status-reason="applications.elastic_stack.statusReason"
@@ -664,7 +632,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:disabled="!helmInstalled"
title-link="https://gitlab.com/gitlab-org/charts/elastic-stack"
>
- <div slot="description">
+ <template #description>
<p>
{{
s__(
@@ -672,12 +640,12 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
)
}}
</p>
- </div>
+ </template>
</application-row>
<application-row
id="fluentd"
- :logo-url="fluentdLogo"
+ :logo-url="$options.logos.fluentdLogo"
:title="applications.fluentd.title"
:status="applications.fluentd.status"
:status-reason="applications.fluentd.statusReason"
@@ -699,7 +667,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:updateable="false"
title-link="https://github.com/helm/charts/tree/master/stable/fluentd"
>
- <div slot="description">
+ <template #description>
<p>
{{
s__(
@@ -717,7 +685,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:status="applications.fluentd.status"
:update-failed="applications.fluentd.updateFailed"
/>
- </div>
+ </template>
</application-row>
</div>
</section>
diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
index 1884b501a20..20f6210aba8 100644
--- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
+++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
@@ -215,10 +215,10 @@ export default {
</div>
<div class="form-group flex flex-wrap">
<gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged">
- <strong>{{ s__('ClusterIntegration|Send ModSecurity Logs') }}</strong>
+ <strong>{{ s__('ClusterIntegration|Send Web Application Firewall Logs') }}</strong>
</gl-form-checkbox>
<gl-form-checkbox :checked="ciliumLogEnabled" @input="ciliumLogChanged">
- <strong>{{ s__('ClusterIntegration|Send Cilium Logs') }}</strong>
+ <strong>{{ s__('ClusterIntegration|Send Container Network Policies Logs') }}</strong>
</gl-form-checkbox>
</div>
<div v-if="showButtons" class="mt-3">
diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
index c2f963f0b34..54f5468bdd0 100644
--- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
+++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
@@ -13,12 +13,12 @@ import {
GlIcon,
} from '@gitlab/ui';
import eventHub from '~/clusters/event_hub';
-import modSecurityLogo from 'images/cluster_app_logos/modsecurity.png';
+import modSecurityLogo from 'images/cluster_app_logos/gitlab.png';
const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS;
export default {
- title: 'ModSecurity Web Application Firewall',
+ title: __('Web Application Firewall'),
modsecurityUrl: 'https://modsecurity.org/about.html',
components: {
GlAlert,
@@ -168,7 +168,7 @@ export default {
}}
</gl-alert>
<div class="gl-responsive-table-row-layout" role="row">
- <div class="table-section append-right-8 section-align-top" role="gridcell">
+ <div class="table-section gl-mr-3 section-align-top" role="gridcell">
<img
:src="modSecurityLogo"
:alt="`${$options.title} logo`"
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index 8136704d13b..ac61cd8e242 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -82,6 +82,9 @@ export default {
showDomainsDropdown() {
return this.availableDomains.length > 0;
},
+ validationError() {
+ return this.knative.validationError;
+ },
},
watch: {
knativeUpdateSuccessful(updateSuccessful) {
@@ -157,6 +160,8 @@ export default {
type="text"
class="form-control js-knative-domainname"
/>
+
+ <span v-if="validationError" class="gl-field-error">{{ validationError }}</span>
</div>
<template v-if="knativeInstalled">
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
index 271f9f74838..c5375cbfbdc 100644
--- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -147,7 +147,7 @@ export default {
)
}}</span>
</template>
- <template slot="modal-footer">
+ <template #modal-footer>
<gl-deprecated-button variant="secondary" @click="handleCancel">{{
s__('Cancel')
}}</gl-deprecated-button>
diff --git a/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue
new file mode 100644
index 00000000000..04aa28e9b74
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import { ELASTIC_STACK } from '../constants';
+
+const CUSTOM_APP_WARNING_TEXT = {
+ [ELASTIC_STACK]: s__(
+ 'ClusterIntegration|Your Elasticsearch cluster will be re-created during this upgrade. Your logs will be re-indexed, and you will lose historical logs from hosts terminated in the last 30 days.',
+ ),
+};
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ application: {
+ type: String,
+ required: true,
+ },
+ applicationTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ title() {
+ return sprintf(s__('ClusterIntegration|Update %{appTitle}'), {
+ appTitle: this.applicationTitle,
+ });
+ },
+ warningText() {
+ return sprintf(
+ s__('ClusterIntegration|You are about to update %{appTitle} on your cluster.'),
+ {
+ appTitle: this.applicationTitle,
+ },
+ );
+ },
+ customAppWarningText() {
+ return CUSTOM_APP_WARNING_TEXT[this.application];
+ },
+ modalId() {
+ return `update-${this.application}`;
+ },
+ },
+ methods: {
+ confirmUpdate() {
+ this.$emit('confirm');
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ok-variant="danger"
+ cancel-variant="light"
+ :ok-title="title"
+ :modal-id="modalId"
+ :title="title"
+ @ok="confirmUpdate()"
+ >
+ {{ warningText }} <span v-html="customAppWarningText"></span>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index af3f1437c64..a3104038c17 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -1,23 +1,34 @@
<script>
+import * as Sentry from '@sentry/browser';
import { mapState, mapActions } from 'vuex';
-import { GlBadge, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
+import {
+ GlDeprecatedBadge as GlBadge,
+ GlLink,
+ GlLoadingIcon,
+ GlPagination,
+ GlSprintf,
+ GlTable,
+} from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { CLUSTER_TYPES, STATUSES } from '../constants';
import { __, sprintf } from '~/locale';
export default {
+ nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'),
+ nodeCpuText: __('%{totalCpu} (%{freeSpacePercentage}%{percentSymbol} free)'),
components: {
GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
+ GlSprintf,
GlTable,
},
directives: {
tooltip,
},
computed: {
- ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'totalCulsters']),
+ ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'providers', 'totalCulsters']),
currentPage: {
get() {
return this.page;
@@ -37,19 +48,18 @@ export default {
key: 'environment_scope',
label: __('Environment scope'),
},
- // Wait for backend to send these fields
- // {
- // key: 'size',
- // label: __('Size'),
- // },
- // {
- // key: 'cpu',
- // label: __('Total cores (vCPUs)'),
- // },
- // {
- // key: 'memory',
- // label: __('Total memory (GB)'),
- // },
+ {
+ key: 'node_size',
+ label: __('Nodes'),
+ },
+ {
+ key: 'total_cpu',
+ label: __('Total cores (CPUs)'),
+ },
+ {
+ key: 'total_memory',
+ label: __('Total memory (GB)'),
+ },
{
key: 'cluster_type',
label: __('Cluster level'),
@@ -66,14 +76,105 @@ export default {
},
methods: {
...mapActions(['fetchClusters', 'setPage']),
- statusClass(status) {
- const iconClass = STATUSES[status] || STATUSES.default;
- return iconClass.className;
+ k8sQuantityToGb(quantity) {
+ if (!quantity) {
+ return 0;
+ } else if (quantity.endsWith(__('Ki'))) {
+ return parseInt(quantity.substr(0, quantity.length - 2), 10) * 0.000001024;
+ } else if (quantity.endsWith(__('Mi'))) {
+ return parseInt(quantity.substr(0, quantity.length - 2), 10) * 0.001048576;
+ }
+
+ // We are trying to track quantity types coming from Kubernetes.
+ // Sentry will notify us if we are missing types.
+ throw new Error(`UnknownK8sMemoryQuantity:${quantity}`);
+ },
+ k8sQuantityToCpu(quantity) {
+ if (!quantity) {
+ return 0;
+ } else if (quantity.endsWith('m')) {
+ return parseInt(quantity.substr(0, quantity.length - 1), 10) / 1000.0;
+ } else if (quantity.endsWith('n')) {
+ return parseInt(quantity.substr(0, quantity.length - 1), 10) / 1000000000.0;
+ }
+
+ // We are trying to track quantity types coming from Kubernetes.
+ // Sentry will notify us if we are missing types.
+ throw new Error(`UnknownK8sCpuQuantity:${quantity}`);
+ },
+ selectedProvider(provider) {
+ return this.providers[provider] || this.providers.default;
},
statusTitle(status) {
const iconTitle = STATUSES[status] || STATUSES.default;
return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false);
},
+ totalMemoryAndUsage(nodes) {
+ try {
+ // For EKS node.usage will not be present unless the user manually
+ // install the metrics server
+ if (nodes && nodes[0].usage) {
+ let totalAllocatableMemory = 0;
+ let totalUsedMemory = 0;
+
+ nodes.reduce((total, node) => {
+ const allocatableMemoryQuantity = node.status.allocatable.memory;
+ const allocatableMemoryGb = this.k8sQuantityToGb(allocatableMemoryQuantity);
+ totalAllocatableMemory += allocatableMemoryGb;
+
+ const usedMemoryQuantity = node.usage.memory;
+ const usedMemoryGb = this.k8sQuantityToGb(usedMemoryQuantity);
+ totalUsedMemory += usedMemoryGb;
+
+ return null;
+ }, 0);
+
+ const freeSpacePercentage = (1 - totalUsedMemory / totalAllocatableMemory) * 100;
+
+ return {
+ totalMemory: totalAllocatableMemory.toFixed(2),
+ freeSpacePercentage: Math.round(freeSpacePercentage),
+ };
+ }
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+
+ return { totalMemory: null, freeSpacePercentage: null };
+ },
+ totalCpuAndUsage(nodes) {
+ try {
+ // For EKS node.usage will not be present unless the user manually
+ // install the metrics server
+ if (nodes && nodes[0].usage) {
+ let totalAllocatableCpu = 0;
+ let totalUsedCpu = 0;
+
+ nodes.reduce((total, node) => {
+ const allocatableCpuQuantity = node.status.allocatable.cpu;
+ const allocatableCpu = this.k8sQuantityToCpu(allocatableCpuQuantity);
+ totalAllocatableCpu += allocatableCpu;
+
+ const usedCpuQuantity = node.usage.cpu;
+ const usedCpuGb = this.k8sQuantityToCpu(usedCpuQuantity);
+ totalUsedCpu += usedCpuGb;
+
+ return null;
+ }, 0);
+
+ const freeSpacePercentage = (1 - totalUsedCpu / totalAllocatableCpu) * 100;
+
+ return {
+ totalCpu: totalAllocatableCpu.toFixed(2),
+ freeSpacePercentage: Math.round(freeSpacePercentage),
+ };
+ }
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+
+ return { totalCpu: null, freeSpacePercentage: null };
+ },
},
};
</script>
@@ -84,27 +185,68 @@ export default {
<section v-else>
<gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table">
<template #cell(name)="{ item }">
- <div class="d-flex flex-row-reverse flex-md-row js-status">
- <gl-link data-qa-selector="cluster" :data-qa-cluster-name="item.name" :href="item.path">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start js-status"
+ >
+ <img
+ :src="selectedProvider(item.provider_type).path"
+ :alt="selectedProvider(item.provider_type).text"
+ class="gl-w-6 gl-h-6 gl-display-flex gl-align-items-center"
+ />
+
+ <gl-link
+ data-qa-selector="cluster"
+ :data-qa-cluster-name="item.name"
+ :href="item.path"
+ class="gl-px-3"
+ >
{{ item.name }}
</gl-link>
<gl-loading-icon
- v-if="item.status === 'deleting'"
+ v-if="item.status === 'deleting' || item.status === 'creating'"
v-tooltip
:title="statusTitle(item.status)"
size="sm"
- class="mr-2 ml-md-2"
/>
- <div
- v-else
- v-tooltip
- class="cluster-status-indicator rounded-circle align-self-center gl-w-4 gl-h-4 mr-2 ml-md-2"
- :class="statusClass(item.status)"
- :title="statusTitle(item.status)"
- ></div>
</div>
</template>
+
+ <template #cell(node_size)="{ item }">
+ <span v-if="item.nodes">{{ item.nodes.length }}</span>
+ <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{
+ __('Unknown')
+ }}</small>
+ </template>
+
+ <template #cell(total_cpu)="{ item }">
+ <span v-if="item.nodes">
+ <gl-sprintf :message="$options.nodeCpuText">
+ <template #totalCpu>{{ totalCpuAndUsage(item.nodes).totalCpu }}</template>
+ <template #freeSpacePercentage>{{
+ totalCpuAndUsage(item.nodes).freeSpacePercentage
+ }}</template>
+ <template #percentSymbol
+ >%</template
+ >
+ </gl-sprintf>
+ </span>
+ </template>
+
+ <template #cell(total_memory)="{ item }">
+ <span v-if="item.nodes">
+ <gl-sprintf :message="$options.nodeMemoryText">
+ <template #totalMemory>{{ totalMemoryAndUsage(item.nodes).totalMemory }}</template>
+ <template #freeSpacePercentage>{{
+ totalMemoryAndUsage(item.nodes).freeSpacePercentage
+ }}</template>
+ <template #percentSymbol
+ >%</template
+ >
+ </gl-sprintf>
+ </span>
+ </template>
+
<template #cell(cluster_type)="{value}">
<gl-badge variant="light">
{{ value }}
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index eebcaa086f9..3e8ef3151a6 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -6,6 +6,8 @@ export const CLUSTER_TYPES = {
instance_type: __('Instance'),
};
+export const MAX_REQUESTS = 3;
+
export const STATUSES = {
default: { className: 'bg-white', title: __('Unknown') },
disabled: { className: 'disabled', title: __('Disabled') },
@@ -13,4 +15,5 @@ export const STATUSES = {
unreachable: { className: 'bg-danger', title: __('Unreachable') },
authentication_failure: { className: 'bg-warning', title: __('Authentication Failure') },
deleting: { title: __('Deleting') },
+ creating: { title: __('Creating') },
};
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index 67d0a33030b..51ad8769250 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -9,12 +9,10 @@ export default () => {
return;
}
- const { endpoint } = entryPoint.dataset;
-
// eslint-disable-next-line no-new
new Vue({
el: '#js-clusters-list-app',
- store: createStore({ endpoint }),
+ store: createStore(entryPoint.dataset),
render(createElement) {
return createElement(Clusters);
},
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 919625f69b4..5245c307c8c 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -2,10 +2,23 @@ import Poll from '~/lib/utils/poll';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import { __ } from '~/locale';
+import { MAX_REQUESTS } from '../constants';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import * as Sentry from '@sentry/browser';
import * as types from './mutation_types';
+const allNodesPresent = (clusters, retryCount) => {
+ /*
+ Nodes are coming from external Kubernetes clusters.
+ They may fail for reasons GitLab cannot control.
+ MAX_REQUESTS will ensure this poll stops at some point.
+ */
+ return retryCount > MAX_REQUESTS || clusters.every(cluster => cluster.nodes != null);
+};
+
export const fetchClusters = ({ state, commit }) => {
+ let retryCount = 0;
+
const poll = new Poll({
resource: {
fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint),
@@ -13,16 +26,40 @@ export const fetchClusters = ({ state, commit }) => {
data: `${state.endpoint}?page=${state.page}`,
method: 'fetchClusters',
successCallback: ({ data, headers }) => {
- if (data.clusters) {
- const normalizedHeaders = normalizeHeaders(headers);
- const paginationInformation = parseIntPagination(normalizedHeaders);
+ retryCount += 1;
+
+ try {
+ if (data.clusters) {
+ const normalizedHeaders = normalizeHeaders(headers);
+ const paginationInformation = parseIntPagination(normalizedHeaders);
+
+ commit(types.SET_CLUSTERS_DATA, { data, paginationInformation });
+ commit(types.SET_LOADING_STATE, false);
- commit(types.SET_CLUSTERS_DATA, { data, paginationInformation });
- commit(types.SET_LOADING_STATE, false);
+ if (allNodesPresent(data.clusters, retryCount)) {
+ poll.stop();
+ }
+ }
+ } catch (error) {
poll.stop();
+
+ Sentry.withScope(scope => {
+ scope.setTag('javascript_clusters_list', 'fetchClustersSuccessCallback');
+ Sentry.captureException(error);
+ });
}
},
- errorCallback: () => flash(__('An error occurred while loading clusters')),
+ errorCallback: response => {
+ poll.stop();
+
+ commit(types.SET_LOADING_STATE, false);
+ flash(__('Clusters|An error occurred while loading clusters'));
+
+ Sentry.withScope(scope => {
+ scope.setTag('javascript_clusters_list', 'fetchClustersErrorCallback');
+ Sentry.captureException(response);
+ });
+ },
});
poll.makeRequest();
diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js
index d590ea09e66..0023b43ed92 100644
--- a/app/assets/javascripts/clusters_list/store/state.js
+++ b/app/assets/javascripts/clusters_list/store/state.js
@@ -5,5 +5,10 @@ export default (initialState = {}) => ({
clusters: [],
clustersPerPage: 0,
page: 1,
+ providers: {
+ aws: { path: initialState.imgTagsAwsPath, text: initialState.imgTagsAwsText },
+ default: { path: initialState.imgTagsDefaultPath, text: initialState.imgTagsDefaultText },
+ gcp: { path: initialState.imgTagsGcpPath, text: initialState.imgTagsGcpText },
+ },
totalCulsters: 0,
});
diff --git a/app/assets/javascripts/code_navigation/components/doc_line.vue b/app/assets/javascripts/code_navigation/components/doc_line.vue
new file mode 100644
index 00000000000..69d398893d9
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/components/doc_line.vue
@@ -0,0 +1,22 @@
+<script>
+export default {
+ props: {
+ language: {
+ type: String,
+ required: true,
+ },
+ tokens: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="line" :lang="language">
+ <span v-for="(token, tokenIndex) in tokens" :key="tokenIndex" :class="token.class">{{
+ token.value
+ }}</span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue
index 7147ce227e8..df5f89e4faf 100644
--- a/app/assets/javascripts/code_navigation/components/popover.vue
+++ b/app/assets/javascripts/code_navigation/components/popover.vue
@@ -1,9 +1,11 @@
<script>
import { GlButton } from '@gitlab/ui';
+import DocLine from './doc_line.vue';
export default {
components: {
GlButton,
+ DocLine,
},
props: {
position: {
@@ -83,8 +85,7 @@ export default {
ref="code-output"
:class="$options.colorScheme"
class="border-0 bg-transparent m-0 code highlight"
- v-html="hover.value"
- ></pre>
+ ><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens"/></pre>
<p v-else ref="doc-output" class="p-3 m-0">
{{ hover.value }}
</p>
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index ddb129f36f4..542890d9b04 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, consistent-return, one-var, no-return-assign */
import $ from 'jquery';
+import 'jquery.waitforimages';
// Width where images must fits in, for 2-up this gets divided by 2
const availWidth = 900;
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index e5e1cbb1e62..df0fa1ae88b 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -70,7 +70,12 @@ whitelist.acronym = [];
whitelist.blockquote = [];
whitelist.del = [];
whitelist.ins = [];
-whitelist['gl-emoji'] = [];
+whitelist['gl-emoji'] = [
+ 'data-name',
+ 'data-unicode-version',
+ 'data-fallback-src',
+ 'data-fallback-sprite-class',
+];
// Whitelisting SVG tags and attributes
whitelist.svg = ['viewBox'];
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
index 25640f71af2..c4d663dfc8d 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -2,8 +2,6 @@ import 'jquery';
// common jQuery plugins
import 'jquery-ujs';
-import 'vendor/jquery.endless-scroll';
import 'jquery.caret'; // must be imported before at.js
import '@gitlab/at.js';
import 'vendor/jquery.scrollTo';
-import 'jquery.waitforimages';
diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
index e5c0d1e4970..f60be52d6ca 100644
--- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
+++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
@@ -66,7 +66,7 @@ export default {
</script>
<template>
<div class="row my-3">
- <h4 class="prepend-top-0 col-lg-8 offset-lg-2">{{ titleText }}</h4>
+ <h4 class="gl-mt-0 col-lg-8 offset-lg-2">{{ titleText }}</h4>
<form ref="form" class="col-lg-8 offset-lg-2" :action="customMetricsPath" method="post">
<custom-metrics-form-fields
:form-operation="formOperation"
diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/design_management/components/design_note_pin.vue
index 50ea69d52ce..0811397fbad 100644
--- a/app/assets/javascripts/design_management/components/design_note_pin.vue
+++ b/app/assets/javascripts/design_management/components/design_note_pin.vue
@@ -13,7 +13,7 @@ export default {
required: true,
},
label: {
- type: String,
+ type: Number,
required: false,
default: null,
},
@@ -47,7 +47,7 @@ export default {
'btn-transparent comment-indicator': isNewNote,
'js-image-badge badge badge-pill': !isNewNote,
}"
- class="position-absolute"
+ class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center"
type="button"
@mousedown="$emit('mousedown', $event)"
@mouseup="$emit('mouseup', $event)"
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index c6c5ee88a93..7e442bb295f 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -1,14 +1,19 @@
<script>
import { ApolloMutation } from 'vue-apollo';
+import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import allVersionsMixin from '../../mixins/all_versions';
import createNoteMutation from '../../graphql/mutations/createNote.mutation.graphql';
+import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
import DesignNote from './design_note.vue';
import DesignReplyForm from './design_reply_form.vue';
import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+import ToggleRepliesWidget from './toggle_replies_widget.vue';
export default {
components: {
@@ -16,6 +21,14 @@ export default {
DesignNote,
ReplyPlaceholder,
DesignReplyForm,
+ GlIcon,
+ GlLoadingIcon,
+ GlLink,
+ ToggleRepliesWidget,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
mixins: [allVersionsMixin],
props: {
@@ -31,21 +44,28 @@ export default {
type: String,
required: true,
},
- discussionIndex: {
- type: Number,
- required: true,
- },
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
+ resolvedDiscussionsExpanded: {
+ type: Boolean,
+ required: true,
+ },
+ discussionWithOpenForm: {
+ type: String,
+ required: true,
+ },
},
apollo: {
activeDiscussion: {
query: activeDiscussionQuery,
result({ data }) {
const discussionId = data.activeDiscussion.id;
+ if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) {
+ return;
+ }
// We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists
// We don't want scrollIntoView to be triggered from the discussion click itself
if (
@@ -66,6 +86,9 @@ export default {
discussionComment: '',
isFormRendered: false,
activeDiscussion: {},
+ isResolving: false,
+ shouldChangeResolvedStatus: false,
+ areRepliesCollapsed: this.discussion.resolved,
};
},
computed: {
@@ -87,6 +110,32 @@ export default {
isDiscussionHighlighted() {
return this.discussion.notes[0].id === this.activeDiscussion.id;
},
+ resolveCheckboxText() {
+ return this.discussion.resolved
+ ? s__('DesignManagement|Unresolve thread')
+ : s__('DesignManagement|Resolve thread');
+ },
+ firstNote() {
+ return this.discussion.notes[0];
+ },
+ discussionReplies() {
+ return this.discussion.notes.slice(1);
+ },
+ areRepliesShown() {
+ return !this.discussion.resolved || !this.areRepliesCollapsed;
+ },
+ resolveIconName() {
+ return this.discussion.resolved ? 'check-circle-filled' : 'check-circle';
+ },
+ isRepliesWidgetVisible() {
+ return this.discussion.resolved && this.discussionReplies.length > 0;
+ },
+ isReplyPlaceholderVisible() {
+ return this.areRepliesShown || !this.discussionReplies.length;
+ },
+ isFormVisible() {
+ return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id;
+ },
},
methods: {
addDiscussionComment(
@@ -106,17 +155,40 @@ export default {
onDone() {
this.discussionComment = '';
this.hideForm();
+ if (this.shouldChangeResolvedStatus) {
+ this.toggleResolvedStatus();
+ }
},
- onError(err) {
- this.$emit('error', err);
+ onCreateNoteError(err) {
+ this.$emit('createNoteError', err);
},
hideForm() {
this.isFormRendered = false;
this.discussionComment = '';
},
showForm() {
+ this.$emit('openForm', this.discussion.id);
this.isFormRendered = true;
},
+ toggleResolvedStatus() {
+ this.isResolving = true;
+ this.$apollo
+ .mutate({
+ mutation: toggleResolveDiscussionMutation,
+ variables: { id: this.discussion.id, resolve: !this.discussion.resolved },
+ })
+ .then(({ data }) => {
+ if (data.errors?.length > 0) {
+ this.$emit('resolveDiscussionError', data.errors[0]);
+ }
+ })
+ .catch(err => {
+ this.$emit('resolveDiscussionError', err);
+ })
+ .finally(() => {
+ this.isResolving = false;
+ });
+ },
},
createNoteMutation,
};
@@ -124,22 +196,71 @@ export default {
<template>
<div class="design-discussion-wrapper">
- <div class="badge badge-pill" type="button">{{ discussionIndex }}</div>
<div
- class="design-discussion bordered-box position-relative"
+ class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center"
+ :class="{ resolved: discussion.resolved }"
+ type="button"
+ >
+ {{ discussion.index }}
+ </div>
+ <ul
+ class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
data-qa-selector="design_discussion_content"
>
<design-note
- v-for="note in discussion.notes"
+ :note="firstNote"
+ :markdown-preview-path="markdownPreviewPath"
+ :is-resolving="isResolving"
+ :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
+ @error="$emit('updateNoteError', $event)"
+ >
+ <template v-if="discussion.resolvable" #resolveDiscussion>
+ <button
+ v-gl-tooltip
+ :class="{ 'is-active': discussion.resolved }"
+ :title="resolveCheckboxText"
+ :aria-label="resolveCheckboxText"
+ type="button"
+ class="line-resolve-btn note-action-button gl-mr-3"
+ data-testid="resolve-button"
+ @click.stop="toggleResolvedStatus"
+ >
+ <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" />
+ <gl-loading-icon v-else inline />
+ </button>
+ </template>
+ <template v-if="discussion.resolved" #resolvedStatus>
+ <p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message">
+ {{ __('Resolved by') }}
+ <gl-link
+ class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color"
+ :href="discussion.resolvedBy.webUrl"
+ target="_blank"
+ >{{ discussion.resolvedBy.name }}</gl-link
+ >
+ <time-ago-tooltip :time="discussion.resolvedAt" tooltip-placement="bottom" />
+ </p>
+ </template>
+ </design-note>
+ <toggle-replies-widget
+ v-if="isRepliesWidgetVisible"
+ :collapsed="areRepliesCollapsed"
+ :replies="discussionReplies"
+ @toggle="areRepliesCollapsed = !areRepliesCollapsed"
+ />
+ <design-note
+ v-for="note in discussionReplies"
+ v-show="areRepliesShown"
:key="note.id"
:note="note"
:markdown-preview-path="markdownPreviewPath"
+ :is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
@error="$emit('updateNoteError', $event)"
/>
- <div class="reply-wrapper">
+ <li v-show="isReplyPlaceholderVisible" class="reply-wrapper">
<reply-placeholder
- v-if="!isFormRendered"
+ v-if="!isFormVisible"
class="qa-discussion-reply"
:button-text="__('Reply...')"
@onClick="showForm"
@@ -153,7 +274,7 @@ export default {
}"
:update="addDiscussionComment"
@done="onDone"
- @error="onError"
+ @error="onCreateNoteError"
>
<design-reply-form
v-model="discussionComment"
@@ -161,9 +282,16 @@ export default {
:markdown-preview-path="markdownPreviewPath"
@submitForm="mutate"
@cancelForm="hideForm"
- />
+ >
+ <template v-if="discussion.resolvable" #resolveCheckbox>
+ <label data-testid="resolve-checkbox">
+ <input v-model="shouldChangeResolvedStatus" type="checkbox" />
+ {{ resolveCheckboxText }}
+ </label>
+ </template>
+ </design-reply-form>
</apollo-mutation>
- </div>
- </div>
+ </li>
+ </ul>
</div>
</template>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index c1c19c0a597..b1f3a43a66d 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -54,6 +54,9 @@ export default {
body: this.noteText,
};
},
+ isEditButtonVisible() {
+ return !this.isEditing && this.note.userPermissions.adminNote;
+ },
},
mounted() {
if (this.isNoteLinked) {
@@ -107,23 +110,28 @@ export default {
</template>
</span>
</div>
- <button
- v-if="!isEditing && note.userPermissions.adminNote"
- v-gl-tooltip
- type="button"
- title="Edit comment"
- class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
- @click="isEditing = true"
- >
- <gl-icon name="pencil" class="link-highlight" />
- </button>
+ <div class="gl-display-flex">
+ <slot name="resolveDiscussion"></slot>
+ <button
+ v-if="isEditButtonVisible"
+ v-gl-tooltip
+ type="button"
+ :title="__('Edit comment')"
+ class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
+ @click="isEditing = true"
+ >
+ <gl-icon name="pencil" class="link-highlight" />
+ </button>
+ </div>
</div>
- <div
- v-if="!isEditing"
- class="note-text js-note-text md"
- data-qa-selector="note_content"
- v-html="note.bodyHtml"
- ></div>
+ <template v-if="!isEditing">
+ <div
+ class="note-text js-note-text md"
+ data-qa-selector="note_content"
+ v-html="note.bodyHtml"
+ ></div>
+ <slot name="resolvedStatus"></slot>
+ </template>
<apollo-mutation
v-else
#default="{ mutate, loading }"
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 40be9867fee..756da7f55aa 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -107,7 +107,8 @@ export default {
</textarea>
</template>
</markdown-field>
- <div class="note-form-actions d-flex justify-content-between">
+ <slot name="resolveCheckbox"></slot>
+ <div class="note-form-actions gl-display-flex gl-justify-content-space-between">
<gl-deprecated-button
ref="submitButton"
:disabled="!hasValue || isSaving"
diff --git a/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue
new file mode 100644
index 00000000000..46c73e3eea8
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlIcon, GlButton, GlLink } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ name: 'ToggleNotesWidget',
+ components: {
+ GlIcon,
+ GlButton,
+ GlLink,
+ TimeAgoTooltip,
+ },
+ props: {
+ collapsed: {
+ type: Boolean,
+ required: true,
+ },
+ replies: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ lastReply() {
+ return this.replies[this.replies.length - 1];
+ },
+ iconName() {
+ return this.collapsed ? 'chevron-right' : 'chevron-down';
+ },
+ toggleText() {
+ return this.collapsed
+ ? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}`
+ : __('Collapse replies');
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3"
+ :class="{ expanded: !collapsed }"
+ data-testid="toggle-comments-wrapper"
+ >
+ <gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" />
+ <gl-button
+ variant="link"
+ class="toggle-comments-button gl-ml-2 gl-mr-2"
+ @click.stop="$emit('toggle')"
+ >
+ {{ toggleText }}
+ </gl-button>
+ <template v-if="collapsed">
+ <span class="gl-text-gray-700">{{ __('Last reply by') }}</span>
+ <gl-link
+ :href="lastReply.author.webUrl"
+ target="_blank"
+ class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2"
+ >
+ {{ lastReply.author.name }}
+ </gl-link>
+ <time-ago-tooltip
+ :time="lastReply.createdAt"
+ tooltip-placement="bottom"
+ class="gl-text-gray-700"
+ />
+ </template>
+ </li>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
index beb51647821..926e7c74802 100644
--- a/app/assets/javascripts/design_management/components/design_overlay.vue
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -33,6 +33,10 @@ export default {
required: false,
default: false,
},
+ resolvedDiscussionsExpanded: {
+ type: Boolean,
+ required: true,
+ },
},
apollo: {
activeDiscussion: {
@@ -140,7 +144,7 @@ export default {
},
onExistingNoteMove(e) {
const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId);
- if (!note) return;
+ if (!note || !this.canMoveNote(note)) return;
const { position } = note;
const { width, height } = position;
@@ -186,8 +190,6 @@ export default {
});
},
onNoteMousedown({ clientX, clientY }, note) {
- if (note && !this.canMoveNote(note)) return;
-
this.movingNoteStartPosition = {
noteId: note?.id,
discussionId: note?.discussion.id,
@@ -236,6 +238,9 @@ export default {
isNoteInactive(note) {
return this.activeDiscussion.id && this.activeDiscussion.id !== note.id;
},
+ designPinClass(note) {
+ return { inactive: this.isNoteInactive(note), resolved: note.resolved };
+ },
},
};
</script>
@@ -254,20 +259,23 @@ export default {
data-qa-selector="design_image_button"
@mouseup="onAddCommentMouseup"
></button>
- <design-note-pin
- v-for="(note, index) in notes"
- :key="note.id"
- :label="`${index + 1}`"
- :repositioning="isMovingNote(note.id)"
- :position="
- isMovingNote(note.id) && movingNoteNewPosition
- ? getNotePositionStyle(movingNoteNewPosition)
- : getNotePositionStyle(note.position)
- "
- :class="{ inactive: isNoteInactive(note) }"
- @mousedown.stop="onNoteMousedown($event, note)"
- @mouseup.stop="onNoteMouseup(note)"
- />
+ <template v-for="note in notes">
+ <design-note-pin
+ v-if="resolvedDiscussionsExpanded || !note.resolved"
+ :key="note.id"
+ :label="note.index"
+ :repositioning="isMovingNote(note.id)"
+ :position="
+ isMovingNote(note.id) && movingNoteNewPosition
+ ? getNotePositionStyle(movingNoteNewPosition)
+ : getNotePositionStyle(note.position)
+ "
+ :class="designPinClass(note)"
+ @mousedown.stop="onNoteMousedown($event, note)"
+ @mouseup.stop="onNoteMouseup(note)"
+ />
+ </template>
+
<design-note-pin
v-if="currentCommentForm"
:position="currentCommentPositionStyle"
diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue
index 5c113b3dbed..84dbb2809d9 100644
--- a/app/assets/javascripts/design_management/components/design_presentation.vue
+++ b/app/assets/javascripts/design_management/components/design_presentation.vue
@@ -35,6 +35,10 @@ export default {
required: false,
default: 1,
},
+ resolvedDiscussionsExpanded: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -54,7 +58,10 @@ export default {
},
computed: {
discussionStartingNotes() {
- return this.discussions.map(discussion => discussion.notes[0]);
+ return this.discussions.map(discussion => ({
+ ...discussion.notes[0],
+ index: discussion.index,
+ }));
},
currentCommentForm() {
return (this.isAnnotating && this.currentAnnotationPosition) || null;
@@ -305,6 +312,7 @@ export default {
:notes="discussionStartingNotes"
:current-comment-form="currentCommentForm"
:disable-commenting="isDraggingDesign"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="moveNote"
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
new file mode 100644
index 00000000000..333ad2557e8
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -0,0 +1,178 @@
+<script>
+import { s__ } from '~/locale';
+import Cookies from 'js-cookie';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
+import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
+import { extractDiscussions, extractParticipants } from '../utils/design_management_utils';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
+import DesignDiscussion from './design_notes/design_discussion.vue';
+import Participants from '~/sidebar/components/participants/participants.vue';
+
+export default {
+ components: {
+ DesignDiscussion,
+ Participants,
+ GlCollapse,
+ GlButton,
+ GlPopover,
+ },
+ props: {
+ design: {
+ type: Object,
+ required: true,
+ },
+ resolvedDiscussionsExpanded: {
+ type: Boolean,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)),
+ discussionWithOpenForm: '',
+ };
+ },
+ computed: {
+ discussions() {
+ return extractDiscussions(this.design.discussions);
+ },
+ issue() {
+ return {
+ ...this.design.issue,
+ webPath: this.design.issue.webPath.substr(1),
+ };
+ },
+ discussionParticipants() {
+ return extractParticipants(this.issue.participants);
+ },
+ resolvedDiscussions() {
+ return this.discussions.filter(discussion => discussion.resolved);
+ },
+ unresolvedDiscussions() {
+ return this.discussions.filter(discussion => !discussion.resolved);
+ },
+ resolvedCommentsToggleIcon() {
+ return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
+ },
+ },
+ methods: {
+ handleSidebarClick() {
+ this.isResolvedCommentsPopoverHidden = true;
+ Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 });
+ this.updateActiveDiscussion();
+ },
+ updateActiveDiscussion(id) {
+ this.$apollo.mutate({
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
+ },
+ });
+ },
+ closeCommentForm() {
+ this.comment = '';
+ this.$emit('closeCommentForm');
+ },
+ updateDiscussionWithOpenForm(id) {
+ this.discussionWithOpenForm = id;
+ },
+ },
+ resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
+ cookieKey: 'hide_design_resolved_comments_popover',
+};
+</script>
+
+<template>
+ <div class="image-notes" @click="handleSidebarClick">
+ <h2 class="gl-font-weight-bold gl-mt-0">
+ {{ issue.title }}
+ </h2>
+ <a
+ class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block"
+ :href="issue.webUrl"
+ >{{ issue.webPath }}</a
+ >
+ <participants
+ :participants="discussionParticipants"
+ :show-participant-label="false"
+ class="gl-mb-4"
+ />
+ <h2
+ v-if="unresolvedDiscussions.length === 0"
+ class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
+ data-testid="new-discussion-disclaimer"
+ >
+ {{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }}
+ </h2>
+ <design-discussion
+ v-for="discussion in unresolvedDiscussions"
+ :key="discussion.id"
+ :discussion="discussion"
+ :design-id="$route.params.id"
+ :noteable-id="design.id"
+ :markdown-preview-path="markdownPreviewPath"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :discussion-with-open-form="discussionWithOpenForm"
+ data-testid="unresolved-discussion"
+ @createNoteError="$emit('onDesignDiscussionError', $event)"
+ @updateNoteError="$emit('updateNoteError', $event)"
+ @resolveDiscussionError="$emit('resolveDiscussionError', $event)"
+ @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ @openForm="updateDiscussionWithOpenForm"
+ />
+ <template v-if="resolvedDiscussions.length > 0">
+ <gl-button
+ id="resolved-comments"
+ data-testid="resolved-comments"
+ :icon="resolvedCommentsToggleIcon"
+ variant="link"
+ class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4"
+ @click="$emit('toggleResolvedComments')"
+ >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
+ </gl-button>
+ <gl-popover
+ v-if="!isResolvedCommentsPopoverHidden"
+ :show="!isResolvedCommentsPopoverHidden"
+ target="resolved-comments"
+ container="popovercontainer"
+ placement="top"
+ :title="s__('DesignManagement|Resolved Comments')"
+ >
+ <p>
+ {{
+ s__(
+ 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
+ )
+ }}
+ </p>
+ <a href="#" rel="noopener noreferrer" target="_blank">{{
+ s__('DesignManagement|Learn more about resolving comments')
+ }}</a>
+ </gl-popover>
+ <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
+ <design-discussion
+ v-for="discussion in resolvedDiscussions"
+ :key="discussion.id"
+ :discussion="discussion"
+ :design-id="$route.params.id"
+ :noteable-id="design.id"
+ :markdown-preview-path="markdownPreviewPath"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :discussion-with-open-form="discussionWithOpenForm"
+ data-testid="resolved-discussion"
+ @error="$emit('onDesignDiscussionError', $event)"
+ @updateNoteError="$emit('updateNoteError', $event)"
+ @openForm="updateDiscussionWithOpenForm"
+ @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ />
+ </gl-collapse>
+ </template>
+ <slot name="replyForm"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
index e3c5e369170..68555104a3c 100644
--- a/app/assets/javascripts/design_management/components/upload/button.vue
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -41,7 +41,7 @@ export default {
variant="success"
@click="openFileUpload"
>
- {{ s__('DesignManagement|Add designs') }}
+ {{ s__('DesignManagement|Upload designs') }}
<gl-loading-icon v-if="isSaving" inline class="ml-1" />
</gl-deprecated-button>
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
index 59d34669ad7..21ff361a277 100644
--- a/app/assets/javascripts/design_management/constants.js
+++ b/app/assets/javascripts/design_management/constants.js
@@ -12,3 +12,5 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
pin: 'pin',
discussion: 'discussion',
};
+
+export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
index ca5b5a52c71..c1439c56ff5 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
@@ -1,6 +1,7 @@
#import "./designNote.fragment.graphql"
#import "./designList.fragment.graphql"
#import "./diffRefs.fragment.graphql"
+#import "./discussion_resolved_status.fragment.graphql"
fragment DesignItem on Design {
...DesignListItem
@@ -12,6 +13,7 @@ fragment DesignItem on Design {
nodes {
id
replyId
+ ...ResolvedStatus
notes {
nodes {
...DesignNote
diff --git a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql
index 2ad84f9cb17..cb7cfd89abf 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql
@@ -10,6 +10,7 @@ fragment DesignNote on Note {
body
bodyHtml
createdAt
+ resolved
position {
diffRefs {
...DesignDiffRefs
diff --git a/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql
new file mode 100644
index 00000000000..7483b508721
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql
@@ -0,0 +1,9 @@
+fragment ResolvedStatus on Discussion {
+ resolvable
+ resolved
+ resolvedAt
+ resolvedBy {
+ name
+ webUrl
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql
new file mode 100644
index 00000000000..d5f54ec9b58
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql
@@ -0,0 +1,17 @@
+#import "../fragments/designNote.fragment.graphql"
+#import "../fragments/discussion_resolved_status.fragment.graphql"
+
+mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) {
+ discussionToggleResolve(input: { id: $id, resolve: $resolve }) {
+ discussion {
+ id
+ ...ResolvedStatus
+ notes {
+ nodes {
+ ...DesignNote
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 7ff3271394d..fe121b6530a 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -1,17 +1,16 @@
<script>
-import { ApolloMutation } from 'vue-apollo';
import Mousetrap from 'mousetrap';
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { ApolloMutation } from 'vue-apollo';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import allVersionsMixin from '../../mixins/all_versions';
import Toolbar from '../../components/toolbar/index.vue';
-import DesignDiscussion from '../../components/design_notes/design_discussion.vue';
-import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
import DesignDestroyer from '../../components/design_destroyer.vue';
import DesignScaler from '../../components/design_scaler.vue';
-import Participants from '~/sidebar/components/participants/participants.vue';
import DesignPresentation from '../../components/design_presentation.vue';
+import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
+import DesignSidebar from '../../components/design_sidebar.vue';
import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
import appDataQuery from '../../graphql/queries/appData.query.graphql';
import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql';
@@ -20,7 +19,6 @@ import updateActiveDiscussionMutation from '../../graphql/mutations/update_activ
import {
extractDiscussions,
extractDesign,
- extractParticipants,
updateImageDiffNoteOptimisticResponse,
} from '../../utils/design_management_utils';
import {
@@ -43,15 +41,14 @@ import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
export default {
components: {
ApolloMutation,
+ DesignReplyForm,
DesignPresentation,
- DesignDiscussion,
DesignScaler,
DesignDestroyer,
Toolbar,
- DesignReplyForm,
GlLoadingIcon,
GlAlert,
- Participants,
+ DesignSidebar,
},
mixins: [allVersionsMixin],
props: {
@@ -69,6 +66,7 @@ export default {
errorMessage: '',
issueIid: '',
scale: 1,
+ resolvedDiscussionsExpanded: false,
};
},
apollo: {
@@ -103,20 +101,17 @@ export default {
return this.$apollo.queries.design.loading && !this.design.filename;
},
discussions() {
+ if (!this.design.discussions) {
+ return [];
+ }
return extractDiscussions(this.design.discussions);
},
- discussionParticipants() {
- return extractParticipants(this.design.issue.participants);
- },
markdownPreviewPath() {
return `/${this.projectPath}/preview_markdown?target_type=Issue`;
},
isSubmitButtonDisabled() {
return this.comment.trim().length === 0;
},
- renderDiscussions() {
- return this.discussions.length || this.annotationCoordinates;
- },
designVariables() {
return {
fullPath: this.projectPath,
@@ -144,18 +139,25 @@ export default {
},
};
},
- issue() {
- return {
- ...this.design.issue,
- webPath: this.design.issue.webPath.substr(1),
- };
- },
isAnnotating() {
return Boolean(this.annotationCoordinates);
},
+ resolvedDiscussions() {
+ return this.discussions.filter(discussion => discussion.resolved);
+ },
+ },
+ watch: {
+ resolvedDiscussions(val) {
+ if (!val.length) {
+ this.resolvedDiscussionsExpanded = false;
+ }
+ },
},
mounted() {
Mousetrap.bind('esc', this.closeDesign);
+ this.trackEvent();
+ // We need to reset the active discussion when opening a new design
+ this.updateActiveDiscussion();
},
beforeDestroy() {
Mousetrap.unbind('esc', this.closeDesign);
@@ -247,6 +249,9 @@ export default {
onDesignDeleteError(e) {
this.onError(designDeletionError({ singular: true }), e);
},
+ onResolveDiscussionError(e) {
+ this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
+ },
openCommentForm(annotationCoordinates) {
this.annotationCoordinates = annotationCoordinates;
},
@@ -278,23 +283,9 @@ export default {
},
});
},
- },
- beforeRouteEnter(to, from, next) {
- next(vm => {
- vm.trackEvent();
- });
- },
- beforeRouteUpdate(to, from, next) {
- this.trackEvent();
- this.closeCommentForm();
- // We need to reset the active discussion when opening a new design
- this.updateActiveDiscussion();
- next();
- },
- beforeRouteLeave(to, from, next) {
- // We need to reset the active discussion when moving to design list view
- this.updateActiveDiscussion();
- next();
+ toggleResolvedComments() {
+ this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
+ },
},
createImageDiffNoteMutation,
DESIGNS_ROUTE_NAME,
@@ -337,6 +328,7 @@ export default {
:discussions="discussions"
:is-annotating="isAnnotating"
:scale="scale"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="onMoveNote"
@@ -346,33 +338,19 @@ export default {
<design-scaler @scale="scale = $event" />
</div>
</div>
- <div class="image-notes" @click="updateActiveDiscussion()">
- <h2 class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0">
- {{ issue.title }}
- </h2>
- <a class="text-tertiary text-decoration-none mb-3 d-block" :href="issue.webUrl">{{
- issue.webPath
- }}</a>
- <participants
- :participants="discussionParticipants"
- :show-participant-label="false"
- class="mb-4"
- />
- <template v-if="renderDiscussions">
- <design-discussion
- v-for="(discussion, index) in discussions"
- :key="discussion.id"
- :discussion="discussion"
- :design-id="id"
- :noteable-id="design.id"
- :discussion-index="index + 1"
- :markdown-preview-path="markdownPreviewPath"
- @error="onDesignDiscussionError"
- @updateNoteError="onUpdateNoteError"
- @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
- />
+ <design-sidebar
+ :design="design"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :markdown-preview-path="markdownPreviewPath"
+ @onDesignDiscussionError="onDesignDiscussionError"
+ @onCreateImageDiffNoteError="onCreateImageDiffNoteError"
+ @updateNoteError="onUpdateNoteError"
+ @resolveDiscussionError="onResolveDiscussionError"
+ @toggleResolvedComments="toggleResolvedComments"
+ >
+ <template #replyForm>
<apollo-mutation
- v-if="annotationCoordinates"
+ v-if="isAnnotating"
#default="{ mutate, loading }"
:mutation="$options.createImageDiffNoteMutation"
:variables="{
@@ -388,13 +366,9 @@ export default {
:markdown-preview-path="markdownPreviewPath"
@submitForm="mutate"
@cancelForm="closeCommentForm"
- />
- </apollo-mutation>
- </template>
- <h2 v-else class="new-discussion-disclaimer gl-font-base m-0">
- {{ __("Click the image where you'd like to start a new discussion") }}
- </h2>
- </div>
+ /> </apollo-mutation
+ ></template>
+ </design-sidebar>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 7d419bc3ded..922c800009f 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -318,6 +318,6 @@ export default {
</li>
</ol>
</div>
- <router-view />
+ <router-view :key="$route.fullPath" />
</div>
</template>
diff --git a/app/assets/javascripts/design_management/router/index.js b/app/assets/javascripts/design_management/router/index.js
index 7dc92f55d47..7494da002c8 100644
--- a/app/assets/javascripts/design_management/router/index.js
+++ b/app/assets/javascripts/design_management/router/index.js
@@ -2,6 +2,9 @@ import $ from 'jquery';
import Vue from 'vue';
import VueRouter from 'vue-router';
import routes from './routes';
+import { DESIGN_ROUTE_NAME } from './constants';
+import { getPageLayoutElement } from '~/design_management/utils/design_management_utils';
+import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants';
Vue.use(VueRouter);
@@ -11,10 +14,20 @@ export default function createRouter(base) {
mode: 'history',
routes,
});
+ const pageEl = getPageLayoutElement();
- router.beforeEach(({ meta: { el } }, from, next) => {
+ router.beforeEach(({ meta: { el }, name }, _, next) => {
$(`#${el}`).tab('show');
+ // apply a fullscreen layout style in Design View (a.k.a design detail)
+ if (pageEl) {
+ if (name === DESIGN_ROUTE_NAME) {
+ pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
+ } else {
+ pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
+ }
+ }
+
next();
});
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index 01c073bddc2..24b374b79fd 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -95,6 +95,10 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
__typename: 'Discussion',
id: createImageDiffNote.note.discussion.id,
replyId: createImageDiffNote.note.discussion.replyId,
+ resolvable: true,
+ resolved: false,
+ resolvedAt: null,
+ resolvedBy: null,
notes: {
__typename: 'NoteConnection',
nodes: [createImageDiffNote.note],
diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js
index e6d8796ffa4..22705cf67a1 100644
--- a/app/assets/javascripts/design_management/utils/design_management_utils.js
+++ b/app/assets/javascripts/design_management/utils/design_management_utils.js
@@ -21,8 +21,9 @@ export const extractNodes = elements => elements.edges.map(({ node }) => node);
*/
export const extractDiscussions = discussions =>
- discussions.nodes.map(discussion => ({
+ discussions.nodes.map((discussion, index) => ({
...discussion,
+ index: index + 1,
notes: discussion.notes.nodes,
}));
@@ -123,3 +124,5 @@ const normalizeAuthor = author => ({
});
export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node));
+
+export const getPageLayoutElement = () => document.querySelector('.layout-page');
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 0c521fa29bd..0991f5282a8 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue */
+/* eslint-disable func-names, no-continue */
/* global CommentsStore */
import $ from 'jquery';
@@ -42,13 +42,13 @@ const JumpToDiscussion = Vue.extend({
},
lastResolvedId() {
let lastId;
- for (const discussionId in this.discussions) {
+ Object.keys(this.discussions).forEach(discussionId => {
const discussion = this.discussions[discussionId];
if (!discussion.isResolved()) {
lastId = discussion.id;
}
- }
+ });
return lastId;
},
},
@@ -95,12 +95,10 @@ const JumpToDiscussion = Vue.extend({
if (unresolvedDiscussionCount === 1) {
hasDiscussionsToJumpTo = false;
}
- } else {
+ } else if (unresolvedDiscussionCount === 0) {
// If there are no unresolved discussions on the diffs tab at all,
// there are no discussions to jump to.
- if (unresolvedDiscussionCount === 0) {
- hasDiscussionsToJumpTo = false;
- }
+ hasDiscussionsToJumpTo = false;
}
} else if (activeTab !== 'show') {
// If we are on the commits or builds tabs,
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index ee93ca020e8..99bc1b5c040 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -144,7 +144,7 @@ export default {
<pre
v-if="commit.description_html"
- class="commit-row-description js-toggle-content append-bottom-8"
+ class="commit-row-description js-toggle-content gl-mb-3"
v-html="commit.description_html"
></pre>
</div>
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index c680c3f4600..6f6fa312865 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -86,7 +86,7 @@ export default {
<button
v-gl-tooltip.hover
type="button"
- class="btn btn-default append-right-8 js-toggle-tree-list"
+ class="btn btn-default gl-mr-3 js-toggle-tree-list"
:class="{
active: showTreeList,
}"
@@ -98,20 +98,20 @@ export default {
<gl-sprintf
v-if="showDropdowns"
class="d-flex align-items-center compare-versions-container"
- :message="s__('MergeRequest|Compare %{source} and %{target}')"
+ :message="s__('MergeRequest|Compare %{target} and %{source}')"
>
- <template #source>
- <compare-dropdown-layout
- :versions="diffCompareDropdownSourceVersions"
- class="mr-version-dropdown"
- />
- </template>
<template #target>
<compare-dropdown-layout
:versions="diffCompareDropdownTargetVersions"
class="mr-version-compare-dropdown"
/>
</template>
+ <template #source>
+ <compare-dropdown-layout
+ :versions="diffCompareDropdownSourceVersions"
+ class="mr-version-dropdown"
+ />
+ </template>
</gl-sprintf>
<div v-else-if="commit">
{{ __('Viewing commit') }}
@@ -126,15 +126,11 @@ export default {
<gl-deprecated-button
v-if="commit || startVersion"
:href="latestVersionPath"
- class="append-right-8 js-latest-version"
+ class="gl-mr-3 js-latest-version"
>
{{ __('Show latest version') }}
</gl-deprecated-button>
- <gl-deprecated-button
- v-show="hasCollapsedFile"
- class="append-right-8"
- @click="expandAllFiles"
- >
+ <gl-deprecated-button v-show="hasCollapsedFile" class="gl-mr-3" @click="expandAllFiles">
{{ __('Expand all') }}
</gl-deprecated-button>
<settings-dropdown />
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 5656bfc4707..741462a849c 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -1,11 +1,12 @@
<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 diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
+import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.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 DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
import InlineDiffView from './inline_diff_view.vue';
import ParallelDiffView from './parallel_diff_view.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -29,7 +30,7 @@ export default {
NotDiffableViewer,
NoPreviewViewer,
userAvatarLink,
- DiffFileDrafts: () => import('ee_component/batch_comments/components/diff_file_drafts.vue'),
+ DiffFileDrafts,
},
mixins: [diffLineNoteFormMixin, draftCommentsMixin],
props: {
@@ -128,6 +129,7 @@ export default {
<no-preview-viewer v-else-if="noPreview" />
<diff-viewer
v-else
+ :diff-file="diffFile"
:diff-mode="diffMode"
:diff-viewer-mode="diffViewerMode"
:new-path="diffFile.new_path"
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 f81f50f8490..74305ee69bc 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,18 +1,22 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
+import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__ } from '~/locale';
import noteForm from '../../notes/components/note_form.vue';
+import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue';
import autosave from '../../notes/mixins/autosave';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { DIFF_NOTE_TYPE } from '../constants';
+import { commentLineOptions } from '../../notes/components/multiline_comment_utils';
export default {
components: {
noteForm,
userAvatarLink,
+ MultilineCommentForm,
},
- mixins: [autosave, diffLineNoteFormMixin],
+ mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()],
props: {
diffFileHash: {
type: String,
@@ -37,6 +41,14 @@ export default {
default: '',
},
},
+ data() {
+ return {
+ commentLineStart: {
+ lineCode: this.line.line_code,
+ type: this.line.type,
+ },
+ };
+ },
computed: {
...mapState({
noteableData: state => state.notes.noteableData,
@@ -62,11 +74,20 @@ export default {
diffViewType: this.diffViewType,
diffFile: this.diffFile,
linePosition: this.linePosition,
+ lineRange: {
+ start_line_code: this.commentLineStart.lineCode,
+ start_line_type: this.commentLineStart.type,
+ end_line_code: this.line.line_code,
+ end_line_type: this.line.type,
+ },
};
},
diffFile() {
return this.getDiffFileByHash(this.diffFileHash);
},
+ commentLineOptions() {
+ return commentLineOptions(this.diffFile.highlighted_diff_lines, this.line.line_code);
+ },
},
mounted() {
if (this.isLoggedIn) {
@@ -83,7 +104,6 @@ export default {
methods: {
...mapActions('diffs', [
'cancelCommentForm',
- 'assignDiscussionsToDiff',
'saveDiffDiscussion',
'setSuggestPopoverDismissed',
]),
@@ -116,6 +136,16 @@ export default {
<template>
<div class="content discussion-form discussion-form-container discussion-notes">
+ <div
+ v-if="glFeatures.multilineComments"
+ class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
+ >
+ <multiline-comment-form
+ v-model="commentLineStart"
+ :line="line"
+ :comment-line-options="commentLineOptions"
+ />
+ </div>
<user-avatar-link
v-if="author"
:link-href="author.path"
@@ -133,7 +163,7 @@ export default {
:diff-file="diffFile"
:show-suggest-popover="showSuggestPopover"
save-button-title="Comment"
- class="diff-comment-form"
+ class="diff-comment-form prepend-top-10"
@handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
@handleFormUpdate="handleSaveNote"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index 8b25cdc2887..ad72016f03b 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
-import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
+import draftCommentsMixin from '~/diffs/mixins/draft_comments';
+import InlineDraftCommentRow from '~/batch_comments/components/inline_draft_comment_row.vue';
import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
import inlineDiffExpansionRow from './inline_diff_expansion_row.vue';
@@ -9,8 +10,7 @@ export default {
components: {
inlineDiffCommentRow,
inlineDiffTableRow,
- InlineDraftCommentRow: () =>
- import('ee_component/batch_comments/components/inline_draft_comment_row.vue'),
+ InlineDraftCommentRow,
inlineDiffExpansionRow,
},
mixins: [draftCommentsMixin],
@@ -80,6 +80,8 @@ export default {
v-if="shouldRenderDraftRow(diffFile.file_hash, line)"
:key="`draft_${index}`"
:draft="draftForLine(diffFile.file_hash, line)"
+ :diff-file="diffFile"
+ :line="line"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index d796aad9d06..b5fcc50ce26 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
-import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
+import draftCommentsMixin from '~/diffs/mixins/draft_comments';
+import ParallelDraftCommentRow from '~/batch_comments/components/parallel_draft_comment_row.vue';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue';
@@ -10,8 +11,7 @@ export default {
parallelDiffExpansionRow,
parallelDiffTableRow,
parallelDiffCommentRow,
- ParallelDraftCommentRow: () =>
- import('ee_component/batch_comments/components/parallel_draft_comment_row.vue'),
+ ParallelDraftCommentRow,
},
mixins: [draftCommentsMixin],
props: {
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index eca9091f92f..52611f3c82a 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -64,7 +64,7 @@ export default {
<template>
<div class="tree-list-holder d-flex flex-column">
- <div class="append-bottom-8 position-relative tree-list-search d-flex">
+ <div class="gl-mb-3 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>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 40e1aec42ed..9269dacd582 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -61,3 +61,22 @@ export const DIFFS_PER_PAGE = 20;
export const DIFF_COMPARE_BASE_VERSION_INDEX = -1;
export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2;
+
+// State machine states
+export const STATE_IDLING = 'idle';
+export const STATE_LOADING = 'loading';
+export const STATE_ERRORED = 'errored';
+
+// State machine transitions
+export const TRANSITION_LOAD_START = 'LOAD_START';
+export const TRANSITION_LOAD_ERROR = 'LOAD_ERROR';
+export const TRANSITION_LOAD_SUCCEED = 'LOAD_SUCCEED';
+export const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR';
+
+export const RENAMED_DIFF_TRANSITIONS = {
+ [`${STATE_IDLING}:${TRANSITION_LOAD_START}`]: STATE_LOADING,
+ [`${STATE_LOADING}:${TRANSITION_LOAD_ERROR}`]: STATE_ERRORED,
+ [`${STATE_LOADING}:${TRANSITION_LOAD_SUCCEED}`]: STATE_IDLING,
+ [`${STATE_ERRORED}:${TRANSITION_LOAD_START}`]: STATE_LOADING,
+ [`${STATE_ERRORED}:${TRANSITION_ACKNOWLEDGE_ERROR}`]: STATE_IDLING,
+};
diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js
index b6c9b132aeb..693b4a84694 100644
--- a/app/assets/javascripts/diffs/mixins/draft_comments.js
+++ b/app/assets/javascripts/diffs/mixins/draft_comments.js
@@ -1,12 +1,17 @@
+import { mapGetters } from 'vuex';
+
export default {
computed: {
- shouldRenderDraftRow: () => () => false,
- shouldRenderParallelDraftRow: () => () => false,
- draftForLine: () => () => ({}),
+ ...mapGetters('batchComments', [
+ 'shouldRenderDraftRow',
+ 'shouldRenderParallelDraftRow',
+ 'draftForLine',
+ 'draftsForFile',
+ 'hasParallelDraftLeft',
+ 'hasParallelDraftRight',
+ ]),
imageDiscussions() {
- return this.diffFile.discussions;
+ return this.diffFile.discussions.concat(this.draftsForFile(this.diffFile.file_hash));
},
- hasParallelDraftLeft: () => () => false,
- hasParallelDraftRight: () => () => false,
},
};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 1975d6996a5..a8d348e1836 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -118,12 +118,7 @@ export const fetchDiffFilesBatch = ({ commit, state }) => {
const getBatch = (page = 1) =>
axios
- .get(state.endpointBatch, {
- params: {
- ...urlParams,
- page,
- },
- })
+ .get(mergeUrlParams({ ...urlParams, page }, state.endpointBatch))
.then(({ data: { pagination, diff_files } }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
commit(types.SET_BATCH_LOADING, false);
@@ -507,9 +502,6 @@ 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.'));
@@ -600,7 +592,7 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => {
}
};
-export const fetchFullDiff = ({ dispatch }, file) =>
+export const fetchFullDiff = ({ commit, dispatch }, file) =>
axios
.get(file.context_lines_path, {
params: {
@@ -609,15 +601,16 @@ export const fetchFullDiff = ({ dispatch }, file) =>
},
})
.then(({ data }) => {
- dispatch('receiveFullDiffSucess', { filePath: file.file_path });
+ commit(types.RECEIVE_FULL_DIFF_SUCCESS, { filePath: file.file_path });
+
dispatch('setExpandedDiffLines', { file, data });
})
.catch(() => dispatch('receiveFullDiffError', file.file_path));
-export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => {
+export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) => {
const file = state.diffFiles.find(f => f.file_path === filePath);
- dispatch('requestFullDiff', filePath);
+ commit(types.REQUEST_FULL_DIFF, filePath);
if (file.isShowingFullFile) {
dispatch('loadCollapsedDiff', file)
@@ -656,11 +649,6 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d
commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines });
dispatch('startRenderDiffsQueue');
- })
- .catch(error => {
- dispatch('receiveFullDiffError', diffFile.file_path);
-
- throw error;
});
}
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 2be71c77087..d261be1b550 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -40,6 +40,7 @@ export function getFormData(params) {
diffViewType,
linePosition,
positionType,
+ lineRange,
} = params;
const position = JSON.stringify({
@@ -55,6 +56,7 @@ export function getFormData(params) {
y: params.y,
width: params.width,
height: params.height,
+ line_range: lineRange,
});
const postData = {
diff --git a/app/assets/javascripts/diffs/utils/uuids.js b/app/assets/javascripts/diffs/utils/uuids.js
new file mode 100644
index 00000000000..1a529c07ccc
--- /dev/null
+++ b/app/assets/javascripts/diffs/utils/uuids.js
@@ -0,0 +1,79 @@
+/**
+ * @module uuids
+ */
+
+/**
+ * A string or number representing a start state for a random generator
+ * @typedef {(Number|String)} Seed
+ */
+/**
+ * A UUIDv4 string in the format <code>Hex{8}-Hex{4}-4Hex{3}-[89ab]Hex{3}-Hex{12}</code>
+ * @typedef {String} UUIDv4
+ */
+
+// https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20
+/* eslint-disable import/prefer-default-export */
+
+import MersenneTwister from 'mersenne-twister';
+import stringHash from 'string-hash';
+import { isString } from 'lodash';
+import { v4 } from 'uuid';
+
+function getSeed(seeds) {
+ return seeds.reduce((seedling, seed, i) => {
+ let thisSeed = 0;
+
+ if (Number.isInteger(seed)) {
+ thisSeed = seed;
+ } else if (isString(seed)) {
+ thisSeed = stringHash(seed);
+ }
+
+ return seedling + (seeds.length - i) * thisSeed;
+ }, 0);
+}
+
+function getPseudoRandomNumberGenerator(...seeds) {
+ let seedNumber;
+
+ if (seeds.length) {
+ seedNumber = getSeed(seeds);
+ } else {
+ seedNumber = Math.floor(Math.random() * 10 ** 15);
+ }
+
+ return new MersenneTwister(seedNumber);
+}
+
+function randomValuesForUuid(prng) {
+ const randomValues = [];
+
+ for (let i = 0; i <= 3; i += 1) {
+ const buffer = new ArrayBuffer(4);
+ const view = new DataView(buffer);
+
+ view.setUint32(0, prng.random_int());
+
+ randomValues.push(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
+ }
+
+ return randomValues;
+}
+
+/**
+ * Get an array of UUIDv4s
+ * @param {Object} [options={}]
+ * @param {Seed[]} [options.seeds=[]] - A list of mixed strings or numbers to seed the UUIDv4 generator
+ * @param {Number} [options.count=1] - A total number of UUIDv4s to generate
+ * @returns {UUIDv4[]} An array of UUIDv4s
+ */
+export function uuids({ seeds = [], count = 1 } = {}) {
+ const rng = getPseudoRandomNumberGenerator(...seeds);
+ return (
+ // Create an array the same size as the number of UUIDs requested
+ Array(count)
+ .fill(0)
+ // Replace each slot in the array with a UUID which needs 16 (pseudo)random values to generate
+ .map(() => v4({ random: randomValuesForUuid(rng) }))
+ );
+}
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 0a5538237f9..b8bcca814cd 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -158,13 +158,13 @@ export default {
:deploy-boards-help-path="deployBoardsHelpPath"
@onChangePage="onChangePage"
>
- <empty-state
- v-if="!isLoading && state.environments.length === 0"
- slot="emptyState"
- :new-path="newEnvironmentPath"
- :help-path="helpPagePath"
- :can-create-environment="canCreateEnvironment"
- />
+ <template v-if="!isLoading && state.environments.length === 0" #emptyState>
+ <empty-state
+ :new-path="newEnvironmentPath"
+ :help-path="helpPagePath"
+ :can-create-environment="canCreateEnvironment"
+ />
+ </template>
</container>
</div>
</template>
diff --git a/app/assets/javascripts/error_tracking/components/constants.js b/app/assets/javascripts/error_tracking/components/constants.js
index 60b217443de..41b952e26d8 100644
--- a/app/assets/javascripts/error_tracking/components/constants.js
+++ b/app/assets/javascripts/error_tracking/components/constants.js
@@ -8,10 +8,10 @@ export const severityLevel = {
export const severityLevelVariant = {
[severityLevel.FATAL]: 'danger',
- [severityLevel.ERROR]: 'dark',
+ [severityLevel.ERROR]: 'neutral',
[severityLevel.WARNING]: 'warning',
[severityLevel.INFO]: 'info',
- [severityLevel.DEBUG]: 'light',
+ [severityLevel.DEBUG]: 'muted',
};
export const errorStatus = {
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 148edfe3a51..1e8f5a26125 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -1,6 +1,5 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import dateFormat from 'dateformat';
import createFlash from '~/flash';
import {
GlDeprecatedButton,
@@ -19,9 +18,14 @@ import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import Stacktrace from './stacktrace.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { trackClickErrorLinkToSentryOptions } from '../utils';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { severityLevel, severityLevelVariant, errorStatus } from './constants';
+import Tracking from '~/tracking';
+import {
+ trackClickErrorLinkToSentryOptions,
+ trackErrorDetailsViewsOptions,
+ trackErrorStatusUpdateOptions,
+} from '../utils';
import query from '../queries/details.query.graphql';
@@ -42,11 +46,11 @@ export default {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ TimeAgoTooltip,
},
directives: {
TrackEvent: TrackEventDirective,
},
- mixins: [timeagoMixin],
props: {
issueUpdatePath: {
type: String,
@@ -172,6 +176,7 @@ export default {
},
},
mounted() {
+ this.trackPageViews();
this.startPollingStacktrace(this.issueStackTracePath);
this.errorPollTimeout = Date.now() + SENTRY_TIMEOUT;
this.$apollo.queries.error.setOptions({
@@ -194,7 +199,10 @@ export default {
onIgnoreStatusUpdate() {
const status =
this.errorStatus === errorStatus.IGNORED ? errorStatus.UNRESOLVED : errorStatus.IGNORED;
- this.updateIgnoreStatus({ endpoint: this.issueUpdatePath, status });
+ // eslint-disable-next-line promise/catch-or-return
+ this.updateIgnoreStatus({ endpoint: this.issueUpdatePath, status }).then(() => {
+ this.trackStatusUpdate(status);
+ });
},
onResolveStatusUpdate() {
const status =
@@ -206,6 +214,7 @@ export default {
if (this.closedIssueId) {
this.isAlertVisible = true;
}
+ this.trackStatusUpdate(status);
});
},
onNoApolloResult() {
@@ -215,8 +224,13 @@ export default {
createFlash(__('Could not connect to Sentry. Refresh the page to try again.'), 'warning');
}
},
- formatDate(date) {
- return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`;
+ trackPageViews() {
+ const { category, action } = trackErrorDetailsViewsOptions;
+ Tracking.event(category, action);
+ },
+ trackStatusUpdate(status) {
+ const { category, action } = trackErrorStatusUpdateOptions(status);
+ Tracking.event(category, action);
},
},
};
@@ -251,7 +265,7 @@ export default {
<strong class="error-details-meta-culprit">{{ error.culprit }}</strong>
</template>
<template #timeAgo>
- {{ timeFormatted(stacktraceData.date_received) }}
+ <time-ago-tooltip :time="stacktraceData.date_received" />
</template>
</gl-sprintf>
</div>
@@ -259,7 +273,7 @@ export default {
<div class="d-inline-flex bv-d-sm-down-none">
<gl-deprecated-button
:loading="updatingIgnoreStatus"
- data-qa-selector="update_ignore_status_button"
+ data-testid="update-ignore-status-btn"
@click="onIgnoreStatusUpdate"
>
{{ ignoreBtnLabel }}
@@ -267,7 +281,7 @@ export default {
<gl-deprecated-button
class="btn-outline-info ml-2"
:loading="updatingResolveStatus"
- data-qa-selector="update_resolve_status_button"
+ data-testid="update-resolve-status-btn"
@click="onResolveStatusUpdate"
>
{{ resolveBtnLabel }}
@@ -275,7 +289,7 @@ export default {
<gl-deprecated-button
v-if="error.gitlabIssuePath"
class="ml-2"
- data-qa-selector="view_issue_button"
+ data-testid="view_issue_button"
:href="error.gitlabIssuePath"
variant="success"
>
@@ -345,16 +359,10 @@ export default {
<h2 class="text-truncate">{{ error.title }}</h2>
</tooltip-on-truncate>
<template v-if="error.tags">
- <gl-badge
- v-if="error.tags.level"
- :variant="errorSeverityVariant"
- class="rounded-pill mr-2"
- >
+ <gl-badge v-if="error.tags.level" :variant="errorSeverityVariant" class="mr-2">
{{ errorLevel }}
</gl-badge>
- <gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill"
- >{{ error.tags.logger }}
- </gl-badge>
+ <gl-badge v-if="error.tags.logger" variant="muted">{{ error.tags.logger }} </gl-badge>
</template>
<ul>
<li v-if="error.gitlabCommit">
@@ -375,6 +383,7 @@ export default {
v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)"
:href="error.externalUrl"
target="_blank"
+ data-testid="external-url-link"
>
<span class="text-truncate">{{ error.externalUrl }}</span>
<icon name="external-link" class="ml-1 flex-shrink-0" />
@@ -382,14 +391,14 @@ export default {
</li>
<li v-if="error.firstReleaseShortVersion">
<strong class="bold">{{ __('First seen') }}:</strong>
- {{ formatDate(error.firstSeen) }}
+ <time-ago-tooltip :time="error.firstSeen" />
<gl-link :href="firstReleaseLink" target="_blank">
<span>{{ __('Release') }}: {{ error.firstReleaseShortVersion.substr(0, 10) }}</span>
</gl-link>
</li>
<li v-if="error.lastReleaseShortVersion">
<strong class="bold">{{ __('Last seen') }}:</strong>
- {{ formatDate(error.lastSeen) }}
+ <time-ago-tooltip :time="error.lastSeen" />
<gl-link :href="lastReleaseLink" target="_blank">
<span>{{ __('Release') }}: {{ error.lastReleaseShortVersion.substr(0, 10) }}</span>
</gl-link>
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 45432e8ebd8..62a73e21096 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -19,6 +19,8 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale';
import { isEmpty } from 'lodash';
import ErrorTrackingActions from './error_tracking_actions.vue';
+import Tracking from '~/tracking';
+import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils';
export const tableDataClass = 'table-col d-flex d-md-table-cell align-items-center';
@@ -150,6 +152,9 @@ export default {
this.startPolling();
}
},
+ mounted() {
+ this.trackPageViews();
+ },
methods: {
...mapActions('list', [
'startPolling',
@@ -197,13 +202,25 @@ export default {
this.filterValue = label;
return this.filterByStatus(status);
},
- updateIssueStatus({ errorId, status }) {
+ updateErrosStatus({ errorId, status }) {
+ // eslint-disable-next-line promise/catch-or-return
this.updateStatus({
endpoint: this.getIssueUpdatePath(errorId),
status,
+ }).then(() => {
+ this.trackStatusUpdate(status);
});
+
this.removeIgnoredResolvedErrors(errorId);
},
+ trackPageViews() {
+ const { category, action } = trackErrorListViewsOptions;
+ Tracking.event(category, action);
+ },
+ trackStatusUpdate(status) {
+ const { category, action } = trackErrorStatusUpdateOptions(status);
+ Tracking.event(category, action);
+ },
},
};
</script>
@@ -359,7 +376,7 @@ export default {
</div>
</template>
<template #cell(status)="errors">
- <error-tracking-actions :error="errors.item" @update-issue-status="updateIssueStatus" />
+ <error-tracking-actions :error="errors.item" @update-issue-status="updateErrosStatus" />
</template>
<template #empty>
{{ __('No errors to display.') }}
diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js
index 4170c1bf759..94cf444d2e4 100644
--- a/app/assets/javascripts/error_tracking/store/list/actions.js
+++ b/app/assets/javascripts/error_tracking/store/list/actions.js
@@ -2,7 +2,7 @@ import Service from '../../services';
import * as types from './mutation_types';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
let eTagPoll;
@@ -31,17 +31,9 @@ export function startPolling({ state, commit, dispatch }) {
commit(types.SET_LOADING, false);
dispatch('stopPolling');
},
- errorCallback: ({ response }) => {
- let errorMessage = '';
- if (response && response.data && response.data.message) {
- errorMessage = response.data.message;
- }
+ errorCallback: () => {
commit(types.SET_LOADING, false);
- createFlash(
- sprintf(__(`Failed to load errors from Sentry. Error message: %{errorMessage}`), {
- errorMessage,
- }),
- );
+ createFlash(__('Failed to load errors from Sentry.'));
},
});
diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js
index d1cd70a72fa..5b705cc5510 100644
--- a/app/assets/javascripts/error_tracking/utils.js
+++ b/app/assets/javascripts/error_tracking/utils.js
@@ -1,4 +1,4 @@
-/* eslint-disable @gitlab/require-i18n-strings, import/prefer-default-export */
+/* eslint-disable @gitlab/require-i18n-strings */
/**
* Tracks snowplow event when User clicks on error link to Sentry
@@ -10,3 +10,27 @@ export const trackClickErrorLinkToSentryOptions = url => ({
label: 'Error Link',
property: url,
});
+
+/**
+ * Tracks snowplow event when user views error list
+ */
+export const trackErrorListViewsOptions = {
+ category: 'Error Tracking',
+ action: 'view_errors_list',
+};
+
+/**
+ * Tracks snowplow event when user views error details
+ */
+export const trackErrorDetailsViewsOptions = {
+ category: 'Error Tracking',
+ action: 'view_error_details',
+};
+
+/**
+ * Tracks snowplow event when error status is updated
+ */
+export const trackErrorStatusUpdateOptions = status => ({
+ category: 'Error Tracking',
+ action: `update_${status}_status`,
+});
diff --git a/app/assets/javascripts/file_pickers.js b/app/assets/javascripts/file_pickers.js
new file mode 100644
index 00000000000..956a4954afb
--- /dev/null
+++ b/app/assets/javascripts/file_pickers.js
@@ -0,0 +1,21 @@
+export default function initFilePickers() {
+ const filePickers = document.querySelectorAll('.js-filepicker');
+
+ filePickers.forEach(filePicker => {
+ const button = filePicker.querySelector('.js-filepicker-button');
+
+ button.addEventListener('click', () => {
+ const form = button.closest('form');
+ form.querySelector('.js-filepicker-input').click();
+ });
+
+ const input = filePicker.querySelector('.js-filepicker-input');
+
+ input.addEventListener('change', () => {
+ const form = input.closest('form');
+ const filename = input.value.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape
+
+ form.querySelector('.js-filepicker-filename').textContent = filename;
+ });
+ });
+}
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 d41d5a543b0..5298e20557d 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -95,7 +95,7 @@ export default class FilteredSearchVisualTokens {
const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
- tokenValueElement.innerText = tokenValue;
+ tokenValueElement.textContent = tokenValue;
const visualTokenValue = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
@@ -140,9 +140,9 @@ export default class FilteredSearchVisualTokens {
li.innerHTML = nameHTML + operatorHTML;
}
- li.querySelector('.name').innerText = name;
+ li.querySelector('.name').textContent = name;
if (hasOperator) {
- li.querySelector('.operator').innerText = operator;
+ li.querySelector('.operator').textContent = operator;
}
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
@@ -162,8 +162,8 @@ export default class FilteredSearchVisualTokens {
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
hasOperator: Boolean(operator),
});
- lastVisualToken.querySelector('.name').innerText = name;
- lastVisualToken.querySelector('.operator').innerText = operator;
+ lastVisualToken.querySelector('.name').textContent = name;
+ lastVisualToken.querySelector('.operator').textContent = operator;
FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value, operator);
}
}
@@ -208,8 +208,8 @@ export default class FilteredSearchVisualTokens {
},
});
} else {
- const previousTokenName = lastVisualToken.querySelector('.name').innerText;
- const previousTokenOperator = lastVisualToken.querySelector('.operator').innerText;
+ const previousTokenName = lastVisualToken.querySelector('.name').textContent;
+ const previousTokenOperator = lastVisualToken.querySelector('.operator').textContent;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
@@ -234,7 +234,7 @@ export default class FilteredSearchVisualTokens {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
- lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
+ lastVisualToken.querySelector('.name').textContent += ` ${searchTerm}`;
} else {
FilteredSearchVisualTokens.addVisualTokenElement({
name: searchTerm,
@@ -261,12 +261,12 @@ export default class FilteredSearchVisualTokens {
const value = lastVisualToken.querySelector('.value');
const name = lastVisualToken.querySelector('.name');
- const valueText = value ? value.innerText : '';
- const nameText = name ? name.innerText : '';
+ const valueText = value ? value.textContent : '';
+ const nameText = name ? name.textContent : '';
if (includeOperator) {
const operator = lastVisualToken.querySelector('.operator');
- const operatorText = operator ? operator.innerText : '';
+ const operatorText = operator ? operator.textContent : '';
return valueText || operatorText || nameText;
}
@@ -278,7 +278,7 @@ export default class FilteredSearchVisualTokens {
const operator = lastVisualToken && lastVisualToken.querySelector('.operator');
- return operator?.innerText;
+ return operator?.textContent;
}
static removeLastTokenPartial() {
@@ -346,8 +346,8 @@ export default class FilteredSearchVisualTokens {
if (token.classList.contains('filtered-search-token')) {
FilteredSearchVisualTokens.addFilterVisualToken(
- nameElement.innerText,
- operatorElement.innerText,
+ nameElement.textContent,
+ operatorElement.textContent,
null,
{
uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
@@ -359,13 +359,13 @@ export default class FilteredSearchVisualTokens {
if (!value) {
const valueElement = valueContainerElement.querySelector('.value');
- value = valueElement.innerText;
+ value = valueElement.textContent;
}
}
// token is a search term
if (!value) {
- value = nameElement.innerText;
+ value = nameElement.textContent;
}
input.value = value;
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 011b37e218d..d8c2c6d79c6 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,13 +1,10 @@
import { __ } from '~/locale';
-class RecentSearchesServiceError {
+class RecentSearchesServiceError extends Error {
constructor(message) {
+ super(message || __('Recent Searches Service is unavailable'));
this.name = 'RecentSearchesServiceError';
- this.message = message || __('Recent Searches Service is unavailable');
}
}
-// Can't use `extends` for builtin prototypes and get true inheritance yet
-RecentSearchesServiceError.prototype = Error.prototype;
-
export default RecentSearchesServiceError;
diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js
index 6263acbab8e..c074f173776 100644
--- a/app/assets/javascripts/frequent_items/index.js
+++ b/app/assets/javascripts/frequent_items/index.js
@@ -1,8 +1,7 @@
import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
-import eventHub from '~/frequent_items/event_hub';
-import frequentItems from './components/app.vue';
+import eventHub from './event_hub';
Vue.use(Translate);
@@ -17,7 +16,7 @@ const frequentItemDropdowns = [
},
];
-const initFrequentItemDropdowns = () => {
+export default function initFrequentItemDropdowns() {
frequentItemDropdowns.forEach(dropdown => {
const { namespace, key } = dropdown;
const el = document.getElementById(`js-${namespace}-dropdown`);
@@ -29,45 +28,40 @@ const initFrequentItemDropdowns = () => {
return;
}
- $(navEl).on('shown.bs.dropdown', () => {
- eventHub.$emit(`${namespace}-dropdownOpen`);
- });
+ $(navEl).on('shown.bs.dropdown', () =>
+ import('./components/app.vue').then(({ default: FrequentItems }) => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ data() {
+ const { dataset } = this.$options.el;
+ const item = {
+ id: Number(dataset[`${key}Id`]),
+ name: dataset[`${key}Name`],
+ namespace: dataset[`${key}Namespace`],
+ webUrl: dataset[`${key}WebUrl`],
+ avatarUrl: dataset[`${key}AvatarUrl`] || null,
+ lastAccessedOn: Date.now(),
+ };
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- frequentItems,
- },
- data() {
- const { dataset } = this.$options.el;
- const item = {
- id: Number(dataset[`${key}Id`]),
- name: dataset[`${key}Name`],
- namespace: dataset[`${key}Namespace`],
- webUrl: dataset[`${key}WebUrl`],
- avatarUrl: dataset[`${key}AvatarUrl`] || null,
- lastAccessedOn: Date.now(),
- };
-
- return {
- currentUserName: dataset.userName,
- currentItem: item,
- };
- },
- render(createElement) {
- return createElement('frequent-items', {
- props: {
- namespace,
- currentUserName: this.currentUserName,
- currentItem: this.currentItem,
+ return {
+ currentUserName: dataset.userName,
+ currentItem: item,
+ };
+ },
+ render(createElement) {
+ return createElement(FrequentItems, {
+ props: {
+ namespace,
+ currentUserName: this.currentUserName,
+ currentItem: this.currentItem,
+ },
+ });
},
});
- },
- });
- });
-};
-document.addEventListener('DOMContentLoaded', () => {
- requestIdleCallback(initFrequentItemDropdowns);
-});
+ eventHub.$emit(`${namespace}-dropdownOpen`);
+ }),
+ );
+ });
+}
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index be4b4b5f87d..ec0d0cf6aef 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import { escape } from 'lodash';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from './lib/utils/axios_utils';
-import { visitUrl } from './lib/utils/url_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
import { isObject } from './lib/utils/type_utility';
import renderItem from './gl_dropdown/render';
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/global_search_input.js
index d8eb981c106..a7c121259d4 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/global_search_input.js
@@ -1,10 +1,8 @@
/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
-import { escape, throttle } from 'lodash';
-import { s__, __ } from '~/locale';
-import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
-import axios from './lib/utils/axios_utils';
+import { throttle } from 'lodash';
+import { s__, __, sprintf } from '~/locale';
import {
isInGroupsPage,
isInProjectPage,
@@ -67,15 +65,11 @@ function setSearchOptions() {
}
}
-export class SearchAutocomplete {
- constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
+export class GlobalSearchInput {
+ constructor({ wrap } = {}) {
setSearchOptions();
this.bindEventContext();
this.wrap = wrap || $('.search');
- this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
- this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath');
- this.projectId = projectId || (this.optsEl.data('autocompleteProjectId') || '');
- this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || '');
this.dropdown = this.wrap.find('.dropdown');
this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
this.dropdownMenu = this.dropdown.find('.dropdown-menu');
@@ -92,7 +86,7 @@ export class SearchAutocomplete {
// Only when user is logged in
if (gon.current_user_id) {
- this.createAutocomplete();
+ this.createGlobalSearchInput();
}
this.bindEvents();
@@ -117,7 +111,7 @@ export class SearchAutocomplete {
return (this.originalState = this.serializeState());
}
- createAutocomplete() {
+ createGlobalSearchInput() {
return this.searchInput.glDropdown({
filterInputBlur: false,
filterable: true,
@@ -149,156 +143,75 @@ export class SearchAutocomplete {
if (glDropdownInstance) {
glDropdownInstance.filter.options.callback(contents);
}
- this.enableAutocomplete();
+ this.enableDropdown();
}
return;
}
- // Prevent multiple ajax calls
- if (this.loadingSuggestions) {
- return;
- }
-
- this.loadingSuggestions = true;
-
- return axios
- .get(this.autocompletePath, {
- params: {
- project_id: this.projectId,
- project_ref: this.projectRef,
- term,
- },
- })
- .then(response => {
- // Hide dropdown menu if no suggestions returns
- if (!response.data.length) {
- this.disableAutocomplete();
- return;
- }
-
- const data = [];
- // List results
- let firstCategory = true;
- let lastCategory;
- for (let i = 0, len = response.data.length; i < len; i += 1) {
- const suggestion = response.data[i];
- // Add group header before list each group
- if (lastCategory !== suggestion.category) {
- if (!firstCategory) {
- data.push({ type: 'separator' });
- }
- if (firstCategory) {
- firstCategory = false;
- }
- data.push({
- type: 'header',
- content: suggestion.category,
- });
- lastCategory = suggestion.category;
- }
- data.push({
- id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
- icon: this.getAvatar(suggestion),
- category: suggestion.category,
- text: suggestion.label,
- url: suggestion.url,
- });
- }
- // Add option to proceed with the search
- if (data.length) {
- const icon = spriteIcon('search', 's16 inline-search-icon');
- let template;
-
- if (this.projectInputEl.val()) {
- template = s__('SearchAutocomplete|in this project');
- }
- if (this.groupInputEl.val()) {
- template = s__('SearchAutocomplete|in this group');
- }
-
- data.unshift({ type: 'separator' });
- data.unshift({
- icon,
- text: term,
- template: s__('SearchAutocomplete|in all GitLab'),
- url: `${gon.relative_url_root}/search?search=${term}`,
- });
-
- if (template) {
- data.unshift({
- icon,
- text: term,
- template,
- url: `${
- gon.relative_url_root
- }/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
- });
- }
- }
+ const options = this.scopedSearchOptions(term);
- callback(data);
+ callback(options);
- this.loadingSuggestions = false;
- this.highlightFirstRow();
- this.setScrollFade();
- })
- .catch(() => {
- this.loadingSuggestions = false;
- });
+ this.highlightFirstRow();
+ this.setScrollFade();
}
- getCategoryContents() {
- const userName = gon.current_username;
- const { projectOptions, groupOptions, dashboardOptions } = gl;
-
- // Get options
- let options;
- if (isInGroupsPage() && groupOptions) {
- options = groupOptions[getGroupSlug()];
- } else if (isInProjectPage() && projectOptions) {
- options = projectOptions[getProjectSlug()];
- } else if (dashboardOptions) {
- options = dashboardOptions;
+ // Add option to proceed with the search for each
+ // scope that is currently available, namely:
+ //
+ // - Search in this project
+ // - Search in this group (or project's group)
+ // - Search in all GitLab
+ scopedSearchOptions(term) {
+ const icon = spriteIcon('search', 's16 inline-search-icon');
+ const projectId = this.projectInputEl.val();
+ const groupId = this.groupInputEl.val();
+ const options = [];
+
+ if (projectId) {
+ const projectOptions = gl.projectOptions[getProjectSlug()];
+ const url = groupId
+ ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}`
+ : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}`;
+
+ options.push({
+ icon,
+ text: term,
+ template: sprintf(
+ s__(`SearchAutocomplete|in project %{projectName}`),
+ {
+ projectName: `<i>${projectOptions.name}</i>`,
+ },
+ false,
+ ),
+ url,
+ });
}
- const { issuesPath, mrPath, name, issuesDisabled } = options;
- const baseItems = [];
-
- if (name) {
- baseItems.push({
- type: 'header',
- content: `${name}`,
+ if (groupId) {
+ const groupOptions = gl.groupOptions[getGroupSlug()];
+ options.push({
+ icon,
+ text: term,
+ template: sprintf(
+ s__(`SearchAutocomplete|in group %{groupName}`),
+ {
+ groupName: `<i>${groupOptions.name}</i>`,
+ },
+ false,
+ ),
+ url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}`,
});
}
- const issueItems = [
- {
- text: s__('SearchAutocomplete|Issues assigned to me'),
- url: `${issuesPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Issues I've created"),
- url: `${issuesPath}/?author_username=${userName}`,
- },
- ];
- const mergeRequestItems = [
- {
- text: s__('SearchAutocomplete|Merge requests assigned to me'),
- url: `${mrPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Merge requests I've created"),
- url: `${mrPath}/?author_username=${userName}`,
- },
- ];
+ options.push({
+ icon,
+ text: term,
+ template: s__('SearchAutocomplete|in all GitLab'),
+ url: `${gon.relative_url_root}/search?search=${term}`,
+ });
- let items;
- if (issuesDisabled) {
- items = baseItems.concat(mergeRequestItems);
- } else {
- items = baseItems.concat(...issueItems, ...mergeRequestItems);
- }
- return items;
+ return options;
}
serializeState() {
@@ -325,7 +238,7 @@ export class SearchAutocomplete {
});
}
- enableAutocomplete() {
+ enableDropdown() {
this.setScrollFade();
// No need to enable anything if user is not logged in
@@ -342,7 +255,7 @@ export class SearchAutocomplete {
}
onSearchInputChange() {
- this.enableAutocomplete();
+ this.enableDropdown();
}
onSearchInputKeyUp(e) {
@@ -351,7 +264,7 @@ export class SearchAutocomplete {
this.restoreOriginalState();
break;
case KEYCODE.ENTER:
- this.disableAutocomplete();
+ this.disableDropdown();
break;
default:
}
@@ -404,7 +317,7 @@ export class SearchAutocomplete {
return results;
}
- disableAutocomplete() {
+ disableDropdown() {
if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('js-autocomplete-disabled');
this.dropdownToggle.dropdown('toggle');
@@ -420,16 +333,8 @@ export class SearchAutocomplete {
onClick(item, $el, e) {
if (window.location.pathname.indexOf(item.url) !== -1) {
if (!e.metaKey) e.preventDefault();
- /* eslint-disable-next-line @gitlab/require-i18n-strings */
- if (item.category === 'Projects') {
- this.projectInputEl.val(item.id);
- }
- // eslint-disable-next-line @gitlab/require-i18n-strings
- if (item.category === 'Groups') {
- this.groupInputEl.val(item.id);
- }
$el.removeClass('is-active');
- this.disableAutocomplete();
+ this.disableDropdown();
return this.searchInput.val('').focus();
}
}
@@ -438,20 +343,58 @@ export class SearchAutocomplete {
this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0);
}
- getAvatar(item) {
- if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
- return false;
+ getCategoryContents() {
+ const userName = gon.current_username;
+ const { projectOptions, groupOptions, dashboardOptions } = gl;
+
+ // Get options
+ let options;
+ if (isInProjectPage() && projectOptions) {
+ options = projectOptions[getProjectSlug()];
+ } else if (isInGroupsPage() && groupOptions) {
+ options = groupOptions[getGroupSlug()];
+ } else if (dashboardOptions) {
+ options = dashboardOptions;
+ }
+
+ const { issuesPath, mrPath, name, issuesDisabled } = options;
+ const baseItems = [];
+
+ if (name) {
+ baseItems.push({
+ type: 'header',
+ content: `${name}`,
+ });
}
- const { label, id } = item;
- const avatarUrl = item.avatar_url;
- const avatar = avatarUrl
- ? `<img class="search-item-avatar" src="${avatarUrl}" />`
- : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
- escape(label),
- )}</div>`;
+ const issueItems = [
+ {
+ text: s__('SearchAutocomplete|Issues assigned to me'),
+ url: `${issuesPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: s__("SearchAutocomplete|Issues I've created"),
+ url: `${issuesPath}/?author_username=${userName}`,
+ },
+ ];
+ const mergeRequestItems = [
+ {
+ text: s__('SearchAutocomplete|Merge requests assigned to me'),
+ url: `${mrPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: s__("SearchAutocomplete|Merge requests I've created"),
+ url: `${mrPath}/?author_username=${userName}`,
+ },
+ ];
- return avatar;
+ let items;
+ if (issuesDisabled) {
+ items = baseItems.concat(mergeRequestItems);
+ } else {
+ items = baseItems.concat(...issueItems, ...mergeRequestItems);
+ }
+ return items;
}
isScrolledUp() {
@@ -477,6 +420,6 @@ export class SearchAutocomplete {
}
}
-export default function initSearchAutocomplete(opts) {
- return new SearchAutocomplete(opts);
+export default function initGlobalSearchInput(opts) {
+ return new GlobalSearchInput(opts);
}
diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql
index 7403fd6d3c2..e6f5d7db11a 100644
--- a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql
@@ -1,4 +1,6 @@
fragment PageInfo on PageInfo {
hasNextPage
+ hasPreviousPage
+ startCursor
endCursor
}
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index eda0f5d1d23..ec8a238192a 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import { slugify } from './lib/utils/text_utility';
import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
import flash from '~/flash';
@@ -6,44 +5,69 @@ import { __ } from '~/locale';
export default class Group {
constructor() {
- this.groupPath = $('#group_path');
- this.groupName = $('#group_name');
- this.parentId = $('#group_parent_id');
+ this.groupPaths = Array.from(document.querySelectorAll('.js-autofill-group-path'));
+ this.groupNames = Array.from(document.querySelectorAll('.js-autofill-group-name'));
+ this.parentId = document.getElementById('group_parent_id');
this.updateHandler = this.update.bind(this);
this.resetHandler = this.reset.bind(this);
this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this);
- if (this.groupName.val() === '') {
- this.groupName.on('keyup', this.updateHandler);
- this.groupPath.on('keydown', this.resetHandler);
- if (!this.parentId.val()) {
- this.groupName.on('blur', this.updateGroupPathSlugHandler);
+
+ this.groupNames.forEach(groupName => {
+ if (groupName.value === '') {
+ groupName.addEventListener('keyup', this.updateHandler);
+
+ if (!this.parentId.value) {
+ groupName.addEventListener('blur', this.updateGroupPathSlugHandler);
+ }
}
- }
+ });
+
+ this.groupPaths.forEach(groupPath => {
+ groupPath.addEventListener('keydown', this.resetHandler);
+ });
}
- update() {
- const slug = slugify(this.groupName.val());
- this.groupPath.val(slug);
+ update({ currentTarget: { value: updatedValue } }) {
+ const slug = slugify(updatedValue);
+
+ this.groupNames.forEach(element => {
+ element.value = updatedValue;
+ });
+ this.groupPaths.forEach(element => {
+ element.value = slug;
+ });
}
reset() {
- this.groupName.off('keyup', this.updateHandler);
- this.groupPath.off('keydown', this.resetHandler);
- this.groupName.off('blur', this.checkPathHandler);
+ this.groupNames.forEach(groupName => {
+ groupName.removeEventListener('keyup', this.updateHandler);
+ groupName.removeEventListener('blur', this.checkPathHandler);
+ });
+
+ this.groupPaths.forEach(groupPath => {
+ groupPath.removeEventListener('keydown', this.resetHandler);
+ });
}
- updateGroupPathSlug() {
- const slug = this.groupPath.val() || slugify(this.groupName.val());
+ updateGroupPathSlug({ currentTarget: { value } = '' } = {}) {
+ const slug = this.groupPaths[0]?.value || slugify(value);
if (!slug) return;
fetchGroupPathAvailability(slug)
.then(({ data }) => data)
- .then(data => {
- if (data.exists && data.suggests.length > 0) {
- const suggestedSlug = data.suggests[0];
- this.groupPath.val(suggestedSlug);
+ .then(({ exists, suggests }) => {
+ if (exists && suggests.length) {
+ const [suggestedSlug] = suggests;
+
+ this.groupPaths.forEach(element => {
+ element.value = suggestedSlug;
+ });
+ } else if (exists && !suggests.length) {
+ flash(__('Unable to suggest a path. Please refresh and try again.'));
}
})
- .catch(() => flash(__('An error occurred while checking group path')));
+ .catch(() =>
+ flash(__('An error occurred while checking group path. Please refresh and try again.')),
+ );
}
}
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 1b8c75202fb..6b9748bb725 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -12,6 +12,8 @@ import itemStats from './item_stats.vue';
import itemStatsValue from './item_stats_value.vue';
import itemActions from './item_actions.vue';
+import { showLearnGitLabGroupItemPopover } from '~/onboarding_issues';
+
export default {
directives: {
tooltip,
@@ -73,6 +75,11 @@ export default {
return GROUP_VISIBILITY_TYPE[this.group.visibility];
},
},
+ mounted() {
+ if (this.group.name === 'Learn GitLab') {
+ showLearnGitLabGroupItemPopover(this.group.id);
+ }
+ },
methods: {
onClickRowGroup(e) {
const NO_EXPAND_CLS = 'no-expand';
@@ -104,7 +111,7 @@ export default {
<gl-loading-icon
v-if="group.isChildrenLoading"
size="lg"
- class="d-none d-sm-inline-flex flex-shrink-0 append-right-8"
+ class="d-none d-sm-inline-flex flex-shrink-0 gl-mr-3"
/>
<div
:class="{ 'd-sm-flex': !group.isChildrenLoading }"
@@ -117,12 +124,12 @@ export default {
</div>
<div class="group-text-container d-flex flex-fill align-items-center">
<div class="group-text flex-grow-1 flex-shrink-1">
- <div class="d-flex align-items-center flex-wrap title namespace-title append-right-8">
+ <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3">
<a
v-tooltip
:href="group.relativePath"
:title="group.fullName"
- class="no-expand prepend-top-8 append-right-8"
+ class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!"
data-placement="bottom"
>{{
// ending bracket must be by closing tag to prevent
@@ -133,9 +140,9 @@ export default {
<item-stats-value
:icon-name="visibilityIcon"
:title="visibilityTooltip"
- css-class="item-visibility d-inline-flex align-items-center prepend-top-8 append-right-4 text-secondary"
+ css-class="item-visibility d-inline-flex align-items-center gl-mt-3 append-right-4 text-secondary"
/>
- <span v-if="group.permission" class="user-access-role prepend-top-8">
+ <span v-if="group.permission" class="user-access-role gl-mt-3">
{{ group.permission }}
</span>
</div>
@@ -150,7 +157,7 @@ export default {
class="metadata align-items-md-center d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"
>
<item-actions v-if="isGroup" :group="group" :parent-group="parentGroup" />
- <item-stats :item="group" class="group-stats prepend-top-2 d-none d-md-flex" />
+ <item-stats :item="group" class="group-stats gl-mt-2 d-none d-md-flex" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index 5454480e61a..985ea5a9019 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -53,7 +53,7 @@ export default {
:aria-label="leaveBtnTitle"
data-container="body"
data-placement="bottom"
- class="leave-group btn btn-xs no-expand"
+ class="leave-group btn btn-xs no-expand gl-text-gray-700 gl-ml-5"
@click.prevent="onLeaveGroup"
>
<icon name="leave" class="position-top-0" />
@@ -66,7 +66,7 @@ export default {
:aria-label="editBtnTitle"
data-container="body"
data-placement="bottom"
- class="edit-group btn btn-xs no-expand"
+ class="edit-group btn btn-xs no-expand gl-text-gray-700 gl-ml-5"
>
<icon name="settings" class="position-top-0 align-middle" />
</a>
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 53da3f7b2ee..ffe4b18dea1 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -44,26 +44,26 @@ export default {
</script>
<template>
- <div class="stats">
+ <div class="stats gl-text-gray-700">
<item-stats-value
v-if="isGroup"
:title="__('Subgroups')"
:value="item.subgroupCount"
- css-class="number-subgroups"
+ css-class="number-subgroups gl-ml-5"
icon-name="folder-o"
/>
<item-stats-value
v-if="isGroup"
:title="__('Projects')"
:value="item.projectCount"
- css-class="number-projects"
+ css-class="number-projects gl-ml-5"
icon-name="bookmark"
/>
<item-stats-value
v-if="isGroup"
:title="__('Members')"
:value="item.memberCount"
- css-class="number-users"
+ css-class="number-users gl-ml-5"
icon-name="users"
/>
<item-stats-value
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 67b068f1c6b..d151cecf5be 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -86,7 +86,7 @@ function trackShowUserDropdownLink(trackEvent, elToTrack, el) {
}
export function initNavUserDropdownTracking() {
const el = document.querySelector('.js-nav-user-dropdown');
- const buyEl = document.querySelector('.js-buy-ci-minutes-link');
+ const buyEl = document.querySelector('.js-buy-pipeline-minutes-link');
const upgradeEl = document.querySelector('.js-upgrade-plan-link');
if (el && buyEl) {
diff --git a/app/assets/javascripts/ide/commit_icon.js b/app/assets/javascripts/ide/commit_icon.js
new file mode 100644
index 00000000000..4984b5bb91d
--- /dev/null
+++ b/app/assets/javascripts/ide/commit_icon.js
@@ -0,0 +1,11 @@
+import { commitItemIconMap } from './constants';
+
+export default file => {
+ if (file.deleted) {
+ return commitItemIconMap.deleted;
+ } else if (file.tempFile && !file.prevPath) {
+ return commitItemIconMap.addition;
+ }
+
+ return commitItemIconMap.modified;
+};
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 186d4b6d7d2..a65af55fcac 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { leftSidebarViews } from '../constants';
@@ -13,7 +13,6 @@ export default {
tooltip,
},
computed: {
- ...mapGetters(['hasChanges']),
...mapState(['currentActivityView']),
},
methods: {
@@ -23,6 +22,8 @@ export default {
this.updateActivityBarView(view);
+ // TODO: We must use JQuery here to interact with the Bootstrap tooltip API
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/217577
$(e.currentTarget).tooltip('hide');
},
},
@@ -67,7 +68,7 @@ export default {
<icon name="file-modified" />
</button>
</li>
- <li v-show="hasChanges">
+ <li>
<button
v-tooltip
:class="{
diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue
index 58a0631ee0d..e7f4cd796b5 100644
--- a/app/assets/javascripts/ide/components/branches/item.vue
+++ b/app/assets/javascripts/ide/components/branches/item.vue
@@ -2,7 +2,6 @@
/* eslint-disable @gitlab/vue-require-i18n-strings */
import Icon from '~/vue_shared/components/icon.vue';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
-import router from '../../ide_router';
export default {
components: {
@@ -26,7 +25,7 @@ export default {
},
computed: {
branchHref() {
- return router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href;
+ return this.$router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href;
},
},
};
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 24499fb9f6d..59a32dd477e 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -29,7 +29,7 @@ export default {
},
},
methods: {
- ...mapActions(['stageChange', 'unstageChange', 'discardFileChanges']),
+ ...mapActions(['unstageChange', 'discardFileChanges']),
showDiscardModal() {
this.$refs.discardModal.show();
},
@@ -56,7 +56,7 @@ export default {
v-if="canDiscard"
ref="discardButton"
type="button"
- class="btn btn-remove btn-inverted append-right-8"
+ class="btn btn-remove btn-inverted gl-mr-3"
@click="showDiscardModal"
>
{{ __('Discard changes') }}
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
index a23bae8e4c7..a13ca0cd138 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
@@ -9,10 +9,7 @@ export default {
</script>
<template>
- <div
- v-if="!lastCommitMsg"
- class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state"
- >
+ <div v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state">
<div class="ide-commit-empty-state-container">
<div class="svg-content svg-80"><img :src="noChangesStateSvgPath" /></div>
<div class="append-right-default prepend-left-default">
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 4cbd33e6ed6..3bba4fbc906 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -26,7 +26,7 @@ export default {
computed: {
...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
- ...mapGetters(['hasChanges']),
+ ...mapGetters(['someUncommittedChanges']),
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
overviewText() {
return n__('%d changed file', '%d changed files', this.stagedFiles.length);
@@ -40,20 +40,9 @@ export default {
},
},
watch: {
- currentActivityView() {
- if (this.lastCommitMsg) {
- this.isCompact = false;
- } else {
- this.isCompact = !(
- this.currentViewIsCommitView && window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT
- );
- }
- },
-
- lastCommitMsg() {
- this.isCompact =
- this.currentActivityView !== leftSidebarViews.commit.name && this.lastCommitMsg === '';
- },
+ currentActivityView: 'handleCompactState',
+ someUncommittedChanges: 'handleCompactState',
+ lastCommitMsg: 'handleCompactState',
},
methods: {
...mapActions(['updateActivityBarView']),
@@ -71,19 +60,24 @@ export default {
forceCreateNewBranch() {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit());
},
- toggleIsCompact() {
- if (this.currentViewIsCommitView) {
- this.isCompact = !this.isCompact;
+ handleCompactState() {
+ if (this.lastCommitMsg) {
+ this.isCompact = false;
} else {
- this.updateActivityBarView(leftSidebarViews.commit.name)
- .then(() => {
- this.isCompact = false;
- })
- .catch(e => {
- throw e;
- });
+ this.isCompact =
+ !this.someUncommittedChanges ||
+ !this.currentViewIsCommitView ||
+ window.innerHeight < MAX_WINDOW_HEIGHT_COMPACT;
}
},
+ toggleIsCompact() {
+ this.isCompact = !this.isCompact;
+ },
+ beginCommit() {
+ return this.updateActivityBarView(leftSidebarViews.commit.name).then(() => {
+ this.isCompact = false;
+ });
+ },
beforeEnterTransition() {
const elHeight = this.isCompact
? this.$refs.formEl && this.$refs.formEl.offsetHeight
@@ -126,16 +120,17 @@ export default {
>
<div v-if="isCompact" ref="compactEl" class="commit-form-compact">
<button
- :disabled="!hasChanges"
+ :disabled="!someUncommittedChanges"
type="button"
class="btn btn-primary btn-sm btn-block qa-begin-commit-button"
- @click="toggleIsCompact"
+ data-testid="begin-commit-button"
+ @click="beginCommit"
>
{{ __('Commit…') }}
</button>
<p class="text-center bold">{{ overviewText }}</p>
</div>
- <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commit">
+ <form v-else ref="formEl" @submit.prevent.stop="commit">
<transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition>
<commit-message-field
:text="commitMessage"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index e6a1a1ba73c..5cff1079eb0 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -55,7 +55,7 @@ export default {
},
},
methods: {
- ...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']),
+ ...mapActions(['unstageAllChanges', 'discardAllChanges']),
openDiscardModal() {
this.$refs.discardAllModal.show();
},
@@ -74,7 +74,7 @@ export default {
<div class="ide-commit-list-container">
<header class="multi-file-commit-panel-header d-flex mb-0">
<div class="d-flex align-items-center flex-fill">
- <icon v-once :name="iconName" :size="18" class="append-right-8" />
+ <icon v-once :name="iconName" :size="18" class="gl-mr-3" />
<strong> {{ titleText }} </strong>
<div class="d-flex ml-auto">
<button
@@ -98,7 +98,7 @@ export default {
</div>
</div>
</header>
- <ul v-if="filesLength" class="multi-file-commit-list list-unstyled append-bottom-0">
+ <ul v-if="filesLength" class="multi-file-commit-list list-unstyled gl-mb-0">
<li v-for="file in fileList" :key="file.key">
<list-item
:file="file"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index e70e251c117..c65169f5d31 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -4,7 +4,7 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { viewerTypes } from '../../constants';
-import { getCommitIconMap } from '../../utils';
+import getCommitIconMap from '../../commit_icon';
export default {
components: {
@@ -87,7 +87,7 @@ export default {
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path d-flex align-items-center">
- <file-icon :file-name="file.name" class="append-right-8" />
+ <file-icon :file-name="file.name" class="gl-mr-3" />
<template v-if="file.prevName && file.prevName !== file.name">
{{ file.prevName }} &#x2192;
</template>
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index 32822a75772..51509cd5fe6 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -89,7 +89,7 @@ export default {
:type="file.type"
:path="file.path"
:is-open="dropdownOpen"
- class="prepend-left-8"
+ class="gl-ml-3"
v-on="$listeners"
/>
</div>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 36c8b18e205..e9f84eb8648 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,5 +1,4 @@
<script>
-import Vue from 'vue';
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
@@ -27,20 +26,13 @@ export default {
CommitEditorHeader,
GlDeprecatedButton,
GlLoadingIcon,
+ RightPane,
},
mixins: [glFeatureFlagsMixin()],
- props: {
- rightPaneComponent: {
- type: Vue.Component,
- required: false,
- default: () => RightPane,
- },
- },
computed: {
...mapState([
'openFiles',
'viewer',
- 'currentMergeRequestId',
'fileFindVisible',
'emptyStateSvgPath',
'currentProjectId',
@@ -49,7 +41,6 @@ export default {
]),
...mapGetters([
'activeFile',
- 'hasChanges',
'someUncommittedChanges',
'isCommitModeActive',
'allBlobs',
@@ -108,14 +99,7 @@ export default {
<div class="multi-file-edit-pane">
<template v-if="activeFile">
<commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" />
- <repo-tabs
- v-else
- :active-file="activeFile"
- :files="openFiles"
- :viewer="viewer"
- :has-changes="hasChanges"
- :merge-request-id="currentMergeRequestId"
- />
+ <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" />
<repo-editor :file="activeFile" class="multi-file-edit-pane-content" />
</template>
<template v-else>
@@ -141,6 +125,7 @@ export default {
variant="success"
:title="__('New file')"
:aria-label="__('New file')"
+ data-qa-selector="first_file_button"
@click="createNewFile()"
>
{{ __('New file') }}
@@ -160,7 +145,7 @@ export default {
</div>
</template>
</div>
- <component :is="rightPaneComponent" v-if="currentProjectId" />
+ <right-pane v-if="currentProjectId" />
</div>
<ide-status-bar />
<new-modal ref="newModal" />
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 7cb31df85ce..1eb89b41495 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -9,7 +9,7 @@ import CommitForm from './commit_sidebar/form.vue';
import IdeReview from './ide_review.vue';
import SuccessMessage from './commit_sidebar/success_message.vue';
import IdeProjectHeader from './ide_project_header.vue';
-import { leftSidebarViews } from '../constants';
+import { leftSidebarViews, SIDEBAR_INIT_WIDTH } from '../constants';
export default {
components: {
@@ -33,11 +33,16 @@ export default {
);
},
},
+ SIDEBAR_INIT_WIDTH,
};
</script>
<template>
- <resizable-panel :initial-width="340" side="left" class="flex-column">
+ <resizable-panel
+ :initial-width="$options.SIDEBAR_INIT_WIDTH"
+ side="left"
+ class="multi-file-commit-panel flex-column"
+ >
<template v-if="loading">
<div class="multi-file-commit-panel-inner">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
diff --git a/app/assets/javascripts/ide/components/ide_sidebar_nav.vue b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue
new file mode 100644
index 00000000000..966c36d6e71
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { otherSide } from '../utils';
+import { SIDE_RIGHT } from '../constants';
+
+export default {
+ directives: {
+ tooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ },
+ props: {
+ tabs: {
+ type: Array,
+ required: true,
+ },
+ side: {
+ type: String,
+ required: true,
+ },
+ currentView: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isOpen: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ otherSide() {
+ return otherSide(this.side);
+ },
+ },
+ methods: {
+ isActiveTab(tab) {
+ return this.isOpen && tab.views.some(view => view.name === this.currentView);
+ },
+ buttonClasses(tab) {
+ return [
+ {
+ 'is-right': this.side === SIDE_RIGHT,
+ active: this.isActiveTab(tab),
+ },
+ ...(tab.buttonClasses || []),
+ ];
+ },
+ clickTab(e, tab) {
+ e.currentTarget.blur();
+ this.$root.$emit('bv::hide::tooltip');
+
+ if (this.isActiveTab(tab)) {
+ this.$emit('close');
+ } else {
+ this.$emit('open', tab.views[0]);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <nav class="ide-activity-bar">
+ <ul class="list-unstyled">
+ <li v-for="tab of tabs" :key="tab.title">
+ <button
+ v-tooltip="{ container: 'body', placement: otherSide }"
+ :title="tab.title"
+ :aria-label="tab.title"
+ :class="buttonClasses(tab)"
+ :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`"
+ class="ide-sidebar-link"
+ type="button"
+ @click="clickTab($event, tab)"
+ >
+ <gl-icon :size="16" :name="tab.icon" />
+ </button>
+ </li>
+ </ul>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 5585343f367..ddc126c3d77 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { mapActions, mapState, mapGetters } from 'vuex';
-import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue';
+import IdeStatusList from './ide_status_list.vue';
import IdeStatusMr from './ide_status_mr.vue';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue
index 364e3f081a1..92d25709bd5 100644
--- a/app/assets/javascripts/ide/components/ide_status_list.vue
+++ b/app/assets/javascripts/ide/components/ide_status_list.vue
@@ -1,9 +1,17 @@
<script>
import { mapGetters } from 'vuex';
+import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue';
+import { getFileEOL } from '../utils';
export default {
+ components: {
+ TerminalSyncStatusSafe,
+ },
computed: {
...mapGetters(['activeFile']),
+ activeFileEOL() {
+ return getFileEOL(this.activeFile.content);
+ },
},
};
</script>
@@ -12,12 +20,12 @@ export default {
<div class="ide-status-list d-flex">
<template v-if="activeFile">
<div class="ide-status-file">{{ activeFile.name }}</div>
- <div class="ide-status-file">{{ activeFile.eol }}</div>
+ <div class="ide-status-file">{{ activeFileEOL }}</div>
<div v-if="!activeFile.binary" class="ide-status-file">
{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}
</div>
<div class="ide-status-file">{{ activeFile.fileLanguage }}</div>
</template>
- <slot></slot>
+ <terminal-sync-status-safe />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue
index 9c0c97bc5ae..f1ba102fffe 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/description.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue
@@ -24,7 +24,7 @@ export default {
<template>
<div class="d-flex align-items-center">
<ci-icon :status="job.status" :borderless="true" :size="24" class="d-flex" />
- <span class="prepend-left-8">
+ <span class="gl-ml-3">
{{ job.name }}
<a :href="job.path" target="_blank" class="ide-external-link position-relative">
{{ jobId }} <icon :size="12" name="external-link" />
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index ba8407382f4..169a948c2da 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -71,11 +71,11 @@ export default {
v-tooltip="showTooltip"
:title="showTooltip ? stage.name : null"
data-container="body"
- class="prepend-left-8 text-truncate"
+ class="gl-ml-3 text-truncate"
>
{{ stage.name }}
</strong>
- <div v-if="!stage.isLoading || stage.jobs.length" class="append-right-8 prepend-left-4">
+ <div v-if="!stage.isLoading || stage.jobs.length" class="gl-mr-3 gl-ml-2">
<span class="badge badge-pill"> {{ jobsCount }} </span>
</div>
<icon :name="collapseIcon" class="ide-stage-collapse-icon" />
diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue
index 60889c893cf..3f060392686 100644
--- a/app/assets/javascripts/ide/components/merge_requests/item.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/item.vue
@@ -1,6 +1,5 @@
<script>
import Icon from '../../../vue_shared/components/icon.vue';
-import router from '../../ide_router';
export default {
components: {
@@ -33,7 +32,7 @@ export default {
mergeRequestHref() {
const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`;
- return router.resolve(path).href;
+ return this.$router.resolve(path).href;
},
},
};
diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue
index cf8a1abbde4..4fab57b6f3e 100644
--- a/app/assets/javascripts/ide/components/mr_file_icon.vue
+++ b/app/assets/javascripts/ide/components/mr_file_icon.vue
@@ -18,6 +18,6 @@ export default {
:title="__('Part of merge request changes')"
:size="12"
name="git-merge"
- class="append-right-8"
+ class="gl-mr-3"
/>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 4766a2fe6ae..586d6867ab4 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -4,7 +4,7 @@ import flash from '~/flash';
import { __, sprintf, s__ } from '~/locale';
import { GlModal } from '@gitlab/ui';
import { modalTypes } from '../../constants';
-import { trimPathComponents } from '../../utils';
+import { trimPathComponents, getPathParent } from '../../utils';
export default {
components: {
@@ -85,8 +85,10 @@ export default {
}
},
createFromTemplate(template) {
+ const parent = getPathParent(this.entryName);
+ const name = parent ? `${parent}/${template.name}` : template.name;
this.createTempEntry({
- name: template.name,
+ name,
type: this.modalType,
});
@@ -133,7 +135,7 @@ export default {
<gl-modal
ref="modal"
modal-id="ide-new-entry"
- modal-class="qa-new-file-modal"
+ data-qa-selector="new_file_modal"
:title="modalTitle"
:ok-title="buttonLabel"
ok-variant="success"
@@ -148,7 +150,8 @@ export default {
ref="fieldName"
v-model.trim="entryName"
type="text"
- class="form-control qa-full-file-path"
+ class="form-control"
+ data-qa-selector="file_name_field"
:placeholder="placeholder"
/>
<ul
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index 7261e0590c8..b2141c13d9f 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -35,7 +35,6 @@ export default {
name: `${this.path ? `${this.path}/` : ''}${name}`,
type: 'blob',
content,
- base64: !isText,
binary: !isText,
rawPath: !isText ? target.result : '',
});
diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
index 91e80be7d18..4e8e1e3a470 100644
--- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
+++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
@@ -2,8 +2,7 @@
import { mapActions, mapState } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
-import ResizablePanel from '../resizable_panel.vue';
-import { GlSkeletonLoading } from '@gitlab/ui';
+import IdeSidebarNav from '../ide_sidebar_nav.vue';
export default {
name: 'CollapsibleSidebar',
@@ -12,8 +11,7 @@ export default {
},
components: {
Icon,
- ResizablePanel,
- GlSkeletonLoading,
+ IdeSidebarNav,
},
props: {
extensionTabs: {
@@ -25,13 +23,8 @@ export default {
type: String,
required: true,
},
- width: {
- type: Number,
- required: true,
- },
},
computed: {
- ...mapState(['loading']),
...mapState({
isOpen(state) {
return state[this.namespace].isOpen;
@@ -39,9 +32,6 @@ export default {
currentView(state) {
return state[this.namespace].currentView;
},
- isActiveView(state, getters) {
- return getters[`${this.namespace}/isActiveView`];
- },
isAliveView(_state, getters) {
return getters[`${this.namespace}/isAliveView`];
},
@@ -59,9 +49,6 @@ export default {
aliveTabViews() {
return this.tabViews.filter(view => this.isAliveView(view.name));
},
- otherSide() {
- return this.side === 'right' ? 'left' : 'right';
- },
},
methods: {
...mapActions({
@@ -72,25 +59,6 @@ export default {
return dispatch(`${this.namespace}/open`, view);
},
}),
- clickTab(e, tab) {
- e.target.blur();
-
- if (this.isActiveTab(tab)) {
- this.toggleOpen();
- } else {
- this.open(tab.views[0]);
- }
- },
- isActiveTab(tab) {
- return tab.views.some(view => this.isActiveView(view.name));
- },
- buttonClasses(tab) {
- return [
- this.side === 'right' ? 'is-right' : '',
- this.isActiveTab(tab) && this.isOpen ? 'active' : '',
- ...(tab.buttonClasses || []),
- ];
- },
},
};
</script>
@@ -101,49 +69,27 @@ export default {
:data-qa-selector="`ide_${side}_sidebar`"
class="multi-file-commit-panel ide-sidebar"
>
- <resizable-panel
+ <div
v-show="isOpen"
- :initial-width="width"
- :min-size="width"
:class="`ide-${side}-sidebar-${currentView}`"
- :side="side"
class="multi-file-commit-panel-inner"
>
- <div class="h-100 d-flex flex-column align-items-stretch">
- <slot v-if="isOpen" name="header"></slot>
- <div
- v-for="tabView in aliveTabViews"
- v-show="isActiveView(tabView.name)"
- :key="tabView.name"
- class="flex-fill gl-overflow-hidden js-tab-view"
- >
- <component :is="tabView.component" />
- </div>
- <slot name="footer"></slot>
+ <div
+ v-for="tabView in aliveTabViews"
+ v-show="tabView.name === currentView"
+ :key="tabView.name"
+ class="flex-fill gl-overflow-hidden js-tab-view gl-h-full"
+ >
+ <component :is="tabView.component" />
</div>
- </resizable-panel>
- <nav class="ide-activity-bar">
- <ul class="list-unstyled">
- <li>
- <slot name="header-icon"></slot>
- </li>
- <li v-for="tab of tabs" :key="tab.title">
- <button
- v-tooltip
- :title="tab.title"
- :aria-label="tab.title"
- :class="buttonClasses(tab)"
- data-container="body"
- :data-placement="otherSide"
- :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`"
- class="ide-sidebar-link"
- type="button"
- @click="clickTab($event, tab)"
- >
- <icon :size="16" :name="tab.icon" />
- </button>
- </li>
- </ul>
- </nav>
+ </div>
+ <ide-sidebar-nav
+ :tabs="tabs"
+ :side="side"
+ :current-view="currentView"
+ :is-open="isOpen"
+ @open="open"
+ @close="toggleOpen"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index 4a9de9e0c03..46ef08a45a9 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -2,26 +2,27 @@
import { mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import CollapsibleSidebar from './collapsible_sidebar.vue';
-import { rightSidebarViews } from '../../constants';
+import ResizablePanel from '../resizable_panel.vue';
+import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants';
import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import Clientside from '../preview/clientside.vue';
+import TerminalView from '../terminal/view.vue';
+
+// Need to add the width of the nav buttons since the resizable container contains those as well
+const WIDTH = SIDEBAR_INIT_WIDTH + SIDEBAR_NAV_WIDTH;
export default {
name: 'RightPane',
components: {
CollapsibleSidebar,
- },
- props: {
- extensionTabs: {
- type: Array,
- required: false,
- default: () => [],
- },
+ ResizablePanel,
},
computed: {
+ ...mapState('terminal', { isTerminalVisible: 'isVisible' }),
...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapGetters(['packageJson']),
+ ...mapState('rightPane', ['isOpen']),
showLivePreview() {
return this.packageJson && this.clientsidePreviewEnabled;
},
@@ -42,13 +43,27 @@ export default {
views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }],
icon: 'live-preview',
},
- ...this.extensionTabs,
+ {
+ show: this.isTerminalVisible,
+ title: __('Terminal'),
+ views: [{ component: TerminalView, ...rightSidebarViews.terminal }],
+ icon: 'terminal',
+ },
];
},
},
+ WIDTH,
};
</script>
<template>
- <collapsible-sidebar :extension-tabs="rightExtensionTabs" side="right" :width="350" />
+ <resizable-panel
+ class="gl-display-flex gl-overflow-hidden"
+ side="right"
+ :initial-width="$options.WIDTH"
+ :min-size="$options.WIDTH"
+ :resizable="isOpen"
+ >
+ <collapsible-sidebar class="gl-w-full" :extension-tabs="rightExtensionTabs" side="right" />
+ </resizable-panel>
</template>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index cf6d01b6351..6958a5d2526 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -63,7 +63,7 @@ export default {
<template v-else-if="hasLoadedPipeline">
<header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header">
<ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" />
- <span class="prepend-left-8">
+ <span class="gl-ml-3">
<strong> {{ __('Pipeline') }} </strong>
<a
:href="latestPipeline.path"
@@ -82,9 +82,9 @@ export default {
class="mb-auto mt-auto"
/>
<div v-else-if="latestPipeline.yamlError" class="bs-callout bs-callout-danger">
- <p class="append-bottom-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p>
- <p class="append-bottom-0 break-word">{{ latestPipeline.yamlError }}</p>
- <p class="append-bottom-0" v-html="ciLintText"></p>
+ <p class="gl-mb-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p>
+ <p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p>
+ <p class="gl-mb-0" v-html="ciLintText"></p>
</div>
<tabs v-else class="ide-pipeline-list">
<tab :active="!pipelineFailed">
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
index ff23485f0f0..0de9dfd8827 100644
--- a/app/assets/javascripts/ide/components/preview/navigator.vue
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -119,7 +119,7 @@ export default {
>
<icon :size="18" name="retry" class="m-auto" />
</button>
- <div class="position-relative w-100 prepend-left-4">
+ <div class="position-relative w-100 gl-ml-2">
<input
:value="path || '/'"
type="text"
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 530fba49df2..5eed57bb6c5 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -3,7 +3,7 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
-import { leftSidebarViews, stageKeys } from '../constants';
+import { stageKeys } from '../constants';
export default {
components: {
@@ -14,39 +14,37 @@ export default {
tooltip,
},
computed: {
- ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg', 'unusedSeal']),
+ ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
- ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']),
+ ...mapGetters(['lastOpenedFile', 'someUncommittedChanges', 'activeFile']),
...mapGetters('commit', ['discardDraftButtonDisabled']),
showStageUnstageArea() {
- return Boolean(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal);
+ return Boolean(this.someUncommittedChanges || this.lastCommitMsg);
},
activeFileKey() {
return this.activeFile ? this.activeFile.key : null;
},
},
- watch: {
- hasChanges() {
- if (!this.hasChanges) {
- this.updateActivityBarView(leftSidebarViews.edit.name);
- }
- },
- },
mounted() {
- if (this.lastOpenedFile && this.lastOpenedFile.type !== 'tree') {
- this.openPendingTab({
- file: this.lastOpenedFile,
- keyPrefix: this.lastOpenedFile.staged ? stageKeys.staged : stageKeys.unstaged,
+ const file =
+ this.lastOpenedFile && this.lastOpenedFile.type !== 'tree'
+ ? this.lastOpenedFile
+ : this.activeFile;
+
+ if (!file) return;
+
+ this.openPendingTab({
+ file,
+ keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged,
+ })
+ .then(changeViewer => {
+ if (changeViewer) {
+ this.updateViewer('diff');
+ }
})
- .then(changeViewer => {
- if (changeViewer) {
- this.updateViewer('diff');
- }
- })
- .catch(e => {
- throw e;
- });
- }
+ .catch(e => {
+ throw e;
+ });
},
methods: {
...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']),
@@ -67,6 +65,6 @@ export default {
icon-name="unstaged"
/>
</template>
- <empty-state v-if="unusedSeal" />
+ <empty-state v-else />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index c72a8b2b0d0..a7646083428 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -14,6 +14,9 @@ import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
+import { getPathParent, readFileAsDataURL } from '../utils';
+import { getRulesWithTraversal } from '../lib/editorconfig/parser';
+import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
export default {
components: {
@@ -31,6 +34,7 @@ export default {
return {
content: '',
images: {},
+ rules: {},
};
},
computed: {
@@ -50,7 +54,6 @@ export default {
'getStagedFile',
'isEditModeActive',
'isCommitModeActive',
- 'isReviewModeActive',
'currentBranch',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
@@ -82,10 +85,6 @@ export default {
active: this.isPreviewViewMode,
};
},
- fileType() {
- const info = viewerInformationForPath(this.file.path);
- return (info && info.id) || '';
- },
showEditor() {
return !this.shouldHideEditor && this.isEditorViewMode;
},
@@ -98,6 +97,12 @@ export default {
currentBranchCommit() {
return this.currentBranch?.commit.id;
},
+ previewMode() {
+ return viewerInformationForPath(this.file.path);
+ },
+ fileType() {
+ return this.previewMode?.id || '';
+ },
},
watch: {
file(newVal, oldVal) {
@@ -165,6 +170,12 @@ export default {
this.editor = Editor.create(this.editorOptions);
}
this.initEditor();
+
+ // listen in capture phase to be able to override Monaco's behaviour.
+ window.addEventListener('paste', this.onPaste, true);
+ },
+ destroyed() {
+ window.removeEventListener('paste', this.onPaste, true);
},
methods: {
...mapActions([
@@ -174,10 +185,10 @@ export default {
'setFileLanguage',
'setEditorPosition',
'setFileViewMode',
- 'setFileEOL',
'updateViewer',
'removePendingTab',
'triggerFilesChange',
+ 'addTempImage',
]),
initEditor() {
if (this.shouldHideEditor && (this.file.content || this.file.raw)) {
@@ -186,7 +197,7 @@ export default {
this.editor.clearEditor();
- this.fetchFileData()
+ Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
.then(() => {
this.createEditorInstance();
})
@@ -223,7 +234,7 @@ export default {
if (this.viewer === viewerTypes.edit) {
this.editor.createInstance(this.$refs.editor);
} else {
- this.editor.createDiffInstance(this.$refs.editor, !this.isReviewModeActive);
+ this.editor.createDiffInstance(this.$refs.editor);
}
this.setupEditor();
@@ -245,15 +256,15 @@ export default {
this.editor.attachModel(this.model);
}
+ this.model.updateOptions(this.rules);
+
this.model.onChange(model => {
const { file } = model;
+ if (!file.active) return;
- if (file.active) {
- this.changeFileContent({
- path: file.path,
- content: model.getModel().getValue(),
- });
- }
+ const monacoModel = model.getModel();
+ const content = monacoModel.getValue();
+ this.changeFileContent({ path: file.path, content });
});
// Handle Cursor Position
@@ -274,16 +285,51 @@ export default {
fileLanguage: this.model.language,
});
- // Get File eol
- this.setFileEOL({
- eol: this.model.eol,
- });
+ this.$emit('editorSetup');
},
refreshEditorDimensions() {
if (this.showEditor) {
this.editor.updateDimensions();
}
},
+ fetchEditorconfigRules() {
+ return getRulesWithTraversal(this.file.path, path => {
+ const entry = this.entries[path];
+ if (!entry) return Promise.resolve(null);
+
+ const content = entry.content || entry.raw;
+ if (content) return Promise.resolve(content);
+
+ return this.getFileData({ path: entry.path, makeFileActive: false }).then(() =>
+ this.getRawFileData({ path: entry.path }),
+ );
+ }).then(rules => {
+ this.rules = mapRulesToMonaco(rules);
+ });
+ },
+ onPaste(event) {
+ const editor = this.editor.instance;
+ const reImage = /^image\/(png|jpg|jpeg|gif)$/;
+ const file = event.clipboardData.files[0];
+
+ if (editor.hasTextFocus() && this.fileType === 'markdown' && reImage.test(file?.type)) {
+ // don't let the event be passed on to Monaco.
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ return readFileAsDataURL(file).then(content => {
+ const parentPath = getPathParent(this.file.path);
+ const path = `${parentPath ? `${parentPath}/` : ''}${file.name}`;
+
+ return this.addTempImage({ name: path, rawPath: content }).then(({ name: fileName }) => {
+ this.editor.replaceSelectedText(`![${fileName}](./${fileName})`);
+ });
+ });
+ }
+
+ // do nothing if no image is found in the clipboard
+ return Promise.resolve();
+ },
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
@@ -301,16 +347,15 @@ export default {
role="button"
@click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>
- <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template>
- <template v-else>{{ __('Review') }}</template>
+ {{ __('Edit') }}
</a>
</li>
- <li v-if="file.previewMode" :class="previewTabCSS">
+ <li v-if="previewMode" :class="previewTabCSS">
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
- >{{ file.previewMode.previewTitle }}</a
+ >{{ previewMode.previewTitle }}</a
>
</li>
</ul>
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
index 1b7f149097b..47c75be3f7c 100644
--- a/app/assets/javascripts/ide/components/repo_tabs.vue
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -1,7 +1,6 @@
<script>
import { mapActions } from 'vuex';
import RepoTab from './repo_tab.vue';
-import router from '../ide_router';
export default {
components: {
@@ -20,15 +19,6 @@ export default {
type: String,
required: true,
},
- hasChanges: {
- type: Boolean,
- required: true,
- },
- mergeRequestId: {
- type: String,
- required: false,
- default: '',
- },
},
methods: {
...mapActions(['updateViewer', 'removePendingTab']),
@@ -37,7 +27,7 @@ export default {
if (this.activeFile.pending) {
return this.removePendingTab(this.activeFile).then(() => {
- router.push(`/project${this.activeFile.url}`);
+ this.$router.push(`/project${this.activeFile.url}`);
});
}
@@ -49,7 +39,7 @@ export default {
<template>
<div class="multi-file-tabs">
- <ul ref="tabsScroller" class="list-unstyled append-bottom-0">
+ <ul ref="tabsScroller" class="list-unstyled gl-mb-0">
<repo-tab v-for="tab in files" :key="tab.key" :tab="tab" />
</ul>
</div>
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
index 86a4622401c..b49d743d877 100644
--- a/app/assets/javascripts/ide/components/resizable_panel.vue
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions } from 'vuex';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import { SIDEBAR_MIN_WIDTH } from '../constants';
export default {
components: {
@@ -14,12 +15,17 @@ export default {
minSize: {
type: Number,
required: false,
- default: 340,
+ default: SIDEBAR_MIN_WIDTH,
},
side: {
type: String,
required: true,
},
+ resizable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -28,7 +34,7 @@ export default {
},
computed: {
panelStyle() {
- if (!this.collapsed) {
+ if (this.resizable) {
return {
width: `${this.width}px`,
};
@@ -45,9 +51,10 @@ export default {
</script>
<template>
- <div :style="panelStyle" class="multi-file-commit-panel">
+ <div class="gl-relative" :style="panelStyle">
<slot></slot>
<panel-resizer
+ v-show="resizable"
:size.sync="width"
:start-size="initialWidth"
:min-size="minSize"
diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue
new file mode 100644
index 00000000000..9841f1ece48
--- /dev/null
+++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ },
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ isValid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ message: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ illustrationPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ onStart() {
+ this.$emit('start');
+ },
+ },
+};
+</script>
+<template>
+ <div class="text-center p-3">
+ <div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div>
+ <h4>{{ __('Web Terminal') }}</h4>
+ <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default" />
+ <template v-else>
+ <p>{{ __('Run tests against your code live using the Web Terminal') }}</p>
+ <p>
+ <button
+ :disabled="!isValid"
+ class="btn btn-info"
+ type="button"
+ data-qa-selector="start_web_terminal_button"
+ @click="onStart"
+ >
+ {{ __('Start Web Terminal') }}
+ </button>
+ </p>
+ <div v-if="!isValid && message" class="bs-callout text-left" v-html="message"></div>
+ <p v-else>
+ <a
+ v-if="helpPath"
+ :href="helpPath"
+ target="_blank"
+ v-text="__('Learn more about Web Terminal')"
+ ></a>
+ </p>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue
new file mode 100644
index 00000000000..a8fe9ea6866
--- /dev/null
+++ b/app/assets/javascripts/ide/components/terminal/session.vue
@@ -0,0 +1,53 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { __ } from '~/locale';
+import Terminal from './terminal.vue';
+import { isEndingStatus } from '../../stores/modules/terminal/utils';
+
+export default {
+ components: {
+ Terminal,
+ },
+ computed: {
+ ...mapState('terminal', ['session']),
+ actionButton() {
+ if (isEndingStatus(this.session.status)) {
+ return {
+ action: () => this.restartSession(),
+ text: __('Restart Terminal'),
+ class: 'btn-primary',
+ };
+ }
+
+ return {
+ action: () => this.stopSession(),
+ text: __('Stop Terminal'),
+ class: 'btn-inverted btn-remove',
+ };
+ },
+ },
+ methods: {
+ ...mapActions('terminal', ['restartSession', 'stopSession']),
+ },
+};
+</script>
+
+<template>
+ <div v-if="session" class="ide-terminal d-flex flex-column">
+ <header class="ide-job-header d-flex align-items-center">
+ <h5>{{ __('Web Terminal') }}</h5>
+ <div class="ml-auto align-self-center">
+ <button
+ v-if="actionButton"
+ type="button"
+ class="btn btn-sm"
+ :class="actionButton.class"
+ @click="actionButton.action"
+ >
+ {{ actionButton.text }}
+ </button>
+ </div>
+ </header>
+ <terminal :terminal-path="session.terminalPath" :status="session.status" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue
new file mode 100644
index 00000000000..0ee4107f9ab
--- /dev/null
+++ b/app/assets/javascripts/ide/components/terminal/terminal.vue
@@ -0,0 +1,117 @@
+<script>
+import { mapState } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import GLTerminal from '~/terminal/terminal';
+import TerminalControls from './terminal_controls.vue';
+import { RUNNING, STOPPING } from '../../stores/modules/terminal/constants';
+import { isStartingStatus } from '../../stores/modules/terminal/utils';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ TerminalControls,
+ },
+ props: {
+ terminalPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ glterminal: null,
+ canScrollUp: false,
+ canScrollDown: false,
+ };
+ },
+ computed: {
+ ...mapState(['panelResizing']),
+ loadingText() {
+ if (isStartingStatus(this.status)) {
+ return __('Starting...');
+ } else if (this.status === STOPPING) {
+ return __('Stopping...');
+ }
+
+ return '';
+ },
+ },
+ watch: {
+ panelResizing() {
+ if (!this.panelResizing && this.glterminal) {
+ this.glterminal.fit();
+ }
+ },
+ status() {
+ this.refresh();
+ },
+ terminalPath() {
+ this.refresh();
+ },
+ },
+ beforeDestroy() {
+ this.destroyTerminal();
+ },
+ methods: {
+ refresh() {
+ if (this.status === RUNNING && this.terminalPath) {
+ this.createTerminal();
+ } else if (this.status === STOPPING) {
+ this.stopTerminal();
+ }
+ },
+ createTerminal() {
+ this.destroyTerminal();
+ this.glterminal = new GLTerminal(this.$refs.terminal);
+ this.glterminal.addScrollListener(({ canScrollUp, canScrollDown }) => {
+ this.canScrollUp = canScrollUp;
+ this.canScrollDown = canScrollDown;
+ });
+ },
+ destroyTerminal() {
+ if (this.glterminal) {
+ this.glterminal.dispose();
+ this.glterminal = null;
+ }
+ },
+ stopTerminal() {
+ if (this.glterminal) {
+ this.glterminal.disable();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="d-flex flex-column flex-fill min-height-0 pr-3">
+ <div class="top-bar d-flex border-left-0 align-items-center">
+ <div v-if="loadingText" data-qa-selector="loading_container">
+ <gl-loading-icon :inline="true" />
+ <span>{{ loadingText }}</span>
+ </div>
+ <terminal-controls
+ v-if="glterminal"
+ class="ml-auto"
+ :can-scroll-up="canScrollUp"
+ :can-scroll-down="canScrollDown"
+ @scroll-up="glterminal.scrollToTop()"
+ @scroll-down="glterminal.scrollToBottom()"
+ />
+ </div>
+ <div class="terminal-wrapper d-flex flex-fill min-height-0">
+ <div
+ ref="terminal"
+ class="ide-terminal-trace flex-fill min-height-0 w-100"
+ :data-project-path="terminalPath"
+ data-qa-selector="terminal_screen"
+ ></div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/terminal/terminal_controls.vue b/app/assets/javascripts/ide/components/terminal/terminal_controls.vue
new file mode 100644
index 00000000000..4c13b4ef103
--- /dev/null
+++ b/app/assets/javascripts/ide/components/terminal/terminal_controls.vue
@@ -0,0 +1,27 @@
+<script>
+import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue';
+
+export default {
+ components: {
+ ScrollButton,
+ },
+ props: {
+ canScrollUp: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ canScrollDown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+};
+</script>
+<template>
+ <div class="controllers">
+ <scroll-button :disabled="!canScrollUp" direction="up" @click="$emit('scroll-up')" />
+ <scroll-button :disabled="!canScrollDown" direction="down" @click="$emit('scroll-down')" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/terminal/view.vue b/app/assets/javascripts/ide/components/terminal/view.vue
new file mode 100644
index 00000000000..db97e95eed9
--- /dev/null
+++ b/app/assets/javascripts/ide/components/terminal/view.vue
@@ -0,0 +1,41 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import EmptyState from './empty_state.vue';
+import TerminalSession from './session.vue';
+
+export default {
+ components: {
+ EmptyState,
+ TerminalSession,
+ },
+ computed: {
+ ...mapState('terminal', ['isShowSplash', 'paths']),
+ ...mapGetters('terminal', ['allCheck']),
+ },
+ methods: {
+ ...mapActions('terminal', ['startSession', 'hideSplash']),
+ start() {
+ this.startSession();
+ this.hideSplash();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="h-100">
+ <div v-if="isShowSplash" class="h-100 d-flex flex-column justify-content-center">
+ <empty-state
+ :is-loading="allCheck.isLoading"
+ :is-valid="allCheck.isValid"
+ :message="allCheck.message"
+ :help-path="paths.webTerminalHelpPath"
+ :illustration-path="paths.webTerminalSvgPath"
+ @start="start()"
+ />
+ </div>
+ <template v-else>
+ <terminal-session />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue
new file mode 100644
index 00000000000..deb13b5615e
--- /dev/null
+++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue
@@ -0,0 +1,76 @@
+<script>
+import { throttle } from 'lodash';
+import { GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
+import { mapState } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+import {
+ MSG_TERMINAL_SYNC_CONNECTING,
+ MSG_TERMINAL_SYNC_UPLOADING,
+ MSG_TERMINAL_SYNC_RUNNING,
+} from '../../stores/modules/terminal_sync/messages';
+
+export default {
+ components: {
+ Icon,
+ GlLoadingIcon,
+ },
+ directives: {
+ 'gl-tooltip': GlTooltipDirective,
+ },
+ data() {
+ return { isLoading: false };
+ },
+ computed: {
+ ...mapState('terminalSync', ['isError', 'isStarted', 'message']),
+ ...mapState('terminalSync', {
+ isLoadingState: 'isLoading',
+ }),
+ status() {
+ if (this.isLoading) {
+ return {
+ icon: '',
+ text: this.isStarted ? MSG_TERMINAL_SYNC_UPLOADING : MSG_TERMINAL_SYNC_CONNECTING,
+ };
+ } else if (this.isError) {
+ return {
+ icon: 'warning',
+ text: this.message,
+ };
+ } else if (this.isStarted) {
+ return {
+ icon: 'mobile-issue-close',
+ text: MSG_TERMINAL_SYNC_RUNNING,
+ };
+ }
+
+ return null;
+ },
+ },
+ watch: {
+ // We want to throttle the `isLoading` updates so that
+ // the user actually sees an indicator that changes are sent.
+ isLoadingState: throttle(function watchIsLoadingState(val) {
+ this.isLoading = val;
+ }, 150),
+ },
+ created() {
+ this.isLoading = this.isLoadingState;
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="status"
+ v-gl-tooltip
+ :title="status.text"
+ role="note"
+ class="d-flex align-items-center"
+ >
+ <span>{{ __('Terminal') }}:</span>
+ <span class="square s16 d-flex-center ml-1" :aria-label="status.text">
+ <gl-loading-icon v-if="isLoading" inline size="sm" class="d-flex-center" />
+ <icon v-else-if="status.icon" :name="status.icon" :size="16" />
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue
new file mode 100644
index 00000000000..afaf06f7f68
--- /dev/null
+++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue
@@ -0,0 +1,22 @@
+<script>
+import { mapState } from 'vuex';
+import TerminalSyncStatus from './terminal_sync_status.vue';
+
+/**
+ * It is possible that the vuex module is not registered.
+ *
+ * This component will gracefully handle this so the actual one can simply use `mapState(moduleName, ...)`.
+ */
+export default {
+ components: {
+ TerminalSyncStatus,
+ },
+ computed: {
+ ...mapState(['terminalSync']),
+ },
+};
+</script>
+
+<template>
+ <terminal-sync-status v-if="terminalSync" />
+</template>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index ae8550cba76..59b1969face 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -4,6 +4,10 @@ export const MAX_WINDOW_HEIGHT_COMPACT = 750;
export const MAX_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72;
+export const SIDEBAR_INIT_WIDTH = 340;
+export const SIDEBAR_MIN_WIDTH = 340;
+export const SIDEBAR_NAV_WIDTH = 60;
+
// File view modes
export const FILE_VIEW_MODE_EDITOR = 'editor';
export const FILE_VIEW_MODE_PREVIEW = 'preview';
@@ -53,6 +57,7 @@ export const rightSidebarViews = {
jobsDetail: { name: 'jobs-detail', keepAlive: false },
mergeRequestInfo: { name: 'merge-request-info', keepAlive: true },
clientSidePreview: { name: 'clientside', keepAlive: false },
+ terminal: { name: 'terminal', keepAlive: true },
};
export const stageKeys = {
@@ -89,3 +94,6 @@ export const commitActionTypes = {
};
export const packageJsonPath = 'package.json';
+
+export const SIDE_LEFT = 'left';
+export const SIDE_RIGHT = 'right';
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 0fab3ee0f3b..152f77effa3 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -2,8 +2,8 @@ import Vue from 'vue';
import IdeRouter from '~/ide/ide_router_extension';
import { joinPaths } from '~/lib/utils/url_utility';
import flash from '~/flash';
-import store from './stores';
import { __ } from '~/locale';
+import { syncRouterAndStore } from './sync_router_and_store';
Vue.use(IdeRouter);
@@ -33,80 +33,85 @@ const EmptyRouterComponent = {
},
};
-const router = new IdeRouter({
- mode: 'history',
- base: joinPaths(gon.relative_url_root || '', '/-/ide/'),
- routes: [
- {
- path: '/project/:namespace+/:project',
- component: EmptyRouterComponent,
- children: [
- {
- path: ':targetmode(edit|tree|blob)/:branchid+/-/*',
- component: EmptyRouterComponent,
- },
- {
- path: ':targetmode(edit|tree|blob)/:branchid+/',
- redirect: to => joinPaths(to.path, '/-/'),
- },
- {
- path: ':targetmode(edit|tree|blob)',
- redirect: to => joinPaths(to.path, '/master/-/'),
- },
- {
- path: 'merge_requests/:mrid',
- component: EmptyRouterComponent,
- },
- {
- path: '',
- redirect: to => joinPaths(to.path, '/edit/master/-/'),
- },
- ],
- },
- ],
-});
+// eslint-disable-next-line import/prefer-default-export
+export const createRouter = store => {
+ const router = new IdeRouter({
+ mode: 'history',
+ base: joinPaths(gon.relative_url_root || '', '/-/ide/'),
+ routes: [
+ {
+ path: '/project/:namespace+/:project',
+ component: EmptyRouterComponent,
+ children: [
+ {
+ path: ':targetmode(edit|tree|blob)/:branchid+/-/*',
+ component: EmptyRouterComponent,
+ },
+ {
+ path: ':targetmode(edit|tree|blob)/:branchid+/',
+ redirect: to => joinPaths(to.path, '/-/'),
+ },
+ {
+ path: ':targetmode(edit|tree|blob)',
+ redirect: to => joinPaths(to.path, '/master/-/'),
+ },
+ {
+ path: 'merge_requests/:mrid',
+ component: EmptyRouterComponent,
+ },
+ {
+ path: '',
+ redirect: to => joinPaths(to.path, '/edit/master/-/'),
+ },
+ ],
+ },
+ ],
+ });
-router.beforeEach((to, from, next) => {
- if (to.params.namespace && to.params.project) {
- store
- .dispatch('getProjectData', {
- namespace: to.params.namespace,
- projectId: to.params.project,
- })
- .then(() => {
- const basePath = to.params.pathMatch || '';
- const projectId = `${to.params.namespace}/${to.params.project}`;
- const branchId = to.params.branchid;
- const mergeRequestId = to.params.mrid;
+ router.beforeEach((to, from, next) => {
+ if (to.params.namespace && to.params.project) {
+ store
+ .dispatch('getProjectData', {
+ namespace: to.params.namespace,
+ projectId: to.params.project,
+ })
+ .then(() => {
+ const basePath = to.params.pathMatch || '';
+ const projectId = `${to.params.namespace}/${to.params.project}`;
+ const branchId = to.params.branchid;
+ const mergeRequestId = to.params.mrid;
- if (branchId) {
- store.dispatch('openBranch', {
- projectId,
- branchId,
- basePath,
- });
- } else if (mergeRequestId) {
- store.dispatch('openMergeRequest', {
- projectId,
- mergeRequestId,
- targetProjectId: to.query.target_project,
- });
- }
- })
- .catch(e => {
- flash(
- __('Error while loading the project data. Please try again.'),
- 'alert',
- document,
- null,
- false,
- true,
- );
- throw e;
- });
- }
+ if (branchId) {
+ store.dispatch('openBranch', {
+ projectId,
+ branchId,
+ basePath,
+ });
+ } else if (mergeRequestId) {
+ store.dispatch('openMergeRequest', {
+ projectId,
+ mergeRequestId,
+ targetProjectId: to.query.target_project,
+ });
+ }
+ })
+ .catch(e => {
+ flash(
+ __('Error while loading the project data. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ throw e;
+ });
+ }
- next();
-});
+ next();
+ });
-export default router;
+ syncRouterAndStore(router, store);
+
+ return router;
+};
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 55a0dd848c8..850cfcb05e3 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate';
import { identity } from 'lodash';
import ide from './components/ide.vue';
import store from './stores';
-import router from './ide_router';
+import { createRouter } from './ide_router';
import { parseBoolean } from '../lib/utils/common_utils';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import { DEFAULT_THEME } from './lib/themes';
@@ -32,6 +32,7 @@ export function initIde(el, options = {}) {
if (!el) return null;
const { rootComponent = ide, extendStore = identity } = options;
+ const router = createRouter(store);
return new Vue({
el,
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index a15f04075d9..c5bb00c3dee 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -1,6 +1,8 @@
import { editor as monacoEditor, Uri } from 'monaco-editor';
import Disposable from './disposable';
import eventHub from '../../eventhub';
+import { trimTrailingWhitespace, insertFinalNewline } from '../../utils';
+import { defaultModelOptions } from '../editor_options';
export default class Model {
constructor(file, head = null) {
@@ -8,6 +10,7 @@ export default class Model {
this.file = file;
this.head = head;
this.content = file.content !== '' || file.deleted ? file.content : file.raw;
+ this.options = { ...defaultModelOptions };
this.disposable.add(
(this.originalModel = monacoEditor.createModel(
@@ -50,10 +53,6 @@ export default class Model {
return this.model.getModeId();
}
- get eol() {
- return this.model.getEOL() === '\n' ? 'LF' : 'CRLF';
- }
-
get path() {
return this.file.key;
}
@@ -94,8 +93,32 @@ export default class Model {
this.getModel().setValue(content);
}
+ updateOptions(obj = {}) {
+ Object.assign(this.options, obj);
+ this.model.updateOptions(obj);
+ this.applyCustomOptions();
+ }
+
+ applyCustomOptions() {
+ this.updateNewContent(
+ Object.entries(this.options).reduce((content, [key, value]) => {
+ switch (key) {
+ case 'endOfLine':
+ this.model.pushEOL(value);
+ return this.model.getValue();
+ case 'insertFinalNewline':
+ return value ? insertFinalNewline(content) : content;
+ case 'trimTrailingWhitespace':
+ return value ? trimTrailingWhitespace(content) : content;
+ default:
+ return content;
+ }
+ }, this.model.getValue()),
+ );
+ }
+
dispose() {
- this.disposable.dispose();
+ if (!this.model.isDisposed()) this.applyCustomOptions();
this.events.forEach(cb => {
if (typeof cb === 'function') cb();
@@ -106,5 +129,7 @@ export default class Model {
eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
+
+ this.disposable.dispose();
}
}
diff --git a/app/assets/javascripts/ide/lib/create_diff.js b/app/assets/javascripts/ide/lib/create_diff.js
new file mode 100644
index 00000000000..3e915afdbcb
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/create_diff.js
@@ -0,0 +1,85 @@
+import { commitActionForFile } from '~/ide/stores/utils';
+import { commitActionTypes } from '~/ide/constants';
+import createFileDiff from './create_file_diff';
+
+const getDeletedParents = (entries, file) => {
+ const parent = file.parentPath && entries[file.parentPath];
+
+ if (parent && parent.deleted) {
+ return [parent, ...getDeletedParents(entries, parent)];
+ }
+
+ return [];
+};
+
+const filesWithChanges = ({ stagedFiles = [], changedFiles = [], entries = {} }) => {
+ // We need changed files to overwrite staged, so put them at the end.
+ const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => {
+ const key = file.path;
+ const action = commitActionForFile(file);
+ const prev = acc[key];
+
+ // If a file was deleted, which was previously added, then we should do nothing.
+ if (action === commitActionTypes.delete && prev && prev.action === commitActionTypes.create) {
+ delete acc[key];
+ } else {
+ acc[key] = { action, file };
+ }
+
+ return acc;
+ }, {});
+
+ // We need to clean "move" actions, because we can only support 100% similarity moves at the moment.
+ // This is because the previous file's content might not be loaded.
+ Object.values(changes)
+ .filter(change => change.action === commitActionTypes.move)
+ .forEach(change => {
+ const prev = changes[change.file.prevPath];
+
+ if (!prev) {
+ return;
+ }
+
+ if (change.file.content === prev.file.content) {
+ // If content is the same, continue with the move but don't do the prevPath's delete.
+ delete changes[change.file.prevPath];
+ } else {
+ // Otherwise, treat the move as a delete / create.
+ Object.assign(change, { action: commitActionTypes.create });
+ }
+ });
+
+ // Next, we need to add deleted directories by looking at the parents
+ Object.values(changes)
+ .filter(change => change.action === commitActionTypes.delete && change.file.parentPath)
+ .forEach(({ file }) => {
+ // Do nothing if we've already visited this directory.
+ if (changes[file.parentPath]) {
+ return;
+ }
+
+ getDeletedParents(entries, file).forEach(parent => {
+ changes[parent.path] = { action: commitActionTypes.delete, file: parent };
+ });
+ });
+
+ return Object.values(changes);
+};
+
+const createDiff = state => {
+ const changes = filesWithChanges(state);
+
+ const toDelete = changes.filter(x => x.action === commitActionTypes.delete).map(x => x.file.path);
+
+ const patch = changes
+ .filter(x => x.action !== commitActionTypes.delete)
+ .map(({ file, action }) => createFileDiff(file, action))
+ .join('');
+
+ return {
+ patch,
+ toDelete,
+ };
+};
+
+export default createDiff;
diff --git a/app/assets/javascripts/ide/lib/create_file_diff.js b/app/assets/javascripts/ide/lib/create_file_diff.js
new file mode 100644
index 00000000000..5ae4993321c
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/create_file_diff.js
@@ -0,0 +1,112 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import { createTwoFilesPatch } from 'diff';
+import { commitActionTypes } from '~/ide/constants';
+
+const DEV_NULL = '/dev/null';
+const DEFAULT_MODE = '100644';
+const NO_NEW_LINE = '\\ No newline at end of file';
+const NEW_LINE = '\n';
+
+/**
+ * Cleans patch generated by `diff` package.
+ *
+ * - Removes "=======" separator added at the beginning
+ */
+const cleanTwoFilesPatch = text => text.replace(/^(=+\s*)/, '');
+
+const endsWithNewLine = val => !val || val[val.length - 1] === NEW_LINE;
+
+const addEndingNewLine = val => (endsWithNewLine(val) ? val : val + NEW_LINE);
+
+const removeEndingNewLine = val => (endsWithNewLine(val) ? val.substr(0, val.length - 1) : val);
+
+const diffHead = (prevPath, newPath = '') =>
+ `diff --git "a/${prevPath}" "b/${newPath || prevPath}"`;
+
+const createDiffBody = (path, content, isCreate) => {
+ if (!content) {
+ return '';
+ }
+
+ const prefix = isCreate ? '+' : '-';
+ const fromPath = isCreate ? DEV_NULL : `a/${path}`;
+ const toPath = isCreate ? `b/${path}` : DEV_NULL;
+
+ const hasNewLine = endsWithNewLine(content);
+ const lines = removeEndingNewLine(content).split(NEW_LINE);
+
+ const chunkHead = isCreate ? `@@ -0,0 +1,${lines.length} @@` : `@@ -1,${lines.length} +0,0 @@`;
+ const chunk = lines
+ .map(line => `${prefix}${line}`)
+ .concat(!hasNewLine ? [NO_NEW_LINE] : [])
+ .join(NEW_LINE);
+
+ return `--- ${fromPath}
++++ ${toPath}
+${chunkHead}
+${chunk}`;
+};
+
+const createMoveFileDiff = (prevPath, newPath) => `${diffHead(prevPath, newPath)}
+rename from ${prevPath}
+rename to ${newPath}`;
+
+const createNewFileDiff = (path, content) => {
+ const diff = createDiffBody(path, content, true);
+
+ return `${diffHead(path)}
+new file mode ${DEFAULT_MODE}
+${diff}`;
+};
+
+const createDeleteFileDiff = (path, content) => {
+ const diff = createDiffBody(path, content, false);
+
+ return `${diffHead(path)}
+deleted file mode ${DEFAULT_MODE}
+${diff}`;
+};
+
+const createUpdateFileDiff = (path, oldContent, newContent) => {
+ const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent);
+
+ return `${diffHead(path)}
+${cleanTwoFilesPatch(patch)}`;
+};
+
+const createFileDiffRaw = (file, action) => {
+ switch (action) {
+ case commitActionTypes.move:
+ return createMoveFileDiff(file.prevPath, file.path);
+ case commitActionTypes.create:
+ return createNewFileDiff(file.path, file.content);
+ case commitActionTypes.delete:
+ return createDeleteFileDiff(file.path, file.content);
+ case commitActionTypes.update:
+ return createUpdateFileDiff(file.path, file.raw || '', file.content);
+ default:
+ return '';
+ }
+};
+
+/**
+ * Create a git diff for a single IDE file.
+ *
+ * ## Notes:
+ * When called with `commitActionType.move`, it assumes that the move
+ * is a 100% similarity move. No diff will be generated. This is because
+ * generating a move with changes is not support by the current IDE, since
+ * the source file might not have it's content loaded yet.
+ *
+ * When called with `commitActionType.delete`, it does not support
+ * deleting files with a mode different than 100644. For the IDE mirror, this
+ * isn't needed because deleting is handled outside the unified patch.
+ *
+ * ## References:
+ * - https://git-scm.com/docs/git-diff#_generating_patches_with_p
+ */
+const createFileDiff = (file, action) =>
+ // It's important that the file diff ends in a new line - git expects this.
+ addEndingNewLine(createFileDiffRaw(file, action));
+
+export default createFileDiff;
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
index 234a7f903a1..35fcda6a6c5 100644
--- a/app/assets/javascripts/ide/lib/diff/controller.js
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -50,10 +50,15 @@ export default class DirtyDiffController {
}
computeDiff(model) {
+ const originalModel = model.getOriginalModel();
+ const newModel = model.getModel();
+
+ if (originalModel.isDisposed() || newModel.isDisposed()) return;
+
this.dirtyDiffWorker.postMessage({
path: model.path,
- originalContent: model.getOriginalModel().getValue(),
- newContent: model.getModel().getValue(),
+ originalContent: originalModel.getValue(),
+ newContent: newModel.getValue(),
});
}
diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js
index 29e29d7fcd3..3a456b7c4d6 100644
--- a/app/assets/javascripts/ide/lib/diff/diff.js
+++ b/app/assets/javascripts/ide/lib/diff/diff.js
@@ -1,8 +1,15 @@
import { diffLines } from 'diff';
+import { defaultDiffOptions } from '../editor_options';
+// See: https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20
// eslint-disable-next-line import/prefer-default-export
export const computeDiff = (originalContent, newContent) => {
- const changes = diffLines(originalContent, newContent);
+ // prevent EOL changes from highlighting the entire file
+ const changes = diffLines(
+ originalContent.replace(/\r\n/g, '\n'),
+ newContent.replace(/\r\n/g, '\n'),
+ defaultDiffOptions,
+ );
let lineNumber = 1;
return changes.reduce((acc, change) => {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 25224abd77c..4dfc27117c0 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -1,11 +1,11 @@
import { debounce } from 'lodash';
-import { editor as monacoEditor, KeyCode, KeyMod } from 'monaco-editor';
+import { editor as monacoEditor, KeyCode, KeyMod, Range } from 'monaco-editor';
import store from '../stores';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
-import editorOptions, { defaultEditorOptions } from './editor_options';
+import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options';
import { themes } from './themes';
import languages from './languages';
import keymap from './keymap.json';
@@ -37,6 +37,10 @@ export default class Editor {
...defaultEditorOptions,
...options,
};
+ this.diffOptions = {
+ ...defaultDiffEditorOptions,
+ ...options,
+ };
setupThemes();
registerLanguages(...languages);
@@ -66,19 +70,14 @@ export default class Editor {
}
}
- createDiffInstance(domElement, readOnly = true) {
+ createDiffInstance(domElement) {
if (!this.instance) {
clearDomElement(domElement);
this.disposable.add(
(this.instance = monacoEditor.createDiffEditor(domElement, {
- ...this.options,
- quickSuggestions: false,
- occurrencesHighlight: false,
+ ...this.diffOptions,
renderSideBySide: Editor.renderSideBySide(domElement),
- readOnly,
- renderLineHighlight: readOnly ? 'all' : 'none',
- hideCursorInOverviewRuler: !readOnly,
})),
);
@@ -187,6 +186,21 @@ export default class Editor {
});
}
+ replaceSelectedText(text) {
+ let selection = this.instance.getSelection();
+ const range = new Range(
+ selection.startLineNumber,
+ selection.startColumn,
+ selection.endLineNumber,
+ selection.endColumn,
+ );
+
+ this.instance.executeEdits('', [{ range, text }]);
+
+ selection = this.instance.getSelection();
+ this.instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
+ }
+
get isDiffEditorType() {
return this.instance.getEditorType() === 'vs.editor.IDiffEditor';
}
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index dac2a8e8b51..f182a1ec50e 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -9,7 +9,27 @@ export const defaultEditorOptions = {
wordWrap: 'on',
};
-export default [
+export const defaultDiffOptions = {
+ ignoreWhitespace: false,
+};
+
+export const defaultDiffEditorOptions = {
+ ...defaultEditorOptions,
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ ignoreTrimWhitespace: false,
+ readOnly: false,
+ renderLineHighlight: 'none',
+ hideCursorInOverviewRuler: true,
+};
+
+export const defaultModelOptions = {
+ endOfLine: 0,
+ insertFinalNewline: true,
+ trimTrailingWhitespace: false,
+};
+
+export const editorOptions = [
{
readOnly: model => Boolean(model.file.file_lock),
quickSuggestions: model => !(model.language === 'markdown'),
diff --git a/app/assets/javascripts/ide/lib/editorconfig/parser.js b/app/assets/javascripts/ide/lib/editorconfig/parser.js
new file mode 100644
index 00000000000..a30a8cb868d
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editorconfig/parser.js
@@ -0,0 +1,55 @@
+import { parseString } from 'editorconfig/src/lib/ini';
+import minimatch from 'minimatch';
+import { getPathParents } from '../../utils';
+
+const dirname = path => path.replace(/\.editorconfig$/, '');
+
+function isRootConfig(config) {
+ return config.some(([pattern, rules]) => !pattern && rules?.root === 'true');
+}
+
+function getRulesForSection(path, [pattern, rules]) {
+ if (!pattern) {
+ return {};
+ }
+ if (minimatch(path, pattern, { matchBase: true })) {
+ return rules;
+ }
+
+ return {};
+}
+
+function getRulesWithConfigs(filePath, configFiles = [], rules = {}) {
+ if (!configFiles.length) return rules;
+
+ const [{ content, path: configPath }, ...nextConfigs] = configFiles;
+ const configDir = dirname(configPath);
+
+ if (!filePath.startsWith(configDir)) return rules;
+
+ const parsed = parseString(content);
+ const isRoot = isRootConfig(parsed);
+ const relativeFilePath = filePath.slice(configDir.length);
+
+ const sectionRules = parsed.reduce(
+ (acc, section) => Object.assign(acc, getRulesForSection(relativeFilePath, section)),
+ {},
+ );
+
+ // prefer existing rules by overwriting to section rules
+ const result = Object.assign(sectionRules, rules);
+
+ return isRoot ? result : getRulesWithConfigs(filePath, nextConfigs, result);
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export function getRulesWithTraversal(filePath, getFileContent) {
+ const editorconfigPaths = [
+ ...getPathParents(filePath).map(x => `${x}/.editorconfig`),
+ '.editorconfig',
+ ];
+
+ return Promise.all(
+ editorconfigPaths.map(path => getFileContent(path).then(content => ({ path, content }))),
+ ).then(results => getRulesWithConfigs(filePath, results.filter(x => x.content)));
+}
diff --git a/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js b/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js
new file mode 100644
index 00000000000..f9d5579511a
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js
@@ -0,0 +1,33 @@
+import { isBoolean, isNumber } from 'lodash';
+
+const map = (key, validValues) => value =>
+ value in validValues ? { [key]: validValues[value] } : {};
+
+const bool = key => value => (isBoolean(value) ? { [key]: value } : {});
+
+const int = (key, isValid) => value =>
+ isNumber(value) && isValid(value) ? { [key]: Math.trunc(value) } : {};
+
+const rulesMapper = {
+ indent_style: map('insertSpaces', { tab: false, space: true }),
+ indent_size: int('tabSize', n => n > 0),
+ tab_width: int('tabSize', n => n > 0),
+ trim_trailing_whitespace: bool('trimTrailingWhitespace'),
+ end_of_line: map('endOfLine', { crlf: 1, lf: 0 }),
+ insert_final_newline: bool('insertFinalNewline'),
+};
+
+const parseValue = x => {
+ let value = typeof x === 'string' ? x.toLowerCase() : x;
+ if (/^[0-9.-]+$/.test(value)) value = Number(value);
+ if (value === 'true') value = true;
+ if (value === 'false') value = false;
+
+ return value;
+};
+
+export default function mapRulesToMonaco(rules) {
+ return Object.entries(rules).reduce((obj, [key, value]) => {
+ return Object.assign(obj, rulesMapper[key]?.(parseValue(value)) || {});
+ }, {});
+}
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
index 26518a2abac..6d85e225fd5 100644
--- a/app/assets/javascripts/ide/lib/files.js
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -19,7 +19,6 @@ export const decorateFiles = ({
branchId,
tempFile = false,
content = '',
- base64 = false,
binary = false,
rawPath = '',
}) => {
@@ -49,7 +48,6 @@ export const decorateFiles = ({
path,
url: `/${projectId}/tree/${branchId}/-/${path}/`,
type: 'tree',
- parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
tempFile,
changed: tempFile,
opened: tempFile,
@@ -86,14 +84,11 @@ export const decorateFiles = ({
path,
url: `/${projectId}/blob/${branchId}/-/${path}`,
type: 'blob',
- parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
tempFile,
changed: tempFile,
content,
- base64,
binary: (previewMode && previewMode.binary) || binary,
rawPath,
- previewMode,
parentPath,
});
diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md
new file mode 100644
index 00000000000..e4d1a4c7818
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/README.md
@@ -0,0 +1,21 @@
+# Web IDE Languages
+
+The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting.
+The Web IDE currently supports all langauges defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository.
+
+## Adding New Languages
+
+While Monaco supports a wide variety of languages, there's always the chance that it's missing something.
+You'll find a list of [unsupported languages in this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), which is the right place to add more if needed.
+
+Should you be willing to help us and add support to GitLab for any missing languages, here are the steps to do so:
+
+1. Create a new issue and add it to [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), if it doesn't already exist.
+2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for.
+3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language.
+ - Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting.
+4. Add tests for the new langauge implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`.
+ - Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js).
+5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language.
+
+Thank you!
diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js
new file mode 100644
index 00000000000..a516c28ad7a
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/mirror.js
@@ -0,0 +1,154 @@
+import createDiff from './create_diff';
+import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+
+export const SERVICE_NAME = 'webide-file-sync';
+export const PROTOCOL = 'webfilesync.gitlab.com';
+export const MSG_CONNECTION_ERROR = __('Could not connect to Web IDE file mirror service.');
+
+// Before actually connecting to the service, we must delay a bit
+// so that the service has sufficiently started.
+
+const noop = () => {};
+export const SERVICE_DELAY = 8000;
+
+const cancellableWait = time => {
+ let timeoutId = 0;
+
+ const cancel = () => clearTimeout(timeoutId);
+
+ const promise = new Promise(resolve => {
+ timeoutId = setTimeout(resolve, time);
+ });
+
+ return [promise, cancel];
+};
+
+const isErrorResponse = error => error && error.code !== 0;
+
+const isErrorPayload = payload => payload && payload.status_code !== 200;
+
+const getErrorFromResponse = data => {
+ if (isErrorResponse(data.error)) {
+ return { message: data.error.Message };
+ } else if (isErrorPayload(data.payload)) {
+ return { message: data.payload.error_message };
+ }
+
+ return null;
+};
+
+const getFullPath = path => mergeUrlParams({ service: SERVICE_NAME }, getWebSocketUrl(path));
+
+const createWebSocket = fullPath =>
+ new Promise((resolve, reject) => {
+ const socket = new WebSocket(fullPath, [PROTOCOL]);
+ const resetCallbacks = () => {
+ socket.onopen = null;
+ socket.onerror = null;
+ };
+
+ socket.onopen = () => {
+ resetCallbacks();
+ resolve(socket);
+ };
+
+ socket.onerror = () => {
+ resetCallbacks();
+ reject(new Error(MSG_CONNECTION_ERROR));
+ };
+ });
+
+export const canConnect = ({ services = [] }) => services.some(name => name === SERVICE_NAME);
+
+export const createMirror = () => {
+ let socket = null;
+ let cancelHandler = noop;
+ let nextMessageHandler = noop;
+
+ const cancelConnect = () => {
+ cancelHandler();
+ cancelHandler = noop;
+ };
+
+ const onCancelConnect = fn => {
+ cancelHandler = fn;
+ };
+
+ const receiveMessage = ev => {
+ const handle = nextMessageHandler;
+ nextMessageHandler = noop;
+ handle(JSON.parse(ev.data));
+ };
+
+ const onNextMessage = fn => {
+ nextMessageHandler = fn;
+ };
+
+ const waitForNextMessage = () =>
+ new Promise((resolve, reject) => {
+ onNextMessage(data => {
+ const err = getErrorFromResponse(data);
+
+ if (err) {
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+
+ const uploadDiff = ({ toDelete, patch }) => {
+ if (!socket) {
+ return Promise.resolve();
+ }
+
+ const response = waitForNextMessage();
+
+ const msg = {
+ code: 'EVENT',
+ namespace: '/files',
+ event: 'PATCH',
+ payload: { diff: patch, delete_files: toDelete },
+ };
+
+ socket.send(JSON.stringify(msg));
+
+ return response;
+ };
+
+ return {
+ upload(state) {
+ return uploadDiff(createDiff(state));
+ },
+ connect(path) {
+ if (socket) {
+ this.disconnect();
+ }
+
+ const fullPath = getFullPath(path);
+ const [wait, cancelWait] = cancellableWait(SERVICE_DELAY);
+
+ onCancelConnect(cancelWait);
+
+ return wait
+ .then(() => createWebSocket(fullPath))
+ .then(newSocket => {
+ socket = newSocket;
+ socket.onmessage = receiveMessage;
+ });
+ },
+ disconnect() {
+ cancelConnect();
+
+ if (!socket) {
+ return;
+ }
+
+ socket.close();
+ socket = null;
+ },
+ };
+};
+
+export default createMirror();
diff --git a/app/assets/javascripts/ide/services/terminals.js b/app/assets/javascripts/ide/services/terminals.js
new file mode 100644
index 00000000000..17b4329037d
--- /dev/null
+++ b/app/assets/javascripts/ide/services/terminals.js
@@ -0,0 +1,15 @@
+import axios from '~/lib/utils/axios_utils';
+
+export const baseUrl = projectPath => `/${projectPath}/ide_terminals`;
+
+export const checkConfig = (projectPath, branch) =>
+ axios.post(`${baseUrl(projectPath)}/check_config`, {
+ branch,
+ format: 'json',
+ });
+
+export const create = (projectPath, branch) =>
+ axios.post(baseUrl(projectPath), {
+ branch,
+ format: 'json',
+ });
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index e32b5ac7bdc..c881f1221e5 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -7,7 +7,6 @@ import * as types from './mutation_types';
import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
import service from '../services';
-import router from '../ide_router';
import eventHub from '../eventhub';
export const redirectToUrl = (self, url) => visitUrl(url);
@@ -20,21 +19,25 @@ export const discardAllChanges = ({ state, commit, dispatch }) => {
commit(types.REMOVE_ALL_CHANGES_FILES);
};
-export const closeAllFiles = ({ state, dispatch }) => {
- state.openFiles.forEach(file => dispatch('closeFile', file));
-};
-
export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing);
};
export const createTempEntry = (
{ state, commit, dispatch, getters },
- { name, type, content = '', base64 = false, binary = false, rawPath = '' },
+ {
+ name,
+ type,
+ content = '',
+ binary = false,
+ rawPath = '',
+ openFile = true,
+ makeFileActive = true,
+ },
) => {
const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
- if (state.entries[name] && !state.entries[name].deleted) {
+ if (getters.entryExists(name)) {
flash(
sprintf(__('The name "%{name}" is already taken in this directory.'), {
name: name.split('/').pop(),
@@ -46,7 +49,7 @@ export const createTempEntry = (
true,
);
- return;
+ return undefined;
}
const data = decorateFiles({
@@ -56,7 +59,6 @@ export const createTempEntry = (
type,
tempFile: true,
content,
- base64,
binary,
rawPath,
});
@@ -69,18 +71,31 @@ export const createTempEntry = (
});
if (type === 'blob') {
- commit(types.TOGGLE_FILE_OPEN, file.path);
+ if (openFile) commit(types.TOGGLE_FILE_OPEN, file.path);
commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) });
- dispatch('setFileActive', file.path);
+ if (openFile && makeFileActive) dispatch('setFileActive', file.path);
dispatch('triggerFilesChange');
}
if (parentPath && !state.entries[parentPath].opened) {
commit(types.TOGGLE_TREE_OPEN, parentPath);
}
+
+ return file;
};
+export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) =>
+ dispatch('createTempEntry', {
+ name: getters.getAvailableFileName(name),
+ type: 'blob',
+ content: rawPath.split('base64,')[1],
+ binary: true,
+ rawPath,
+ openFile: false,
+ makeFileActive: false,
+ });
+
export const scrollToTab = () => {
Vue.nextTick(() => {
const tabs = document.getElementById('tabs');
@@ -239,7 +254,7 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name,
}
if (newEntry.opened) {
- router.push(`/project${newEntry.url}`);
+ dispatch('router/push', `/project${newEntry.url}`, { root: true });
}
}
@@ -297,6 +312,3 @@ export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
export * from './actions/merge_request';
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index da7d4a44bde..47f9337a288 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -3,8 +3,7 @@ import { __ } from '~/locale';
import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
-import router from '../../ide_router';
-import { addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils';
+import { setPageTitleForFile } from '../utils';
import { viewerTypes, stageKeys } from '../../constants';
export const closeFile = ({ commit, state, dispatch }, file) => {
@@ -30,10 +29,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => {
keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged',
});
} else {
- router.push(`/project${nextFileToOpen.url}`);
+ dispatch('router/push', `/project${nextFileToOpen.url}`, { root: true });
}
} else if (!state.openFiles.length) {
- router.push(`/project/${file.projectId}/tree/${file.branchId}/`);
+ dispatch('router/push', `/project/${file.projectId}/tree/${file.branchId}/`, { root: true });
}
eventHub.$emit(`editor.update.model.dispose.${file.key}`);
@@ -152,7 +151,7 @@ export const changeFileContent = ({ commit, state, getters }, { path, content })
const file = state.entries[path];
commit(types.UPDATE_FILE_CONTENT, {
path,
- content: addFinalNewlineIfNeeded(content),
+ content,
});
const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path);
@@ -170,12 +169,6 @@ export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
}
};
-export const setFileEOL = ({ getters, commit }, { eol }) => {
- if (getters.activeFile) {
- commit(types.SET_FILE_EOL, { file: getters.activeFile, eol });
- }
-};
-
export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => {
if (getters.activeFile) {
commit(types.SET_FILE_POSITION, {
@@ -226,7 +219,7 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) =
if (!isDestructiveDiscard && file.path === getters.activeFile?.path) {
dispatch('updateDelayViewerUpdated', true)
.then(() => {
- router.push(`/project${file.url}`);
+ dispatch('router/push', `/project${file.url}`, { root: true });
})
.catch(e => {
throw e;
@@ -275,14 +268,16 @@ export const unstageChange = ({ commit, dispatch, getters }, path) => {
}
};
-export const openPendingTab = ({ commit, getters, state }, { file, keyPrefix }) => {
+export const openPendingTab = ({ commit, dispatch, getters, state }, { file, keyPrefix }) => {
if (getters.activeFile && getters.activeFile.key === `${keyPrefix}-${file.key}`) return false;
state.openFiles.forEach(f => eventHub.$emit(`editor.update.model.dispose.${f.key}`));
commit(types.ADD_PENDING_TAB, { file, keyPrefix });
- router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`);
+ dispatch('router/push', `/project/${file.projectId}/tree/${state.currentBranchId}/`, {
+ root: true,
+ });
return true;
};
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 6c8fb9f90aa..d172bb31ae5 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -4,7 +4,6 @@ import { __, sprintf } from '~/locale';
import service from '../../services';
import api from '../../../api';
import * as types from '../mutation_types';
-import router from '../../ide_router';
export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) =>
new Promise((resolve, reject) => {
@@ -57,7 +56,7 @@ export const createNewBranchFromDefault = ({ state, dispatch, getters }, branch)
})
.then(() => {
dispatch('setErrorMessage', null);
- router.push(`${router.currentRoute.path}?${Date.now()}`);
+ window.location.reload();
})
.catch(() => {
dispatch('setErrorMessage', {
diff --git a/app/assets/javascripts/ide/stores/extend.js b/app/assets/javascripts/ide/stores/extend.js
new file mode 100644
index 00000000000..1c1636cf6ca
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/extend.js
@@ -0,0 +1,14 @@
+import terminal from './plugins/terminal';
+import terminalSync from './plugins/terminal_sync';
+
+const plugins = () => [
+ terminal,
+ ...(gon.features && gon.features.buildServiceProxy ? [terminalSync] : []),
+];
+
+export default (store, el) => {
+ // plugins is actually an array of plugin factories, so we have to create first then call
+ plugins().forEach(plugin => plugin(el)(store));
+
+ return store;
+};
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 5d0a8570906..53734fa626b 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -50,9 +50,6 @@ export const emptyRepo = state =>
export const currentTree = state =>
state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
-export const hasChanges = state =>
- Boolean(state.changedFiles.length) || Boolean(state.stagedFiles.length);
-
export const hasMergeRequest = state => Boolean(state.currentMergeRequestId);
export const allBlobs = state =>
@@ -162,5 +159,18 @@ export const canCreateMergeRequests = (state, getters) =>
export const canPushCode = (state, getters) =>
Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_PUSH_CODE]);
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
+export const entryExists = state => path =>
+ Boolean(state.entries[path] && !state.entries[path].deleted);
+
+export const getAvailableFileName = (state, getters) => path => {
+ let newPath = path;
+
+ while (getters.entryExists(newPath)) {
+ newPath = newPath.replace(
+ /([ _-]?)(\d*)(\..+?$|$)/,
+ (_, before, number, after) => `${before || '_'}${Number(number) + 1}${after}`,
+ );
+ }
+
+ return newPath;
+};
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
index 85550578e94..18c466cc93d 100644
--- a/app/assets/javascripts/ide/stores/index.js
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -11,24 +11,27 @@ import branches from './modules/branches';
import fileTemplates from './modules/file_templates';
import paneModule from './modules/pane';
import clientsideModule from './modules/clientside';
+import routerModule from './modules/router';
Vue.use(Vuex);
-export const createStore = () =>
- new Vuex.Store({
- state: state(),
- actions,
- mutations,
- getters,
- modules: {
- commit: commitModule,
- pipelines,
- mergeRequests,
- branches,
- fileTemplates: fileTemplates(),
- rightPane: paneModule(),
- clientside: clientsideModule(),
- },
- });
+export const createStoreOptions = () => ({
+ state: state(),
+ actions,
+ mutations,
+ getters,
+ modules: {
+ commit: commitModule,
+ pipelines,
+ mergeRequests,
+ branches,
+ fileTemplates: fileTemplates(),
+ rightPane: paneModule(),
+ clientside: clientsideModule(),
+ router: routerModule,
+ },
+});
+
+export const createStore = () => new Vuex.Store(createStoreOptions());
export default createStore();
diff --git a/app/assets/javascripts/ide/stores/modules/branches/index.js b/app/assets/javascripts/ide/stores/modules/branches/index.js
index 04e7e0f08f1..deda95cd0c9 100644
--- a/app/assets/javascripts/ide/stores/modules/branches/index.js
+++ b/app/assets/javascripts/ide/stores/modules/branches/index.js
@@ -4,7 +4,7 @@ import mutations from './mutations';
export default {
namespaced: true,
- state: state(),
+ state,
actions,
mutations,
};
diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
index eb3bcdff2ae..2bebf8b90ce 100644
--- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
@@ -8,5 +8,4 @@ export const pingUsage = ({ rootGetters }) => {
return axios.post(url);
};
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
+export default pingUsage;
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 592c7e15918..005bd0240e2 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -3,7 +3,6 @@ import flash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import * as rootTypes from '../../mutation_types';
import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
-import router from '../../../ide_router';
import service from '../../../services';
import * as types from './mutation_types';
import consts from './constants';
@@ -196,8 +195,10 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
dispatch('updateViewer', 'editor', { root: true });
if (rootGetters.activeFile) {
- router.push(
+ dispatch(
+ 'router/push',
`/project/${rootState.currentProjectId}/blob/${branchName}/-/${rootGetters.activeFile.path}`,
+ { root: true },
);
}
}
@@ -234,6 +235,3 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
window.dispatchEvent(new Event('resize'));
});
};
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index 413c4b0110d..37f887bcf0a 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -59,6 +59,3 @@ export const shouldDisableNewMrOption = (state, getters, rootState, rootGetters)
export const shouldCreateMR = (state, getters) =>
state.shouldCreateMR && !getters.shouldDisableNewMrOption;
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js
index 3bf65b02847..5cec73bde2e 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/index.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/index.js
@@ -5,7 +5,7 @@ import * as getters from './getters';
export default {
namespaced: true,
- state: state(),
+ state,
mutations,
actions,
getters,
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 59ead8a3dcf..6b2c929cd44 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js
@@ -117,6 +117,3 @@ export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => {
dispatch('discardFileChanges', file.path, { root: true });
}
};
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/pane/actions.js b/app/assets/javascripts/ide/stores/modules/pane/actions.js
index a8fcdf539ec..b7cff368fe4 100644
--- a/app/assets/javascripts/ide/stores/modules/pane/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pane/actions.js
@@ -25,6 +25,3 @@ export const open = ({ state, commit }, view) => {
export const close = ({ commit }) => {
commit(types.SET_OPEN, false);
};
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/pane/getters.js b/app/assets/javascripts/ide/stores/modules/pane/getters.js
index c346cf13689..7816172bb6f 100644
--- a/app/assets/javascripts/ide/stores/modules/pane/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/pane/getters.js
@@ -1,4 +1,3 @@
-export const isActiveView = state => view => state.currentView === view;
-
-export const isAliveView = (state, getters) => view =>
- state.keepAliveViews[view] || (state.isOpen && getters.isActiveView(view));
+// eslint-disable-next-line import/prefer-default-export
+export const isAliveView = state => view =>
+ state.keepAliveViews[view] || (state.isOpen && state.currentView === view);
diff --git a/app/assets/javascripts/ide/stores/modules/router/actions.js b/app/assets/javascripts/ide/stores/modules/router/actions.js
new file mode 100644
index 00000000000..849067599f2
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/router/actions.js
@@ -0,0 +1,6 @@
+import * as types from './mutation_types';
+
+// eslint-disable-next-line import/prefer-default-export
+export const push = ({ commit }, fullPath) => {
+ commit(types.PUSH, fullPath);
+};
diff --git a/app/assets/javascripts/ide/stores/modules/router/index.js b/app/assets/javascripts/ide/stores/modules/router/index.js
new file mode 100644
index 00000000000..68c81bb4509
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/router/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/router/mutation_types.js b/app/assets/javascripts/ide/stores/modules/router/mutation_types.js
new file mode 100644
index 00000000000..ae99073cc4c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/router/mutation_types.js
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export const PUSH = 'PUSH';
diff --git a/app/assets/javascripts/ide/stores/modules/router/mutations.js b/app/assets/javascripts/ide/stores/modules/router/mutations.js
new file mode 100644
index 00000000000..471cace314c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/router/mutations.js
@@ -0,0 +1,7 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.PUSH](state, fullPath) {
+ state.fullPath = fullPath;
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/router/state.js b/app/assets/javascripts/ide/stores/modules/router/state.js
new file mode 100644
index 00000000000..abb6c5239e4
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/router/state.js
@@ -0,0 +1,3 @@
+export default () => ({
+ fullPath: '',
+});
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js
new file mode 100644
index 00000000000..43b6650b241
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js
@@ -0,0 +1,98 @@
+import Api from '~/api';
+import httpStatus from '~/lib/utils/http_status';
+import * as types from '../mutation_types';
+import * as messages from '../messages';
+import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from '../constants';
+import * as terminalService from '../../../../services/terminals';
+
+export const requestConfigCheck = ({ commit }) => {
+ commit(types.REQUEST_CHECK, CHECK_CONFIG);
+};
+
+export const receiveConfigCheckSuccess = ({ commit }) => {
+ commit(types.SET_VISIBLE, true);
+ commit(types.RECEIVE_CHECK_SUCCESS, CHECK_CONFIG);
+};
+
+export const receiveConfigCheckError = ({ commit, state }, e) => {
+ const { status } = e.response;
+ const { paths } = state;
+
+ const isVisible = status !== httpStatus.FORBIDDEN && status !== httpStatus.NOT_FOUND;
+ commit(types.SET_VISIBLE, isVisible);
+
+ const message = messages.configCheckError(status, paths.webTerminalConfigHelpPath);
+ commit(types.RECEIVE_CHECK_ERROR, { type: CHECK_CONFIG, message });
+};
+
+export const fetchConfigCheck = ({ dispatch, rootState, rootGetters }) => {
+ dispatch('requestConfigCheck');
+
+ const { currentBranchId } = rootState;
+ const { currentProject } = rootGetters;
+
+ terminalService
+ .checkConfig(currentProject.path_with_namespace, currentBranchId)
+ .then(() => {
+ dispatch('receiveConfigCheckSuccess');
+ })
+ .catch(e => {
+ dispatch('receiveConfigCheckError', e);
+ });
+};
+
+export const requestRunnersCheck = ({ commit }) => {
+ commit(types.REQUEST_CHECK, CHECK_RUNNERS);
+};
+
+export const receiveRunnersCheckSuccess = ({ commit, dispatch, state }, data) => {
+ if (data.length) {
+ commit(types.RECEIVE_CHECK_SUCCESS, CHECK_RUNNERS);
+ } else {
+ const { paths } = state;
+
+ commit(types.RECEIVE_CHECK_ERROR, {
+ type: CHECK_RUNNERS,
+ message: messages.runnersCheckEmpty(paths.webTerminalRunnersHelpPath),
+ });
+
+ dispatch('retryRunnersCheck');
+ }
+};
+
+export const receiveRunnersCheckError = ({ commit }) => {
+ commit(types.RECEIVE_CHECK_ERROR, {
+ type: CHECK_RUNNERS,
+ message: messages.UNEXPECTED_ERROR_RUNNERS,
+ });
+};
+
+export const retryRunnersCheck = ({ dispatch, state }) => {
+ // if the overall check has failed, don't worry about retrying
+ const check = state.checks[CHECK_CONFIG];
+ if (!check.isLoading && !check.isValid) {
+ return;
+ }
+
+ setTimeout(() => {
+ dispatch('fetchRunnersCheck', { background: true });
+ }, RETRY_RUNNERS_INTERVAL);
+};
+
+export const fetchRunnersCheck = ({ dispatch, rootGetters }, options = {}) => {
+ const { background = false } = options;
+
+ if (!background) {
+ dispatch('requestRunnersCheck');
+ }
+
+ const { currentProject } = rootGetters;
+
+ Api.projectRunners(currentProject.id, { params: { scope: 'active' } })
+ .then(({ data }) => {
+ dispatch('receiveRunnersCheckSuccess', data);
+ })
+ .catch(e => {
+ dispatch('receiveRunnersCheckError', e);
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js
new file mode 100644
index 00000000000..112b3794114
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js
@@ -0,0 +1,5 @@
+export * from './setup';
+export * from './checks';
+export * from './session_controls';
+export * from './session_status';
+export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
new file mode 100644
index 00000000000..d3dcb9dd125
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
@@ -0,0 +1,118 @@
+import axios from '~/lib/utils/axios_utils';
+import httpStatus from '~/lib/utils/http_status';
+import flash from '~/flash';
+import * as types from '../mutation_types';
+import * as messages from '../messages';
+import * as terminalService from '../../../../services/terminals';
+import { STARTING, STOPPING, STOPPED } from '../constants';
+
+export const requestStartSession = ({ commit }) => {
+ commit(types.SET_SESSION_STATUS, STARTING);
+};
+
+export const receiveStartSessionSuccess = ({ commit, dispatch }, data) => {
+ commit(types.SET_SESSION, {
+ id: data.id,
+ status: data.status,
+ showPath: data.show_path,
+ cancelPath: data.cancel_path,
+ retryPath: data.retry_path,
+ terminalPath: data.terminal_path,
+ proxyWebsocketPath: data.proxy_websocket_path,
+ services: data.services,
+ });
+
+ dispatch('pollSessionStatus');
+};
+
+export const receiveStartSessionError = ({ dispatch }) => {
+ flash(messages.UNEXPECTED_ERROR_STARTING);
+ dispatch('killSession');
+};
+
+export const startSession = ({ state, dispatch, rootGetters, rootState }) => {
+ if (state.session && state.session.status === STARTING) {
+ return;
+ }
+
+ const { currentProject } = rootGetters;
+ const { currentBranchId } = rootState;
+
+ dispatch('requestStartSession');
+
+ terminalService
+ .create(currentProject.path_with_namespace, currentBranchId)
+ .then(({ data }) => {
+ dispatch('receiveStartSessionSuccess', data);
+ })
+ .catch(error => {
+ dispatch('receiveStartSessionError', error);
+ });
+};
+
+export const requestStopSession = ({ commit }) => {
+ commit(types.SET_SESSION_STATUS, STOPPING);
+};
+
+export const receiveStopSessionSuccess = ({ dispatch }) => {
+ dispatch('killSession');
+};
+
+export const receiveStopSessionError = ({ dispatch }) => {
+ flash(messages.UNEXPECTED_ERROR_STOPPING);
+ dispatch('killSession');
+};
+
+export const stopSession = ({ state, dispatch }) => {
+ const { cancelPath } = state.session;
+
+ dispatch('requestStopSession');
+
+ axios
+ .post(cancelPath)
+ .then(() => {
+ dispatch('receiveStopSessionSuccess');
+ })
+ .catch(err => {
+ dispatch('receiveStopSessionError', err);
+ });
+};
+
+export const killSession = ({ commit, dispatch }) => {
+ dispatch('stopPollingSessionStatus');
+ commit(types.SET_SESSION_STATUS, STOPPED);
+};
+
+export const restartSession = ({ state, dispatch, rootState }) => {
+ const { status, retryPath } = state.session;
+ const { currentBranchId } = rootState;
+
+ if (status !== STOPPED) {
+ return;
+ }
+
+ if (!retryPath) {
+ dispatch('startSession');
+ return;
+ }
+
+ dispatch('requestStartSession');
+
+ axios
+ .post(retryPath, { branch: currentBranchId, format: 'json' })
+ .then(({ data }) => {
+ dispatch('receiveStartSessionSuccess', data);
+ })
+ .catch(error => {
+ const responseStatus = error.response && error.response.status;
+ // We may have removed the build, in this case we'll just create a new session
+ if (
+ responseStatus === httpStatus.NOT_FOUND ||
+ responseStatus === httpStatus.UNPROCESSABLE_ENTITY
+ ) {
+ dispatch('startSession');
+ } else {
+ dispatch('receiveStartSessionError', error);
+ }
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js
new file mode 100644
index 00000000000..59ba1605c47
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js
@@ -0,0 +1,64 @@
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import * as types from '../mutation_types';
+import * as messages from '../messages';
+import { isEndingStatus } from '../utils';
+
+export const pollSessionStatus = ({ state, dispatch, commit }) => {
+ dispatch('stopPollingSessionStatus');
+ dispatch('fetchSessionStatus');
+
+ const interval = setInterval(() => {
+ if (!state.session) {
+ dispatch('stopPollingSessionStatus');
+ } else {
+ dispatch('fetchSessionStatus');
+ }
+ }, 5000);
+
+ commit(types.SET_SESSION_STATUS_INTERVAL, interval);
+};
+
+export const stopPollingSessionStatus = ({ state, commit }) => {
+ const { sessionStatusInterval } = state;
+
+ if (!sessionStatusInterval) {
+ return;
+ }
+
+ clearInterval(sessionStatusInterval);
+
+ commit(types.SET_SESSION_STATUS_INTERVAL, 0);
+};
+
+export const receiveSessionStatusSuccess = ({ commit, dispatch }, data) => {
+ const status = data && data.status;
+
+ commit(types.SET_SESSION_STATUS, status);
+
+ if (isEndingStatus(status)) {
+ dispatch('killSession');
+ }
+};
+
+export const receiveSessionStatusError = ({ dispatch }) => {
+ flash(messages.UNEXPECTED_ERROR_STATUS);
+ dispatch('killSession');
+};
+
+export const fetchSessionStatus = ({ dispatch, state }) => {
+ if (!state.session) {
+ return;
+ }
+
+ const { showPath } = state.session;
+
+ axios
+ .get(showPath)
+ .then(({ data }) => {
+ dispatch('receiveSessionStatusSuccess', data);
+ })
+ .catch(error => {
+ dispatch('receiveSessionStatusError', error);
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js
new file mode 100644
index 00000000000..78ad94f8a91
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js
@@ -0,0 +1,14 @@
+import * as types from '../mutation_types';
+
+export const init = ({ dispatch }) => {
+ dispatch('fetchConfigCheck');
+ dispatch('fetchRunnersCheck');
+};
+
+export const hideSplash = ({ commit }) => {
+ commit(types.HIDE_SPLASH);
+};
+
+export const setPaths = ({ commit }, paths) => {
+ commit(types.SET_PATHS, paths);
+};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/constants.js b/app/assets/javascripts/ide/stores/modules/terminal/constants.js
new file mode 100644
index 00000000000..f7ae9d8f4ea
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/constants.js
@@ -0,0 +1,9 @@
+export const CHECK_CONFIG = 'config';
+export const CHECK_RUNNERS = 'runners';
+export const RETRY_RUNNERS_INTERVAL = 10000;
+
+export const STARTING = 'starting';
+export const PENDING = 'pending';
+export const RUNNING = 'running';
+export const STOPPING = 'stopping';
+export const STOPPED = 'stopped';
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/getters.js b/app/assets/javascripts/ide/stores/modules/terminal/getters.js
new file mode 100644
index 00000000000..6d64ee4ab6e
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/getters.js
@@ -0,0 +1,19 @@
+export const allCheck = state => {
+ const checks = Object.values(state.checks);
+
+ if (checks.some(check => check.isLoading)) {
+ return { isLoading: true };
+ }
+
+ const invalidCheck = checks.find(check => !check.isValid);
+ const isValid = !invalidCheck;
+ const message = !invalidCheck ? '' : invalidCheck.message;
+
+ return {
+ isLoading: false,
+ isValid,
+ message,
+ };
+};
+
+export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/index.js b/app/assets/javascripts/ide/stores/modules/terminal/index.js
new file mode 100644
index 00000000000..ef1289e1722
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/index.js
@@ -0,0 +1,12 @@
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import state from './state';
+
+export default () => ({
+ namespaced: true,
+ actions,
+ getters,
+ mutations,
+ state: state(),
+});
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js
new file mode 100644
index 00000000000..38c5a8a28d8
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js
@@ -0,0 +1,55 @@
+import { escape } from 'lodash';
+import { __, sprintf } from '~/locale';
+import httpStatus from '~/lib/utils/http_status';
+
+export const UNEXPECTED_ERROR_CONFIG = __(
+ 'An unexpected error occurred while checking the project environment.',
+);
+export const UNEXPECTED_ERROR_RUNNERS = __(
+ 'An unexpected error occurred while checking the project runners.',
+);
+export const UNEXPECTED_ERROR_STATUS = __(
+ 'An unexpected error occurred while communicating with the Web Terminal.',
+);
+export const UNEXPECTED_ERROR_STARTING = __(
+ 'An unexpected error occurred while starting the Web Terminal.',
+);
+export const UNEXPECTED_ERROR_STOPPING = __(
+ 'An unexpected error occurred while stopping the Web Terminal.',
+);
+export const EMPTY_RUNNERS = __(
+ 'Configure GitLab runners to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}',
+);
+export const ERROR_CONFIG = __(
+ 'Configure a <code>.gitlab-webide.yml</code> file in the <code>.gitlab</code> directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}',
+);
+export const ERROR_PERMISSION = __(
+ 'You do not have permission to run the Web Terminal. Please contact a project administrator.',
+);
+
+export const configCheckError = (status, helpUrl) => {
+ if (status === httpStatus.UNPROCESSABLE_ENTITY) {
+ return sprintf(
+ ERROR_CONFIG,
+ {
+ helpStart: `<a href="${escape(helpUrl)}" target="_blank">`,
+ helpEnd: '</a>',
+ },
+ false,
+ );
+ } else if (status === httpStatus.FORBIDDEN) {
+ return ERROR_PERMISSION;
+ }
+
+ return UNEXPECTED_ERROR_CONFIG;
+};
+
+export const runnersCheckEmpty = helpUrl =>
+ sprintf(
+ EMPTY_RUNNERS,
+ {
+ helpStart: `<a href="${escape(helpUrl)}" target="_blank">`,
+ helpEnd: '</a>',
+ },
+ false,
+ );
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js b/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js
new file mode 100644
index 00000000000..b6a6f28abfa
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js
@@ -0,0 +1,11 @@
+export const SET_VISIBLE = 'SET_VISIBLE';
+export const HIDE_SPLASH = 'HIDE_SPLASH';
+export const SET_PATHS = 'SET_PATHS';
+
+export const REQUEST_CHECK = 'REQUEST_CHECK';
+export const RECEIVE_CHECK_SUCCESS = 'RECEIVE_CHECK_SUCCESS';
+export const RECEIVE_CHECK_ERROR = 'RECEIVE_CHECK_ERROR';
+
+export const SET_SESSION = 'SET_SESSION';
+export const SET_SESSION_STATUS = 'SET_SESSION_STATUS';
+export const SET_SESSION_STATUS_INTERVAL = 'SET_SESSION_STATUS_INTERVAL';
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js
new file mode 100644
index 00000000000..37f40af9c2e
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js
@@ -0,0 +1,64 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_VISIBLE](state, isVisible) {
+ Object.assign(state, {
+ isVisible,
+ });
+ },
+ [types.HIDE_SPLASH](state) {
+ Object.assign(state, {
+ isShowSplash: false,
+ });
+ },
+ [types.SET_PATHS](state, paths) {
+ Object.assign(state, {
+ paths,
+ });
+ },
+ [types.REQUEST_CHECK](state, type) {
+ Object.assign(state.checks, {
+ [type]: {
+ isLoading: true,
+ },
+ });
+ },
+ [types.RECEIVE_CHECK_ERROR](state, { type, message }) {
+ Object.assign(state.checks, {
+ [type]: {
+ isLoading: false,
+ isValid: false,
+ message,
+ },
+ });
+ },
+ [types.RECEIVE_CHECK_SUCCESS](state, type) {
+ Object.assign(state.checks, {
+ [type]: {
+ isLoading: false,
+ isValid: true,
+ message: null,
+ },
+ });
+ },
+ [types.SET_SESSION](state, session) {
+ Object.assign(state, {
+ session,
+ });
+ },
+ [types.SET_SESSION_STATUS](state, status) {
+ const session = {
+ ...(state.session || {}),
+ status,
+ };
+
+ Object.assign(state, {
+ session,
+ });
+ },
+ [types.SET_SESSION_STATUS_INTERVAL](state, sessionStatusInterval) {
+ Object.assign(state, {
+ sessionStatusInterval,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/state.js b/app/assets/javascripts/ide/stores/modules/terminal/state.js
new file mode 100644
index 00000000000..f35a10ed2fe
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/state.js
@@ -0,0 +1,13 @@
+import { CHECK_CONFIG, CHECK_RUNNERS } from './constants';
+
+export default () => ({
+ checks: {
+ [CHECK_CONFIG]: { isLoading: true },
+ [CHECK_RUNNERS]: { isLoading: true },
+ },
+ isVisible: false,
+ isShowSplash: true,
+ paths: {},
+ session: null,
+ sessionStatusInterval: 0,
+});
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/utils.js b/app/assets/javascripts/ide/stores/modules/terminal/utils.js
new file mode 100644
index 00000000000..c30136b5277
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal/utils.js
@@ -0,0 +1,5 @@
+import { STARTING, PENDING, RUNNING } from './constants';
+
+export const isStartingStatus = status => status === STARTING || status === PENDING;
+export const isRunningStatus = status => status === RUNNING;
+export const isEndingStatus = status => !isStartingStatus(status) && !isRunningStatus(status);
diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js
new file mode 100644
index 00000000000..2fee6b4e974
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js
@@ -0,0 +1,41 @@
+import * as types from './mutation_types';
+import mirror, { canConnect } from '../../../lib/mirror';
+
+export const upload = ({ rootState, commit }) => {
+ commit(types.START_LOADING);
+
+ return mirror
+ .upload(rootState)
+ .then(() => {
+ commit(types.SET_SUCCESS);
+ })
+ .catch(err => {
+ commit(types.SET_ERROR, err);
+ });
+};
+
+export const stop = ({ commit }) => {
+ mirror.disconnect();
+
+ commit(types.STOP);
+};
+
+export const start = ({ rootState, commit }) => {
+ const { session } = rootState.terminal;
+ const path = session && session.proxyWebsocketPath;
+ if (!path || !canConnect(session)) {
+ return Promise.reject();
+ }
+
+ commit(types.START_LOADING);
+
+ return mirror
+ .connect(path)
+ .then(() => {
+ commit(types.SET_SUCCESS);
+ })
+ .catch(err => {
+ commit(types.SET_ERROR, err);
+ throw err;
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js
new file mode 100644
index 00000000000..795c2fad724
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default () => ({
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+});
diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js
new file mode 100644
index 00000000000..e50e1a1406b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js
@@ -0,0 +1,5 @@
+import { __ } from '~/locale';
+
+export const MSG_TERMINAL_SYNC_CONNECTING = __('Connecting to terminal sync service');
+export const MSG_TERMINAL_SYNC_UPLOADING = __('Uploading changes to terminal');
+export const MSG_TERMINAL_SYNC_RUNNING = __('Terminal sync service is running');
diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js
new file mode 100644
index 00000000000..ec809540c18
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js
@@ -0,0 +1,4 @@
+export const START_LOADING = 'START_LOADING';
+export const SET_ERROR = 'SET_ERROR';
+export const SET_SUCCESS = 'SET_SUCCESS';
+export const STOP = 'STOP';
diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js
new file mode 100644
index 00000000000..70ed137776a
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js
@@ -0,0 +1,22 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.START_LOADING](state) {
+ state.isLoading = true;
+ state.isError = false;
+ },
+ [types.SET_ERROR](state, { message }) {
+ state.isLoading = false;
+ state.isError = true;
+ state.message = message;
+ },
+ [types.SET_SUCCESS](state) {
+ state.isLoading = false;
+ state.isError = false;
+ state.isStarted = true;
+ },
+ [types.STOP](state) {
+ state.isLoading = false;
+ state.isStarted = false;
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js
new file mode 100644
index 00000000000..7ec3e38f675
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ isLoading: false,
+ isStarted: false,
+ isError: false,
+ message: '',
+});
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 5c78bfefa04..d94adc3760f 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -27,7 +27,6 @@ export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN';
export const SET_TREE_OPEN = 'SET_TREE_OPEN';
-export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL';
export const CREATE_TREE = 'CREATE_TREE';
export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES';
@@ -41,7 +40,6 @@ export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
export const SET_FILE_POSITION = 'SET_FILE_POSITION';
export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE';
-export const SET_FILE_EOL = 'SET_FILE_EOL';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 12ac10df206..e827aacac13 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -65,14 +65,10 @@ export default {
// NOTE: We can't clone `entry` in any of the below assignments because
// we need `state.entries` and the `entry.tree` to reference the same object.
- if (!foundEntry) {
+ if (!foundEntry || foundEntry.deleted) {
Object.assign(state.entries, {
[key]: entry,
});
- } else if (foundEntry.deleted) {
- Object.assign(state.entries, {
- [key]: Object.assign(entry, { replaces: true }),
- });
} else {
const tree = entry.tree.filter(
f => foundEntry.tree.find(e => e.path === f.path) === undefined,
@@ -147,7 +143,6 @@ export default {
raw: file.content,
changed: Boolean(changedFile),
staged: false,
- replaces: false,
lastCommitSha: lastCommit.commit.id,
prevId: undefined,
@@ -164,9 +159,6 @@ export default {
Object.assign(state.entries[file.path], {
rawPath: file.rawPath.replace(regex, file.path),
- permalink: file.permalink.replace(regex, file.path),
- commitsPath: file.commitsPath.replace(regex, file.path),
- blamePath: file.blamePath.replace(regex, file.path),
});
}
},
@@ -207,8 +199,6 @@ export default {
state.changedFiles = state.changedFiles.concat(entry);
}
}
-
- state.unusedSeal = false;
},
[types.RENAME_ENTRY](state, { path, name, parentPath }) {
const oldEntry = state.entries[path];
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 5c5920a3027..c90bc2a3320 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -99,11 +99,6 @@ export default {
fileLanguage,
});
},
- [types.SET_FILE_EOL](state, { file, eol }) {
- Object.assign(state.entries[file.path], {
- eol,
- });
- },
[types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
Object.assign(state.entries[file.path], {
editorRow,
@@ -153,13 +148,11 @@ export default {
[types.ADD_FILE_TO_CHANGED](state, path) {
Object.assign(state, {
changedFiles: state.changedFiles.concat(state.entries[path]),
- unusedSeal: false,
});
},
[types.REMOVE_FILE_FROM_CHANGED](state, path) {
Object.assign(state, {
changedFiles: state.changedFiles.filter(f => f.path !== path),
- unusedSeal: false,
});
},
[types.STAGE_CHANGE](state, { path, diffInfo }) {
@@ -175,7 +168,6 @@ export default {
deleted: diffInfo.deleted,
}),
}),
- unusedSeal: false,
});
if (stagedFile) {
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
index c8f14a680c2..cce43a99bd9 100644
--- a/app/assets/javascripts/ide/stores/mutations/tree.js
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -34,11 +34,6 @@ export default {
Object.assign(selectedTree, { tree });
},
- [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
- Object.assign(tree, {
- lastCommitPath: url,
- });
- },
[types.REMOVE_ALL_CHANGES_FILES](state) {
Object.assign(state, {
changedFiles: [],
diff --git a/app/assets/javascripts/ide/stores/plugins/terminal.js b/app/assets/javascripts/ide/stores/plugins/terminal.js
new file mode 100644
index 00000000000..66539c7bd4f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/plugins/terminal.js
@@ -0,0 +1,25 @@
+import * as mutationTypes from '~/ide/stores/mutation_types';
+import terminalModule from '../modules/terminal';
+
+function getPathsFromData(el) {
+ return {
+ webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath,
+ webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath,
+ webTerminalConfigHelpPath: el.dataset.eeWebTerminalConfigHelpPath,
+ webTerminalRunnersHelpPath: el.dataset.eeWebTerminalRunnersHelpPath,
+ };
+}
+
+export default function createTerminalPlugin(el) {
+ return store => {
+ store.registerModule('terminal', terminalModule());
+
+ store.dispatch('terminal/setPaths', getPathsFromData(el));
+
+ store.subscribe(({ type }) => {
+ if (type === mutationTypes.SET_BRANCH_WORKING_REFERENCE) {
+ store.dispatch('terminal/init');
+ }
+ });
+ };
+}
diff --git a/app/assets/javascripts/ide/stores/plugins/terminal_sync.js b/app/assets/javascripts/ide/stores/plugins/terminal_sync.js
new file mode 100644
index 00000000000..c60bba4293a
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/plugins/terminal_sync.js
@@ -0,0 +1,49 @@
+import { debounce } from 'lodash';
+import eventHub from '~/ide/eventhub';
+import terminalSyncModule from '../modules/terminal_sync';
+import { isEndingStatus, isRunningStatus } from '../modules/terminal/utils';
+
+const UPLOAD_DEBOUNCE = 200;
+
+/**
+ * Registers and controls the terminalSync vuex module based on IDE events.
+ *
+ * - Watches the terminal session status state to control start/stop.
+ * - Listens for file change event to control upload.
+ */
+export default function createMirrorPlugin() {
+ return store => {
+ store.registerModule('terminalSync', terminalSyncModule());
+
+ const upload = debounce(() => {
+ store.dispatch(`terminalSync/upload`);
+ }, UPLOAD_DEBOUNCE);
+
+ const stop = () => {
+ store.dispatch(`terminalSync/stop`);
+ eventHub.$off('ide.files.change', upload);
+ };
+
+ const start = () => {
+ store
+ .dispatch(`terminalSync/start`)
+ .then(() => {
+ eventHub.$on('ide.files.change', upload);
+ })
+ .catch(() => {
+ // error is handled in store
+ });
+ };
+
+ store.watch(
+ x => x.terminal && x.terminal.session && x.terminal.session.status,
+ val => {
+ if (isRunningStatus(val)) {
+ start();
+ } else if (isEndingStatus(val)) {
+ stop();
+ }
+ },
+ );
+ };
+}
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 0c95c22e8f8..c1a83bf0726 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -9,10 +9,8 @@ export default () => ({
stagedFiles: [],
endpoints: {},
lastCommitMsg: '',
- lastCommitPath: '',
loading: false,
openFiles: [],
- parentTreeUrl: '',
trees: {},
projects: {},
panelResizing: false,
@@ -20,7 +18,6 @@ export default () => ({
viewer: viewerTypes.edit,
delayViewerUpdated: false,
currentActivityView: leftSidebarViews.edit.name,
- unusedSeal: true,
fileFindVisible: false,
links: {},
errorMessage: null,
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 56671142bd4..1c5fe9fe9a5 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,5 +1,10 @@
import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
-import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility';
+import {
+ relativePathToAbsolute,
+ isAbsolute,
+ isRootRelative,
+ isBase64DataUrl,
+} from '~/lib/utils/url_utility';
export const dataStructure = () => ({
id: '',
@@ -19,8 +24,6 @@ export const dataStructure = () => ({
active: false,
changed: false,
staged: false,
- replaces: false,
- lastCommitPath: '',
lastCommitSha: '',
lastCommit: {
id: '',
@@ -29,23 +32,14 @@ export const dataStructure = () => ({
updatedAt: '',
author: '',
},
- blamePath: '',
- commitsPath: '',
- permalink: '',
rawPath: '',
binary: false,
- html: '',
raw: '',
content: '',
- parentTreeUrl: '',
- renderError: false,
- base64: false,
editorRow: 1,
editorColumn: 1,
fileLanguage: '',
- eol: '',
viewMode: FILE_VIEW_MODE_EDITOR,
- previewMode: null,
size: 0,
parentPath: null,
lastOpenedAt: 0,
@@ -63,19 +57,14 @@ export const decorateData = entity => {
url,
name,
path,
- renderError,
content = '',
tempFile = false,
active = false,
opened = false,
changed = false,
- parentTreeUrl = '',
- base64 = false,
binary = false,
rawPath = '',
- previewMode,
file_lock,
- html,
parentPath = '',
} = entity;
@@ -91,25 +80,15 @@ export const decorateData = entity => {
tempFile,
opened,
active,
- parentTreeUrl,
changed,
- renderError,
content,
- base64,
binary,
rawPath,
- previewMode,
file_lock,
- html,
parentPath,
});
};
-export const findEntry = (tree, type, name, prop = 'name') =>
- tree.find(f => f.type === type && f[prop] === name);
-
-export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path);
-
export const setPageTitle = title => {
document.title = title;
};
@@ -124,7 +103,7 @@ export const commitActionForFile = file => {
return commitActionTypes.move;
} else if (file.deleted) {
return commitActionTypes.delete;
- } else if (file.tempFile && !file.replaces) {
+ } else if (file.tempFile) {
return commitActionTypes.create;
}
@@ -155,9 +134,8 @@ export const createCommitPayload = ({
file_path: f.path,
previous_path: f.prevPath || undefined,
content: f.prevPath && !f.changed ? null : f.content || undefined,
- encoding: f.base64 ? 'base64' : 'text',
- last_commit_id:
- newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha,
+ encoding: isBase64DataUrl(f.rawPath) ? 'base64' : 'text',
+ last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
})),
start_sha: newBranch ? rootGetters.lastCommit.id : undefined,
});
@@ -272,10 +250,6 @@ export const pathsAreEqual = (a, b) => {
return cleanA === cleanB;
};
-// if the contents of a file dont end with a newline, this function adds a newline
-export const addFinalNewlineIfNeeded = content =>
- content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content;
-
export function extractMarkdownImagesFromEntries(mdFile, entries) {
/**
* Regex to identify an image tag in markdown, like:
diff --git a/app/assets/javascripts/ide/sync_router_and_store.js b/app/assets/javascripts/ide/sync_router_and_store.js
new file mode 100644
index 00000000000..1782c32b3b2
--- /dev/null
+++ b/app/assets/javascripts/ide/sync_router_and_store.js
@@ -0,0 +1,55 @@
+/* eslint-disable import/prefer-default-export */
+/**
+ * This method adds listeners to the given router and store and syncs their state with eachother
+ *
+ * ### Why?
+ *
+ * Previously the IDE had a circular dependency between a singleton router and a singleton store.
+ * This causes some integration testing headaches...
+ *
+ * At the time, the most effecient way to break this ciruclar dependency was to:
+ *
+ * - Replace the router with a factory function that receives a store reference
+ * - Have the store write to a certain state that can be watched by the router
+ *
+ * Hence... This helper function...
+ */
+export const syncRouterAndStore = (router, store) => {
+ const disposables = [];
+
+ let currentPath = '';
+
+ // sync store to router
+ disposables.push(
+ store.watch(
+ state => state.router.fullPath,
+ fullPath => {
+ if (currentPath === fullPath) {
+ return;
+ }
+
+ currentPath = fullPath;
+
+ router.push(fullPath);
+ },
+ ),
+ );
+
+ // sync router to store
+ disposables.push(
+ router.afterEach(to => {
+ if (currentPath === to.fullPath) {
+ return;
+ }
+
+ currentPath = to.fullPath;
+ store.dispatch('router/push', currentPath, { root: true });
+ }),
+ );
+
+ const unsync = () => {
+ disposables.forEach(fn => fn());
+ };
+
+ return unsync;
+};
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 1ea2b199237..c28a2bd9f1d 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -1,4 +1,4 @@
-import { commitItemIconMap } from './constants';
+import { SIDE_LEFT, SIDE_RIGHT } from './constants';
import { languages } from 'monaco-editor';
import { flatten } from 'lodash';
@@ -53,16 +53,6 @@ export function isTextFile(content, mimeType, fileName) {
return asciiRegex.test(content);
}
-export const getCommitIconMap = file => {
- if (file.deleted) {
- return commitItemIconMap.deleted;
- } else if (file.tempFile && !file.prevPath) {
- return commitItemIconMap.addition;
- }
-
- return commitItemIconMap.modified;
-};
-
export const createPathWithExt = p => {
const ext = p.lastIndexOf('.') >= 0 ? p.substring(p.lastIndexOf('.') + 1) : '';
@@ -84,3 +74,52 @@ export function registerLanguages(def, ...defs) {
languages.setMonarchTokensProvider(languageId, def.language);
languages.setLanguageConfiguration(languageId, def.conf);
}
+
+export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT);
+
+export function trimTrailingWhitespace(content) {
+ return content.replace(/[^\S\r\n]+$/gm, '');
+}
+
+export function insertFinalNewline(content, eol = '\n') {
+ return content.slice(-eol.length) !== eol ? `${content}${eol}` : content;
+}
+
+export function getPathParents(path, maxDepth = Infinity) {
+ const pathComponents = path.split('/');
+ const paths = [];
+
+ let depth = 0;
+ while (pathComponents.length && depth < maxDepth) {
+ pathComponents.pop();
+
+ let parentPath = pathComponents.join('/');
+ if (parentPath.startsWith('/')) parentPath = parentPath.slice(1);
+ if (parentPath) paths.push(parentPath);
+
+ depth += 1;
+ }
+
+ return paths;
+}
+
+export function getPathParent(path) {
+ return getPathParents(path, 1)[0];
+}
+
+/**
+ * Takes a file object and returns a data uri of its contents.
+ *
+ * @param {File} file
+ */
+export function readFileAsDataURL(file) {
+ return new Promise(resolve => {
+ const reader = new FileReader();
+ reader.addEventListener('load', e => resolve(e.target.result), { once: true });
+ reader.readAsDataURL(file);
+ });
+}
+
+export function getFileEOL(content = '') {
+ return content.includes('\r\n') ? 'CRLF' : 'LF';
+}
diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue
new file mode 100644
index 00000000000..1a9974db727
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import ImportProjectsTable from './import_projects_table.vue';
+
+export default {
+ components: {
+ ImportProjectsTable,
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ providerTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isWarningDismissed: false,
+ };
+ },
+ computed: {
+ currentPage() {
+ return window.location.href;
+ },
+ },
+};
+</script>
+<template>
+ <import-projects-table provider-title="providerTitle">
+ <template #actions>
+ <slot name="actions"></slot>
+ </template>
+ <template #incompatible-repos-warning>
+ <gl-alert
+ v-if="!isWarningDismissed"
+ variant="warning"
+ class="gl-my-2"
+ @dismiss="isWarningDismissed = true"
+ >
+ <gl-sprintf
+ :message="
+ __(
+ 'One or more of your %{provider} projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.',
+ )
+ "
+ >
+ <template #provider>
+ {{ providerTitle }}
+ </template>
+ </gl-sprintf>
+ <gl-sprintf
+ :message="
+ __(
+ 'Please convert %{linkStart}them to Git%{linkEnd}, and go through the %{linkToImportFlow} again.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ href="https://www.atlassian.com/git/tutorials/migrating-overview"
+ target="_blank"
+ >{{ content }}</gl-link
+ >
+ </template>
+ <template #linkToImportFlow>
+ <gl-link :href="currentPage">{{ __('import flow') }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </template>
+ </import-projects-table>
+</template>
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue
index 849bda28d03..6a467fb8c6a 100644
--- a/app/assets/javascripts/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue
@@ -1,11 +1,11 @@
<script>
import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
+import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue';
import eventHub from '../event_hub';
const reposFetchThrottleDelay = 1000;
@@ -15,19 +15,42 @@ export default {
components: {
ImportedProjectTableRow,
ProviderRepoTableRow,
- LoadingButton,
+ IncompatibleRepoTableRow,
GlLoadingIcon,
+ GlButton,
},
props: {
providerTitle: {
type: String,
required: true,
},
+ filterable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
- ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos', 'filter']),
- ...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']),
+ ...mapState([
+ 'importedProjects',
+ 'providerRepos',
+ 'incompatibleRepos',
+ 'isLoadingRepos',
+ 'filter',
+ ]),
+ ...mapGetters([
+ 'isImportingAnyRepo',
+ 'hasProviderRepos',
+ 'hasImportedProjects',
+ 'hasIncompatibleRepos',
+ ]),
+
+ importAllButtonText() {
+ return this.hasIncompatibleRepos
+ ? __('Import all compatible repositories')
+ : __('Import all repositories');
+ },
emptyStateText() {
return sprintf(__('No %{providerTitle} repositories found'), {
@@ -68,7 +91,6 @@ export default {
},
throttledFetchRepos: throttle(function fetch() {
- eventHub.$off('importAll');
this.fetchRepos();
}, reposFetchThrottleDelay),
},
@@ -80,17 +102,24 @@ export default {
<p class="light text-nowrap mt-2">
{{ s__('ImportProjects|Select the projects you want to import') }}
</p>
-
- <div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
- <loading-button
- container-class="btn btn-success js-import-all"
+ <template v-if="hasIncompatibleRepos">
+ <slot name="incompatible-repos-warning"> </slot>
+ </template>
+ <div
+ v-if="!isLoadingRepos"
+ class="d-flex justify-content-between align-items-end flex-wrap mb-3"
+ >
+ <gl-button
+ variant="success"
:loading="isImportingAnyRepo"
- :label="__('Import all repositories')"
:disabled="!hasProviderRepos"
type="button"
@click="importAll"
- />
- <form novalidate @submit.prevent>
+ >
+ {{ importAllButtonText }}
+ </gl-button>
+ <slot name="actions"></slot>
+ <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
<input
:value="filter"
data-qa-selector="githubish_import_filter_field"
@@ -109,7 +138,10 @@ export default {
class="js-loading-button-icon import-projects-loading-icon"
size="md"
/>
- <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive">
+ <div
+ v-else-if="hasProviderRepos || hasImportedProjects || hasIncompatibleRepos"
+ class="table-responsive"
+ >
<table class="table import-table">
<thead>
<th class="import-jobs-from-col">{{ fromHeaderText }}</th>
@@ -124,6 +156,11 @@ export default {
:project="project"
/>
<provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" />
+ <incompatible-repo-table-row
+ v-for="repo in incompatibleRepos"
+ :key="repo.id"
+ :repo="repo"
+ />
</tbody>
</table>
</div>
diff --git a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue
new file mode 100644
index 00000000000..fa2fb439eac
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ props: {
+ repo: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="import-row">
+ <td>
+ <a :href="repo.providerLink" rel="noreferrer noopener" target="_blank">
+ {{ repo.fullName }}
+ </a>
+ </td>
+ <td></td>
+ <td></td>
+ <td>
+ <gl-badge variant="danger">{{ __('Incompatible project') }}</gl-badge>
+ </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
index 6e227ab3d82..63524d61146 100644
--- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
@@ -53,7 +53,11 @@ export default {
},
created() {
- eventHub.$on('importAll', () => this.importRepo());
+ eventHub.$on('importAll', this.importRepo);
+ },
+
+ beforeDestroy() {
+ eventHub.$off('importAll', this.importRepo);
},
methods: {
diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js
index b069dcb7766..68ba04aa9dd 100644
--- a/app/assets/javascripts/import_projects/index.js
+++ b/app/assets/javascripts/import_projects/index.js
@@ -1,5 +1,4 @@
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';
@@ -7,42 +6,45 @@ import createStore from './store';
Vue.use(Translate);
-export default function mountImportProjectsTable(mountElement) {
- if (!mountElement) return undefined;
-
+export function initStoreFromElement(element) {
const {
reposPath,
provider,
- providerTitle,
canSelectNamespace,
jobsPath,
importPath,
ciCdOnly,
- } = mountElement.dataset;
+ } = element.dataset;
- const store = createStore();
- return new Vue({
- el: mountElement,
- store,
+ return createStore({
+ reposPath,
+ provider,
+ jobsPath,
+ importPath,
+ defaultTargetNamespace: gon.current_username,
+ ciCdOnly: parseBoolean(ciCdOnly),
+ canSelectNamespace: parseBoolean(canSelectNamespace),
+ });
+}
- created() {
- this.setInitialData({
- reposPath,
- provider,
- jobsPath,
- importPath,
- defaultTargetNamespace: gon.current_username,
- ciCdOnly: parseBoolean(ciCdOnly),
- canSelectNamespace: parseBoolean(canSelectNamespace),
- });
- },
+export function initPropsFromElement(element) {
+ return {
+ providerTitle: element.dataset.providerTitle,
+ filterable: parseBoolean(element.dataset.filterable),
+ };
+}
- methods: {
- ...mapActions(['setInitialData', 'setFilter']),
- },
+export default function mountImportProjectsTable(mountElement) {
+ if (!mountElement) return undefined;
+
+ const store = initStoreFromElement(mountElement);
+ const props = initPropsFromElement(mountElement);
+ return new Vue({
+ el: mountElement,
+ store,
render(createElement) {
- return createElement(ImportProjectsTable, { props: { providerTitle } });
+ return createElement(ImportProjectsTable, { props });
},
});
}
diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js
index 0fb9a4cdfd4..2422a1ed2e4 100644
--- a/app/assets/javascripts/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_projects/store/actions.js
@@ -2,6 +2,7 @@ import Visibility from 'visibilityjs';
import * as types from './mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
+import { visitUrl } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
@@ -9,6 +10,9 @@ import { jobsPathWithFilter, reposPathWithFilter } from './getters';
let eTagPoll;
+const hasRedirectInError = e => e?.response?.data?.error?.redirect;
+const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect);
+
export const clearJobsEtagPoll = () => {
eTagPoll = null;
};
@@ -19,45 +23,39 @@ export const restartJobsPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
-export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter);
-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 }) => {
+export const fetchRepos = ({ state, dispatch, commit }) => {
dispatch('stopJobsPolling');
- dispatch('requestRepos');
+ commit(types.REQUEST_REPOS);
const { provider } = state;
return axios
.get(reposPathWithFilter(state))
.then(({ data }) =>
- dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
+ commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })),
)
.then(() => dispatch('fetchJobs'))
- .catch(() => {
- createFlash(
- sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), {
- provider,
- }),
- );
-
- dispatch('receiveReposError');
+ .catch(e => {
+ if (hasRedirectInError(e)) {
+ redirectToUrlInError(e);
+ } else {
+ createFlash(
+ sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), {
+ provider,
+ }),
+ );
+
+ commit(types.RECEIVE_REPOS_ERROR);
+ }
});
};
-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);
+export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo }) => {
+ if (!state.reposBeingImported.includes(repo.id)) {
+ commit(types.REQUEST_IMPORT, repo.id);
+ }
return axios
.post(state.importPath, {
@@ -67,7 +65,7 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep
target_namespace: targetNamespace,
})
.then(({ data }) =>
- dispatch('receiveImportSuccess', {
+ commit(types.RECEIVE_IMPORT_SUCCESS, {
importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
repoId: repo.id,
}),
@@ -75,13 +73,14 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep
.catch(() => {
createFlash(s__('ImportProjects|Importing the project failed'));
- dispatch('receiveImportError', { repoId: repo.id });
+ commit(types.RECEIVE_IMPORT_ERROR, repo.id);
});
};
export const receiveJobsSuccess = ({ commit }, updatedProjects) =>
commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects);
-export const fetchJobs = ({ state, dispatch }) => {
+
+export const fetchJobs = ({ state, commit, dispatch }) => {
const { filter } = state;
if (eTagPoll) {
@@ -95,9 +94,14 @@ export const fetchJobs = ({ state, dispatch }) => {
},
method: 'fetchJobs',
successCallback: ({ data }) =>
- dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
- errorCallback: () =>
- createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')),
+ commit(types.RECEIVE_JOBS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })),
+ errorCallback: e => {
+ if (hasRedirectInError(e)) {
+ redirectToUrlInError(e);
+ } else {
+ createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed'));
+ }
+ },
data: { filter },
});
diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js
index b107c293181..e6eb8f523de 100644
--- a/app/assets/javascripts/import_projects/store/getters.js
+++ b/app/assets/javascripts/import_projects/store/getters.js
@@ -21,6 +21,8 @@ export const hasProviderRepos = state => state.providerRepos.length > 0;
export const hasImportedProjects = state => state.importedProjects.length > 0;
+export const hasIncompatibleRepos = state => state.incompatibleRepos.length > 0;
+
export const reposPathWithFilter = ({ reposPath, filter = '' }) =>
filter ? `${reposPath}?filter=${filter}` : reposPath;
export const jobsPathWithFilter = ({ jobsPath, filter = '' }) =>
diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js
index ff1fd1e598e..29deb7868ba 100644
--- a/app/assets/javascripts/import_projects/store/index.js
+++ b/app/assets/javascripts/import_projects/store/index.js
@@ -9,9 +9,9 @@ Vue.use(Vuex);
export { state, actions, getters, mutations };
-export default () =>
+export default initialState =>
new Vuex.Store({
- state: state(),
+ state: { ...state(), ...initialState },
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
index 16574f4450f..a23b7eef986 100644
--- a/app/assets/javascripts/import_projects/store/mutation_types.js
+++ b/app/assets/javascripts/import_projects/store/mutation_types.js
@@ -1,5 +1,3 @@
-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';
diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js
index 6c56cfa8298..ec62d0640ef 100644
--- a/app/assets/javascripts/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_projects/store/mutations.js
@@ -2,10 +2,6 @@ import Vue from 'vue';
import * as types from './mutation_types';
export default {
- [types.SET_INITIAL_DATA](state, data) {
- Object.assign(state, data);
- },
-
[types.SET_FILTER](state, filter) {
state.filter = filter;
},
@@ -14,11 +10,15 @@ export default {
state.isLoadingRepos = true;
},
- [types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) {
+ [types.RECEIVE_REPOS_SUCCESS](
+ state,
+ { importedProjects, providerRepos, incompatibleRepos, namespaces },
+ ) {
state.isLoadingRepos = false;
state.importedProjects = importedProjects;
state.providerRepos = providerRepos;
+ state.incompatibleRepos = incompatibleRepos ?? [];
state.namespaces = namespaces;
},
diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js
index 829f3aa4fbb..0418d735b1d 100644
--- a/app/assets/javascripts/import_projects/store/state.js
+++ b/app/assets/javascripts/import_projects/store/state.js
@@ -7,6 +7,7 @@ export default () => ({
currentUsername: '',
importedProjects: [],
providerRepos: [],
+ incompatibleRepos: [],
namespaces: [],
reposBeingImported: [],
isLoadingRepos: false,
diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue
index 8b95b04d93c..dc89e139320 100644
--- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue
@@ -1,12 +1,15 @@
<script>
import eventHub from '../event_hub';
-import { GlToggle } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { GlFormGroup, GlToggle } from '@gitlab/ui';
export default {
name: 'ActiveToggle',
components: {
+ GlFormGroup,
GlToggle,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
initialActivated: {
type: Boolean,
@@ -33,7 +36,17 @@ export default {
</script>
<template>
- <div>
+ <div v-if="glFeatures.integrationFormRefactor">
+ <gl-form-group :label="__('Enable integration')" label-for="service[active]">
+ <gl-toggle
+ v-model="activated"
+ name="service[active]"
+ class="gl-display-block gl-line-height-0"
+ @change="onToggle"
+ />
+ </gl-form-group>
+ </div>
+ <div v-else>
<div class="form-group row" role="group">
<label for="service[active]" class="col-form-label col-sm-2">{{ __('Active') }}</label>
<div class="col-sm-10 pt-1">
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
new file mode 100644
index 00000000000..29318d6aaa8
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -0,0 +1,172 @@
+<script>
+import eventHub from '../event_hub';
+import { capitalize, lowerCase, isEmpty } from 'lodash';
+import { __, sprintf } from '~/locale';
+import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui';
+
+export default {
+ name: 'DynamicField',
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormSelect,
+ GlFormTextarea,
+ },
+ props: {
+ choices: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ help: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ required: {
+ type: Boolean,
+ required: false,
+ },
+ title: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ model: this.value,
+ validated: false,
+ };
+ },
+ computed: {
+ isCheckbox() {
+ return this.type === 'checkbox';
+ },
+ isPassword() {
+ return this.type === 'password';
+ },
+ isSelect() {
+ return this.type === 'select';
+ },
+ isTextarea() {
+ return this.type === 'textarea';
+ },
+ isNonEmptyPassword() {
+ return this.isPassword && !isEmpty(this.value);
+ },
+ label() {
+ if (this.isNonEmptyPassword) {
+ return sprintf(__('Enter new %{field_title}'), {
+ field_title: this.humanizedTitle,
+ });
+ }
+ return this.humanizedTitle;
+ },
+ humanizedTitle() {
+ return this.title || capitalize(lowerCase(this.name));
+ },
+ passwordRequired() {
+ return isEmpty(this.value) && this.required;
+ },
+ options() {
+ return this.choices.map(choice => {
+ return {
+ value: choice[1],
+ text: choice[0],
+ };
+ });
+ },
+ fieldId() {
+ return `service_${this.name}`;
+ },
+ fieldName() {
+ return `service[${this.name}]`;
+ },
+ sharedProps() {
+ return {
+ id: this.fieldId,
+ name: this.fieldName,
+ };
+ },
+ valid() {
+ return !this.required || !isEmpty(this.model) || !this.validated;
+ },
+ },
+ created() {
+ if (this.isNonEmptyPassword) {
+ this.model = null;
+ }
+ eventHub.$on('validateForm', this.validateForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('validateForm', this.validateForm);
+ },
+ methods: {
+ validateForm() {
+ this.validated = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ :label="label"
+ :label-for="fieldId"
+ :invalid-feedback="__('This field is required.')"
+ :state="valid"
+ :description="help"
+ >
+ <template v-if="isCheckbox">
+ <input :name="fieldName" type="hidden" value="false" />
+ <gl-form-checkbox v-model="model" v-bind="sharedProps">
+ {{ humanizedTitle }}
+ </gl-form-checkbox>
+ </template>
+ <gl-form-select v-else-if="isSelect" v-model="model" v-bind="sharedProps" :options="options" />
+ <gl-form-textarea
+ v-else-if="isTextarea"
+ v-model="model"
+ v-bind="sharedProps"
+ :placeholder="placeholder"
+ :required="required"
+ />
+ <gl-form-input
+ v-else-if="isPassword"
+ v-model="model"
+ v-bind="sharedProps"
+ :type="type"
+ autocomplete="new-password"
+ :placeholder="placeholder"
+ :required="passwordRequired"
+ />
+ <gl-form-input
+ v-else
+ v-model="model"
+ v-bind="sharedProps"
+ :type="type"
+ :placeholder="placeholder"
+ :required="required"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index fbe58c30b13..ef7a4d44b20 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -2,6 +2,7 @@
import ActiveToggle from './active_toggle.vue';
import JiraTriggerFields from './jira_trigger_fields.vue';
import TriggerFields from './trigger_fields.vue';
+import DynamicField from './dynamic_field.vue';
export default {
name: 'IntegrationForm',
@@ -9,6 +10,7 @@ export default {
ActiveToggle,
JiraTriggerFields,
TriggerFields,
+ DynamicField,
},
props: {
activeToggleProps: {
@@ -28,6 +30,11 @@ export default {
required: false,
default: () => [],
},
+ fields: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
type: {
type: String,
required: true,
@@ -46,5 +53,6 @@ export default {
<active-toggle v-if="showActive" v-bind="activeToggleProps" />
<jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" />
<trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" />
+ <dynamic-field v-for="field in fields" :key="field.name" v-bind="field" />
</div>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 70278e401ce..64e5789764f 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -1,12 +1,31 @@
<script>
-import { GlFormCheckbox, GlFormRadio } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { s__ } from '~/locale';
+import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui';
+
+const commentDetailOptions = [
+ {
+ value: 'standard',
+ label: s__('Integrations|Standard'),
+ help: s__('Integrations|Includes commit title and branch'),
+ },
+ {
+ value: 'all_details',
+ label: s__('Integrations|All details'),
+ help: s__(
+ 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs',
+ ),
+ },
+];
export default {
name: 'JiraTriggerFields',
components: {
+ GlFormGroup,
GlFormCheckbox,
GlFormRadio,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
initialTriggerCommit: {
type: Boolean,
@@ -32,13 +51,71 @@ export default {
triggerMergeRequest: this.initialTriggerMergeRequest,
enableComments: this.initialEnableComments,
commentDetail: this.initialCommentDetail,
+ commentDetailOptions,
};
},
+ computed: {
+ showEnableComments() {
+ return this.triggerCommit || this.triggerMergeRequest;
+ },
+ },
};
</script>
<template>
- <div class="form-group row pt-2" role="group">
+ <div v-if="glFeatures.integrationFormRefactor">
+ <gl-form-group
+ :label="__('Trigger')"
+ label-for="service[trigger]"
+ :description="
+ s__(
+ 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.',
+ )
+ "
+ >
+ <input name="service[commit_events]" type="hidden" value="false" />
+ <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]">
+ {{ __('Commit') }}
+ </gl-form-checkbox>
+
+ <input name="service[merge_requests_events]" type="hidden" value="false" />
+ <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]">
+ {{ __('Merge request') }}
+ </gl-form-checkbox>
+ </gl-form-group>
+
+ <gl-form-group
+ v-show="showEnableComments"
+ :label="s__('Integrations|Comment settings:')"
+ data-testid="comment-settings"
+ >
+ <input name="service[comment_on_event_enabled]" type="hidden" value="false" />
+ <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]">
+ {{ s__('Integrations|Enable comments') }}
+ </gl-form-checkbox>
+ </gl-form-group>
+
+ <gl-form-group
+ v-show="showEnableComments && enableComments"
+ :label="s__('Integrations|Comment detail:')"
+ data-testid="comment-detail"
+ >
+ <gl-form-radio
+ v-for="commentDetailOption in commentDetailOptions"
+ :key="commentDetailOption.value"
+ v-model="commentDetail"
+ :value="commentDetailOption.value"
+ name="service[comment_detail]"
+ >
+ {{ commentDetailOption.label }}
+ <template #help>
+ {{ commentDetailOption.help }}
+ </template>
+ </gl-form-radio>
+ </gl-form-group>
+ </div>
+
+ <div v-else class="form-group row pt-2" role="group">
<label for="service[trigger]" class="col-form-label col-sm-2 pt-0">{{ __('Trigger') }}</label>
<div class="col-sm-10">
<label class="weight-normal mb-2">
@@ -76,20 +153,16 @@ export default {
<label>
{{ s__('Integrations|Comment detail:') }}
</label>
- <gl-form-radio v-model="commentDetail" value="standard" name="service[comment_detail]">
- {{ s__('Integrations|Standard') }}
- <template #help>
- {{ s__('Integrations|Includes commit title and branch') }}
- </template>
- </gl-form-radio>
- <gl-form-radio v-model="commentDetail" value="all_details" name="service[comment_detail]">
- {{ s__('Integrations|All details') }}
+ <gl-form-radio
+ v-for="commentDetailOption in commentDetailOptions"
+ :key="commentDetailOption.value"
+ v-model="commentDetail"
+ :value="commentDetailOption.value"
+ name="service[comment_detail]"
+ >
+ {{ commentDetailOption.label }}
<template #help>
- {{
- s__(
- 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs',
- )
- }}
+ {{ commentDetailOption.help }}
</template>
</gl-form-radio>
</div>
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index 2ae1342a558..21b5ca17951 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -15,7 +15,7 @@ export default el => {
return result;
}
- const { type, commentDetail, triggerEvents, ...booleanAttributes } = el.dataset;
+ const { type, commentDetail, triggerEvents, fields, ...booleanAttributes } = el.dataset;
const {
showActive,
activated,
@@ -41,6 +41,7 @@ export default el => {
initialCommentDetail: commentDetail,
},
triggerEvents: JSON.parse(triggerEvents),
+ fields: JSON.parse(fields),
},
});
},
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index 3067f4090b1..8844cbebe85 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -45,10 +45,15 @@ export default class IntegrationSettingsForm {
// 2) If this service can be tested
// If both conditions are true, we override form submission
// and test the service using provided configuration.
- if (this.$form.get(0).checkValidity() && this.canTestService) {
+ if (this.$form.get(0).checkValidity()) {
+ if (this.canTestService) {
+ e.preventDefault();
+ // eslint-disable-next-line no-jquery/no-serialize
+ this.testSettings(this.$form.serialize());
+ }
+ } else {
e.preventDefault();
- // eslint-disable-next-line no-jquery/no-serialize
- this.testSettings(this.$form.serialize());
+ eventHub.$emit('validateForm');
}
}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index 95e10cc75cc..01ea3eee16e 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -99,8 +99,11 @@ export default {
setOriginalDropdownData() {
const $labelSelect = $('.bulk-update .js-label-select');
+ const dirtyLabelIds = $labelSelect.data('marked') || [];
+ const chosenLabelIds = [...this.getOriginalMarkedIds(), ...dirtyLabelIds];
+
$labelSelect.data('common', this.getOriginalCommonIds());
- $labelSelect.data('marked', this.getOriginalMarkedIds());
+ $labelSelect.data('marked', chosenLabelIds);
$labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
},
diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue
index 76e4fac5107..51904c64085 100644
--- a/app/assets/javascripts/issuable_suggestions/components/item.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/item.vue
@@ -128,7 +128,7 @@ export default {
:key="id"
v-gl-tooltip.bottom
:title="tooltipTitle"
- class="suggestion-help-hover prepend-left-8 text-tertiary"
+ class="suggestion-help-hover gl-ml-3 text-tertiary"
>
<icon :name="icon" /> {{ count }}
</span>
diff --git a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
index 27a04da9541..49a89d15c35 100644
--- a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
+++ b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
@@ -1,7 +1,11 @@
<script>
import { GlAlert, GlLabel } from '@gitlab/ui';
import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql';
-import { calculateJiraImportLabel, isFinished, isInProgress } from '~/jira_import/utils';
+import {
+ calculateJiraImportLabel,
+ isFinished,
+ isInProgress,
+} from '~/jira_import/utils/jira_import_utils';
export default {
name: 'IssuableListRoot',
diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
index 640827fe564..1c395fd9795 100644
--- a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
@@ -245,7 +245,7 @@ export default {
<template>
<ul v-if="loading" class="content-list">
- <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue">
+ <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!">
<gl-skeleton-loading />
</li>
</ul>
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 4b53225e100..252e8e92f5e 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -6,6 +6,7 @@ import { addDelimiter } from './lib/utils/text_utility';
import flash from './flash';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
import IssuablesHelper from './helpers/issuables_helper';
+import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from './locale';
export default class Issue {
@@ -14,6 +15,16 @@ export default class Issue {
if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener();
+ if ($('.js-alert-moved-from-service-desk-warning').length) {
+ const trimmedPathname = window.location.pathname.slice(1);
+ this.alertMovedFromServiceDeskDismissedKey = joinPaths(
+ trimmedPathname,
+ 'alert-issue-moved-from-service-desk-dismissed',
+ );
+
+ this.initIssueMovedFromServiceDeskDismissHandler();
+ }
+
Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
@@ -169,6 +180,21 @@ export default class Issue {
});
}
+ initIssueMovedFromServiceDeskDismissHandler() {
+ const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning');
+
+ if (!localStorage.getItem(this.alertMovedFromServiceDeskDismissedKey)) {
+ alertMovedFromServiceDeskWarning.show();
+ }
+
+ alertMovedFromServiceDeskWarning.on('click', '.js-close', e => {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ alertMovedFromServiceDeskWarning.remove();
+ localStorage.setItem(this.alertMovedFromServiceDeskDismissedKey, true);
+ });
+ }
+
static submitNoteForm(form) {
const noteText = form.find('textarea.js-note-text').val();
if (noteText && noteText.trim().length > 0) {
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 8cf2cda64a4..09acfd1cfae 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,9 +1,10 @@
<script>
+import { GlIcon, GlIntersectionObserver } from '@gitlab/ui';
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 { visitUrl } from '~/lib/utils/url_utility';
+import Poll from '~/lib/utils/poll';
import eventHub from '../event_hub';
import Service from '../services/index';
import Store from '../stores';
@@ -12,10 +13,13 @@ import descriptionComponent from './description.vue';
import editedComponent from './edited.vue';
import formComponent from './form.vue';
import PinnedLinks from './pinned_links.vue';
-import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
+import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
+import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants';
export default {
components: {
+ GlIcon,
+ GlIntersectionObserver,
descriptionComponent,
titleComponent,
editedComponent,
@@ -58,12 +62,22 @@ export default {
zoomMeetingUrl: {
type: String,
required: false,
- default: null,
+ default: '',
+ },
+ publishedIncidentUrl: {
+ type: String,
+ required: false,
+ default: '',
},
issuableRef: {
type: String,
required: true,
},
+ issuableStatus: {
+ type: String,
+ required: false,
+ default: '',
+ },
initialTitleHtml: {
type: String,
required: true,
@@ -157,6 +171,7 @@ export default {
state: store.state,
showForm: false,
templatesRequested: false,
+ isStickyHeaderShowing: false,
};
},
computed: {
@@ -191,6 +206,18 @@ export default {
defaultErrorMessage() {
return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
},
+ isOpenStatus() {
+ return this.issuableStatus === IssuableStatus.Open;
+ },
+ statusIcon() {
+ return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
+ },
+ statusText() {
+ return IssuableStatusText[this.issuableStatus];
+ },
+ shouldShowStickyHeader() {
+ return this.isStickyHeaderShowing && this.issuableType === IssuableType.Issue;
+ },
},
created() {
this.service = new Service(this.endpoint);
@@ -344,6 +371,14 @@ export default {
);
});
},
+
+ hideStickyHeader() {
+ this.isStickyHeaderShowing = false;
+ },
+
+ showStickyHeader() {
+ this.isStickyHeaderShowing = true;
+ },
},
};
</script>
@@ -380,7 +415,40 @@ export default {
:title-text="state.titleText"
:show-inline-edit-button="showInlineEditButton"
/>
- <pinned-links :zoom-meeting-url="zoomMeetingUrl" />
+
+ <gl-intersection-observer @appear="hideStickyHeader" @disappear="showStickyHeader">
+ <transition name="issuable-header-slide">
+ <div
+ v-if="shouldShowStickyHeader"
+ class="issue-sticky-header gl-fixed gl-z-index-2 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-200 gl-py-3"
+ data-testid="issue-sticky-header"
+ >
+ <div
+ class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
+ >
+ <p
+ class="issuable-status-box status-box gl-my-0"
+ :class="[isOpenStatus ? 'status-box-open' : 'status-box-issue-closed']"
+ >
+ <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
+ <span class="gl-display-none d-sm-block">{{ statusText }}</span>
+ </p>
+ <p
+ class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
+ :title="state.titleText"
+ >
+ {{ state.titleText }}
+ </p>
+ </div>
+ </div>
+ </transition>
+ </gl-intersection-observer>
+
+ <pinned-links
+ :zoom-meeting-url="zoomMeetingUrl"
+ :published-incident-url="publishedIncidentUrl"
+ />
+
<description-component
v-if="state.descriptionHtml"
:can-update="canUpdate"
@@ -393,6 +461,7 @@ export default {
:lock-version="state.lock_version"
@taskListUpdateFailed="updateStoreState"
/>
+
<edited-component
v-if="hasUpdated"
:updated-at="state.updatedAt"
diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue
index 965e8a3d751..4b50acceb62 100644
--- a/app/assets/javascripts/issue_show/components/pinned_links.vue
+++ b/app/assets/javascripts/issue_show/components/pinned_links.vue
@@ -11,21 +11,40 @@ export default {
zoomMeetingUrl: {
type: String,
required: false,
- default: null,
+ default: '',
+ },
+ publishedIncidentUrl: {
+ type: String,
+ required: false,
+ default: '',
},
},
};
</script>
<template>
- <div v-if="zoomMeetingUrl" class="border-bottom mb-3 mt-n2">
- <gl-link
- :href="zoomMeetingUrl"
- target="_blank"
- class="btn btn-inverted btn-secondary btn-sm text-dark mb-3"
- >
- <icon name="brand-zoom" :size="14" />
- <strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong>
- </gl-link>
+ <div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start">
+ <div v-if="publishedIncidentUrl" class="gl-pr-3">
+ <gl-link
+ :href="publishedIncidentUrl"
+ target="_blank"
+ class="btn btn-inverted btn-secondary btn-sm text-dark mb-3"
+ data-testid="publishedIncidentUrl"
+ >
+ <icon name="tanuki" :size="14" />
+ <strong class="vertical-align-top">{{ __('Published on status page') }}</strong>
+ </gl-link>
+ </div>
+ <div v-if="zoomMeetingUrl">
+ <gl-link
+ :href="zoomMeetingUrl"
+ target="_blank"
+ class="btn btn-inverted btn-secondary btn-sm text-dark mb-3"
+ data-testid="zoomMeetingUrl"
+ >
+ <icon name="brand-zoom" :size="14" />
+ <strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong>
+ </gl-link>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js
new file mode 100644
index 00000000000..d73cc8cf007
--- /dev/null
+++ b/app/assets/javascripts/issue_show/constants.js
@@ -0,0 +1,17 @@
+import { __ } from '~/locale';
+
+export const IssuableStatus = {
+ Open: 'opened',
+ Closed: 'closed',
+};
+
+export const IssuableStatusText = {
+ [IssuableStatus.Open]: __('Open'),
+ [IssuableStatus.Closed]: __('Closed'),
+};
+
+export const IssuableType = {
+ Issue: 'issue',
+ Epic: 'epic',
+ MergeRequest: 'merge_request',
+};
diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue
index d1570f52c8c..ef0fc4716dd 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_app.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue
@@ -4,7 +4,8 @@ import { last } from 'lodash';
import { __ } from '~/locale';
import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql';
-import { IMPORT_STATE, isInProgress } from '../utils';
+import { addInProgressImportToStore } from '../utils/cache_update';
+import { isInProgress, extractJiraProjectsOptions } from '../utils/jira_import_utils';
import JiraImportForm from './jira_import_form.vue';
import JiraImportProgress from './jira_import_progress.vue';
import JiraImportSetup from './jira_import_setup.vue';
@@ -20,14 +21,14 @@ export default {
JiraImportSetup,
},
props: {
- isJiraConfigured: {
- type: Boolean,
- required: true,
- },
inProgressIllustration: {
type: String,
required: true,
},
+ isJiraConfigured: {
+ type: Boolean,
+ required: true,
+ },
issuesPath: {
type: String,
required: true,
@@ -36,10 +37,6 @@ export default {
type: String,
required: true,
},
- jiraProjects: {
- type: Array,
- required: true,
- },
projectPath: {
type: String,
required: true,
@@ -51,6 +48,7 @@ export default {
},
data() {
return {
+ jiraImportDetails: {},
errorMessage: '',
showAlert: false,
selectedProject: undefined,
@@ -65,8 +63,10 @@ export default {
};
},
update: ({ project }) => ({
- status: project.jiraImportStatus,
imports: project.jiraImports.nodes,
+ isInProgress: isInProgress(project.jiraImportStatus),
+ mostRecentImport: last(project.jiraImports.nodes),
+ projects: extractJiraProjectsOptions(project.services.nodes[0].projects.nodes),
}),
skip() {
return !this.isJiraConfigured;
@@ -74,35 +74,22 @@ export default {
},
},
computed: {
- isImportInProgress() {
- return isInProgress(this.jiraImportDetails?.status);
- },
- jiraProjectsOptions() {
- return this.jiraProjects.map(([text, value]) => ({ text, value }));
- },
- mostRecentImport() {
- // The backend returns JiraImports ordered by created_at asc in app/models/project.rb
- return last(this.jiraImportDetails?.imports);
- },
- numberOfPreviousImportsForProject() {
- return this.jiraImportDetails?.imports?.reduce?.(
+ numberOfPreviousImports() {
+ return this.jiraImportDetails.imports?.reduce?.(
(acc, jiraProject) => (jiraProject.jiraProjectKey === this.selectedProject ? acc + 1 : acc),
0,
);
},
+ hasPreviousImports() {
+ return this.numberOfPreviousImports > 0;
+ },
importLabel() {
return this.selectedProject
- ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImportsForProject + 1}`
+ ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImports + 1}`
: 'jira-import::KEY-1';
},
- hasPreviousImports() {
- return this.numberOfPreviousImportsForProject > 0;
- },
},
methods: {
- dismissAlert() {
- this.showAlert = false;
- },
initiateJiraImport(project) {
this.$apollo
.mutate({
@@ -113,39 +100,8 @@ export default {
jiraProjectKey: project,
},
},
- update: (store, { data }) => {
- if (data.jiraImportStart.errors.length) {
- return;
- }
-
- const cacheData = store.readQuery({
- query: getJiraImportDetailsQuery,
- variables: {
- fullPath: this.projectPath,
- },
- });
-
- store.writeQuery({
- query: getJiraImportDetailsQuery,
- variables: {
- fullPath: this.projectPath,
- },
- data: {
- project: {
- jiraImportStatus: IMPORT_STATE.SCHEDULED,
- jiraImports: {
- nodes: [
- ...cacheData.project.jiraImports.nodes,
- data.jiraImportStart.jiraImport,
- ],
- __typename: 'JiraImportConnection',
- },
- // eslint-disable-next-line @gitlab/require-i18n-strings
- __typename: 'Project',
- },
- },
- });
- },
+ update: (store, { data }) =>
+ addInProgressImportToStore(store, data.jiraImportStart, this.projectPath),
})
.then(({ data }) => {
if (data.jiraImportStart.errors.length) {
@@ -160,7 +116,13 @@ export default {
this.errorMessage = message;
this.showAlert = true;
},
+ dismissAlert() {
+ this.showAlert = false;
+ },
},
+ previousImportsMessage: __(
+ 'You have imported from this project %{numberOfPreviousImports} times before. Each new import will create duplicate issues.',
+ ),
};
</script>
@@ -170,16 +132,8 @@ export default {
{{ errorMessage }}
</gl-alert>
<gl-alert v-if="hasPreviousImports" variant="warning" :dismissible="false">
- <gl-sprintf
- :message="
- __(
- 'You have imported from this project %{numberOfPreviousImportsForProject} times before. Each new import will create duplicate issues.',
- )
- "
- >
- <template #numberOfPreviousImportsForProject>{{
- numberOfPreviousImportsForProject
- }}</template>
+ <gl-sprintf :message="$options.previousImportsMessage">
+ <template #numberOfPreviousImports>{{ numberOfPreviousImports }}</template>
</gl-sprintf>
</gl-alert>
@@ -190,11 +144,11 @@ export default {
/>
<gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" />
<jira-import-progress
- v-else-if="isImportInProgress"
+ v-else-if="jiraImportDetails.isInProgress"
:illustration="inProgressIllustration"
- :import-initiator="mostRecentImport.scheduledBy.name"
- :import-project="mostRecentImport.jiraProjectKey"
- :import-time="mostRecentImport.scheduledAt"
+ :import-initiator="jiraImportDetails.mostRecentImport.scheduledBy.name"
+ :import-project="jiraImportDetails.mostRecentImport.jiraProjectKey"
+ :import-time="jiraImportDetails.mostRecentImport.scheduledAt"
:issues-path="issuesPath"
/>
<jira-import-form
@@ -202,7 +156,7 @@ export default {
v-model="selectedProject"
:import-label="importLabel"
:issues-path="issuesPath"
- :jira-projects="jiraProjectsOptions"
+ :jira-projects="jiraImportDetails.projects"
@initiateJiraImport="initiateJiraImport"
/>
</div>
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index b576668fe7c..924cc7e6864 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -28,7 +28,6 @@ export default function mountJiraImportApp() {
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
issuesPath: el.dataset.issuesPath,
jiraIntegrationPath: el.dataset.jiraIntegrationPath,
- jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [],
projectPath: el.dataset.projectPath,
setupIllustration: el.dataset.setupIllustration,
},
diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
index aa8d03c7f17..2aacc5cf668 100644
--- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
+++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
@@ -8,5 +8,17 @@ query($fullPath: ID!) {
...JiraImport
}
}
+ services(active: true, type: JIRA_SERVICE) {
+ nodes {
+ ... on JiraService {
+ projects {
+ nodes {
+ key
+ name
+ }
+ }
+ }
+ }
+ }
}
}
diff --git a/app/assets/javascripts/jira_import/utils/cache_update.js b/app/assets/javascripts/jira_import/utils/cache_update.js
new file mode 100644
index 00000000000..6aaf2010866
--- /dev/null
+++ b/app/assets/javascripts/jira_import/utils/cache_update.js
@@ -0,0 +1,37 @@
+import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
+import { IMPORT_STATE } from './jira_import_utils';
+
+export const addInProgressImportToStore = (store, jiraImportStart, fullPath) => {
+ if (jiraImportStart.errors.length) {
+ return;
+ }
+
+ const queryDetails = {
+ query: getJiraImportDetailsQuery,
+ variables: {
+ fullPath,
+ },
+ };
+
+ const cacheData = store.readQuery({
+ ...queryDetails,
+ });
+
+ store.writeQuery({
+ ...queryDetails,
+ data: {
+ project: {
+ ...cacheData.project,
+ jiraImportStatus: IMPORT_STATE.SCHEDULED,
+ jiraImports: {
+ ...cacheData.project.jiraImports,
+ nodes: cacheData.project.jiraImports.nodes.concat(jiraImportStart.jiraImport),
+ },
+ },
+ },
+ });
+};
+
+export default {
+ addInProgressImportToStore,
+};
diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils/jira_import_utils.js
index aa10dfc8099..e82a3f44a29 100644
--- a/app/assets/javascripts/jira_import/utils.js
+++ b/app/assets/javascripts/jira_import/utils/jira_import_utils.js
@@ -14,6 +14,17 @@ export const isInProgress = state =>
export const isFinished = state => state === IMPORT_STATE.FINISHED;
/**
+ * Converts the list of Jira projects into a format consumable by GlFormSelect.
+ *
+ * @param {Object[]} projects - List of Jira projects
+ * @param {string} projects[].key - Jira project key
+ * @param {string} projects[].name - Jira project name
+ * @returns {Object[]} - List of Jira projects in a format consumable by GlFormSelect
+ */
+export const extractJiraProjectsOptions = projects =>
+ projects.map(({ key, name }) => ({ text: `${name} (${key})`, value: key }));
+
+/**
* Calculates the label title for the most recent Jira import.
*
* @param {Object[]} jiraImports - List of Jira imports
diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue
index 0bce860df91..b2f9bf2a348 100644
--- a/app/assets/javascripts/jobs/components/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/artifacts_block.vue
@@ -17,11 +17,14 @@ export default {
},
computed: {
isExpired() {
- return this.artifact.expired;
+ return this.artifact?.expired && !this.isLocked;
+ },
+ isLocked() {
+ return this.artifact?.locked;
},
// Only when the key is `false` we can render this block
willExpire() {
- return this.artifact.expired === false;
+ return this.artifact?.expired === false && !this.isLocked;
},
},
};
@@ -29,42 +32,45 @@ export default {
<template>
<div class="block">
<div class="title font-weight-bold">{{ s__('Job|Job artifacts') }}</div>
-
<p
v-if="isExpired || willExpire"
- :class="{
- 'js-artifacts-removed': isExpired,
- 'js-artifacts-will-be-removed': willExpire,
- }"
class="build-detail-row"
+ data-testid="artifacts-remove-timeline"
>
<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="isLocked" class="build-detail-row">
+ <span data-testid="job-locked-message">{{
+ s__(
+ 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.',
+ )
+ }}</span>
+ </p>
<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"
+ class="btn btn-sm btn-default"
data-method="post"
+ data-testid="keep-artifacts"
>{{ s__('Job|Keep') }}</gl-link
>
-
<gl-link
v-if="artifact.download_path"
:href="artifact.download_path"
- class="js-download-artifacts btn btn-sm btn-default"
+ class="btn btn-sm btn-default"
download
rel="nofollow"
+ data-testid="download-artifacts"
>{{ 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"
+ class="btn btn-sm btn-default"
+ data-testid="browse-artifacts"
>{{ s__('Job|Browse') }}</gl-link
>
</div>
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 88649ddbdb7..72a5ff01672 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -53,6 +53,6 @@ export default {
</span>
</p>
- <p class="append-bottom-0">{{ commit.title }}</p>
+ <p class="gl-mb-0">{{ commit.title }}</p>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index 28cc03c88cb..c34a3488dbd 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -277,7 +277,7 @@ export default {
<div class="prepend-top-default append-bottom-default js-environment-container">
<div class="environment-information">
<ci-icon :status="iconStatus" />
- <p class="inline append-bottom-0" v-html="environment"></p>
+ <p class="inline gl-mb-0" v-html="environment"></p>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index ddcfc3d6db6..116331d9549 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -81,7 +81,7 @@ export default {
<button
type="button"
data-toggle="dropdown"
- class="js-selected-stage dropdown-menu-toggle prepend-top-8"
+ class="js-selected-stage dropdown-menu-toggle gl-mt-3"
>
{{ selectedStage }} <i class="fa fa-chevron-down"></i>
</button>
diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue
index ec52d272168..da01269a50c 100644
--- a/app/assets/javascripts/jobs/components/stuck_block.vue
+++ b/app/assets/javascripts/jobs/components/stuck_block.vue
@@ -26,31 +26,31 @@ export default {
</script>
<template>
<div class="bs-callout bs-callout-warning">
- <p v-if="tags.length" class="js-stuck-with-tags append-bottom-0">
+ <p v-if="tags.length" class="js-stuck-with-tags gl-mb-0">
{{
s__(`This job is stuck because you don't have
- any active runners online with any of these tags assigned to them:`)
+ any active runners online or available with any of these tags assigned to them:`)
}}
<span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4">
{{ tag }}
</span>
</p>
- <p v-else-if="hasNoRunnersForProject" class="js-stuck-no-runners append-bottom-0">
+ <p v-else-if="hasNoRunnersForProject" class="js-stuck-no-runners gl-mb-0">
{{
s__(`Job|This job is stuck because the project
doesn't have any runners online assigned to it.`)
}}
</p>
- <p v-else class="js-stuck-no-active-runner append-bottom-0">
+ <p v-else class="js-stuck-no-active-runner gl-mb-0">
{{
s__(`This job is stuck because you don't
have any active runners that can run this job.`)
}}
</p>
- {{ __('Go to') }}
+ {{ __('Go to project') }}
<gl-link v-if="runnersPath" :href="runnersPath" class="js-runners-path">
- {{ __('Runners page') }}
+ {{ __('CI settings') }}
</gl-link>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index 7c9b2824a43..1a076249fe7 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -46,7 +46,7 @@ export default {
<p
v-if="trigger.short_token"
class="js-short-token"
- :class="{ 'append-bottom-5': hasVariables, 'append-bottom-0': !hasVariables }"
+ :class="{ 'append-bottom-5': hasVariables, 'gl-mb-0': !hasVariables }"
>
<span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }}
</p>
diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
index 25a8da84873..633561c879e 100644
--- a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
+++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
@@ -17,7 +17,7 @@ export default {
</script>
<template>
<div class="bs-callout bs-callout-danger">
- <p class="js-failed-unmet-prerequisites append-bottom-0">
+ <p class="js-failed-unmet-prerequisites gl-mb-0">
{{
s__(`Job|This job failed because the necessary resources were not successfully created.`)
}}
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index a464290ffb5..75542267f37 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -15,7 +15,7 @@ export default class LazyLoader {
}
static supportsIntersectionObserver() {
- return 'IntersectionObserver' in window;
+ return Boolean(window.IntersectionObserver);
}
searchLazyImages() {
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 4a48852159a..a60748215ab 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -31,7 +31,7 @@ export const getProjectSlug = () => {
};
export const getGroupSlug = () => {
- if (isInGroupsPage()) {
+ if (isInProjectPage() || isInGroupsPage()) {
return $('body').data('group');
}
return null;
@@ -244,22 +244,28 @@ export const contentTop = () => {
);
};
-export const scrollToElement = element => {
+export const scrollToElement = (element, options = {}) => {
let $el = element;
if (!(element instanceof $)) {
$el = $(element);
}
const { top } = $el.offset();
+ const { offset = 0 } = options;
// eslint-disable-next-line no-jquery/no-animate
return $('body, html').animate(
{
- scrollTop: top - contentTop(),
+ scrollTop: top - contentTop() + offset,
},
200,
);
};
+export const scrollToElementWithContext = element => {
+ const offsetMultiplier = -0.1;
+ return scrollToElement(element, { offset: window.innerHeight * offsetMultiplier });
+};
+
/**
* Returns a function that can only be invoked once between
* each browser screen repaint.
@@ -718,6 +724,8 @@ export const convertObjectProps = (conversionFunction, obj = {}, options = {}) =
} else {
acc[conversionFunction(prop)] = convertObjectProps(conversionFunction, val, options);
}
+ } else if (isObjParameterArray) {
+ acc[prop] = val;
} else {
acc[conversionFunction(prop)] = val;
}
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 9a61003ef30..eb6c9bf7eb6 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,2 +1,10 @@
export const BYTES_IN_KIB = 1024;
export const HIDDEN_CLASS = 'hidden';
+
+export const DATETIME_RANGE_TYPES = {
+ fixed: 'fixed',
+ anchored: 'anchored',
+ rolling: 'rolling',
+ open: 'open',
+ invalid: 'invalid',
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js
index 9275b9e74e1..8efbcb89607 100644
--- a/app/assets/javascripts/lib/utils/datetime_range.js
+++ b/app/assets/javascripts/lib/utils/datetime_range.js
@@ -1,5 +1,6 @@
import dateformat from 'dateformat';
import { pick, omit, isEqual, isEmpty } from 'lodash';
+import { DATETIME_RANGE_TYPES } from './constants';
import { secondsToMilliseconds } from './datetime_utility';
const MINIMUM_DATE = new Date(0);
@@ -153,18 +154,22 @@ export function getRangeType(range) {
const { start, end, anchor, duration } = range;
if ((start || end) && !anchor && !duration) {
- return isValidDateString(start) && isValidDateString(end) ? 'fixed' : 'invalid';
+ return isValidDateString(start) && isValidDateString(end)
+ ? DATETIME_RANGE_TYPES.fixed
+ : DATETIME_RANGE_TYPES.invalid;
}
if (anchor && duration) {
- return isValidDateString(anchor) && isValidDuration(duration) ? 'anchored' : 'invalid';
+ return isValidDateString(anchor) && isValidDuration(duration)
+ ? DATETIME_RANGE_TYPES.anchored
+ : DATETIME_RANGE_TYPES.invalid;
}
if (duration && !anchor) {
- return isValidDuration(duration) ? 'rolling' : 'invalid';
+ return isValidDuration(duration) ? DATETIME_RANGE_TYPES.rolling : DATETIME_RANGE_TYPES.invalid;
}
if (anchor && !duration) {
- return isValidDateString(anchor) ? 'open' : 'invalid';
+ return isValidDateString(anchor) ? DATETIME_RANGE_TYPES.open : DATETIME_RANGE_TYPES.invalid;
}
- return 'invalid';
+ return DATETIME_RANGE_TYPES.invalid;
}
/**
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index 7933c234384..8fa235f8afb 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -12,3 +12,16 @@ export const canScrollUp = ({ scrollTop }, margin = 0) => scrollTop > margin;
export const canScrollDown = ({ scrollTop, offsetHeight, scrollHeight }, margin = 0) =>
scrollTop + offsetHeight < scrollHeight - margin;
+
+export const toggleContainerClasses = (containerEl, classList) => {
+ if (containerEl) {
+ // eslint-disable-next-line array-callback-return
+ Object.entries(classList).map(([key, value]) => {
+ if (value) {
+ containerEl.classList.add(key);
+ } else {
+ containerEl.classList.remove(key);
+ }
+ });
+ }
+};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 86714471823..be3fe1ed620 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -90,6 +90,13 @@ export const truncatePathMiddleToLength = (text, maxWidth) => {
while (returnText.length >= maxWidth) {
const textSplit = returnText.split('/').filter(s => s !== ELLIPSIS_CHAR);
+
+ if (textSplit.length === 0) {
+ // There are n - 1 path separators for n segments, so 2n - 1 <= maxWidth
+ const maxSegments = Math.floor((maxWidth + 1) / 2);
+ return new Array(maxSegments).fill(ELLIPSIS_CHAR).join('/');
+ }
+
const middleIndex = Math.floor(textSplit.length / 2);
returnText = textSplit
@@ -168,7 +175,7 @@ export const convertToCamelCase = string =>
* @param {*} string
*/
export const convertToSnakeCase = string =>
- slugifyWithUnderscore(string.match(/([a-zA-Z][^A-Z]*)/g).join(' '));
+ slugifyWithUnderscore((string.match(/([a-zA-Z][^A-Z]*)/g) || [string]).join(' '));
/**
* Converts a sentence to lower case from the second word onwards
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 966e6d42b80..0472b8cf51f 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -109,9 +109,10 @@ export function mergeUrlParams(params, url) {
*
* @param {string[]} params - the query param names to remove
* @param {string} [url=windowLocation().href] - url from which the query param will be removed
+ * @param {boolean} skipEncoding - set to true when the url does not require encoding
* @returns {string} A copy of the original url but without the query param
*/
-export function removeParams(params, url = window.location.href) {
+export function removeParams(params, url = window.location.href, skipEncoding = false) {
const [rootAndQuery, fragment] = url.split('#');
const [root, query] = rootAndQuery.split('?');
@@ -119,12 +120,13 @@ export function removeParams(params, url = window.location.href) {
return url;
}
- const encodedParams = params.map(param => encodeURIComponent(param));
+ const removableParams = skipEncoding ? params : params.map(param => encodeURIComponent(param));
+
const updatedQuery = query
.split('&')
.filter(paramPair => {
const [foundParam] = paramPair.split('=');
- return encodedParams.indexOf(foundParam) < 0;
+ return removableParams.indexOf(foundParam) < 0;
})
.join('&');
@@ -242,6 +244,15 @@ export function isRootRelative(url) {
}
/**
+ * Returns true if url is a base64 data URL
+ *
+ * @param {String} url
+ */
+export function isBase64DataUrl(url) {
+ return /^data:[.\w+-]+\/[.\w+-]+;base64,/.test(url);
+}
+
+/**
* Returns true if url is an absolute or root-relative URL
*
* @param {String} url
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index 838652f7210..01a4cbd41f6 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -91,7 +91,7 @@ export default {
'setInitData',
'showEnvironment',
'fetchEnvironments',
- 'fetchLogs',
+ 'refreshPodLogs',
'fetchMoreLogsPrepend',
'dismissRequestEnvironmentsError',
'dismissInvalidTimeRangeWarning',
@@ -204,7 +204,7 @@ export default {
ref="scrollButtons"
class="flex-grow-0 pr-2 mb-2 controllers"
:scroll-down-button-disabled="scrollDownButtonDisabled"
- @refresh="fetchLogs()"
+ @refresh="refreshPodLogs()"
@scrollDown="scrollDown"
/>
</div>
diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js
index 51770aa7a1c..f83d369c6b8 100644
--- a/app/assets/javascripts/logs/constants.js
+++ b/app/assets/javascripts/logs/constants.js
@@ -1,3 +1,11 @@
export const dateFormatMask = 'mmm dd HH:MM:ss.l';
export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
+
+export const tracking = {
+ USED_SEARCH_BAR: 'used_search_bar',
+ POD_LOG_CHANGED: 'pod_log_changed',
+ TIME_RANGE_SET: 'time_range_set',
+ ENVIRONMENT_SELECTED: 'environment_selected',
+ REFRESH_POD_LOGS: 'refresh_pod_logs',
+};
diff --git a/app/assets/javascripts/logs/logs_tracking_helper.js b/app/assets/javascripts/logs/logs_tracking_helper.js
new file mode 100644
index 00000000000..91b0392f71f
--- /dev/null
+++ b/app/assets/javascripts/logs/logs_tracking_helper.js
@@ -0,0 +1,18 @@
+import Tracking from '~/tracking';
+
+/**
+ * The value of 1 in count, means there was one action performed
+ * related to the tracked action, in either of the following categories
+ * 1. Refreshing the logs
+ * 2. Select an environment
+ * 3. Change the time range
+ * 4. Use the search bar
+ */
+const trackLogs = label =>
+ Tracking.event(document.body.dataset.page, 'logs_view', {
+ label,
+ property: 'count',
+ value: 1,
+ });
+
+export default trackLogs;
diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js
index a86d3c775a9..d828e8f8a3e 100644
--- a/app/assets/javascripts/logs/stores/actions.js
+++ b/app/assets/javascripts/logs/stores/actions.js
@@ -2,7 +2,8 @@ import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { TOKEN_TYPE_POD_NAME } from '../constants';
+import { TOKEN_TYPE_POD_NAME, tracking } from '../constants';
+import trackLogs from '../logs_tracking_helper';
import * as types from './mutation_types';
@@ -81,22 +82,27 @@ export const showFilteredLogs = ({ dispatch, commit }, filters = []) => {
commit(types.SET_CURRENT_POD_NAME, podName);
commit(types.SET_SEARCH, search);
- dispatch('fetchLogs');
+ dispatch('fetchLogs', tracking.USED_SEARCH_BAR);
};
export const showPodLogs = ({ dispatch, commit }, podName) => {
commit(types.SET_CURRENT_POD_NAME, podName);
- dispatch('fetchLogs');
+ dispatch('fetchLogs', tracking.POD_LOG_CHANGED);
};
export const setTimeRange = ({ dispatch, commit }, timeRange) => {
commit(types.SET_TIME_RANGE, timeRange);
- dispatch('fetchLogs');
+ dispatch('fetchLogs', tracking.TIME_RANGE_SET);
};
export const showEnvironment = ({ dispatch, commit }, environmentName) => {
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
- dispatch('fetchLogs');
+ dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
+};
+
+export const refreshPodLogs = ({ dispatch, commit }) => {
+ commit(types.REFRESH_POD_LOGS);
+ dispatch('fetchLogs', tracking.REFRESH_POD_LOGS);
};
/**
@@ -111,19 +117,22 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
.get(environmentsPath)
.then(({ data }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
- dispatch('fetchLogs');
+ dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
})
.catch(() => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
});
};
-export const fetchLogs = ({ commit, state }) => {
+export const fetchLogs = ({ commit, state }, trackingLabel) => {
commit(types.REQUEST_LOGS_DATA);
return requestLogsUntilData({ commit, state })
.then(({ data }) => {
const { pod_name, pods, logs, cursor } = data;
+ if (logs && logs.length > 0) {
+ trackLogs(trackingLabel);
+ }
commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
commit(types.SET_CURRENT_POD_NAME, pod_name);
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js
index c1cc7eca52e..9010ec51817 100644
--- a/app/assets/javascripts/logs/stores/mutation_types.js
+++ b/app/assets/javascripts/logs/stores/mutation_types.js
@@ -19,6 +19,7 @@ export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND';
export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS';
export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR';
export const HIDE_REQUEST_LOGS_ERROR = 'HIDE_REQUEST_LOGS_ERROR';
+export const REFRESH_POD_LOGS = 'REFRESH_POD_LOGS';
export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
export const RECEIVE_PODS_DATA_ERROR = 'RECEIVE_PODS_DATA_ERROR';
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 713f57a2b27..5f5fd790f67 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -28,15 +28,15 @@ import initLayoutNav from './layout_nav';
import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader';
import initLogoAnimation from './logo';
-import './frequent_items';
+import initFrequentItemDropdowns from './frequent_items';
import initBreadcrumbs from './breadcrumb';
import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar';
-import initSearchAutocomplete from './search_autocomplete';
+import initGlobalSearchInput from './global_search_input';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
-import PersistentUserCallout from './persistent_user_callout';
+import initPersistentUserCallouts from './persistent_user_callouts';
import { initUserTracking } from './tracking';
import { __ } from './locale';
@@ -107,14 +107,10 @@ function deferredInitialisation() {
initUsagePingConsent();
initUserPopovers();
initBroadcastNotifications();
+ initFrequentItemDropdowns();
+ initPersistentUserCallouts();
- const recoverySettingsCallout = document.querySelector('.js-recovery-settings-callout');
- PersistentUserCallout.factory(recoverySettingsCallout);
-
- const usersOverLicenseCallout = document.querySelector('.js-users-over-license-callout');
- PersistentUserCallout.factory(usersOverLicenseCallout);
-
- if (document.querySelector('.search')) initSearchAutocomplete();
+ if (document.querySelector('.search')) initGlobalSearchInput();
addSelectOnFocusBehaviour('.js-select-on-focus');
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 1795a0dbdf8..6c63ab7cf95 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -126,6 +126,13 @@ export default class MergeRequestTabs {
bindEvents() {
$('.merge-request-tabs a[data-toggle="tabvue"]').on('click', this.clickTab);
+ window.addEventListener('popstate', event => {
+ if (event.state && event.state.action) {
+ this.tabShown(event.state.action, event.target.location);
+ this.currentAction = event.state.action;
+ this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction());
+ }
+ });
}
// Used in tests
@@ -155,6 +162,12 @@ export default class MergeRequestTabs {
} else if (action) {
const href = e.currentTarget.getAttribute('href');
this.tabShown(action, href);
+
+ if (this.setUrl) {
+ this.setCurrentAction(action);
+ }
+
+ this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction());
}
}
}
@@ -213,11 +226,6 @@ export default class MergeRequestTabs {
this.resetViewContainer();
this.destroyPipelinesView();
}
- if (this.setUrl) {
- this.setCurrentAction(action);
- }
-
- this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction());
} else if (action === this.currentAction) {
// ContentTop is used to handle anything at the top of the page before the main content
const mainContentContainer = document.querySelector('.content-wrapper');
@@ -287,19 +295,25 @@ export default class MergeRequestTabs {
// Ensure parameters and hash come along for the ride
newState += location.search + location.hash;
- // TODO: Consider refactoring in light of turbolinks removal.
-
- // Replace the current history state with the new one without breaking
- // Turbolinks' history.
- //
- // See https://github.com/rails/turbolinks/issues/363
- window.history.replaceState(
- {
- url: newState,
- },
- document.title,
- newState,
- );
+ if (window.history.state && window.history.state.url && window.location.pathname !== newState) {
+ window.history.pushState(
+ {
+ url: newState,
+ action: this.currentAction,
+ },
+ document.title,
+ newState,
+ );
+ } else {
+ window.history.replaceState(
+ {
+ url: window.location.href,
+ action,
+ },
+ document.title,
+ window.location.href,
+ );
+ }
return newState;
}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index e14212254a8..caa45184bfc 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -130,10 +130,13 @@ export default class MilestoneSelect {
fieldName: $dropdown.data('fieldName'),
text: milestone => escape(milestone.title),
id: milestone => {
- if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
- return milestone.name;
+ if (milestone !== undefined) {
+ if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
+ return milestone.name;
+ }
+
+ return milestone.id;
}
- return milestone.id;
},
hidden: () => {
$selectBox.hide();
diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue
index 86a793c854e..5562981fe1c 100644
--- a/app/assets/javascripts/monitoring/components/alert_widget.vue
+++ b/app/assets/javascripts/monitoring/components/alert_widget.vue
@@ -234,11 +234,7 @@ export default {
class="alert-current-setting cursor-pointer d-flex"
@click="showModal"
>
- <gl-badge
- :variant="isFiring ? 'danger' : 'secondary'"
- pill
- class="d-flex-center text-truncate"
- >
+ <gl-badge :variant="isFiring ? 'danger' : 'neutral'" class="d-flex-center text-truncate">
<gl-icon name="warning" :size="16" class="flex-shrink-0" />
<span class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me">
<gl-sprintf
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
index 74324daa1e3..b2d7ca0c4e0 100644
--- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue
+++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
@@ -238,7 +238,7 @@ export default {
<icon
v-gl-tooltip="$options.alertQueryText.descriptionTooltip"
name="question"
- class="prepend-left-4"
+ class="gl-ml-2"
/>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
index 7a2e3e1b511..d7d01def45e 100644
--- a/app/assets/javascripts/monitoring/components/charts/column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/column.vue
@@ -5,7 +5,8 @@ import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { chartHeight } from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';
-import { getYAxisOptions, getChartGrid } from './options';
+import { getTimeAxisOptions, getYAxisOptions, getChartGrid } from './options';
+import { timezones } from '../../format_date';
export default {
components: {
@@ -20,6 +21,11 @@ export default {
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
+ timezone: {
+ type: String,
+ required: false,
+ default: timezones.LOCAL,
+ },
},
data() {
return {
@@ -43,6 +49,8 @@ export default {
};
},
chartOptions() {
+ const xAxis = getTimeAxisOptions({ timezone: this.timezone });
+
const yAxis = {
...getYAxisOptions(this.graphData.yAxis),
scale: false,
@@ -50,8 +58,9 @@ export default {
return {
grid: getChartGrid(),
+ xAxis,
yAxis,
- dataZoom: this.dataZoomConfig,
+ dataZoom: [this.dataZoomConfig],
};
},
xAxisTitle() {
diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
index e015ef32d8c..ad176637538 100644
--- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
+++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
@@ -23,10 +23,10 @@ export default {
<template>
<div class="d-flex flex-column justify-content-center">
<div
- class="prepend-top-8 svg-w-100 d-flex align-items-center"
+ class="gl-mt-3 svg-w-100 d-flex align-items-center"
:style="svgContainerStyle"
v-html="chartEmptyStateIllustration"
></div>
- <h5 class="text-center prepend-top-8">{{ __('No data to display') }}</h5>
+ <h5 class="text-center gl-mt-3">{{ __('No data to display') }}</h5>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index 55a25ee09fd..f6f266dacf3 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -1,8 +1,8 @@
<script>
import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlHeatmap } from '@gitlab/ui/dist/charts';
-import dateformat from 'dateformat';
import { graphDataValidatorForValues } from '../../utils';
+import { formatDate, timezones, formats } from '../../format_date';
export default {
components: {
@@ -17,6 +17,11 @@ export default {
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
+ timezone: {
+ type: String,
+ required: false,
+ default: timezones.LOCAL,
+ },
},
data() {
return {
@@ -43,7 +48,7 @@ export default {
return this.result.values.map(val => {
const [yLabel] = val;
- return dateformat(new Date(yLabel), 'HH:MM:ss');
+ return formatDate(new Date(yLabel), { format: formats.shortTime, timezone: this.timezone });
});
},
result() {
diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js
index 09b03774580..f7822e69b1d 100644
--- a/app/assets/javascripts/monitoring/components/charts/options.js
+++ b/app/assets/javascripts/monitoring/components/charts/options.js
@@ -1,5 +1,6 @@
import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
+import { formatDate, timezones, formats } from '../../format_date';
const yAxisBoundaryGap = [0.1, 0.1];
/**
@@ -21,6 +22,21 @@ const chartGridLeft = 75;
// Axis options
/**
+ * Axis types
+ * @see https://echarts.apache.org/en/option.html#xAxis.type
+ */
+export const axisTypes = {
+ /**
+ * Category axis, suitable for discrete category data.
+ */
+ category: 'category',
+ /**
+ * Time axis, suitable for continuous time series data.
+ */
+ time: 'time',
+};
+
+/**
* Converts .yml parameters to echarts axis options for data axis
* @param {Object} param - Dashboard .yml definition options
*/
@@ -58,6 +74,17 @@ export const getYAxisOptions = ({
};
};
+export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({
+ name: __('Time'),
+ type: axisTypes.time,
+ axisLabel: {
+ formatter: date => formatDate(date, { format: formats.shortTime, timezone }),
+ },
+ axisPointer: {
+ snap: false,
+ },
+});
+
// Chart grid
/**
diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
index 66ba20c125f..ac31d107e63 100644
--- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
@@ -2,8 +2,11 @@
import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
-import { chartHeight } from '../../constants';
+import { chartHeight, legendLayoutTypes } from '../../constants';
+import { s__ } from '~/locale';
import { graphDataValidatorForValues } from '../../utils';
+import { getTimeAxisOptions, axisTypes } from './options';
+import { timezones } from '../../format_date';
export default {
components: {
@@ -18,6 +21,36 @@ export default {
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
+ timezone: {
+ type: String,
+ required: false,
+ default: timezones.LOCAL,
+ },
+ legendLayout: {
+ type: String,
+ required: false,
+ default: legendLayoutTypes.table,
+ },
+ legendAverageText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Avg'),
+ },
+ legendCurrentText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Current'),
+ },
+ legendMaxText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Max'),
+ },
+ legendMinText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Min'),
+ },
},
data() {
return {
@@ -28,7 +61,14 @@ export default {
},
computed: {
chartData() {
- return this.graphData.metrics.map(metric => metric.result[0].values.map(val => val[1]));
+ return this.graphData.metrics.map(({ result }) => {
+ // This needs a fix. Not only metrics[0] should be shown.
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492
+ if (!result || result.length === 0) {
+ return [];
+ }
+ return result[0].values.map(val => val[1]);
+ });
},
xAxisTitle() {
return this.graphData.x_label !== undefined ? this.graphData.x_label : '';
@@ -37,10 +77,17 @@ export default {
return this.graphData.y_label !== undefined ? this.graphData.y_label : '';
},
xAxisType() {
- return this.graphData.x_type !== undefined ? this.graphData.x_type : 'category';
+ // stacked-column component requires the x-axis to be of type `category`
+ return axisTypes.category;
},
groupBy() {
- return this.graphData.metrics[0].result[0].values.map(val => val[0]);
+ // This needs a fix. Not only metrics[0] should be shown.
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492
+ const { result } = this.graphData.metrics[0];
+ if (!result || result.length === 0) {
+ return [];
+ }
+ return result[0].values.map(val => val[0]);
},
dataZoomConfig() {
const handleIcon = this.svgs['scroll-handle'];
@@ -49,11 +96,15 @@ export default {
},
chartOptions() {
return {
- dataZoom: this.dataZoomConfig,
+ xAxis: {
+ ...getTimeAxisOptions({ timezone: this.timezone }),
+ type: this.xAxisType,
+ },
+ dataZoom: [this.dataZoomConfig],
};
},
seriesNames() {
- return this.graphData.metrics.map(metric => metric.series_name);
+ return this.graphData.metrics.map(metric => metric.label);
},
},
created() {
@@ -94,6 +145,11 @@ export default {
:width="width"
:height="height"
:series-names="seriesNames"
+ :legend-layout="legendLayout"
+ :legend-average-text="legendAverageText"
+ :legend-current-text="legendCurrentText"
+ :legend-max-text="legendMaxText"
+ :legend-min-text="legendMinText"
/>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 8f37a12af75..28af2d8ba77 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -2,18 +2,19 @@
import { omit, throttle } from 'lodash';
import { GlLink, GlDeprecatedButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
-import dateFormat from 'dateformat';
-import { s__, __ } from '~/locale';
+import { s__ } from '~/locale';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
-import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants';
-import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options';
+import { panelTypes, chartHeight, lineTypes, lineWidths, legendLayoutTypes } from '../../constants';
+import { getYAxisOptions, getTimeAxisOptions, getChartGrid, getTooltipFormatter } from './options';
import { annotationsYAxis, generateAnnotationsSeries } from './annotations';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';
+import { formatDate, timezones } from '../../format_date';
+
+export const timestampToISODate = timestamp => new Date(timestamp).toISOString();
const THROTTLED_DATAZOOM_WAIT = 1000; // milliseconds
-const timestampToISODate = timestamp => new Date(timestamp).toISOString();
const events = {
datazoom: 'datazoom',
@@ -74,21 +75,41 @@ export default {
required: false,
default: () => [],
},
+ legendLayout: {
+ type: String,
+ required: false,
+ default: legendLayoutTypes.table,
+ },
legendAverageText: {
type: String,
required: false,
default: s__('Metrics|Avg'),
},
+ legendCurrentText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Current'),
+ },
legendMaxText: {
type: String,
required: false,
default: s__('Metrics|Max'),
},
+ legendMinText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Min'),
+ },
groupId: {
type: String,
required: false,
default: '',
},
+ timezone: {
+ type: String,
+ required: false,
+ default: timezones.LOCAL,
+ },
},
data() {
return {
@@ -154,23 +175,16 @@ export default {
const { yAxis, xAxis } = this.option;
const option = omit(this.option, ['series', 'yAxis', 'xAxis']);
+ const timeXAxis = {
+ ...getTimeAxisOptions({ timezone: this.timezone }),
+ ...xAxis,
+ };
+
const dataYAxis = {
...getYAxisOptions(this.graphData.yAxis),
...yAxis,
};
- const timeXAxis = {
- name: __('Time'),
- type: 'time',
- axisLabel: {
- formatter: date => dateFormat(date, dateFormats.timeOfDay),
- },
- axisPointer: {
- snap: true,
- },
- ...xAxis,
- };
-
return {
series: this.chartOptionSeries,
xAxis: timeXAxis,
@@ -271,12 +285,13 @@ export default {
*/
formatAnnotationsTooltipText(params) {
return {
- title: dateFormat(params.data?.tooltipData?.title, dateFormats.default),
+ title: formatDate(params.data?.tooltipData?.title, { timezone: this.timezone }),
content: params.data?.tooltipData?.content,
};
},
formatTooltipText(params) {
- this.tooltip.title = dateFormat(params.value, dateFormats.default);
+ this.tooltip.title = formatDate(params.value, { timezone: this.timezone });
+
this.tooltip.content = [];
params.seriesData.forEach(dataPoint => {
@@ -368,8 +383,11 @@ export default {
:thresholds="thresholds"
:width="width"
:height="height"
- :average-text="legendAverageText"
- :max-text="legendMaxText"
+ :legend-layout="legendLayout"
+ :legend-average-text="legendAverageText"
+ :legend-current-text="legendCurrentText"
+ :legend-max-text="legendMaxText"
+ :legend-min-text="legendMinText"
@created="onChartCreated"
@updated="onChartUpdated"
>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 2018c706b11..f54319d283e 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,73 +1,45 @@
<script>
-import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
-import {
- GlIcon,
- GlButton,
- GlDeprecatedButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownHeader,
- GlDropdownDivider,
- GlModal,
- GlLoadingIcon,
- GlSearchBoxByType,
- GlModalDirective,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import DashboardHeader from './dashboard_header.vue';
import DashboardPanel from './dashboard_panel.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys';
-import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
-import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility';
+import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import Icon from '~/vue_shared/components/icon.vue';
-import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
-import DashboardsDropdown from './dashboards_dropdown.vue';
import VariablesSection from './variables_section.vue';
+import LinksSection from './links_section.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import {
- getAddMetricTrackingOptions,
- timeRangeToUrl,
timeRangeFromUrl,
panelToUrl,
expandedPanelPayloadFromUrl,
convertVariablesForURL,
} from '../utils';
import { metricStates } from '../constants';
-import { defaultTimeRange, timeRanges } from '~/vue_shared/constants';
+import { defaultTimeRange } from '~/vue_shared/constants';
export default {
components: {
VueDraggable,
+ DashboardHeader,
DashboardPanel,
Icon,
GlIcon,
GlButton,
- GlDeprecatedButton,
- GlDropdown,
- GlLoadingIcon,
- GlDropdownItem,
- GlDropdownHeader,
- GlDropdownDivider,
- GlSearchBoxByType,
- GlModal,
- CustomMetricsFormFields,
-
- DateTimePicker,
GraphGroup,
EmptyState,
GroupEmptyState,
- DashboardsDropdown,
-
VariablesSection,
+ LinksSection,
},
directives: {
GlModal: GlModalDirective,
@@ -111,27 +83,10 @@ export default {
type: String,
required: true,
},
- projectPath: {
- type: String,
- required: true,
- },
- logsPath: {
- type: String,
- required: false,
- default: invalidUrl,
- },
defaultBranch: {
type: String,
- required: true,
- },
- metricsEndpoint: {
- type: String,
- required: true,
- },
- deploymentsEndpoint: {
- type: String,
required: false,
- default: null,
+ default: '',
},
emptyGettingStartedSvgPath: {
type: String,
@@ -153,10 +108,6 @@ export default {
type: String,
required: true,
},
- currentEnvironmentName: {
- type: String,
- required: true,
- },
customMetricsAvailable: {
type: Boolean,
required: false,
@@ -172,21 +123,6 @@ export default {
required: false,
default: invalidUrl,
},
- dashboardEndpoint: {
- type: String,
- required: false,
- default: invalidUrl,
- },
- dashboardsEndpoint: {
- type: String,
- required: false,
- default: invalidUrl,
- },
- currentDashboard: {
- type: String,
- required: false,
- default: '',
- },
smallEmptyState: {
type: Boolean,
required: false,
@@ -210,11 +146,9 @@ export default {
},
data() {
return {
- formIsValid: null,
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
- hasValidDates: true,
- timeRanges,
isRearrangingPanels: false,
+ originalDocumentTitle: document.title,
};
},
computed: {
@@ -222,36 +156,17 @@ export default {
'dashboard',
'emptyState',
'showEmptyState',
- 'useDashboardEndpoint',
- 'allDashboards',
- 'environmentsLoading',
'expandedPanel',
- 'promVariables',
- 'isUpdatingStarredValue',
- ]),
- ...mapGetters('monitoringDashboard', [
- 'selectedDashboard',
- 'getMetricStates',
- 'filteredEnvironments',
+ 'variables',
+ 'links',
+ 'currentDashboard',
]),
- showRearrangePanelsBtn() {
- return !this.showEmptyState && this.rearrangePanelsAvailable;
- },
- addingMetricsAvailable() {
- return (
- this.customMetricsAvailable &&
- !this.showEmptyState &&
- // Custom metrics only avaialble on system dashboards because
- // they are stored in the database. This can be improved. See:
- // https://gitlab.com/gitlab-org/gitlab/-/issues/28241
- this.selectedDashboard?.system_dashboard
- );
- },
- shouldShowEnvironmentsDropdownNoMatchedMsg() {
- return !this.environmentsLoading && this.filteredEnvironments.length === 0;
- },
+ ...mapGetters('monitoringDashboard', ['selectedDashboard', 'getMetricStates']),
shouldShowVariablesSection() {
- return Object.keys(this.promVariables).length > 0;
+ return Object.keys(this.variables).length > 0;
+ },
+ shouldShowLinksSection() {
+ return Object.keys(this.links).length > 0;
},
},
watch: {
@@ -273,24 +188,17 @@ export default {
handler({ group, panel }) {
const dashboardPath = this.currentDashboard || this.selectedDashboard?.path;
updateHistory({
- url: panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), group, panel),
+ url: panelToUrl(dashboardPath, convertVariablesForURL(this.variables), group, panel),
title: document.title,
});
},
deep: true,
},
+ selectedDashboard(dashboard) {
+ this.prependToDocumentTitle(dashboard?.display_name);
+ },
},
created() {
- this.setInitialState({
- metricsEndpoint: this.metricsEndpoint,
- deploymentsEndpoint: this.deploymentsEndpoint,
- dashboardEndpoint: this.dashboardEndpoint,
- dashboardsEndpoint: this.dashboardsEndpoint,
- currentDashboard: this.currentDashboard,
- projectPath: this.projectPath,
- logsPath: this.logsPath,
- currentEnvironmentName: this.currentEnvironmentName,
- });
window.addEventListener('keyup', this.onKeyup);
},
destroyed() {
@@ -308,14 +216,10 @@ export default {
...mapActions('monitoringDashboard', [
'setTimeRange',
'fetchData',
- 'fetchDashboardData',
'setGettingStartedEmptyState',
- 'setInitialState',
'setPanelGroupMetrics',
- 'filterEnvironments',
'setExpandedPanel',
'clearExpandedPanel',
- 'toggleStarredValue',
]),
updatePanels(key, panels) {
this.setPanelGroupMetrics({
@@ -329,37 +233,9 @@ export default {
key,
});
},
-
- onDateTimePickerInput(timeRange) {
- redirectTo(timeRangeToUrl(timeRange));
- },
- onDateTimePickerInvalid() {
- createFlash(
- s__(
- 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
- ),
- );
- // As a fallback, switch to default time range instead
- this.selectedTimeRange = defaultTimeRange;
- },
generatePanelUrl(groupKey, panel) {
const dashboardPath = this.currentDashboard || this.selectedDashboard?.path;
- return panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), groupKey, panel);
- },
- hideAddMetricModal() {
- this.$refs.addMetricModal.hide();
- },
- toggleRearrangingPanels() {
- this.isRearrangingPanels = !this.isRearrangingPanels;
- },
- setFormValidity(isValid) {
- this.formIsValid = isValid;
- },
- debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
- this.filterEnvironments(searchTerm);
- }, 500),
- submitCustomMetricsForm() {
- this.$refs.customMetricsForm.submit();
+ return panelToUrl(dashboardPath, convertVariablesForURL(this.variables), groupKey, panel);
},
/**
* Return a single empty state for a group.
@@ -387,25 +263,20 @@ export default {
// Collapse group if no data is available
return !this.getMetricStates(groupKey).includes(metricStates.OK);
},
- getAddMetricTrackingOptions,
-
- selectDashboard(dashboard) {
- const params = {
- dashboard: dashboard.path,
- };
- redirectTo(mergeUrlParams(params, window.location.href));
- },
-
- refreshDashboard() {
- this.fetchDashboardData();
+ prependToDocumentTitle(text) {
+ if (text) {
+ document.title = `${text} · ${this.originalDocumentTitle}`;
+ }
},
-
onTimeRangeZoom({ start, end }) {
updateHistory({
url: mergeUrlParams({ start, end }, window.location.href),
title: document.title,
});
this.selectedTimeRange = { start, end };
+ // keep the current dashboard time range
+ // in sync with the Vuex store
+ this.setTimeRange(this.selectedTimeRange);
},
onExpandPanel(group, panel) {
this.setExpandedPanel({ group, panel });
@@ -419,213 +290,45 @@ export default {
this.clearExpandedPanel();
}
},
- },
- addMetric: {
- title: s__('Metrics|Add metric'),
- modalId: 'add-metric',
+ onSetRearrangingPanels(isRearrangingPanels) {
+ this.isRearrangingPanels = isRearrangingPanels;
+ },
+ onDateTimePickerInvalid() {
+ createFlash(
+ s__(
+ 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
+ ),
+ );
+ // As a fallback, switch to default time range instead
+ this.selectedTimeRange = defaultTimeRange;
+ },
},
i18n: {
goBackLabel: s__('Metrics|Go back (Esc)'),
- starDashboard: s__('Metrics|Star dashboard'),
- unstarDashboard: s__('Metrics|Unstar dashboard'),
},
};
</script>
<template>
<div class="prometheus-graphs" data-qa-selector="prometheus_graphs">
- <div
+ <dashboard-header
v-if="showHeader"
ref="prometheusGraphsHeader"
class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light"
- >
- <div class="mb-2 pr-2 d-flex d-sm-block">
- <dashboards-dropdown
- id="monitor-dashboards-dropdown"
- data-qa-selector="dashboards_filter_dropdown"
- class="flex-grow-1"
- toggle-class="dropdown-menu-toggle"
- :default-branch="defaultBranch"
- @selectDashboard="selectDashboard($event)"
- />
- </div>
-
- <div class="mb-2 pr-2 d-flex d-sm-block">
- <gl-dropdown
- id="monitor-environments-dropdown"
- ref="monitorEnvironmentsDropdown"
- class="flex-grow-1"
- data-qa-selector="environments_dropdown"
- toggle-class="dropdown-menu-toggle"
- menu-class="monitor-environment-dropdown-menu"
- :text="currentEnvironmentName"
- >
- <div class="d-flex flex-column overflow-hidden">
- <gl-dropdown-header class="monitor-environment-dropdown-header text-center">
- {{ __('Environment') }}
- </gl-dropdown-header>
- <gl-dropdown-divider />
- <gl-search-box-by-type
- ref="monitorEnvironmentsDropdownSearch"
- class="m-2"
- @input="debouncedEnvironmentsSearch"
- />
- <gl-loading-icon
- v-if="environmentsLoading"
- ref="monitorEnvironmentsDropdownLoading"
- :inline="true"
- />
- <div v-else class="flex-fill overflow-auto">
- <gl-dropdown-item
- v-for="environment in filteredEnvironments"
- :key="environment.id"
- :active="environment.name === currentEnvironmentName"
- active-class="is-active"
- :href="environment.metrics_path"
- >{{ environment.name }}</gl-dropdown-item
- >
- </div>
- <div
- v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
- ref="monitorEnvironmentsDropdownMsg"
- class="text-secondary no-matches-message"
- >
- {{ __('No matching results') }}
- </div>
- </div>
- </gl-dropdown>
- </div>
-
- <div class="mb-2 pr-2 d-flex d-sm-block">
- <date-time-picker
- ref="dateTimePicker"
- class="flex-grow-1 show-last-dropdown"
- data-qa-selector="range_picker_dropdown"
- :value="selectedTimeRange"
- :options="timeRanges"
- @input="onDateTimePickerInput"
- @invalid="onDateTimePickerInvalid"
- />
- </div>
-
- <div class="mb-2 pr-2 d-flex d-sm-block">
- <gl-deprecated-button
- ref="refreshDashboardBtn"
- v-gl-tooltip
- class="flex-grow-1"
- variant="default"
- :title="s__('Metrics|Refresh dashboard')"
- @click="refreshDashboard"
- >
- <icon name="retry" />
- </gl-deprecated-button>
- </div>
-
- <div class="flex-grow-1"></div>
-
- <div class="d-sm-flex">
- <div v-if="selectedDashboard" class="mb-2 mr-2 d-flex">
- <!--
- wrapper for tooltip as button can be `disabled`
- https://bootstrap-vue.org/docs/components/tooltip#disabled-elements
- -->
- <div
- v-gl-tooltip
- class="flex-grow-1"
- :title="
- selectedDashboard.starred
- ? $options.i18n.unstarDashboard
- : $options.i18n.starDashboard
- "
- >
- <gl-deprecated-button
- ref="toggleStarBtn"
- class="w-100"
- :disabled="isUpdatingStarredValue"
- variant="default"
- @click="toggleStarredValue()"
- >
- <gl-icon :name="selectedDashboard.starred ? 'star' : 'star-o'" />
- </gl-deprecated-button>
- </div>
- </div>
-
- <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex">
- <gl-deprecated-button
- :pressed="isRearrangingPanels"
- variant="default"
- class="flex-grow-1 js-rearrange-button"
- @click="toggleRearrangingPanels"
- >
- {{ __('Arrange charts') }}
- </gl-deprecated-button>
- </div>
- <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block">
- <gl-deprecated-button
- ref="addMetricBtn"
- v-gl-modal="$options.addMetric.modalId"
- variant="outline-success"
- data-qa-selector="add_metric_button"
- class="flex-grow-1"
- >
- {{ $options.addMetric.title }}
- </gl-deprecated-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-deprecated-button @click="hideAddMetricModal">
- {{ __('Cancel') }}
- </gl-deprecated-button>
- <gl-deprecated-button
- ref="submitCustomMetricsFormBtn"
- v-track-event="getAddMetricTrackingOptions()"
- :disabled="!formIsValid"
- variant="success"
- @click="submitCustomMetricsForm"
- >
- {{ __('Save changes') }}
- </gl-deprecated-button>
- </div>
- </gl-modal>
- </div>
-
- <div
- v-if="selectedDashboard && selectedDashboard.can_edit"
- class="mb-2 mr-2 d-flex d-sm-block"
- >
- <gl-deprecated-button
- class="flex-grow-1 js-edit-link"
- :href="selectedDashboard.project_blob_path"
- data-qa-selector="edit_dashboard_button"
- >
- {{ __('Edit dashboard') }}
- </gl-deprecated-button>
- </div>
-
- <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block">
- <gl-deprecated-button
- class="flex-grow-1 js-external-dashboard-link"
- variant="primary"
- :href="externalDashboardUrl"
- target="_blank"
- rel="noopener noreferrer"
- >
- {{ __('View full dashboard') }} <icon name="external-link" />
- </gl-deprecated-button>
- </div>
- </div>
- </div>
+ :default-branch="defaultBranch"
+ :rearrange-panels-available="rearrangePanelsAvailable"
+ :custom-metrics-available="customMetricsAvailable"
+ :custom-metrics-path="customMetricsPath"
+ :validate-query-path="validateQueryPath"
+ :external-dashboard-url="externalDashboardUrl"
+ :has-metrics="hasMetrics"
+ :is-rearranging-panels="isRearrangingPanels"
+ :selected-time-range="selectedTimeRange"
+ @dateTimePickerInvalid="onDateTimePickerInvalid"
+ @setRearrangingPanels="onSetRearrangingPanels"
+ />
<variables-section v-if="shouldShowVariablesSection && !showEmptyState" />
+ <links-section v-if="shouldShowLinksSection && !showEmptyState" />
<div v-if="!showEmptyState">
<dashboard-panel
v-show="expandedPanel.panel"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
new file mode 100644
index 00000000000..16a21ae0d3c
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -0,0 +1,369 @@
+<script>
+import { debounce } from 'lodash';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import {
+ GlIcon,
+ GlDeprecatedButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownHeader,
+ GlDropdownDivider,
+ GlModal,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlModalDirective,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
+import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
+import invalidUrl from '~/lib/utils/invalid_url';
+import Icon from '~/vue_shared/components/icon.vue';
+import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
+
+import DashboardsDropdown from './dashboards_dropdown.vue';
+
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { getAddMetricTrackingOptions, timeRangeToUrl } from '../utils';
+import { timeRanges } from '~/vue_shared/constants';
+import { timezones } from '../format_date';
+
+export default {
+ components: {
+ Icon,
+ GlIcon,
+ GlDeprecatedButton,
+ GlDropdown,
+ GlLoadingIcon,
+ GlDropdownItem,
+ GlDropdownHeader,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlModal,
+ CustomMetricsFormFields,
+
+ DateTimePicker,
+ DashboardsDropdown,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
+ },
+ props: {
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ rearrangePanelsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ customMetricsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ customMetricsPath: {
+ type: String,
+ required: false,
+ default: invalidUrl,
+ },
+ validateQueryPath: {
+ type: String,
+ required: false,
+ default: invalidUrl,
+ },
+ externalDashboardUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasMetrics: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ isRearrangingPanels: {
+ type: Boolean,
+ required: true,
+ },
+ selectedTimeRange: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ formIsValid: null,
+ };
+ },
+ computed: {
+ ...mapState('monitoringDashboard', [
+ 'environmentsLoading',
+ 'currentEnvironmentName',
+ 'isUpdatingStarredValue',
+ 'showEmptyState',
+ 'dashboardTimezone',
+ ]),
+ ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']),
+ shouldShowEnvironmentsDropdownNoMatchedMsg() {
+ return !this.environmentsLoading && this.filteredEnvironments.length === 0;
+ },
+ addingMetricsAvailable() {
+ return (
+ this.customMetricsAvailable &&
+ !this.showEmptyState &&
+ // Custom metrics only avaialble on system dashboards because
+ // they are stored in the database. This can be improved. See:
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/28241
+ this.selectedDashboard?.system_dashboard
+ );
+ },
+ showRearrangePanelsBtn() {
+ return !this.showEmptyState && this.rearrangePanelsAvailable;
+ },
+ displayUtc() {
+ return this.dashboardTimezone === timezones.UTC;
+ },
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', [
+ 'filterEnvironments',
+ 'fetchDashboardData',
+ 'toggleStarredValue',
+ ]),
+ selectDashboard(dashboard) {
+ const params = {
+ dashboard: dashboard.path,
+ };
+ redirectTo(mergeUrlParams(params, window.location.href));
+ },
+ debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
+ this.filterEnvironments(searchTerm);
+ }, 500),
+ onDateTimePickerInput(timeRange) {
+ redirectTo(timeRangeToUrl(timeRange));
+ },
+ onDateTimePickerInvalid() {
+ this.$emit('dateTimePickerInvalid');
+ },
+ refreshDashboard() {
+ this.fetchDashboardData();
+ },
+
+ toggleRearrangingPanels() {
+ this.$emit('setRearrangingPanels', !this.isRearrangingPanels);
+ },
+ setFormValidity(isValid) {
+ this.formIsValid = isValid;
+ },
+ hideAddMetricModal() {
+ this.$refs.addMetricModal.hide();
+ },
+ getAddMetricTrackingOptions,
+ submitCustomMetricsForm() {
+ this.$refs.customMetricsForm.submit();
+ },
+ },
+ addMetric: {
+ title: s__('Metrics|Add metric'),
+ modalId: 'add-metric',
+ },
+ i18n: {
+ starDashboard: s__('Metrics|Star dashboard'),
+ unstarDashboard: s__('Metrics|Unstar dashboard'),
+ },
+ timeRanges,
+};
+</script>
+
+<template>
+ <div ref="prometheusGraphsHeader">
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <dashboards-dropdown
+ id="monitor-dashboards-dropdown"
+ data-qa-selector="dashboards_filter_dropdown"
+ class="flex-grow-1"
+ toggle-class="dropdown-menu-toggle"
+ :default-branch="defaultBranch"
+ @selectDashboard="selectDashboard"
+ />
+ </div>
+
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <gl-dropdown
+ id="monitor-environments-dropdown"
+ ref="monitorEnvironmentsDropdown"
+ class="flex-grow-1"
+ data-qa-selector="environments_dropdown"
+ toggle-class="dropdown-menu-toggle"
+ menu-class="monitor-environment-dropdown-menu"
+ :text="currentEnvironmentName"
+ >
+ <div class="d-flex flex-column overflow-hidden">
+ <gl-dropdown-header class="monitor-environment-dropdown-header text-center">
+ {{ __('Environment') }}
+ </gl-dropdown-header>
+ <gl-dropdown-divider />
+ <gl-search-box-by-type
+ ref="monitorEnvironmentsDropdownSearch"
+ class="m-2"
+ @input="debouncedEnvironmentsSearch"
+ />
+ <gl-loading-icon
+ v-if="environmentsLoading"
+ ref="monitorEnvironmentsDropdownLoading"
+ :inline="true"
+ />
+ <div v-else class="flex-fill overflow-auto">
+ <gl-dropdown-item
+ v-for="environment in filteredEnvironments"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ :href="environment.metrics_path"
+ >{{ environment.name }}</gl-dropdown-item
+ >
+ </div>
+ <div
+ v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
+ ref="monitorEnvironmentsDropdownMsg"
+ class="text-secondary no-matches-message"
+ >
+ {{ __('No matching results') }}
+ </div>
+ </div>
+ </gl-dropdown>
+ </div>
+
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <date-time-picker
+ ref="dateTimePicker"
+ class="flex-grow-1 show-last-dropdown"
+ data-qa-selector="range_picker_dropdown"
+ :value="selectedTimeRange"
+ :options="$options.timeRanges"
+ :utc="displayUtc"
+ @input="onDateTimePickerInput"
+ @invalid="onDateTimePickerInvalid"
+ />
+ </div>
+
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ ref="refreshDashboardBtn"
+ v-gl-tooltip
+ class="flex-grow-1"
+ variant="default"
+ :title="s__('Metrics|Refresh dashboard')"
+ @click="refreshDashboard"
+ >
+ <icon name="retry" />
+ </gl-deprecated-button>
+ </div>
+
+ <div class="flex-grow-1"></div>
+
+ <div class="d-sm-flex">
+ <div v-if="selectedDashboard" class="mb-2 mr-2 d-flex">
+ <!--
+ wrapper for tooltip as button can be `disabled`
+ https://bootstrap-vue.org/docs/components/tooltip#disabled-elements
+ -->
+ <div
+ v-gl-tooltip
+ class="flex-grow-1"
+ :title="
+ selectedDashboard.starred ? $options.i18n.unstarDashboard : $options.i18n.starDashboard
+ "
+ >
+ <gl-deprecated-button
+ ref="toggleStarBtn"
+ class="w-100"
+ :disabled="isUpdatingStarredValue"
+ variant="default"
+ @click="toggleStarredValue()"
+ >
+ <gl-icon :name="selectedDashboard.starred ? 'star' : 'star-o'" />
+ </gl-deprecated-button>
+ </div>
+ </div>
+
+ <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex">
+ <gl-deprecated-button
+ :pressed="isRearrangingPanels"
+ variant="default"
+ class="flex-grow-1 js-rearrange-button"
+ @click="toggleRearrangingPanels"
+ >
+ {{ __('Arrange charts') }}
+ </gl-deprecated-button>
+ </div>
+ <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ ref="addMetricBtn"
+ v-gl-modal="$options.addMetric.modalId"
+ variant="outline-success"
+ data-qa-selector="add_metric_button"
+ class="flex-grow-1"
+ >
+ {{ $options.addMetric.title }}
+ </gl-deprecated-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-deprecated-button @click="hideAddMetricModal">
+ {{ __('Cancel') }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ ref="submitCustomMetricsFormBtn"
+ v-track-event="getAddMetricTrackingOptions()"
+ :disabled="!formIsValid"
+ variant="success"
+ @click="submitCustomMetricsForm"
+ >
+ {{ __('Save changes') }}
+ </gl-deprecated-button>
+ </div>
+ </gl-modal>
+ </div>
+
+ <div
+ v-if="selectedDashboard && selectedDashboard.can_edit"
+ class="mb-2 mr-2 d-flex d-sm-block"
+ >
+ <gl-deprecated-button
+ class="flex-grow-1 js-edit-link"
+ :href="selectedDashboard.project_blob_path"
+ data-qa-selector="edit_dashboard_button"
+ >
+ {{ __('Edit dashboard') }}
+ </gl-deprecated-button>
+ </div>
+
+ <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ class="flex-grow-1 js-external-dashboard-link"
+ variant="primary"
+ :href="externalDashboardUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('View full dashboard') }} <icon name="external-link" />
+ </gl-deprecated-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 48825fda5c8..9545a211bbd 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -6,8 +6,9 @@ import {
GlResizeObserverDirective,
GlIcon,
GlLoadingIcon,
- GlDropdown,
- GlDropdownItem,
+ GlNewDropdown as GlDropdown,
+ GlNewDropdownItem as GlDropdownItem,
+ GlNewDropdownDivider as GlDropdownDivider,
GlModal,
GlModalDirective,
GlTooltip,
@@ -28,6 +29,7 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import AlertWidget from './alert_widget.vue';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
+import { isSafeURL } from '~/lib/utils/url_utility';
const events = {
timeRangeZoom: 'timerangezoom',
@@ -43,6 +45,7 @@ export default {
GlTooltip,
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
GlModal,
},
directives: {
@@ -115,9 +118,15 @@ export default {
timeRange(state) {
return state[this.namespace].timeRange;
},
+ dashboardTimezone(state) {
+ return state[this.namespace].dashboardTimezone;
+ },
metricsSavedToDb(state, getters) {
return getters[`${this.namespace}/metricsSavedToDb`];
},
+ selectedDashboard(state, getters) {
+ return getters[`${this.namespace}/selectedDashboard`];
+ },
}),
title() {
return this.graphData?.title || '';
@@ -266,6 +275,9 @@ export default {
this.$delete(this.allAlerts, alertPath);
}
},
+ safeUrl(url) {
+ return isSafeURL(url) ? url : '#';
+ },
},
panelTypes,
};
@@ -276,7 +288,8 @@ export default {
<slot name="topLeft"></slot>
<h5
ref="graphTitle"
- class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate append-right-8"
+ class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3"
+ tabindex="0"
>
{{ title }}
</h5>
@@ -304,14 +317,13 @@ export default {
<div class="d-flex align-items-center">
<gl-dropdown
v-gl-tooltip
- toggle-class="btn btn-transparent border-0"
+ toggle-class="shadow-none border-0"
data-qa-selector="prometheus_widgets_dropdown"
right
- no-caret
:title="__('More actions')"
>
<template slot="button-content">
- <gl-icon name="ellipsis_v" class="text-secondary" />
+ <gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" />
</template>
<gl-dropdown-item
v-if="expandBtnAvailable"
@@ -362,6 +374,23 @@ export default {
>
{{ __('Alerts') }}
</gl-dropdown-item>
+
+ <template v-if="graphData.links.length">
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ v-for="(link, index) in graphData.links"
+ :key="index"
+ :href="safeUrl(link.url)"
+ class="text-break"
+ >{{ link.title }}</gl-dropdown-item
+ >
+ </template>
+ <template v-if="selectedDashboard && selectedDashboard.can_edit">
+ <gl-dropdown-divider />
+ <gl-dropdown-item ref="manageLinksItem" :href="selectedDashboard.project_blob_path">{{
+ s__('Metrics|Manage chart links')
+ }}</gl-dropdown-item>
+ </template>
</gl-dropdown>
</div>
</div>
@@ -372,6 +401,7 @@ export default {
:is="basicChartComponent"
v-else-if="basicChartComponent"
:graph-data="graphData"
+ :timezone="dashboardTimezone"
v-bind="$attrs"
v-on="$listeners"
/>
@@ -385,6 +415,7 @@ export default {
:project-path="projectPath"
:thresholds="getGraphAlertValues(graphData.metrics)"
:group-id="groupId"
+ :timezone="dashboardTimezone"
v-bind="$attrs"
v-on="$listeners"
@datazoom="onDatazoom"
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 5a7981b6534..08fcfa3bc56 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -52,10 +52,17 @@ export default {
</script>
<template>
- <div v-if="showPanels" ref="graph-group" class="card prometheus-panel">
+ <div v-if="showPanels" ref="graph-group" class="card prometheus-panel" tabindex="0">
<div class="card-header d-flex align-items-center">
<h4 class="flex-grow-1">{{ name }}</h4>
- <a role="button" class="js-graph-group-toggle" @click="collapse">
+ <a
+ data-testid="group-toggle-button"
+ role="button"
+ class="js-graph-group-toggle gl-text-gray-900"
+ tabindex="0"
+ @click="collapse"
+ @keyup.enter="collapse"
+ >
<icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" />
</a>
</div>
diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue
new file mode 100644
index 00000000000..98b07d17694
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/links_section.vue
@@ -0,0 +1,32 @@
+<script>
+import { mapGetters } from 'vuex';
+import { GlIcon, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ computed: {
+ ...mapGetters('monitoringDashboard', { links: 'linksWithMetadata' }),
+ },
+};
+</script>
+<template>
+ <div
+ ref="linksSection"
+ class="gl-display-sm-flex gl-flex-sm-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section"
+ >
+ <div
+ v-for="(link, key) in links"
+ :key="key"
+ class="gl-mb-1 gl-mr-5 gl-display-flex gl-display-sm-block gl-hover-text-blue-600-children gl-word-break-all"
+ >
+ <gl-link :href="link.url" class="gl-text-gray-900 gl-text-decoration-none!"
+ ><gl-icon name="link" class="gl-text-gray-700 gl-vertical-align-text-bottom gl-mr-2" />{{
+ link.title
+ }}
+ </gl-link>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue
index e054c9d8e26..3d1d111d5b3 100644
--- a/app/assets/javascripts/monitoring/components/variables_section.vue
+++ b/app/assets/javascripts/monitoring/components/variables_section.vue
@@ -2,7 +2,7 @@
import { mapState, mapActions } from 'vuex';
import CustomVariable from './variables/custom_variable.vue';
import TextVariable from './variables/text_variable.vue';
-import { setPromCustomVariablesFromUrl } from '../utils';
+import { setCustomVariablesFromUrl } from '../utils';
export default {
components: {
@@ -10,23 +10,21 @@ export default {
TextVariable,
},
computed: {
- ...mapState('monitoringDashboard', ['promVariables']),
+ ...mapState('monitoringDashboard', ['variables']),
},
methods: {
- ...mapActions('monitoringDashboard', ['fetchDashboardData', 'updateVariableValues']),
+ ...mapActions('monitoringDashboard', ['updateVariablesAndFetchData']),
refreshDashboard(variable, value) {
- if (this.promVariables[variable].value !== value) {
+ if (this.variables[variable].value !== value) {
const changedVariable = { key: variable, value };
// update the Vuex store
- this.updateVariableValues(changedVariable);
+ this.updateVariablesAndFetchData(changedVariable);
// the below calls can ideally be moved out of the
// component and into the actions and let the
// mutation respond directly.
// This can be further investigate in
// https://gitlab.com/gitlab-org/gitlab/-/issues/217713
- setPromCustomVariablesFromUrl(this.promVariables);
- // fetch data
- this.fetchDashboardData();
+ setCustomVariablesFromUrl(this.variables);
}
},
variableComponent(type) {
@@ -41,7 +39,7 @@ export default {
</script>
<template>
<div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section">
- <div v-for="(variable, key) in promVariables" :key="key" class="mb-1 pr-2 d-flex d-sm-block">
+ <div v-for="(variable, key) in variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block">
<component
:is="variableComponent(variable.type)"
class="mb-0 flex-grow-1"
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 0c2eafeed54..50330046c99 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -127,9 +127,25 @@ export const lineWidths = {
default: 2,
};
-export const dateFormats = {
- timeOfDay: 'h:MM TT',
- default: 'dd mmm yyyy, h:MMTT',
+/**
+ * User-defined links can be passed in dashboard yml file.
+ * These are the supported type of links.
+ */
+export const linkTypes = {
+ GRAFANA: 'grafana',
+};
+
+/**
+ * These are the supported values for the GitLab-UI
+ * chart legend layout.
+ *
+ * Currently defined in
+ * https://gitlab.com/gitlab-org/gitlab-ui/-/blob/master/src/utils/charts/constants.js
+ *
+ */
+export const legendLayoutTypes = {
+ inline: 'inline',
+ table: 'table',
};
/**
@@ -140,7 +156,6 @@ export const dateFormats = {
* Currently used in `receiveMetricsDashboardSuccess` action.
*/
export const endpointKeys = [
- 'metricsEndpoint',
'deploymentsEndpoint',
'dashboardEndpoint',
'dashboardsEndpoint',
diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js
new file mode 100644
index 00000000000..a50d441a09e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/format_date.js
@@ -0,0 +1,39 @@
+import dateFormat from 'dateformat';
+
+export const timezones = {
+ /**
+ * Renders a date with a local timezone
+ */
+ LOCAL: 'LOCAL',
+
+ /**
+ * Renders at date with UTC
+ */
+ UTC: 'UTC',
+};
+
+export const formats = {
+ shortTime: 'h:MM TT',
+ default: 'dd mmm yyyy, h:MMTT (Z)',
+};
+
+/**
+ * Formats a date for a metric dashboard or chart.
+ *
+ * Convenience wrapper of dateFormat with default formats
+ * and settings.
+ *
+ * dateFormat has some limitations and we could use `toLocaleString` instead
+ * See: https://gitlab.com/gitlab-org/gitlab/-/issues/219246
+ *
+ * @param {Date|String|Number} date
+ * @param {Object} options - Formatting options
+ * @param {string} options.format - Format or mask from `formats`.
+ * @param {string} options.timezone - Timezone abbreviation.
+ * Accepts "LOCAL" for the client local timezone.
+ */
+export const formatDate = (date, options = {}) => {
+ const { format = formats.default, timezone = timezones.LOCAL } = options;
+ const useUTC = timezone === timezones.UTC;
+ return dateFormat(date, format, useUTC);
+};
diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js
new file mode 100644
index 00000000000..08543fa6eb3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/monitoring_app.js
@@ -0,0 +1,59 @@
+import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { getParameterValues } from '~/lib/utils/url_utility';
+import { createStore } from './stores';
+import createRouter from './router';
+
+Vue.use(GlToast);
+
+export default (props = {}) => {
+ const el = document.getElementById('prometheus-graphs');
+
+ if (el && el.dataset) {
+ const [currentDashboard] = getParameterValues('dashboard');
+
+ const {
+ deploymentsEndpoint,
+ dashboardEndpoint,
+ dashboardsEndpoint,
+ projectPath,
+ logsPath,
+ currentEnvironmentName,
+ dashboardTimezone,
+ metricsDashboardBasePath,
+ ...dataProps
+ } = el.dataset;
+
+ const store = createStore({
+ currentDashboard,
+ deploymentsEndpoint,
+ dashboardEndpoint,
+ dashboardsEndpoint,
+ dashboardTimezone,
+ projectPath,
+ logsPath,
+ currentEnvironmentName,
+ });
+
+ // HTML attributes are always strings, parse other types.
+ dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics);
+ dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable);
+ dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable);
+
+ const router = createRouter(metricsDashboardBasePath);
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ store,
+ router,
+ data() {
+ return {
+ dashboardProps: { ...dataProps, ...props },
+ };
+ },
+ template: `<router-view :dashboardProps="dashboardProps"/>`,
+ });
+ }
+};
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
deleted file mode 100644
index 2bbf9ef9d78..00000000000
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import { GlToast } from '@gitlab/ui';
-import Dashboard from '~/monitoring/components/dashboard.vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterValues } from '~/lib/utils/url_utility';
-import store from './stores';
-
-Vue.use(GlToast);
-
-export default (props = {}) => {
- const el = document.getElementById('prometheus-graphs');
-
- if (el && el.dataset) {
- const [currentDashboard] = getParameterValues('dashboard');
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- store,
- render(createElement) {
- return createElement(Dashboard, {
- props: {
- ...el.dataset,
- currentDashboard,
- hasMetrics: parseBoolean(el.dataset.hasMetrics),
- ...props,
- },
- });
- },
- });
- }
-};
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js
deleted file mode 100644
index afe5ee0938d..00000000000
--- a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { parseBoolean } from '~/lib/utils/common_utils';
-import initCeBundle from '~/monitoring/monitoring_bundle';
-
-export default () => {
- const el = document.getElementById('prometheus-graphs');
-
- if (el && el.dataset) {
- initCeBundle({
- customMetricsAvailable: parseBoolean(el.dataset.customMetricsAvailable),
- prometheusAlertsAvailable: parseBoolean(el.dataset.prometheusAlertsAvailable),
- });
- }
-};
diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
new file mode 100644
index 00000000000..519a20d7be3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
@@ -0,0 +1,18 @@
+<script>
+import Dashboard from '../components/dashboard.vue';
+
+export default {
+ components: {
+ Dashboard,
+ },
+ props: {
+ dashboardProps: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <dashboard v-bind="{ ...dashboardProps }" />
+</template>
diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js
new file mode 100644
index 00000000000..acfcd03f928
--- /dev/null
+++ b/app/assets/javascripts/monitoring/router/constants.js
@@ -0,0 +1,3 @@
+export const BASE_DASHBOARD_PAGE = 'dashboard';
+
+export default {};
diff --git a/app/assets/javascripts/monitoring/router/index.js b/app/assets/javascripts/monitoring/router/index.js
new file mode 100644
index 00000000000..12692612bbc
--- /dev/null
+++ b/app/assets/javascripts/monitoring/router/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import routes from './routes';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base) {
+ const router = new VueRouter({
+ base,
+ mode: 'history',
+ routes,
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js
new file mode 100644
index 00000000000..1e0cc1715a7
--- /dev/null
+++ b/app/assets/javascripts/monitoring/router/routes.js
@@ -0,0 +1,18 @@
+import DashboardPage from '../pages/dashboard_page.vue';
+
+import { BASE_DASHBOARD_PAGE } from './constants';
+
+/**
+ * Because the cluster health page uses the dashboard
+ * app instead the of the dashboard component, hitting
+ * `/` route is not possible. Hence using `*` until the
+ * health page is refactored.
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/221096
+ */
+export default [
+ {
+ name: BASE_DASHBOARD_PAGE,
+ path: '*',
+ component: DashboardPage,
+ },
+];
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 9e3edfb495d..3a9cccec438 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -3,8 +3,6 @@ import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { parseTemplatingVariables } from './variable_mapping';
-import { mergeURLVariables } from '../utils';
import {
gqClient,
parseEnvironmentsResponse,
@@ -161,7 +159,6 @@ export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response
commit(types.SET_ALL_DASHBOARDS, all_dashboards);
commit(types.RECEIVE_METRICS_DASHBOARD_SUCCESS, dashboard);
- commit(types.SET_VARIABLES, mergeURLVariables(parseTemplatingVariables(dashboard.templating)));
commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data));
return dispatch('fetchDashboardData');
@@ -223,7 +220,7 @@ export const fetchPrometheusMetric = (
queryParams.step = metric.step;
}
- if (Object.keys(state.promVariables).length > 0) {
+ if (Object.keys(state.variables).length > 0) {
queryParams = {
...queryParams,
...getters.getCustomVariablesParams,
@@ -317,8 +314,7 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => {
export const fetchAnnotations = ({ state, dispatch }) => {
const { start } = convertToFixedRange(state.timeRange);
- const dashboardPath =
- state.currentDashboard === '' ? DEFAULT_DASHBOARD_PATH : state.currentDashboard;
+ const dashboardPath = state.currentDashboard || DEFAULT_DASHBOARD_PATH;
return gqClient
.mutate({
mutation: getAnnotations,
@@ -373,7 +369,7 @@ export const toggleStarredValue = ({ commit, state, getters }) => {
method,
})
.then(() => {
- commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, newStarredValue);
+ commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, { selectedDashboard, newStarredValue });
})
.catch(() => {
commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE);
@@ -419,8 +415,10 @@ export const duplicateSystemDashboard = ({ state }, payload) => {
// Variables manipulation
-export const updateVariableValues = ({ commit }, updatedVariable) => {
- commit(types.UPDATE_VARIABLE_VALUES, updatedVariable);
+export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => {
+ commit(types.UPDATE_VARIABLES, updatedVariable);
+
+ return dispatch('fetchDashboardData');
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
index f309addee6b..b7681012472 100644
--- a/app/assets/javascripts/monitoring/stores/getters.js
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -1,5 +1,5 @@
import { NOT_IN_DB_PREFIX } from '../constants';
-import { addPrefixToCustomVariableParams } from './utils';
+import { addPrefixToCustomVariableParams, addDashboardMetaDataToLink } from './utils';
const metricsIdsInPanel = panel =>
panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
@@ -113,6 +113,22 @@ export const filteredEnvironments = state =>
);
/**
+ * User-defined links from the yml file can have other
+ * dashboard-related metadata baked into it. This method
+ * returns modified links which will get rendered in the
+ * metrics dashboard
+ *
+ * @param {Object} state
+ * @returns {Array} modified array of links
+ */
+export const linksWithMetadata = state => {
+ const metadata = {
+ timeRange: state.timeRange,
+ };
+ return state.links?.map(addDashboardMetaDataToLink(metadata));
+};
+
+/**
* Maps an variables object to an array along with stripping
* the variable prefix.
*
@@ -133,8 +149,8 @@ export const filteredEnvironments = state =>
*/
export const getCustomVariablesParams = state =>
- Object.keys(state.promVariables).reduce((acc, variable) => {
- acc[addPrefixToCustomVariableParams(variable)] = state.promVariables[variable]?.value;
+ Object.keys(state.variables).reduce((acc, variable) => {
+ acc[addPrefixToCustomVariableParams(variable)] = state.variables[variable]?.value;
return acc;
}, {});
diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js
index f08a6402aa6..213a8508aa2 100644
--- a/app/assets/javascripts/monitoring/stores/index.js
+++ b/app/assets/javascripts/monitoring/stores/index.js
@@ -15,11 +15,15 @@ export const monitoringDashboard = {
state,
};
-export const createStore = () =>
+export const createStore = (initState = {}) =>
new Vuex.Store({
modules: {
- monitoringDashboard,
+ monitoringDashboard: {
+ ...monitoringDashboard,
+ state: {
+ ...state(),
+ ...initState,
+ },
+ },
},
});
-
-export default createStore();
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index d60334609fd..4593461776b 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -3,7 +3,7 @@ export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD';
export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS';
export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE';
export const SET_VARIABLES = 'SET_VARIABLES';
-export const UPDATE_VARIABLE_VALUES = 'UPDATE_VARIABLE_VALUES';
+export const UPDATE_VARIABLES = 'UPDATE_VARIABLES';
export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING';
export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index f41cf3fc477..2d63fdd6e34 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
import { pick } from 'lodash';
import * as types from './mutation_types';
-import { selectedDashboard } from './getters';
import { mapToDashboardViewModel, normalizeQueryResult } from './utils';
import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
import { endpointKeys, initialStateKeys, metricStates } from '../constants';
@@ -61,8 +60,14 @@ export default {
state.emptyState = 'loading';
state.showEmptyState = true;
},
- [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboard) {
- state.dashboard = mapToDashboardViewModel(dashboard);
+ [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboardYML) {
+ const { dashboard, panelGroups, variables, links } = mapToDashboardViewModel(dashboardYML);
+ state.dashboard = {
+ dashboard,
+ panelGroups,
+ };
+ state.variables = variables;
+ state.links = links;
if (!state.dashboard.panelGroups.length) {
state.emptyState = 'noData';
@@ -76,15 +81,14 @@ export default {
[types.REQUEST_DASHBOARD_STARRING](state) {
state.isUpdatingStarredValue = true;
},
- [types.RECEIVE_DASHBOARD_STARRING_SUCCESS](state, newStarredValue) {
- const dashboard = selectedDashboard(state);
- const index = state.allDashboards.findIndex(d => d === dashboard);
+ [types.RECEIVE_DASHBOARD_STARRING_SUCCESS](state, { selectedDashboard, newStarredValue }) {
+ const index = state.allDashboards.findIndex(d => d === selectedDashboard);
state.isUpdatingStarredValue = false;
// Trigger state updates in the reactivity system for this change
// https://vuejs.org/v2/guide/reactivity.html#For-Arrays
- Vue.set(state.allDashboards, index, { ...dashboard, starred: newStarredValue });
+ Vue.set(state.allDashboards, index, { ...selectedDashboard, starred: newStarredValue });
},
[types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) {
state.isUpdatingStarredValue = false;
@@ -189,11 +193,11 @@ export default {
state.expandedPanel.panel = panel;
},
[types.SET_VARIABLES](state, variables) {
- state.promVariables = variables;
+ state.variables = variables;
},
- [types.UPDATE_VARIABLE_VALUES](state, updatedVariable) {
- Object.assign(state.promVariables[updatedVariable.key], {
- ...state.promVariables[updatedVariable.key],
+ [types.UPDATE_VARIABLES](state, updatedVariable) {
+ Object.assign(state.variables[updatedVariable.key], {
+ ...state.variables[updatedVariable.key],
value: updatedVariable.value,
});
},
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 9ae1da93e5f..8000f27c0d5 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -1,10 +1,11 @@
import invalidUrl from '~/lib/utils/invalid_url';
+import { timezones } from '../format_date';
export default () => ({
// API endpoints
- metricsEndpoint: null,
deploymentsEndpoint: null,
dashboardEndpoint: invalidUrl,
+ dashboardsEndpoint: invalidUrl,
// Dashboard request parameters
timeRange: null,
@@ -34,14 +35,24 @@ export default () => ({
panel: null,
},
allDashboards: [],
- promVariables: {},
-
+ /**
+ * User-defined custom variables are passed
+ * via the dashboard yml file.
+ */
+ variables: {},
+ /**
+ * User-defined custom links are passed
+ * via the dashboard yml file.
+ */
+ links: [],
// Other project data
+ dashboardTimezone: timezones.LOCAL,
annotations: [],
deploymentData: [],
environments: [],
environmentsSearchTerm: '',
environmentsLoading: false,
+ currentEnvironmentName: null,
// GitLab paths to other pages
projectPath: null,
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index b6817e7279a..058fab5f4fc 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -2,7 +2,11 @@ import { slugify } from '~/lib/utils/text_utility';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { NOT_IN_DB_PREFIX } from '../constants';
+import { parseTemplatingVariables } from './variable_mapping';
+import { NOT_IN_DB_PREFIX, linkTypes } from '../constants';
+import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants';
+import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range';
+import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility';
export const gqClient = createGqClient(
{},
@@ -138,6 +142,24 @@ const mapYAxisToViewModel = ({
};
/**
+ * Maps a link to its view model, expects an url and
+ * (optionally) a title.
+ *
+ * Unsafe URLs are ignored.
+ *
+ * @param {Object} Link
+ * @returns {Object} Link object with a `title`, `url` and `type`
+ *
+ */
+const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => {
+ return {
+ title: title || String(url),
+ type,
+ url: url && isSafeURL(url) ? String(url) : '#',
+ };
+};
+
+/**
* Maps a metrics panel to its view model
*
* @param {Object} panel - Metrics panel
@@ -152,6 +174,7 @@ const mapPanelToViewModel = ({
y_label,
y_axis = {},
metrics = [],
+ links = [],
max_value,
}) => {
// Both `x_axis.name` and `x_label` are supported for now
@@ -171,7 +194,8 @@ const mapPanelToViewModel = ({
yAxis,
xAxis,
maxValue: max_value,
- metrics: mapToMetricsViewModel(metrics, yAxis.name),
+ links: links.map(mapLinksToViewModel),
+ metrics: mapToMetricsViewModel(metrics),
};
};
@@ -190,6 +214,66 @@ const mapToPanelGroupViewModel = ({ group = '', panels = [] }, i) => {
};
/**
+ * Convert dashboard time range to Grafana
+ * dashboards time range.
+ *
+ * @param {Object} timeRange
+ * @returns {Object}
+ */
+export const convertToGrafanaTimeRange = timeRange => {
+ const timeRangeType = getRangeType(timeRange);
+ if (timeRangeType === DATETIME_RANGE_TYPES.fixed) {
+ return {
+ from: new Date(timeRange.start).getTime(),
+ to: new Date(timeRange.end).getTime(),
+ };
+ } else if (timeRangeType === DATETIME_RANGE_TYPES.rolling) {
+ const { seconds } = timeRange.duration;
+ return {
+ from: `now-${seconds}s`,
+ to: 'now',
+ };
+ }
+ // fallback to returning the time range as is
+ return timeRange;
+};
+
+/**
+ * Convert dashboard time ranges to other supported
+ * link formats.
+ *
+ * @param {Object} timeRange metrics dashboard time range
+ * @param {String} type type of link
+ * @returns {String}
+ */
+export const convertTimeRanges = (timeRange, type) => {
+ if (type === linkTypes.GRAFANA) {
+ return convertToGrafanaTimeRange(timeRange);
+ }
+ return timeRangeToParams(timeRange);
+};
+
+/**
+ * Adds dashboard-related metadata to the user-defined links.
+ *
+ * As of %13.1, metadata only includes timeRange but in the
+ * future more info will be added to the links.
+ *
+ * @param {Object} metadata
+ * @returns {Function}
+ */
+export const addDashboardMetaDataToLink = metadata => link => {
+ let modifiedLink = { ...link };
+ if (metadata.timeRange) {
+ modifiedLink = {
+ ...modifiedLink,
+ url: mergeUrlParams(convertTimeRanges(metadata.timeRange, link.type), link.url),
+ };
+ }
+ return modifiedLink;
+};
+
+/**
* Maps a dashboard json object to its view model
*
* @param {Object} dashboard - Dashboard object
@@ -197,13 +281,33 @@ const mapToPanelGroupViewModel = ({ group = '', panels = [] }, i) => {
* @param {Array} dashboard.panel_groups - Panel groups array
* @returns {Object}
*/
-export const mapToDashboardViewModel = ({ dashboard = '', panel_groups = [] }) => {
+export const mapToDashboardViewModel = ({
+ dashboard = '',
+ templating = {},
+ links = [],
+ panel_groups = [],
+}) => {
return {
dashboard,
+ variables: parseTemplatingVariables(templating),
+ links: links.map(mapLinksToViewModel),
panelGroups: panel_groups.map(mapToPanelGroupViewModel),
};
};
+/**
+ * Processes a single Range vector, part of the result
+ * of type `matrix` in the form:
+ *
+ * {
+ * "metric": { "<label_name>": "<label_value>", ... },
+ * "values": [ [ <unix_time>, "<sample_value>" ], ... ]
+ * },
+ *
+ * See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors
+ *
+ * @param {*} timeSeries
+ */
export const normalizeQueryResult = timeSeries => {
let normalizedResult = {};
diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js
index bfb469da19e..66b9899f673 100644
--- a/app/assets/javascripts/monitoring/stores/variable_mapping.js
+++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js
@@ -47,7 +47,7 @@ const textAdvancedVariableParser = advTextVar => ({
*/
const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({
default: defaultOpt,
- text,
+ text: text || value,
value,
});
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 1f028ffbcad..95d544bd6d4 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -151,7 +151,7 @@ export const removePrefixFromLabel = label =>
/**
* Convert parsed template variables to an object
- * with just keys and values. Prepare the promVariables
+ * with just keys and values. Prepare the variables
* to be added to the URL. Keys of the object will
* have a prefix so that these params can be
* differentiated from other URL params.
@@ -183,15 +183,15 @@ export const getPromCustomVariablesFromUrl = (search = window.location.search) =
};
/**
- * Update the URL with promVariables. This usually get triggered when
+ * Update the URL with variables. This usually get triggered when
* the user interacts with the dynamic input elements in the monitoring
* dashboard header.
*
- * @param {Object} promVariables user defined variables
+ * @param {Object} variables user defined variables
*/
-export const setPromCustomVariablesFromUrl = promVariables => {
+export const setCustomVariablesFromUrl = variables => {
// prep the variables to append to URL
- const parsedVariables = convertVariablesForURL(promVariables);
+ const parsedVariables = convertVariablesForURL(variables);
// update the URL
updateHistory({
url: mergeUrlParams(parsedVariables, window.location.href),
@@ -262,7 +262,7 @@ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.
* If no group/panel is set, the dashboard URL is returned.
*
* @param {?String} dashboard - Dashboard path, used as identifier for a dashboard
- * @param {?Object} promVariables - Custom variables that came from the URL
+ * @param {?Object} variables - Custom variables that came from the URL
* @param {?String} group - Group Identifier
* @param {?Object} panel - Panel object from the dashboard
* @param {?String} url - Base URL including current search params
@@ -270,14 +270,14 @@ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.
*/
export const panelToUrl = (
dashboard = null,
- promVariables,
+ variables,
group,
panel,
url = window.location.href,
) => {
const params = {
dashboard,
- ...promVariables,
+ ...variables,
};
if (group && panel) {
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index c1edf7be870..fb6ef0249bb 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import store from 'ee_else_ce/mr_notes/stores';
+import store from '~/mr_notes/stores';
import initNotesApp from './init_notes';
import initDiffsApp from '../diffs';
import discussionCounter from '../notes/components/discussion_counter.vue';
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index 2580f8e86b1..fcde9bf7849 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
-import store from 'ee_else_ce/mr_notes/stores';
+import store from '~/mr_notes/stores';
import notesApp from '../notes/components/notes_app.vue';
import discussionKeyboardNavigator from '../notes/components/discussion_keyboard_navigator.vue';
import initWidget from '../vue_merge_request_widget';
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
index 8fbd0291a7d..8492b8d0aff 100644
--- a/app/assets/javascripts/mr_notes/stores/index.js
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments';
import notesModule from '~/notes/stores/modules';
import diffsModule from '~/diffs/store/modules';
import mrPageModule from './modules';
@@ -12,6 +13,7 @@ export const createStore = () =>
page: mrPageModule(),
notes: notesModule(),
diffs: diffsModule(),
+ batchComments: batchCommentsModule(),
},
});
diff --git a/app/assets/javascripts/namespace_storage_limit_alert.js b/app/assets/javascripts/namespace_storage_limit_alert.js
new file mode 100644
index 00000000000..34ad93c127d
--- /dev/null
+++ b/app/assets/javascripts/namespace_storage_limit_alert.js
@@ -0,0 +1,20 @@
+import Cookies from 'js-cookie';
+
+const handleOnDismiss = ({ currentTarget }) => {
+ const {
+ dataset: { id, level },
+ } = currentTarget;
+
+ Cookies.set(`hide_storage_limit_alert_${id}_${level}`, true, { expires: 365 });
+
+ const notification = document.querySelector('.js-namespace-storage-alert');
+ notification.parentNode.removeChild(notification);
+};
+
+export default () => {
+ const alert = document.querySelector('.js-namespace-storage-alert-dismiss');
+
+ if (alert) {
+ alert.addEventListener('click', handleOnDismiss);
+ }
+};
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index a070cf8866a..16dcde46262 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -29,6 +29,7 @@ export default {
name: 'CommentForm',
components: {
issueWarning,
+ epicWarning: () => import('ee_component/vue_shared/components/epic/epic_warning.vue'),
noteSignedOutWidget,
discussionLockedWidget,
markdownField,
@@ -60,6 +61,7 @@ export default {
'getCurrentUserLastNote',
'getUserData',
'getNoteableData',
+ 'getNoteableDataByProp',
'getNotesData',
'openState',
'getBlockedByIssues',
@@ -135,6 +137,9 @@ export default {
? __('merge request')
: __('issue');
},
+ isIssueType() {
+ return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE;
+ },
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
},
@@ -346,13 +351,13 @@ export default {
<div class="error-alert"></div>
<issue-warning
- v-if="hasWarning(getNoteableData)"
+ v-if="hasWarning(getNoteableData) && isIssueType"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
:locked-issue-docs-path="lockedIssueDocsPath"
:confidential-issue-docs-path="confidentialIssueDocsPath"
/>
-
+ <epic-warning :is-confidential="isConfidential(getNoteableData)" />
<markdown-field
ref="markdownField"
:is-submitting="isSubmitting"
@@ -412,7 +417,7 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
</gl-alert>
<div class="note-form-actions">
<div
- class="float-left btn-group
+ class="btn-group
append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<button
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index cd5cfc09ea0..8897b54fac7 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -116,6 +116,7 @@ export default {
</div>
<div v-else>
<diff-viewer
+ :diff-file="discussion.diff_file"
:diff-mode="diffMode"
:diff-viewer-mode="diffViewerMode"
:new-path="discussion.diff_file.new_path"
diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue
new file mode 100644
index 00000000000..5fba011a153
--- /dev/null
+++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlFormSelect, GlSprintf } from '@gitlab/ui';
+import { getSymbol, getLineClasses } from './multiline_comment_utils';
+
+export default {
+ components: { GlFormSelect, GlSprintf },
+ props: {
+ lineRange: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ line: {
+ type: Object,
+ required: true,
+ },
+ commentLineOptions: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ commentLineStart: {
+ lineCode: this.lineRange ? this.lineRange.start_line_code : this.line.line_code,
+ type: this.lineRange ? this.lineRange.start_line_type : this.line.type,
+ },
+ };
+ },
+ methods: {
+ getSymbol({ type }) {
+ return getSymbol(type);
+ },
+ getLineClasses(line) {
+ return getLineClasses(line);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-sprintf
+ :message="
+ s__('MergeRequestDiffs|Commenting on lines %{selectStart}start%{selectEnd} to %{end}')
+ "
+ >
+ <template #select>
+ <label for="comment-line-start" class="sr-only">{{
+ s__('MergeRequestDiffs|Select comment starting line')
+ }}</label>
+ <gl-form-select
+ id="comment-line-start"
+ :value="commentLineStart"
+ :options="commentLineOptions"
+ size="sm"
+ class="gl-w-auto gl-vertical-align-baseline"
+ @change="$emit('input', $event)"
+ />
+ </template>
+ <template #end>
+ <span :class="getLineClasses(line)">
+ {{ getSymbol(line) + (line.new_line || line.old_line) }}
+ </span>
+ </template>
+ </gl-sprintf>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js
new file mode 100644
index 00000000000..dc9c55e9b30
--- /dev/null
+++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js
@@ -0,0 +1,57 @@
+import { takeRightWhile } from 'lodash';
+
+export function getSymbol(type) {
+ if (type === 'new') return '+';
+ if (type === 'old') return '-';
+ return '';
+}
+
+function getLineNumber(lineRange, key) {
+ if (!lineRange || !key) return '';
+ const lineCode = lineRange[`${key}_line_code`] || '';
+ const lineType = lineRange[`${key}_line_type`] || '';
+ const lines = lineCode.split('_') || [];
+ const lineNumber = lineType === 'old' ? lines[1] : lines[2];
+ return (lineNumber && getSymbol(lineType) + lineNumber) || '';
+}
+
+export function getStartLineNumber(lineRange) {
+ return getLineNumber(lineRange, 'start');
+}
+
+export function getEndLineNumber(lineRange) {
+ return getLineNumber(lineRange, 'end');
+}
+
+export function getLineClasses(line) {
+ const symbol = typeof line === 'string' ? line.charAt(0) : getSymbol(line?.type);
+
+ if (symbol !== '+' && symbol !== '-') return '';
+
+ return [
+ 'gl-px-1 gl-rounded-small gl-border-solid gl-border-1 gl-border-white',
+ {
+ 'gl-bg-green-100 gl-text-green-800': symbol === '+',
+ 'gl-bg-red-100 gl-text-red-800': symbol === '-',
+ },
+ ];
+}
+
+export function commentLineOptions(diffLines, lineCode) {
+ const selectedIndex = diffLines.findIndex(line => line.line_code === lineCode);
+ const notMatchType = l => l.type !== 'match';
+
+ // We're limiting adding comments to only lines above the current line
+ // to make rendering simpler. Future interations will use a more
+ // intuitive dragging interface that will make this unnecessary
+ const upToSelected = diffLines.slice(0, selectedIndex + 1);
+
+ // Only include the lines up to the first "Show unchanged lines" block
+ // i.e. not a "match" type
+ const lines = takeRightWhile(upToSelected, notMatchType);
+
+ return lines.map(l => ({
+ value: { lineCode: l.line_code, type: l.type },
+ text: `${getSymbol(l.type)}${l.new_line || l.old_line}`,
+ }));
+}
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index dc514f00801..f1af8be590a 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,9 +1,13 @@
<script>
+import { __ } from '~/locale';
import { mapGetters } from 'vuex';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
-import resolvedStatusMixin from 'ee_else_ce/batch_comments/mixins/resolved_status';
+import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import Icon from '~/vue_shared/components/icon.vue';
import ReplyButton from './note_actions/reply_button.vue';
+import eventHub from '~/sidebar/event_hub';
+import Api from '~/api';
+import flash from '~/flash';
export default {
name: 'NoteActions',
@@ -17,6 +21,10 @@ export default {
},
mixins: [resolvedStatusMixin],
props: {
+ author: {
+ type: Object,
+ required: true,
+ },
authorId: {
type: Number,
required: true,
@@ -87,7 +95,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getUserDataByProp']),
+ ...mapGetters(['getUserDataByProp', 'getNoteableData']),
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@@ -100,6 +108,26 @@ export default {
currentUserId() {
return this.getUserDataByProp('id');
},
+ isUserAssigned() {
+ return this.assignees && this.assignees.some(({ id }) => id === this.author.id);
+ },
+ displayAssignUserText() {
+ return this.isUserAssigned
+ ? __('Unassign from commenting user')
+ : __('Assign to commenting user');
+ },
+ sidebarAction() {
+ return this.isUserAssigned ? 'sidebar.addAssignee' : 'sidebar.removeAssignee';
+ },
+ targetType() {
+ return this.getNoteableData.targetType;
+ },
+ assignees() {
+ return this.getNoteableData.assignees || [];
+ },
+ isIssue() {
+ return this.targetType === 'issue';
+ },
},
methods: {
onEdit() {
@@ -116,6 +144,29 @@ export default {
this.$root.$emit('bv::hide::tooltip');
});
},
+ handleAssigneeUpdate(assignees) {
+ this.$emit('updateAssignees', assignees);
+ eventHub.$emit(this.sidebarAction, this.author);
+ eventHub.$emit('sidebar.saveAssignees');
+ },
+ assignUser() {
+ let { assignees } = this;
+ const { project_id, iid } = this.getNoteableData;
+
+ if (this.isUserAssigned) {
+ assignees = assignees.filter(assignee => assignee.id !== this.author.id);
+ } else {
+ assignees.push({ id: this.author.id });
+ }
+
+ if (this.targetType === 'issue') {
+ Api.updateIssue(project_id, iid, {
+ assignee_ids: assignees.map(assignee => assignee.id),
+ })
+ .then(() => this.handleAssigneeUpdate(assignees))
+ .catch(() => flash(__('Something went wrong while updating assignees')));
+ }
+ },
},
};
</script>
@@ -215,6 +266,16 @@ export default {
<span class="text-danger">{{ __('Delete comment') }}</span>
</button>
</li>
+ <li v-if="isIssue">
+ <button
+ class="btn-default btn-transparent"
+ data-testid="assign-user"
+ type="button"
+ @click="assignUser"
+ >
+ {{ displayAssignUserText }}
+ </button>
+ </li>
</ul>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 358f49deb35..42b78929f8a 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,8 +1,7 @@
<script>
-import { mapActions } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
-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';
@@ -18,7 +17,7 @@ export default {
noteForm,
Suggestions,
},
- mixins: [autosave, getDiscussion],
+ mixins: [autosave],
props: {
note: {
type: Object,
@@ -45,6 +44,15 @@ export default {
},
},
computed: {
+ ...mapGetters(['getDiscussion']),
+ discussion() {
+ if (!this.note.isDraft) return {};
+
+ return this.getDiscussion(this.note.discussion_id);
+ },
+ ...mapState({
+ batchSuggestionsInfo: state => state.notes.batchSuggestionsInfo,
+ }),
noteBody() {
return this.note.note;
},
@@ -74,7 +82,12 @@ export default {
}
},
methods: {
- ...mapActions(['submitSuggestion']),
+ ...mapActions([
+ 'submitSuggestion',
+ 'submitSuggestionBatch',
+ 'addSuggestionInfoToBatch',
+ 'removeSuggestionInfoFromBatch',
+ ]),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
@@ -91,6 +104,17 @@ export default {
callback,
);
},
+ applySuggestionBatch({ flashContainer }) {
+ return this.submitSuggestionBatch({ flashContainer });
+ },
+ addSuggestionToBatch(suggestionId) {
+ const { discussion_id: discussionId, id: noteId } = this.note;
+
+ this.addSuggestionInfoToBatch({ suggestionId, discussionId, noteId });
+ },
+ removeSuggestionFromBatch(suggestionId) {
+ this.removeSuggestionInfoFromBatch(suggestionId);
+ },
},
};
</script>
@@ -100,10 +124,14 @@ export default {
<suggestions
v-if="hasSuggestion && !isEditing"
:suggestions="note.suggestions"
+ :batch-suggestions-info="batchSuggestionsInfo"
:note-html="note.note_html"
:line-type="lineType"
:help-page-path="helpPagePath"
@apply="applySuggestion"
+ @applyBatch="applySuggestionBatch"
+ @addToBatch="addSuggestionToBatch"
+ @removeFromBatch="removeSuggestionFromBatch"
/>
<div v-else class="note-text md" v-html="note.note_html"></div>
<note-form
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 21d0bffdf1c..795ee10ca0f 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,6 +1,5 @@
<script>
-import { mapGetters, mapActions } from 'vuex';
-import noteFormMixin from 'ee_else_ce/notes/mixins/note_form';
+import { mapGetters, mapActions, mapState } from 'vuex';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
@@ -16,7 +15,7 @@ export default {
issueWarning,
markdownField,
},
- mixins: [issuableStateMixin, resolvable, noteFormMixin],
+ mixins: [issuableStateMixin, resolvable],
props: {
noteBody: {
type: String,
@@ -82,6 +81,11 @@ export default {
required: false,
default: false,
},
+ isDraft: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
let updatedNoteBody = this.noteBody;
@@ -107,6 +111,16 @@ export default {
'getNotesDataByProp',
'getUserDataByProp',
]),
+ ...mapState({
+ withBatchComments: state => state.batchComments?.withBatchComments,
+ }),
+ ...mapGetters('batchComments', ['hasDrafts']),
+ showBatchCommentsActions() {
+ return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit;
+ },
+ showResolveDiscussionToggle() {
+ return (this.discussion?.id && this.discussion.resolvable) || this.isDraft;
+ },
noteHash() {
if (this.noteId) {
return `#note_${this.noteId}`;
@@ -202,8 +216,6 @@ export default {
methods: {
...mapActions(['toggleResolveNote']),
shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState) {
- // shouldBeResolved() checks the actual resolution state,
- // considering batchComments (EEP), if applicable/enabled.
const newResolvedStateAfterUpdate =
this.shouldBeResolved && this.shouldBeResolved(shouldResolve);
@@ -234,6 +246,50 @@ export default {
updateDraft(autosaveKey, text);
}
},
+ handleKeySubmit() {
+ if (this.showBatchCommentsActions) {
+ this.handleAddToReview();
+ } else {
+ 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);
+ }
+ },
+ this.discussionResolved ? !this.isUnresolving : this.isResolving,
+ );
+ },
+ shouldBeResolved(resolveStatus) {
+ if (this.withBatchComments) {
+ return (
+ (this.discussionResolved && !this.isUnresolving) ||
+ (!this.discussionResolved && this.isResolving)
+ );
+ }
+
+ return resolveStatus;
+ },
+ handleAddToReview() {
+ // check if draft should resolve thread
+ const shouldResolve =
+ (this.discussionResolved && !this.isUnresolving) ||
+ (!this.discussionResolved && this.isResolving);
+ this.isSubmitting = true;
+
+ this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve);
+ },
},
};
</script>
@@ -293,6 +349,7 @@ export default {
<input
v-model="isUnresolving"
type="checkbox"
+ class="js-unresolve-checkbox"
data-qa-selector="unresolve_review_discussion_checkbox"
/>
{{ __('Unresolve thread') }}
@@ -301,6 +358,7 @@ export default {
<input
v-model="isResolving"
type="checkbox"
+ class="js-resolve-checkbox"
data-qa-selector="resolve_review_discussion_checkbox"
/>
{{ __('Resolve thread') }}
@@ -320,7 +378,7 @@ export default {
<button
:disabled="isDisabled"
type="button"
- class="btn qa-comment-now"
+ class="btn qa-comment-now js-comment-button"
@click="handleUpdate()"
>
{{ __('Add comment now') }}
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 189ff88feb3..7fe50d36c0c 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,11 +1,12 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
-import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
+import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import { s__, __ } from '~/locale';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import icon from '~/vue_shared/components/icon.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import DraftNote from '~/batch_comments/components/draft_note.vue';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import diffDiscussionHeader from './diff_discussion_header.vue';
@@ -26,7 +27,7 @@ export default {
diffDiscussionHeader,
noteSignedOutWidget,
noteForm,
- DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'),
+ DraftNote,
TimelineEntryItem,
DiscussionNotes,
DiscussionActions,
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 37675e20b3d..0e4dd1b9c84 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -2,7 +2,8 @@
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
import { escape } from 'lodash';
-import draftMixin from 'ee_else_ce/notes/mixins/draft';
+import { GlSprintf } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '../../locale';
@@ -15,17 +16,26 @@ import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import httpStatusCodes from '~/lib/utils/http_status';
+import {
+ getStartLineNumber,
+ getEndLineNumber,
+ getLineClasses,
+ commentLineOptions,
+} from './multiline_comment_utils';
+import MultilineCommentForm from './multiline_comment_form.vue';
export default {
name: 'NoteableNote',
components: {
+ GlSprintf,
userAvatarLink,
noteHeader,
noteActions,
NoteBody,
TimelineEntryItem,
+ MultilineCommentForm,
},
- mixins: [noteable, resolvable, draftMixin],
+ mixins: [noteable, resolvable, glFeatureFlagsMixin()],
props: {
note: {
type: Object,
@@ -51,6 +61,11 @@ export default {
required: false,
default: false,
},
+ diffLines: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -58,9 +73,14 @@ export default {
isDeleting: false,
isRequesting: false,
isResolving: false,
+ commentLineStart: {
+ line_code: this.line?.line_code,
+ type: this.line?.type,
+ },
};
},
computed: {
+ ...mapGetters('diffs', ['getDiffFileByHash']),
...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']),
author() {
return this.note.author;
@@ -105,6 +125,41 @@ export default {
)}</a>`;
return sprintf(s__('MergeRequests|commented on commit %{commitLink}'), { commitLink }, false);
},
+ isDraft() {
+ return this.note.isDraft;
+ },
+ canResolve() {
+ return (
+ this.note.current_user.can_resolve ||
+ (this.note.isDraft && this.note.discussion_id !== null)
+ );
+ },
+ lineRange() {
+ return this.note.position?.line_range;
+ },
+ startLineNumber() {
+ return getStartLineNumber(this.lineRange);
+ },
+ endLineNumber() {
+ return getEndLineNumber(this.lineRange);
+ },
+ showMultiLineComment() {
+ return (
+ this.glFeatures.multilineComments &&
+ this.startLineNumber &&
+ this.endLineNumber &&
+ (this.startLineNumber !== this.endLineNumber || this.isEditing)
+ );
+ },
+ commentLineOptions() {
+ if (this.diffLines) {
+ return commentLineOptions(this.diffLines, this.line.line_code);
+ }
+
+ const diffFile = this.diffFile || this.getDiffFileByHash(this.targetNoteHash);
+ if (!diffFile) return null;
+ return commentLineOptions(diffFile.highlighted_diff_lines, this.line.line_code);
+ },
},
created() {
@@ -129,6 +184,7 @@ export default {
'updateNote',
'toggleResolveNote',
'scrollToNoteIfNeeded',
+ 'updateAssignees',
]),
editHandler() {
this.isEditing = true;
@@ -166,10 +222,20 @@ export default {
this.$emit('updateSuccess');
},
formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) {
+ const position = {
+ ...this.note.position,
+ line_range: {
+ start_line_code: this.commentLineStart?.lineCode,
+ start_line_type: this.commentLineStart?.type,
+ end_line_code: this.line?.line_code,
+ end_line_type: this.line?.type,
+ },
+ };
this.$emit('handleUpdateNote', {
note: this.note,
noteText,
resolveDiscussion,
+ position,
callback: () => this.updateSuccess(),
});
@@ -231,6 +297,12 @@ export default {
noteBody.note.note = noteText;
}
},
+ getLineClasses(lineNumber) {
+ return getLineClasses(lineNumber);
+ },
+ assigneesUpdate(assignees) {
+ this.updateAssignees(assignees);
+ },
},
};
</script>
@@ -243,6 +315,26 @@ export default {
:data-note-id="note.id"
class="note note-wrapper qa-noteable-note-item"
>
+ <div v-if="showMultiLineComment" data-testid="multiline-comment">
+ <multiline-comment-form
+ v-if="isEditing && commentLineOptions && line"
+ v-model="commentLineStart"
+ :line="line"
+ :comment-line-options="commentLineOptions"
+ :line-range="note.position.line_range"
+ class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
+ />
+ <div v-else class="gl-mb-3 gl-text-gray-700">
+ <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
+ <template #startLine>
+ <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
+ </template>
+ <template #endLine>
+ <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
<div v-once class="timeline-icon">
<user-avatar-link
:link-href="author.path"
@@ -267,6 +359,7 @@ export default {
<span v-else-if="note.created_at" class="d-none d-sm-inline">&middot;</span>
</note-header>
<note-actions
+ :author="author"
:author-id="author.id"
:note-id="note.id"
:note-url="note.noteable_note_url"
@@ -289,6 +382,7 @@ export default {
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
@startReplying="$emit('startReplying')"
+ @updateAssignees="assigneesUpdate"
/>
</div>
<div class="timeline-discussion-body">
diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js
index 66e6685cfd8..d1006e37a70 100644
--- a/app/assets/javascripts/notes/mixins/description_version_history.js
+++ b/app/assets/javascripts/notes/mixins/description_version_history.js
@@ -3,7 +3,7 @@
export default {
computed: {
canSeeDescriptionVersion() {},
- canDeleteDescriptionVersion() {},
+ displayDeleteButton() {},
shouldShowDescriptionVersion() {},
descriptionVersionToggleIcon() {},
},
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
index 188556e8921..5930b5f3321 100644
--- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -1,10 +1,100 @@
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils';
+import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { clearDraft } from '~/lib/utils/autosave';
+
export default {
computed: {
- draftForDiscussion: () => () => ({}),
+ ...mapState({
+ noteableData: state => state.notes.noteableData,
+ notesData: state => state.notes.notesData,
+ withBatchComments: state => state.batchComments?.withBatchComments,
+ }),
+ ...mapGetters('diffs', ['getDiffFileByHash']),
+ ...mapGetters('batchComments', ['shouldRenderDraftRowInDiscussion', 'draftForDiscussion']),
+ ...mapState('diffs', ['commit']),
},
methods: {
- showDraft: () => false,
- addReplyToReview: () => {},
- addToReview: () => {},
+ ...mapActions('diffs', ['cancelCommentForm']),
+ ...mapActions('batchComments', ['addDraftToReview', 'saveDraft', 'insertDraftIntoDrafts']),
+ addReplyToReview(noteText, isResolving) {
+ const postData = getDraftReplyFormData({
+ in_reply_to_discussion_id: this.discussion.reply_id,
+ target_type: this.getNoteableData.targetType,
+ notesData: this.notesData,
+ draft_note: {
+ note: noteText,
+ resolve_discussion: isResolving,
+ },
+ });
+
+ if (this.discussion.for_commit) {
+ postData.note_project_id = this.discussion.project_id;
+ }
+
+ this.isReplying = false;
+
+ this.saveDraft(postData)
+ .then(() => {
+ this.handleClearForm(this.discussion.line_code);
+ })
+ .catch(() => {
+ createFlash(s__('MergeRequests|An error occurred while saving the draft comment.'));
+ });
+ },
+ addToReview(note) {
+ const positionType = this.diffFileCommentForm
+ ? IMAGE_DIFF_POSITION_TYPE
+ : TEXT_DIFF_POSITION_TYPE;
+ const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash);
+ const postData = getDraftFormData({
+ note,
+ notesData: this.notesData,
+ noteableData: this.noteableData,
+ noteableType: this.noteableType,
+ noteTargetLine: this.noteTargetLine,
+ diffViewType: this.diffViewType,
+ diffFile: selectedDiffFile,
+ linePosition: this.position,
+ positionType,
+ ...this.diffFileCommentForm,
+ });
+
+ const diffFileHeadSha = this.commit && this?.diffFile?.diff_refs?.head_sha;
+
+ postData.data.note.commit_id = diffFileHeadSha || null;
+
+ return this.saveDraft(postData)
+ .then(() => {
+ if (positionType === IMAGE_DIFF_POSITION_TYPE) {
+ this.closeDiffFileCommentForm(this.diffFileHash);
+ } else {
+ this.handleClearForm(this.line.line_code);
+ }
+ })
+ .catch(() => {
+ createFlash(s__('MergeRequests|An error occurred while saving the draft comment.'));
+ });
+ },
+ handleClearForm(lineCode) {
+ this.cancelCommentForm({
+ lineCode,
+ fileHash: this.diffFileHash,
+ });
+ this.$nextTick(() => {
+ if (this.autosaveKey) {
+ clearDraft(this.autosaveKey);
+ } else {
+ // TODO: remove the following after replacing the autosave mixin
+ // https://gitlab.com/gitlab-org/gitlab-foss/issues/60587
+ this.resetAutoSave();
+ }
+ });
+ },
+ showDraft(replyId) {
+ return this.withBatchComments && this.shouldRenderDraftRowInDiscussion(replyId);
+ },
},
};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index c9026352d18..9281149d9d3 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,5 +1,5 @@
import { mapGetters, mapActions, mapState } from 'vuex';
-import { scrollToElement } from '~/lib/utils/common_utils';
+import { scrollToElementWithContext } from '~/lib/utils/common_utils';
import eventHub from '../event_hub';
/**
@@ -10,7 +10,7 @@ function scrollTo(selector) {
const el = document.querySelector(selector);
if (el) {
- scrollToElement(el);
+ scrollToElementWithContext(el);
return true;
}
diff --git a/app/assets/javascripts/notes/mixins/draft.js b/app/assets/javascripts/notes/mixins/draft.js
deleted file mode 100644
index 1370f3978df..00000000000
--- a/app/assets/javascripts/notes/mixins/draft.js
+++ /dev/null
@@ -1,8 +0,0 @@
-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
deleted file mode 100644
index b5d820fe083..00000000000
--- a/app/assets/javascripts/notes/mixins/get_discussion.js
+++ /dev/null
@@ -1,7 +0,0 @@
-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
deleted file mode 100644
index b74879f2256..00000000000
--- a/app/assets/javascripts/notes/mixins/note_form.js
+++ /dev/null
@@ -1,24 +0,0 @@
-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/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 0999d0aa7ac..a5b006fc301 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -524,12 +524,55 @@ export const submitSuggestion = (
const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.',
);
- const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage;
+
+ const errorMessage = err.response.data?.message;
+
+ const flashMessage = errorMessage || defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
});
};
+export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContainer }) => {
+ const suggestionIds = state.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId);
+
+ const applyAllSuggestions = () =>
+ state.batchSuggestionsInfo.map(suggestionInfo =>
+ commit(types.APPLY_SUGGESTION, suggestionInfo),
+ );
+
+ const resolveAllDiscussions = () =>
+ state.batchSuggestionsInfo.map(suggestionInfo => {
+ const { discussionId } = suggestionInfo;
+ return dispatch('resolveDiscussion', { discussionId }).catch(() => {});
+ });
+
+ commit(types.SET_APPLYING_BATCH_STATE, true);
+
+ return Api.applySuggestionBatch(suggestionIds)
+ .then(() => Promise.all(applyAllSuggestions()))
+ .then(() => Promise.all(resolveAllDiscussions()))
+ .then(() => commit(types.CLEAR_SUGGESTION_BATCH))
+ .catch(err => {
+ const defaultMessage = __(
+ 'Something went wrong while applying the batch of suggestions. Please try again.',
+ );
+
+ const errorMessage = err.response.data?.message;
+
+ const flashMessage = errorMessage || defaultMessage;
+
+ Flash(__(flashMessage), 'alert', flashContainer);
+ })
+ .finally(() => commit(types.SET_APPLYING_BATCH_STATE, false));
+};
+
+export const addSuggestionInfoToBatch = ({ commit }, { suggestionId, noteId, discussionId }) =>
+ commit(types.ADD_SUGGESTION_TO_BATCH, { suggestionId, noteId, discussionId });
+
+export const removeSuggestionInfoFromBatch = ({ commit }, suggestionId) =>
+ commit(types.REMOVE_SUGGESTION_FROM_BATCH, suggestionId);
+
export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId);
@@ -587,6 +630,10 @@ export const softDeleteDescriptionVersion = (
.catch(error => {
dispatch('receiveDeleteDescriptionVersionError', error);
Flash(__('Something went wrong while deleting description changes. Please try again.'));
+
+ // Throw an error here because a component like SystemNote -
+ // needs to know if the request failed to reset its internal state.
+ throw new Error();
});
};
@@ -600,5 +647,9 @@ export const receiveDeleteDescriptionVersionError = ({ commit }, error) => {
commit(types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR, error);
};
+export const updateAssignees = ({ commit }, assignees) => {
+ commit(types.UPDATE_ASSIGNEES, assignees);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 25f0f546103..329bf5e147e 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -11,6 +11,7 @@ export default () => ({
targetNoteHash: null,
lastFetchedAt: null,
currentDiscussionId: null,
+ batchSuggestionsInfo: [],
// View layer
isToggleStateButtonLoading: false,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 2f7b2788d8a..538774ee467 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -17,8 +17,13 @@ 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 SET_APPLYING_BATCH_STATE = 'SET_APPLYING_BATCH_STATE';
+export const ADD_SUGGESTION_TO_BATCH = 'ADD_SUGGESTION_TO_BATCH';
+export const REMOVE_SUGGESTION_FROM_BATCH = 'REMOVE_SUGGESTION_FROM_BATCH';
+export const CLEAR_SUGGESTION_BATCH = 'CLEAR_SUGGESTION_BATCH';
export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION';
export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION';
+export const UPDATE_ASSIGNEES = 'UPDATE_ASSIGNEES';
// 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 f06874991f0..2aeadcb2da1 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -225,6 +225,39 @@ export default {
}));
},
+ [types.SET_APPLYING_BATCH_STATE](state, isApplyingBatch) {
+ state.batchSuggestionsInfo.forEach(suggestionInfo => {
+ const { discussionId, noteId, suggestionId } = suggestionInfo;
+
+ const noteObj = utils.findNoteObjectById(state.discussions, discussionId);
+ const comment = utils.findNoteObjectById(noteObj.notes, noteId);
+
+ comment.suggestions = comment.suggestions.map(suggestion => ({
+ ...suggestion,
+ is_applying_batch: suggestion.id === suggestionId && isApplyingBatch,
+ }));
+ });
+ },
+
+ [types.ADD_SUGGESTION_TO_BATCH](state, { noteId, discussionId, suggestionId }) {
+ state.batchSuggestionsInfo.push({
+ suggestionId,
+ noteId,
+ discussionId,
+ });
+ },
+
+ [types.REMOVE_SUGGESTION_FROM_BATCH](state, id) {
+ const index = state.batchSuggestionsInfo.findIndex(({ suggestionId }) => suggestionId === id);
+ if (index !== -1) {
+ state.batchSuggestionsInfo.splice(index, 1);
+ }
+ },
+
+ [types.CLEAR_SUGGESTION_BATCH](state) {
+ state.batchSuggestionsInfo.splice(0, state.batchSuggestionsInfo.length);
+ },
+
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
@@ -322,4 +355,7 @@ export default {
[types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR](state) {
state.isLoadingDescriptionVersion = false;
},
+ [types.UPDATE_ASSIGNEES](state, assignees) {
+ state.noteableData.assignees = assignees;
+ },
};
diff --git a/app/assets/javascripts/onboarding_issues/index.js b/app/assets/javascripts/onboarding_issues/index.js
new file mode 100644
index 00000000000..5a6f952ffdf
--- /dev/null
+++ b/app/assets/javascripts/onboarding_issues/index.js
@@ -0,0 +1,120 @@
+import $ from 'jquery';
+import { parseBoolean, getCookie, setCookie, removeCookie } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+
+const COOKIE_NAME = 'onboarding_issues_settings';
+
+const POPOVER_LOCATIONS = {
+ GROUPS_SHOW: 'groups#show',
+ PROJECTS_SHOW: 'projects#show',
+ ISSUES_INDEX: 'issues#index',
+};
+
+const removeLearnGitLabCookie = () => {
+ removeCookie(COOKIE_NAME);
+};
+
+function disposePopover(event) {
+ event.preventDefault();
+ this.popover('dispose');
+ removeLearnGitLabCookie();
+ Tracking.event('Growth::Conversion::Experiment::OnboardingIssues', 'dismiss_popover');
+}
+
+const showPopover = (el, path, footer, options) => {
+ // Cookie value looks like `{ 'groups#show': true, 'projects#show': true, 'issues#index': true }`. When it doesn't exist, don't show the popover.
+ const cookie = getCookie(COOKIE_NAME);
+ if (!cookie) return;
+
+ // When the popover action has already been taken, don't show the popover.
+ const settings = JSON.parse(cookie);
+ if (!parseBoolean(settings[path])) return;
+
+ const defaultOptions = {
+ boundary: 'window',
+ html: true,
+ placement: 'top',
+ template: `<div class="popover blue learn-gitlab d-none d-xl-block" role="tooltip">
+ <div class="arrow"></div>
+ <div class="close cursor-pointer gl-font-base text-white gl-opacity-10 p-2">&#10005</div>
+ <div class="popover-body gl-font-base gl-line-height-20 pb-0 px-3"></div>
+ <div class="bold text-right text-white p-2">${footer}</div>
+ </div>`,
+ };
+
+ // When one of the popovers is dismissed, remove the cookie.
+ const closeButton = () => document.querySelector('.learn-gitlab.popover .close');
+
+ // We still have to use jQuery, since Bootstrap's Popover is based on jQuery.
+ const jQueryEl = $(el);
+ const clickCloseButton = disposePopover.bind(jQueryEl);
+
+ jQueryEl
+ .popover({ ...defaultOptions, ...options })
+ .on('inserted.bs.popover', () => closeButton().addEventListener('click', clickCloseButton))
+ .on('hide.bs.dropdown', () => closeButton().removeEventListener('click', clickCloseButton))
+ .popover('show');
+
+ // The previous popover actions have been taken, don't show those popovers anymore.
+ Object.keys(settings).forEach(pathSetting => {
+ if (path !== pathSetting) {
+ settings[pathSetting] = false;
+ } else {
+ setCookie(COOKIE_NAME, settings);
+ }
+ });
+
+ // The final popover action will be taken on click, we then no longer need the cookie.
+ if (path === POPOVER_LOCATIONS.ISSUES_INDEX) {
+ el.addEventListener('click', removeLearnGitLabCookie);
+ }
+};
+
+export const showLearnGitLabGroupItemPopover = id => {
+ const el = document.querySelector(`#group-${id} .group-text a`);
+
+ if (!el) return;
+
+ const options = {
+ content: __(
+ 'Here are all your projects in your group, including the one you just created. To start, let’s take a look at your personalized learning project which will help you learn about GitLab at your own pace.',
+ ),
+ };
+
+ showPopover(el, POPOVER_LOCATIONS.GROUPS_SHOW, '1 / 2', options);
+};
+
+export const showLearnGitLabProjectPopover = () => {
+ // Do not show a popover if we are not viewing the 'Learn GitLab' project.
+ if (!window.location.pathname.includes('learn-gitlab')) return;
+
+ const el = document.querySelector('a.shortcuts-issues');
+
+ if (!el) return;
+
+ const options = {
+ content: __(
+ 'Go to <strong>Issues</strong> > <strong>Boards</strong> to access your personalized learning issue board.',
+ ),
+ };
+
+ showPopover(el, POPOVER_LOCATIONS.PROJECTS_SHOW, '2 / 2', options);
+};
+
+export const showLearnGitLabIssuesPopover = () => {
+ // Do not show a popover if we are not viewing the 'Learn GitLab' project.
+ if (!window.location.pathname.includes('learn-gitlab')) return;
+
+ const el = document.querySelector('a[data-qa-selector="issue_boards_link"]');
+
+ if (!el) return;
+
+ const options = {
+ content: __(
+ 'Go to <strong>Issues</strong> > <strong>Boards</strong> to access your personalized learning issue board.',
+ ),
+ };
+
+ showPopover(el, POPOVER_LOCATIONS.ISSUES_INDEX, '2 / 2', options);
+};
diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
deleted file mode 100644
index e9c7d7c5d56..00000000000
--- a/app/assets/javascripts/operation_settings/components/external_dashboard.vue
+++ /dev/null
@@ -1,72 +0,0 @@
-<script>
-import { mapState, mapActions } from 'vuex';
-import { GlDeprecatedButton, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui';
-
-export default {
- components: {
- GlDeprecatedButton,
- GlFormGroup,
- GlFormInput,
- GlLink,
- },
- computed: {
- ...mapState([
- 'externalDashboardHelpPagePath',
- 'externalDashboardUrl',
- 'operationsSettingsEndpoint',
- ]),
- userDashboardUrl: {
- get() {
- return this.externalDashboardUrl;
- },
- set(url) {
- this.setExternalDashboardUrl(url);
- },
- },
- },
- methods: {
- ...mapActions(['setExternalDashboardUrl', 'updateExternalDashboardUrl']),
- },
-};
-</script>
-
-<template>
- <section class="settings no-animate">
- <div class="settings-header">
- <h3 class="js-section-header h4">
- {{ s__('ExternalMetrics|External Dashboard') }}
- </h3>
- <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button>
- <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')"
- label-for="full-dashboard-url"
- :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')"
- >
- <!-- placeholder with a url is a false positive -->
- <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
- <gl-form-input
- id="full-dashboard-url"
- v-model="userDashboardUrl"
- placeholder="https://my-org.gitlab.io/my-dashboards"
- @keydown.enter.native.prevent="updateExternalDashboardUrl"
- />
- <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
- </gl-form-group>
- <gl-deprecated-button variant="success" @click="updateExternalDashboardUrl">
- {{ __('Save Changes') }}
- </gl-deprecated-button>
- </form>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue b/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue
new file mode 100644
index 00000000000..42c9d876595
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue
@@ -0,0 +1,60 @@
+<script>
+import { s__ } from '~/locale';
+import { mapState, mapActions } from 'vuex';
+import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { timezones } from '~/monitoring/format_date';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormSelect,
+ },
+ computed: {
+ ...mapState(['dashboardTimezone']),
+ dashboardTimezoneModel: {
+ get() {
+ return this.dashboardTimezone.selected;
+ },
+ set(selected) {
+ this.setDashboardTimezone(selected);
+ },
+ },
+ options() {
+ return [
+ {
+ value: timezones.LOCAL,
+ text: s__("MetricsSettings|User's local timezone"),
+ },
+ {
+ value: timezones.UTC,
+ text: s__('MetricsSettings|UTC (Coordinated Universal Time)'),
+ },
+ ];
+ },
+ },
+ methods: {
+ ...mapActions(['setDashboardTimezone']),
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ :label="s__('MetricsSettings|Dashboard timezone')"
+ label-for="dashboard-timezone-setting"
+ >
+ <template #description>
+ {{
+ s__(
+ "MetricsSettings|Choose whether to display dashboard metrics in UTC or the user's local timezone.",
+ )
+ }}
+ </template>
+
+ <gl-form-select
+ id="dashboard-timezone-setting"
+ v-model="dashboardTimezoneModel"
+ :options="options"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue
new file mode 100644
index 00000000000..812c5a3fe9a
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue
@@ -0,0 +1,48 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ computed: {
+ ...mapState(['externalDashboard']),
+ userDashboardUrl: {
+ get() {
+ return this.externalDashboard.url;
+ },
+ set(url) {
+ this.setExternalDashboardUrl(url);
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['setExternalDashboardUrl']),
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ :label="s__('MetricsSettings|External dashboard URL')"
+ label-for="external-dashboard-url"
+ >
+ <template #description>
+ {{
+ s__(
+ 'MetricsSettings|Add a button to the metrics dashboard linking directly to your existing external dashboard.',
+ )
+ }}
+ </template>
+ <!-- placeholder with a url is a false positive -->
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="external-dashboard-url"
+ v-model="userDashboardUrl"
+ placeholder="https://my-org.gitlab.io/my-dashboards"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
new file mode 100644
index 00000000000..77c356e5a7f
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
@@ -0,0 +1,53 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlDeprecatedButton, GlLink } from '@gitlab/ui';
+import ExternalDashboard from './form_group/external_dashboard.vue';
+import DashboardTimezone from './form_group/dashboard_timezone.vue';
+
+export default {
+ components: {
+ GlDeprecatedButton,
+ GlLink,
+ ExternalDashboard,
+ DashboardTimezone,
+ },
+ computed: {
+ ...mapState(['helpPage']),
+ userDashboardUrl: {
+ get() {
+ return this.externalDashboard.url;
+ },
+ set(url) {
+ this.setExternalDashboardUrl(url);
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['saveChanges']),
+ },
+};
+</script>
+
+<template>
+ <section class="settings no-animate">
+ <div class="settings-header">
+ <h3 class="js-section-header h4">
+ {{ s__('MetricsSettings|Metrics Dashboard') }}
+ </h3>
+ <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button>
+ <p class="js-section-sub-header">
+ {{ s__('MetricsSettings|Manage Metrics Dashboard settings.') }}
+ <gl-link :href="helpPage">{{ __('Learn more') }}</gl-link>
+ </p>
+ </div>
+ <div class="settings-content">
+ <form>
+ <dashboard-timezone />
+ <external-dashboard />
+ <gl-deprecated-button variant="success" @click="saveChanges">
+ {{ __('Save Changes') }}
+ </gl-deprecated-button>
+ </form>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js
index f075291ce98..426a060949e 100644
--- a/app/assets/javascripts/operation_settings/index.js
+++ b/app/assets/javascripts/operation_settings/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import store from './store';
-import ExternalDashboardForm from './components/external_dashboard.vue';
+import MetricsSettingsForm from './components/metrics_settings.vue';
export default () => {
const el = document.querySelector('.js-operation-settings');
@@ -9,7 +9,7 @@ export default () => {
el,
store: store(el.dataset),
render(createElement) {
- return createElement(ExternalDashboardForm);
+ return createElement(MetricsSettingsForm);
},
});
};
diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js
index ec05b0c76cf..122acb6bdcf 100644
--- a/app/assets/javascripts/operation_settings/store/actions.js
+++ b/app/assets/javascripts/operation_settings/store/actions.js
@@ -7,19 +7,23 @@ import * as mutationTypes from './mutation_types';
export const setExternalDashboardUrl = ({ commit }, url) =>
commit(mutationTypes.SET_EXTERNAL_DASHBOARD_URL, url);
-export const updateExternalDashboardUrl = ({ state, dispatch }) =>
+export const setDashboardTimezone = ({ commit }, selected) =>
+ commit(mutationTypes.SET_DASHBOARD_TIMEZONE, selected);
+
+export const saveChanges = ({ state, dispatch }) =>
axios
.patch(state.operationsSettingsEndpoint, {
project: {
metrics_setting_attributes: {
- external_dashboard_url: state.externalDashboardUrl,
+ dashboard_timezone: state.dashboardTimezone.selected,
+ external_dashboard_url: state.externalDashboard.url,
},
},
})
- .then(() => dispatch('receiveExternalDashboardUpdateSuccess'))
- .catch(error => dispatch('receiveExternalDashboardUpdateError', error));
+ .then(() => dispatch('receiveSaveChangesSuccess'))
+ .catch(error => dispatch('receiveSaveChangesError', error));
-export const receiveExternalDashboardUpdateSuccess = () => {
+export const receiveSaveChangesSuccess = () => {
/**
* The operations_controller currently handles successful requests
* by creating a flash banner messsage to notify the user.
@@ -27,8 +31,8 @@ export const receiveExternalDashboardUpdateSuccess = () => {
refreshCurrentPage();
};
-export const receiveExternalDashboardUpdateError = (_, error) => {
- const { response } = error;
+export const receiveSaveChangesError = (_, error) => {
+ const { response = {} } = error;
const message = response.data && response.data.message ? response.data.message : '';
createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert');
diff --git a/app/assets/javascripts/operation_settings/store/mutation_types.js b/app/assets/javascripts/operation_settings/store/mutation_types.js
index 237d2b6122f..92543fd7f03 100644
--- a/app/assets/javascripts/operation_settings/store/mutation_types.js
+++ b/app/assets/javascripts/operation_settings/store/mutation_types.js
@@ -1,3 +1,2 @@
-/* eslint-disable import/prefer-default-export */
-
export const SET_EXTERNAL_DASHBOARD_URL = 'SET_EXTERNAL_DASHBOARD_URL';
+export const SET_DASHBOARD_TIMEZONE = 'SET_DASHBOARD_TIMEZONE';
diff --git a/app/assets/javascripts/operation_settings/store/mutations.js b/app/assets/javascripts/operation_settings/store/mutations.js
index 64bb33bb89f..f55717f6c98 100644
--- a/app/assets/javascripts/operation_settings/store/mutations.js
+++ b/app/assets/javascripts/operation_settings/store/mutations.js
@@ -2,6 +2,9 @@ import * as types from './mutation_types';
export default {
[types.SET_EXTERNAL_DASHBOARD_URL](state, url) {
- state.externalDashboardUrl = url;
+ state.externalDashboard.url = url;
+ },
+ [types.SET_DASHBOARD_TIMEZONE](state, selected) {
+ state.dashboardTimezone.selected = selected;
},
};
diff --git a/app/assets/javascripts/operation_settings/store/state.js b/app/assets/javascripts/operation_settings/store/state.js
index 72167141c48..c0eca580848 100644
--- a/app/assets/javascripts/operation_settings/store/state.js
+++ b/app/assets/javascripts/operation_settings/store/state.js
@@ -1,5 +1,10 @@
export default (initialState = {}) => ({
- externalDashboardUrl: initialState.externalDashboardUrl || '',
operationsSettingsEndpoint: initialState.operationsSettingsEndpoint,
- externalDashboardHelpPagePath: initialState.externalDashboardHelpPagePath,
+ helpPage: initialState.helpPage,
+ externalDashboard: {
+ url: initialState.externalDashboardUrl,
+ },
+ dashboardTimezone: {
+ selected: initialState.dashboardTimezoneSetting,
+ },
});
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 46e80ba72e3..2aa37842707 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -1,7 +1,8 @@
import $ from 'jquery';
+import 'vendor/jquery.endless-scroll';
import { getParameterByName } from '~/lib/utils/common_utils';
-import axios from './lib/utils/axios_utils';
-import { removeParams } from './lib/utils/url_utility';
+import axios from '~/lib/utils/axios_utils';
+import { removeParams } from '~/lib/utils/url_utility';
const ENDLESS_SCROLL_BOTTOM_PX = 400;
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index 493c216cc6e..143d15f92cd 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -2,8 +2,12 @@ import initSettingsPanels from '~/settings_panels';
import projectSelect from '~/project_select';
import selfMonitor from '~/self_monitor';
import maintenanceModeSettings from '~/maintenance_mode_settings';
+import initVariableList from '~/ci_variable_list';
document.addEventListener('DOMContentLoaded', () => {
+ if (gon.features?.ciInstanceVariablesUi) {
+ initVariableList('js-instance-variables');
+ }
selfMonitor();
maintenanceModeSettings();
// Initialize expandable settings panels
diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js
index ad7276132b9..a4e5df559ff 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 initAvatarPicker from '~/avatar_picker';
+import initFilePickers from '~/file_pickers';
-document.addEventListener('DOMContentLoaded', initAvatarPicker);
+document.addEventListener('DOMContentLoaded', initFilePickers);
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
index 6de740ee9ce..b94c999ed12 100644
--- a/app/assets/javascripts/pages/admin/groups/new/index.js
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -1,9 +1,10 @@
import BindInOut from '../../../../behaviors/bind_in_out';
import Group from '../../../../group';
-import initAvatarPicker from '~/avatar_picker';
+import initFilePickers from '~/file_pickers';
document.addEventListener('DOMContentLoaded', () => {
BindInOut.initAll();
- new Group(); // eslint-disable-line no-new
- initAvatarPicker();
+ initFilePickers();
+
+ return new Group();
});
diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
index b22fbf6b833..8bb093da771 100644
--- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -92,7 +92,7 @@ export default {
@submit="onSubmit"
@cancel="onCancel"
>
- <template slot="body" slot-scope="props">
+ <template #body="props">
<p v-html="props.text"></p>
<p v-html="confirmationTextLabel"></p>
<form ref="form" :action="deleteProjectUrl" method="post">
diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/clusters/index.js
index 4d04c37caa7..4d04c37caa7 100644
--- a/app/assets/javascripts/pages/groups/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/index.js
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index f32392c9e29..33e552cd1ba 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -1,4 +1,4 @@
-import initAvatarPicker from '~/avatar_picker';
+import initFilePickers from '~/file_pickers';
import TransferDropdown from '~/groups/transfer_dropdown';
import initConfirmDangerModal from '~/confirm_danger_modal';
import initSettingsPanels from '~/settings_panels';
@@ -10,8 +10,7 @@ import groupsSelect from '~/groups_select';
import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => {
- initAvatarPicker();
- new TransferDropdown(); // eslint-disable-line no-new
+ initFilePickers();
initConfirmDangerModal();
initSettingsPanels();
dirtySubmitFactory(
@@ -24,4 +23,6 @@ document.addEventListener('DOMContentLoaded', () => {
groupsSelect();
projectSelect();
+
+ return new TransferDropdown();
});
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 0710fefe70c..640e64b5d3e 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import BindInOut from '~/behaviors/bind_in_out';
import Group from '~/group';
-import initAvatarPicker from '~/avatar_picker';
import GroupPathValidator from './group_path_validator';
+import initFilePickers from '~/file_pickers';
document.addEventListener('DOMContentLoaded', () => {
const parentId = $('#group_parent_id');
@@ -10,6 +10,7 @@ document.addEventListener('DOMContentLoaded', () => {
new GroupPathValidator(); // eslint-disable-line no-new
}
BindInOut.initAll();
- new Group(); // eslint-disable-line no-new
- initAvatarPicker();
+ initFilePickers();
+
+ return new Group();
});
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 37b253d7c48..85daff3f60f 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -8,6 +8,7 @@ import NotificationsForm from '~/notifications_form';
import ProjectsList from '~/projects_list';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GroupTabs from './group_tabs';
+import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert';
export default function initGroupDetails(actionName = 'show') {
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
@@ -27,4 +28,6 @@ export default function initGroupDetails(actionName = 'show') {
if (newGroupChildWrapper) {
new NewGroupChild(newGroupChildWrapper);
}
+
+ initNamespaceStorageLimitAlert();
}
diff --git a/app/assets/javascripts/pages/ide/index.js b/app/assets/javascripts/pages/ide/index.js
index d192df3561e..15933256e75 100644
--- a/app/assets/javascripts/pages/ide/index.js
+++ b/app/assets/javascripts/pages/ide/index.js
@@ -1,3 +1,4 @@
import { startIde } from '~/ide/index';
+import extendStore from '~/ide/stores/extend';
-startIde();
+startIde({ extendStore });
diff --git a/app/assets/javascripts/pages/import/bitbucket/status/index.js b/app/assets/javascripts/pages/import/bitbucket/status/index.js
new file mode 100644
index 00000000000..52b5adb79d1
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bitbucket/status/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import { initStoreFromElement, initPropsFromElement } from '~/import_projects';
+import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mountElement = document.getElementById('import-projects-mount-element');
+ if (!mountElement) return undefined;
+
+ const store = initStoreFromElement(mountElement);
+ const props = initPropsFromElement(mountElement);
+
+ return new Vue({
+ el: mountElement,
+ store,
+ render(createElement) {
+ return createElement(BitbucketStatusTable, { props });
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue
new file mode 100644
index 00000000000..e01c7b80e1a
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue';
+
+export default {
+ components: {
+ BitbucketStatusTable,
+ GlButton,
+ },
+ props: {
+ providerTitle: {
+ type: String,
+ required: true,
+ },
+ reconfigurePath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <bitbucket-status-table :provider-title="providerTitle">
+ <template #actions>
+ <gl-button variant="info" class="gl-ml-3" data-method="post" :href="reconfigurePath">{{
+ __('Reconfigure')
+ }}</gl-button>
+ </template>
+ </bitbucket-status-table>
+</template>
diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js
new file mode 100644
index 00000000000..88455c9b7b9
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import { initStoreFromElement, initPropsFromElement } from '~/import_projects';
+import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mountElement = document.getElementById('import-projects-mount-element');
+ if (!mountElement) return undefined;
+
+ const store = initStoreFromElement(mountElement);
+ const props = initPropsFromElement(mountElement);
+ const { reconfigurePath } = mountElement.dataset;
+
+ return new Vue({
+ el: mountElement,
+ store,
+ render(createElement) {
+ return createElement(BitbucketServerStatusTable, { props: { ...props, reconfigurePath } });
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/import/fogbugz/status/index.js b/app/assets/javascripts/pages/import/fogbugz/status/index.js
new file mode 100644
index 00000000000..dcd84f0faf9
--- /dev/null
+++ b/app/assets/javascripts/pages/import/fogbugz/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/gitlab/status/index.js b/app/assets/javascripts/pages/import/gitlab/status/index.js
new file mode 100644
index 00000000000..dcd84f0faf9
--- /dev/null
+++ b/app/assets/javascripts/pages/import/gitlab/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/ldap/omniauth_callbacks/index.js b/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js
index edd7e38471b..e93def5323f 100644
--- a/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js
+++ b/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js
@@ -1,3 +1,3 @@
-import initU2F from '../../../shared/sessions/u2f';
+import { mount2faAuthentication } from '~/authentication/mount_2fa';
-document.addEventListener('DOMContentLoaded', initU2F);
+document.addEventListener('DOMContentLoaded', mount2faAuthentication);
diff --git a/app/assets/javascripts/pages/omniauth_callbacks/index.js b/app/assets/javascripts/pages/omniauth_callbacks/index.js
index c2c069d1ca8..e93def5323f 100644
--- a/app/assets/javascripts/pages/omniauth_callbacks/index.js
+++ b/app/assets/javascripts/pages/omniauth_callbacks/index.js
@@ -1,3 +1,3 @@
-import initU2F from '../../shared/sessions/u2f';
+import { mount2faAuthentication } from '~/authentication/mount_2fa';
-document.addEventListener('DOMContentLoaded', initU2F);
+document.addEventListener('DOMContentLoaded', mount2faAuthentication);
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index 95936c2d1db..1aeba6669ee 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -1,6 +1,5 @@
-import $ from 'jquery';
-import U2FRegister from '~/u2f/register';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { mount2faRegistration } from '~/authentication/mount_2fa';
document.addEventListener('DOMContentLoaded', () => {
const twoFactorNode = document.querySelector('.js-two-factor-auth');
@@ -12,6 +11,5 @@ document.addEventListener('DOMContentLoaded', () => {
if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button);
}
- const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f);
- u2fRegister.start();
+ mount2faRegistration();
});
diff --git a/app/assets/javascripts/pages/projects/clusters/index.js b/app/assets/javascripts/pages/projects/clusters/index.js
new file mode 100644
index 00000000000..4d04c37caa7
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/index.js
@@ -0,0 +1,5 @@
+import initCreateCluster from '~/create_cluster/init_create_cluster';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initCreateCluster(document, gon);
+});
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index c9dbe576c4b..9fb07917f9b 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -4,12 +4,12 @@ import setupTransferEdit from '~/transfer_edit';
import initConfirmDangerModal from '~/confirm_danger_modal';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
-import initAvatarPicker from '~/avatar_picker';
+import initFilePickers from '~/file_pickers';
import initProjectLoadingSpinner from '../shared/save_project_loader';
import initProjectPermissionsSettings from '../shared/permissions';
document.addEventListener('DOMContentLoaded', () => {
- initAvatarPicker();
+ initFilePickers();
initConfirmDangerModal();
initSettingsPanels();
mountBadgeSettings(PROJECT_BADGE);
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
index 31ec4e29ad2..d3028aec313 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_with_alerts';
+import monitoringApp from '~/monitoring/monitoring_app';
-document.addEventListener('DOMContentLoaded', monitoringBundle);
+document.addEventListener('DOMContentLoaded', monitoringApp);
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index 803f4e37705..03504fba1ae 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -1,10 +1,12 @@
import Vue from 'vue';
import { __ } from '~/locale';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
+import CodeCoverage from '../components/code_coverage.vue';
import SeriesDataMixin from './series_data_mixin';
document.addEventListener('DOMContentLoaded', () => {
const languagesContainer = document.getElementById('js-languages-chart');
+ const codeCoverageContainer = document.getElementById('js-code-coverage-chart');
const monthContainer = document.getElementById('js-month-chart');
const weekdayContainer = document.getElementById('js-weekday-chart');
const hourContainer = document.getElementById('js-hour-chart');
@@ -59,6 +61,18 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line no-new
new Vue({
+ el: codeCoverageContainer,
+ render(h) {
+ return h(CodeCoverage, {
+ props: {
+ graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint,
+ },
+ });
+ },
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
el: monthContainer,
components: {
GlColumnChart,
diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
new file mode 100644
index 00000000000..af8fb032c22
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -0,0 +1,177 @@
+<script>
+import { GlAlert, GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import dateFormat from 'dateformat';
+import axios from '~/lib/utils/axios_utils';
+import { get } from 'lodash';
+
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlAlert,
+ GlAreaChart,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSprintf,
+ },
+ props: {
+ graphEndpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dailyCoverageData: [],
+ hasFetchError: false,
+ isLoading: true,
+ selectedCoverageIndex: 0,
+ tooltipTitle: '',
+ coveragePercentage: '',
+ chartOptions: {
+ yAxis: {
+ name: __('Bi-weekly code coverage'),
+ type: 'value',
+ min: 0,
+ max: 100,
+ },
+ xAxis: {
+ name: '',
+ type: 'category',
+ },
+ },
+ };
+ },
+ computed: {
+ hasData() {
+ return this.dailyCoverageData.length > 0;
+ },
+ isReady() {
+ return !this.isLoading && !this.hasFetchError;
+ },
+ canShowData() {
+ return this.isReady && this.hasData;
+ },
+ noDataAvailable() {
+ return this.isReady && !this.hasData;
+ },
+ selectedDailyCoverage() {
+ return this.hasData && this.dailyCoverageData[this.selectedCoverageIndex];
+ },
+ selectedDailyCoverageName() {
+ return this.selectedDailyCoverage?.group_name;
+ },
+ formattedData() {
+ if (this.selectedDailyCoverage?.data) {
+ return this.selectedDailyCoverage.data.map(value => [
+ dateFormat(value.date, 'mmm dd'),
+ value.coverage,
+ ]);
+ }
+
+ // If the fetching failed, we return an empty array which
+ // allow the graph to render while empty
+ return [];
+ },
+ chartData() {
+ return [
+ {
+ // The default string 'data' will get shown in the legend if we fail to fetch the data
+ name: this.canShowData ? this.selectedDailyCoverageName : __('data'),
+ data: this.formattedData,
+ type: 'line',
+ smooth: true,
+ },
+ ];
+ },
+ },
+ created() {
+ axios
+ .get(this.graphEndpoint)
+ .then(({ data }) => {
+ this.dailyCoverageData = data;
+ })
+ .catch(() => {
+ this.hasFetchError = true;
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ methods: {
+ setSelectedCoverage(index) {
+ this.selectedCoverageIndex = index;
+ },
+ formatTooltipText(params) {
+ this.tooltipTitle = params.value;
+ this.coveragePercentage = get(params, 'seriesData[0].data[1]', '');
+ },
+ },
+ height: 200,
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-mt-3 gl-mb-3">
+ <gl-alert
+ v-if="hasFetchError"
+ variant="danger"
+ :title="s__('Code Coverage|Couldn\'t fetch the code coverage data')"
+ :dismissible="false"
+ />
+ <gl-alert
+ v-if="noDataAvailable"
+ variant="info"
+ :title="s__('Code Coverage| Empty code coverage data')"
+ :dismissible="false"
+ >
+ <span>
+ {{ __('It seems that there is currently no available data for code coverage') }}
+ </span>
+ </gl-alert>
+ <gl-dropdown v-if="canShowData" :text="selectedDailyCoverageName">
+ <gl-dropdown-item
+ v-for="({ group_name }, index) in dailyCoverageData"
+ :key="index"
+ :value="group_name"
+ @click="setSelectedCoverage(index)"
+ >
+ <div class="gl-display-flex">
+ <gl-icon
+ v-if="index === selectedCoverageIndex"
+ name="mobile-issue-close"
+ class="gl-absolute"
+ />
+ <span class="gl-display-flex align-items-center ml-4">
+ {{ group_name }}
+ </span>
+ </div>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ <gl-area-chart
+ v-if="!isLoading"
+ :height="$options.height"
+ :data="chartData"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ >
+ <template v-if="canShowData" #tooltipTitle>
+ {{ tooltipTitle }}
+ </template>
+ <template v-if="canShowData" #tooltipContent>
+ <gl-sprintf :message="__('Code Coverage: %{coveragePercentage}%{percentSymbol}')">
+ <template #coveragePercentage>
+ {{ coveragePercentage }}
+ </template>
+ <template #percentSymbol>
+ %
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-area-chart>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index 190d0806c28..8e0af018b61 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,10 +1,7 @@
import Project from './project';
import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
-import initCreateCluster from '~/create_cluster/init_create_cluster';
document.addEventListener('DOMContentLoaded', () => {
- initCreateCluster(document, gon);
-
new Project(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
});
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index e8e0cda2139..a66b665d152 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -9,6 +9,7 @@ import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
import initIssuablesList from '~/issuables_list';
import initManualOrdering from '~/manual_ordering';
+import { showLearnGitLabIssuesPopover } from '~/onboarding_issues';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
@@ -24,4 +25,5 @@ document.addEventListener('DOMContentLoaded', () => {
initManualOrdering();
initIssuablesList();
+ showLearnGitLabIssuesPopover();
});
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
index 3b26047455d..08078fa6b62 100644
--- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
@@ -90,7 +90,7 @@ export default {
footer-primary-button-variant="warning"
@submit="onSubmit"
>
- <div slot="title" class="modal-title-with-label" v-html="title">{{ title }}</div>
+ <div slot="title" class="modal-title-with-label" v-html="title"></div>
{{ text }}
</gl-modal>
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index ddc648702f1..4708970efef 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,4 +1,5 @@
import initMrNotes from '~/mr_notes';
+import { initReviewBar } from '~/batch_comments';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initShow from '../init_merge_request_show';
@@ -8,4 +9,5 @@ document.addEventListener('DOMContentLoaded', () => {
initSidebarBundle();
}
initMrNotes();
+ initReviewBar();
});
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index 097403ba9e2..e17059dd55a 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -1,7 +1,46 @@
import initProjectVisibilitySelector from '../../../project_visibility';
import initProjectNew from '../../../projects/project_new';
+import { __ } from '~/locale';
+import createFlash from '~/flash';
+import Tracking from '~/tracking';
document.addEventListener('DOMContentLoaded', () => {
initProjectVisibilitySelector();
initProjectNew.bindEvents();
+
+ const { category, property } = gon.tracking_data ?? { category: 'projects:new' };
+ const hasNewCreateProjectUi = 'newCreateProjectUi' in gon?.features;
+
+ if (!hasNewCreateProjectUi) {
+ // Setting additional tracking for HAML template
+
+ Array.from(
+ document.querySelectorAll('.project-edit-container [data-experiment-track-label]'),
+ ).forEach(node =>
+ node.addEventListener('click', event => {
+ const { experimentTrackLabel: label } = event.currentTarget.dataset;
+ Tracking.event(category, 'click_tab', { property, label });
+ }),
+ );
+ } else {
+ import(
+ /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation'
+ )
+ .then(m => {
+ const el = document.querySelector('.js-experiment-new-project-creation');
+
+ if (!el) {
+ return;
+ }
+
+ const config = {
+ hasErrors: 'hasErrors' in el.dataset,
+ isCiCdAvailable: 'isCiCdAvailable' in el.dataset,
+ };
+ m.default(el, config);
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while loading project creation UI'));
+ });
+ }
});
diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js
index bbad3238ec4..2c37d7da4a7 100644
--- a/app/assets/javascripts/pages/projects/pipelines/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js
@@ -51,6 +51,7 @@ document.addEventListener(
ciLintPath: this.dataset.ciLintPath,
resetCachePath: this.dataset.resetCachePath,
projectId: this.dataset.projectId,
+ params: JSON.parse(this.dataset.params),
},
});
},
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 ab32fe18972..7181332a1d6 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
@@ -46,7 +46,11 @@ export default {
allowedVisibilityOptions: {
type: Array,
required: false,
- default: () => [0, 10, 20],
+ default: () => [
+ visibilityOptions.PRIVATE,
+ visibilityOptions.INTERNAL,
+ visibilityOptions.PUBLIC,
+ ],
},
lfsAvailable: {
type: Boolean,
@@ -118,16 +122,14 @@ export default {
const defaults = {
visibilityOptions,
visibilityLevel: visibilityOptions.PUBLIC,
- // TODO: Change all of these to use the visibilityOptions constants
- // https://gitlab.com/gitlab-org/gitlab/-/issues/214667
- issuesAccessLevel: 20,
- repositoryAccessLevel: 20,
- forkingAccessLevel: 20,
- mergeRequestsAccessLevel: 20,
- buildsAccessLevel: 20,
- wikiAccessLevel: 20,
- snippetsAccessLevel: 20,
- pagesAccessLevel: 20,
+ issuesAccessLevel: featureAccessLevel.EVERYONE,
+ repositoryAccessLevel: featureAccessLevel.EVERYONE,
+ forkingAccessLevel: featureAccessLevel.EVERYONE,
+ mergeRequestsAccessLevel: featureAccessLevel.EVERYONE,
+ buildsAccessLevel: featureAccessLevel.EVERYONE,
+ wikiAccessLevel: featureAccessLevel.EVERYONE,
+ snippetsAccessLevel: featureAccessLevel.EVERYONE,
+ pagesAccessLevel: featureAccessLevel.EVERYONE,
metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
containerRegistryEnabled: true,
lfsEnabled: true,
@@ -180,7 +182,7 @@ export default {
},
repositoryEnabled() {
- return this.repositoryAccessLevel > 0;
+ return this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED;
},
visibilityLevelDescription() {
@@ -206,40 +208,70 @@ export default {
visibilityLevel(value, oldValue) {
if (value === visibilityOptions.PRIVATE) {
// when private, features are restricted to "only team members"
- this.issuesAccessLevel = Math.min(10, this.issuesAccessLevel);
- this.repositoryAccessLevel = Math.min(10, this.repositoryAccessLevel);
- this.mergeRequestsAccessLevel = Math.min(10, this.mergeRequestsAccessLevel);
- this.buildsAccessLevel = Math.min(10, this.buildsAccessLevel);
- this.wikiAccessLevel = Math.min(10, this.wikiAccessLevel);
- this.snippetsAccessLevel = Math.min(10, this.snippetsAccessLevel);
- this.metricsDashboardAccessLevel = Math.min(10, this.metricsDashboardAccessLevel);
- if (this.pagesAccessLevel === 20) {
+ this.issuesAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.issuesAccessLevel,
+ );
+ this.repositoryAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.repositoryAccessLevel,
+ );
+ this.mergeRequestsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.mergeRequestsAccessLevel,
+ );
+ this.buildsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.buildsAccessLevel,
+ );
+ this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel);
+ this.snippetsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.snippetsAccessLevel,
+ );
+ this.metricsDashboardAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.metricsDashboardAccessLevel,
+ );
+ if (this.pagesAccessLevel === featureAccessLevel.EVERYONE) {
// When from Internal->Private narrow access for only members
- this.pagesAccessLevel = 10;
+ this.pagesAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
}
this.highlightChanges();
} else if (oldValue === visibilityOptions.PRIVATE) {
// if changing away from private, make enabled features more permissive
- if (this.issuesAccessLevel > 0) this.issuesAccessLevel = 20;
- if (this.repositoryAccessLevel > 0) this.repositoryAccessLevel = 20;
- if (this.mergeRequestsAccessLevel > 0) this.mergeRequestsAccessLevel = 20;
- if (this.buildsAccessLevel > 0) this.buildsAccessLevel = 20;
- if (this.wikiAccessLevel > 0) this.wikiAccessLevel = 20;
- if (this.snippetsAccessLevel > 0) this.snippetsAccessLevel = 20;
- if (this.pagesAccessLevel === 10) this.pagesAccessLevel = 20;
- if (this.metricsDashboardAccessLevel === 10) this.metricsDashboardAccessLevel = 20;
+ if (this.issuesAccessLevel > featureAccessLevel.NOT_ENABLED)
+ this.issuesAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED)
+ this.repositoryAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.mergeRequestsAccessLevel > featureAccessLevel.NOT_ENABLED)
+ this.mergeRequestsAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.buildsAccessLevel > featureAccessLevel.NOT_ENABLED)
+ this.buildsAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.wikiAccessLevel > featureAccessLevel.NOT_ENABLED)
+ this.wikiAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.snippetsAccessLevel > featureAccessLevel.NOT_ENABLED)
+ this.snippetsAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.pagesAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
+ this.pagesAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.metricsDashboardAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
+ this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE;
this.highlightChanges();
}
},
issuesAccessLevel(value, oldValue) {
- if (value === 0) toggleHiddenClassBySelector('.issues-feature', true);
- else if (oldValue === 0) toggleHiddenClassBySelector('.issues-feature', false);
+ if (value === featureAccessLevel.NOT_ENABLED)
+ toggleHiddenClassBySelector('.issues-feature', true);
+ else if (oldValue === featureAccessLevel.NOT_ENABLED)
+ toggleHiddenClassBySelector('.issues-feature', false);
},
mergeRequestsAccessLevel(value, oldValue) {
- if (value === 0) toggleHiddenClassBySelector('.merge-requests-feature', true);
- else if (oldValue === 0) toggleHiddenClassBySelector('.merge-requests-feature', false);
+ if (value === featureAccessLevel.NOT_ENABLED)
+ toggleHiddenClassBySelector('.merge-requests-feature', true);
+ else if (oldValue === featureAccessLevel.NOT_ENABLED)
+ toggleHiddenClassBySelector('.merge-requests-feature', false);
},
},
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 6ae10c98058..3c44053e2b2 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -1,4 +1,6 @@
import $ from 'jquery';
+import 'jquery.waitforimages';
+
import initBlob from '~/blob_edit/blob_bundle';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import NotificationsForm from '~/notifications_form';
@@ -12,9 +14,12 @@ import initReadMore from '~/read_more';
import leaveByUrl from '~/namespaces/leave_by_url';
import Star from '../../../star';
import notificationsDropdown from '../../../notifications_dropdown';
+import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert';
+import { showLearnGitLabProjectPopover } from '~/onboarding_issues';
document.addEventListener('DOMContentLoaded', () => {
initReadMore();
+ initNamespaceStorageLimitAlert();
new Star(); // eslint-disable-line no-new
notificationsDropdown();
new ShortcutsNavigation(); // eslint-disable-line no-new
@@ -55,4 +60,6 @@ document.addEventListener('DOMContentLoaded', () => {
throw e;
});
}
+
+ showLearnGitLabProjectPopover();
});
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
index 16d71379e31..0d1d32317fe 100644
--- a/app/assets/javascripts/pages/projects/tree/show/index.js
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -1,4 +1,6 @@
import $ from 'jquery';
+import 'jquery.waitforimages';
+
import Vue from 'vue';
import initBlob from '~/blob_edit/blob_bundle';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js
index f5fd84d69ac..9c75531ca40 100644
--- a/app/assets/javascripts/pages/projects/wikis/index.js
+++ b/app/assets/javascripts/pages/projects/wikis/index.js
@@ -1,41 +1,3 @@
-import $ from 'jquery';
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import csrf from '~/lib/utils/csrf';
-import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki';
-import Wikis from './wikis';
-import ZenMode from '../../../zen_mode';
-import GLForm from '../../../gl_form';
-import deleteWikiModal from './components/delete_wiki_modal.vue';
+import initWikis from '~/pages/shared/wikis';
-document.addEventListener('DOMContentLoaded', () => {
- new Wikis(); // eslint-disable-line no-new
- new ShortcutsWiki(); // eslint-disable-line no-new
- new ZenMode(); // eslint-disable-line no-new
- new GLForm($('.wiki-form')); // eslint-disable-line no-new
-
- const deleteWikiModalWrapperEl = document.getElementById('delete-wiki-modal-wrapper');
-
- if (deleteWikiModalWrapperEl) {
- Vue.use(Translate);
-
- const { deleteWikiUrl, pageTitle } = deleteWikiModalWrapperEl.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el: deleteWikiModalWrapperEl,
- data: {
- deleteWikiUrl: '',
- },
- render(createElement) {
- return createElement(deleteWikiModal, {
- props: {
- pageTitle,
- deleteWikiUrl,
- csrfToken: csrf.token,
- },
- });
- },
- });
- }
-});
+document.addEventListener('DOMContentLoaded', initWikis);
diff --git a/app/assets/javascripts/pages/sessions/index.js b/app/assets/javascripts/pages/sessions/index.js
index c2c069d1ca8..e93def5323f 100644
--- a/app/assets/javascripts/pages/sessions/index.js
+++ b/app/assets/javascripts/pages/sessions/index.js
@@ -1,3 +1,3 @@
-import initU2F from '../../shared/sessions/u2f';
+import { mount2faAuthentication } from '~/authentication/mount_2fa';
-document.addEventListener('DOMContentLoaded', initU2F);
+document.addEventListener('DOMContentLoaded', mount2faAuthentication);
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 191221a48cd..8d2d5d41f6a 100644
--- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -5,13 +5,12 @@ import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
* OAuth-based login buttons have a separate "remember me" checkbox.
*
* Toggling this checkbox adds/removes a `remember_me` parameter to the
- * login buttons' href, which is passed on to the omniauth callback.
+ * login buttons' parent form action, which is passed on to the omniauth callback.
*/
export default class OAuthRememberMe {
constructor(opts = {}) {
this.container = opts.container || '';
- this.loginLinkSelector = '.oauth-login';
}
bindEvents() {
@@ -22,12 +21,13 @@ export default class OAuthRememberMe {
const rememberMe = $(event.target).is(':checked');
$('.oauth-login', this.container).each((i, element) => {
- const href = $(element).attr('href');
+ const $form = $(element).parent('form');
+ const href = $form.attr('action');
if (rememberMe) {
- $(element).attr('href', mergeUrlParams({ remember_me: 1 }, href));
+ $form.attr('action', mergeUrlParams({ remember_me: 1 }, href));
} else {
- $(element).attr('href', removeParams(['remember_me'], href));
+ $form.attr('action', 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
index e617fecaa0f..1d47a9aed47 100644
--- a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js
+++ b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js
@@ -12,7 +12,7 @@ export default function preserveUrlFragment(fragment = '') {
// 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');
+ const forms = document.querySelectorAll('#signin-container .tab-content form');
Array.prototype.forEach.call(forms, form => {
const actionWithFragment = setUrlFragment(form.getAttribute('action'), `#${normalFragment}`);
form.setAttribute('action', actionWithFragment);
@@ -20,13 +20,13 @@ export default function preserveUrlFragment(fragment = '') {
// 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 oauthForms = document.querySelectorAll('#signin-container .omniauth-container form');
+ Array.prototype.forEach.call(oauthForms, oauthForm => {
const newHref = mergeUrlParams(
{ redirect_fragment: normalFragment },
- anchor.getAttribute('href'),
+ oauthForm.getAttribute('action'),
);
- anchor.setAttribute('href', newHref);
+ oauthForm.setAttribute('action', newHref);
});
}
}
diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue
index 580cca49b5e..580cca49b5e 100644
--- a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue
diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js
new file mode 100644
index 00000000000..5e23b9bab4e
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/index.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import csrf from '~/lib/utils/csrf';
+import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki';
+import Wikis from './wikis';
+import ZenMode from '../../../zen_mode';
+import GLForm from '../../../gl_form';
+import deleteWikiModal from './components/delete_wiki_modal.vue';
+
+export default () => {
+ new Wikis(); // eslint-disable-line no-new
+ new ShortcutsWiki(); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($('.wiki-form')); // eslint-disable-line no-new
+
+ const deleteWikiModalWrapperEl = document.getElementById('delete-wiki-modal-wrapper');
+
+ if (deleteWikiModalWrapperEl) {
+ Vue.use(Translate);
+
+ const { deleteWikiUrl, pageTitle } = deleteWikiModalWrapperEl.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: deleteWikiModalWrapperEl,
+ data: {
+ deleteWikiUrl: '',
+ },
+ render(createElement) {
+ return createElement(deleteWikiModal, {
+ props: {
+ pageTitle,
+ deleteWikiUrl,
+ csrfToken: csrf.token,
+ },
+ });
+ },
+ });
+ }
+};
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index ed67219383b..ed67219383b 100644
--- a/app/assets/javascripts/pages/projects/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index e1a0e2df0e0..ef24dbfb6ce 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -39,6 +39,11 @@ export default {
metricDetails() {
return this.currentRequest.details[this.metric];
},
+ metricDetailsLabel() {
+ return this.metricDetails.duration
+ ? `${this.metricDetails.duration} / ${this.metricDetails.calls}`
+ : this.metricDetails.calls;
+ },
detailsList() {
return this.metricDetails.details;
},
@@ -68,7 +73,7 @@ export default {
type="button"
data-toggle="modal"
>
- {{ metricDetails.duration }} / {{ metricDetails.calls }}
+ {{ metricDetailsLabel }}
</button>
<gl-modal
:id="`modal-peek-${metric}-details`"
@@ -80,7 +85,9 @@ export default {
<template v-if="detailsList.length">
<tr v-for="(item, index) in detailsList" :key="index">
<td>
- <span>{{ sprintf(__('%{duration}ms'), { duration: item.duration }) }}</span>
+ <span v-if="item.duration">{{
+ sprintf(__('%{duration}ms'), { duration: item.duration })
+ }}</span>
</td>
<td>
<div class="js-toggle-container">
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 1df5562e1b6..cccb5e1be06 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -38,6 +38,11 @@ export default {
keys: ['sql'],
},
{
+ metric: 'bullet',
+ header: s__('PerformanceBar|Bullet notifications'),
+ keys: ['notification'],
+ },
+ {
metric: 'gitaly',
header: s__('PerformanceBar|Gitaly calls'),
keys: ['feature', 'request'],
@@ -50,7 +55,12 @@ export default {
{
metric: 'redis',
header: s__('PerformanceBar|Redis calls'),
- keys: ['cmd'],
+ keys: ['cmd', 'instance'],
+ },
+ {
+ metric: 'es',
+ header: s__('PerformanceBar|Elasticsearch calls'),
+ keys: ['request', 'body'],
},
{
metric: 'total',
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
new file mode 100644
index 00000000000..6e292299778
--- /dev/null
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -0,0 +1,15 @@
+import PersistentUserCallout from './persistent_user_callout';
+
+const PERSISTENT_USER_CALLOUTS = [
+ '.js-recovery-settings-callout',
+ '.js-users-over-license-callout',
+ '.js-admin-licensed-user-count-threshold',
+];
+
+const initCallouts = () => {
+ PERSISTENT_USER_CALLOUTS.forEach(calloutContainer =>
+ PersistentUserCallout.factory(document.querySelector(calloutContainer)),
+ );
+};
+
+export default initCallouts;
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js
new file mode 100644
index 00000000000..51b1fb4f4cc
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/constants.js
@@ -0,0 +1,10 @@
+/* Error constants */
+export const PARSE_FAILURE = 'parse_failure';
+export const LOAD_FAILURE = 'load_failure';
+export const UNSUPPORTED_DATA = 'unsupported_data';
+export const DEFAULT = 'default';
+
+/* Interaction handles */
+export const IS_HIGHLIGHTED = 'dag-highlighted';
+export const LINK_SELECTOR = 'dag-link';
+export const NODE_SELECTOR = 'dag-node';
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
new file mode 100644
index 00000000000..6e0d23ef87f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -0,0 +1,136 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import DagGraph from './dag_graph.vue';
+import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants';
+import { parseData } from './parsing_utils';
+
+export default {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Dag',
+ components: {
+ DagGraph,
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ graphUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ showFailureAlert: false,
+ showBetaInfo: true,
+ failureType: null,
+ graphData: null,
+ };
+ },
+ errorTexts: {
+ [LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'),
+ [PARSE_FAILURE]: __('There was an error parsing the data for this graph.'),
+ [UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'),
+ [DEFAULT]: __('An unknown error occurred while loading this graph.'),
+ },
+ computed: {
+ betaMessage() {
+ return __(
+ 'This feature is currently in beta. We invite you to %{linkStart}give feedback%{linkEnd}.',
+ );
+ },
+ failure() {
+ switch (this.failureType) {
+ case LOAD_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE],
+ variant: 'danger',
+ };
+ case PARSE_FAILURE:
+ return {
+ text: this.$options.errorTexts[PARSE_FAILURE],
+ variant: 'danger',
+ };
+ case UNSUPPORTED_DATA:
+ return {
+ text: this.$options.errorTexts[UNSUPPORTED_DATA],
+ variant: 'info',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ vatiant: 'danger',
+ };
+ }
+ },
+ shouldDisplayGraph() {
+ return Boolean(!this.showFailureAlert && this.graphData);
+ },
+ },
+ mounted() {
+ const { processGraphData, reportFailure } = this;
+
+ if (!this.graphUrl) {
+ reportFailure();
+ return;
+ }
+
+ axios
+ .get(this.graphUrl)
+ .then(response => {
+ processGraphData(response.data);
+ })
+ .catch(() => reportFailure(LOAD_FAILURE));
+ },
+ methods: {
+ processGraphData(data) {
+ let parsed;
+
+ try {
+ parsed = parseData(data.stages);
+ } catch {
+ this.reportFailure(PARSE_FAILURE);
+ return;
+ }
+
+ if (parsed.links.length < 2) {
+ this.reportFailure(UNSUPPORTED_DATA);
+ return;
+ }
+
+ this.graphData = parsed;
+ },
+ hideAlert() {
+ this.showFailureAlert = false;
+ },
+ hideBetaInfo() {
+ this.showBetaInfo = false;
+ },
+ reportFailure(type) {
+ this.showFailureAlert = true;
+ this.failureType = type;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">
+ {{ failure.text }}
+ </gl-alert>
+
+ <gl-alert v-if="showBetaInfo" @dismiss="hideBetaInfo">
+ <gl-sprintf :message="betaMessage">
+ <template #link="{ content }">
+ <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/220368" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
new file mode 100644
index 00000000000..063ec091e4d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
@@ -0,0 +1,299 @@
+<script>
+import * as d3 from 'd3';
+import { uniqueId } from 'lodash';
+import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants';
+import {
+ highlightLinks,
+ restoreLinks,
+ toggleLinkHighlight,
+ togglePathHighlights,
+} from './interactions';
+import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
+import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
+
+export default {
+ viewOptions: {
+ baseHeight: 300,
+ baseWidth: 1000,
+ minNodeHeight: 60,
+ nodeWidth: 16,
+ nodePadding: 25,
+ paddingForLabels: 100,
+ labelMargin: 8,
+
+ baseOpacity: 0.8,
+ containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join(
+ ' ',
+ ),
+ },
+ gitLabColorRotation: [
+ '#e17223',
+ '#83ab4a',
+ '#5772ff',
+ '#b24800',
+ '#25d2d2',
+ '#006887',
+ '#487900',
+ '#d84280',
+ '#3547de',
+ '#6f3500',
+ '#006887',
+ '#275600',
+ '#b31756',
+ ],
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ color: () => {},
+ width: 0,
+ height: 0,
+ };
+ },
+ mounted() {
+ let countedAndTransformed;
+
+ try {
+ countedAndTransformed = this.transformData(this.graphData);
+ } catch {
+ this.$emit('onFailure', PARSE_FAILURE);
+ return;
+ }
+
+ this.drawGraph(countedAndTransformed);
+ },
+ methods: {
+ addSvg() {
+ return d3
+ .select('.dag-graph-container')
+ .append('svg')
+ .attr('viewBox', [0, 0, this.width, this.height])
+ .attr('width', this.width)
+ .attr('height', this.height);
+ },
+
+ appendLinks(link) {
+ return (
+ link
+ .append('path')
+ .attr('d', (d, i) => createLinkPath(d, i, this.$options.viewOptions.nodeWidth))
+ .attr('stroke', ({ gradId }) => `url(#${gradId})`)
+ .style('stroke-linejoin', 'round')
+ // minus two to account for the rounded nodes
+ .attr('stroke-width', ({ width }) => Math.max(1, width - 2))
+ .attr('clip-path', ({ clipId }) => `url(#${clipId})`)
+ );
+ },
+
+ appendLinkInteractions(link) {
+ return link
+ .on('mouseover', highlightLinks)
+ .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity))
+ .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity));
+ },
+
+ appendNodeInteractions(node) {
+ return node.on(
+ 'click',
+ togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity),
+ );
+ },
+
+ appendLabelAsForeignObject(d, i, n) {
+ const currentNode = n[i];
+ const { height, wrapperWidth, width, x, y, textAlign } = labelPosition(d, {
+ ...this.$options.viewOptions,
+ width: this.width,
+ });
+
+ const labelClasses = [
+ 'gl-display-flex',
+ 'gl-pointer-events-none',
+ 'gl-flex-direction-column',
+ 'gl-justify-content-center',
+ 'gl-overflow-wrap-break',
+ ].join(' ');
+
+ return (
+ d3
+ .select(currentNode)
+ .attr('requiredFeatures', 'http://www.w3.org/TR/SVG11/feature#Extensibility')
+ .attr('height', height)
+ /*
+ items with a 'max-content' width will have a wrapperWidth for the foreignObject
+ */
+ .attr('width', wrapperWidth || width)
+ .attr('x', x)
+ .attr('y', y)
+ .classed('gl-overflow-visible', true)
+ .append('xhtml:div')
+ .classed(labelClasses, true)
+ .style('height', height)
+ .style('width', width)
+ .style('text-align', textAlign)
+ .text(({ name }) => name)
+ );
+ },
+
+ createAndAssignId(datum, field, modifier = '') {
+ const id = uniqueId(modifier);
+ /* eslint-disable-next-line no-param-reassign */
+ datum[field] = id;
+ return id;
+ },
+
+ createClip(link) {
+ return link
+ .append('clipPath')
+ .attr('id', d => {
+ return this.createAndAssignId(d, 'clipId', 'dag-clip');
+ })
+ .append('path')
+ .attr('d', calculateClip);
+ },
+
+ createGradient(link) {
+ const gradient = link
+ .append('linearGradient')
+ .attr('id', d => {
+ return this.createAndAssignId(d, 'gradId', 'dag-grad');
+ })
+ .attr('gradientUnits', 'userSpaceOnUse')
+ .attr('x1', ({ source }) => source.x1)
+ .attr('x2', ({ target }) => target.x0);
+
+ gradient
+ .append('stop')
+ .attr('offset', '0%')
+ .attr('stop-color', ({ source }) => this.color(source));
+
+ gradient
+ .append('stop')
+ .attr('offset', '100%')
+ .attr('stop-color', ({ target }) => this.color(target));
+ },
+
+ createLinks(svg, linksData) {
+ const links = this.generateLinks(svg, linksData);
+ this.createGradient(links);
+ this.createClip(links);
+ this.appendLinks(links);
+ this.appendLinkInteractions(links);
+ },
+
+ createNodes(svg, nodeData) {
+ const nodes = this.generateNodes(svg, nodeData);
+ this.labelNodes(svg, nodeData);
+ this.appendNodeInteractions(nodes);
+ },
+
+ drawGraph({ maxNodesPerLayer, linksAndNodes }) {
+ const {
+ baseWidth,
+ baseHeight,
+ minNodeHeight,
+ nodeWidth,
+ nodePadding,
+ paddingForLabels,
+ } = this.$options.viewOptions;
+
+ this.width = baseWidth;
+ this.height = baseHeight + maxNodesPerLayer * minNodeHeight;
+ this.color = this.initColors();
+
+ const { links, nodes } = createSankey({
+ width: this.width,
+ height: this.height,
+ nodeWidth,
+ nodePadding,
+ paddingForLabels,
+ })(linksAndNodes);
+
+ const svg = this.addSvg();
+ this.createLinks(svg, links);
+ this.createNodes(svg, nodes);
+ },
+
+ generateLinks(svg, linksData) {
+ return svg
+ .append('g')
+ .attr('fill', 'none')
+ .attr('stroke-opacity', this.$options.viewOptions.baseOpacity)
+ .selectAll(`.${LINK_SELECTOR}`)
+ .data(linksData)
+ .enter()
+ .append('g')
+ .attr('id', d => {
+ return this.createAndAssignId(d, 'uid', LINK_SELECTOR);
+ })
+ .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true);
+ },
+
+ generateNodes(svg, nodeData) {
+ const { nodeWidth } = this.$options.viewOptions;
+
+ return svg
+ .append('g')
+ .selectAll(`.${NODE_SELECTOR}`)
+ .data(nodeData)
+ .enter()
+ .append('line')
+ .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true)
+ .attr('id', d => {
+ return this.createAndAssignId(d, 'uid', NODE_SELECTOR);
+ })
+ .attr('stroke', d => {
+ const color = this.color(d);
+ /* eslint-disable-next-line no-param-reassign */
+ d.color = color;
+ return color;
+ })
+ .attr('stroke-width', nodeWidth)
+ .attr('stroke-linecap', 'round')
+ .attr('x1', d => Math.floor((d.x1 + d.x0) / 2))
+ .attr('x2', d => Math.floor((d.x1 + d.x0) / 2))
+ .attr('y1', d => d.y0 + 4)
+ .attr('y2', d => d.y1 - 4);
+ },
+
+ labelNodes(svg, nodeData) {
+ return svg
+ .append('g')
+ .classed('gl-font-sm', true)
+ .selectAll('text')
+ .data(nodeData)
+ .enter()
+ .append('foreignObject')
+ .each(this.appendLabelAsForeignObject);
+ },
+
+ initColors() {
+ const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation);
+ return ({ name }) => colorFn(name);
+ },
+
+ transformData(parsed) {
+ const baseLayout = createSankey()(parsed);
+ const cleanedNodes = removeOrphanNodes(baseLayout.nodes);
+ const maxNodesPerLayer = getMaxNodes(cleanedNodes);
+
+ return {
+ maxNodesPerLayer,
+ linksAndNodes: {
+ links: parsed.links,
+ nodes: cleanedNodes,
+ },
+ };
+ },
+ },
+};
+</script>
+<template>
+ <div :class="$options.viewOptions.containerClasses" data-testid="dag-graph-container">
+ <!-- graph goes here -->
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/drawing_utils.js b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js
new file mode 100644
index 00000000000..d56addc473f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js
@@ -0,0 +1,134 @@
+import * as d3 from 'd3';
+import { sankey, sankeyLeft } from 'd3-sankey';
+
+export const calculateClip = ({ y0, y1, source, target, width }) => {
+ /*
+ Because large link values can overrun their box, we create a clip path
+ to trim off the excess in charts that have few nodes per column and are
+ therefore tall.
+
+ The box is created by
+ M: moving to outside midpoint of the source node
+ V: drawing a vertical line to maximum of the bottom link edge or
+ the lowest edge of the node (can be d.y0 or d.y1 depending on the link's path)
+ H: drawing a horizontal line to the outside edge of the destination node
+ V: drawing a vertical line back up to the minimum of the top link edge or
+ the highest edge of the node (can be d.y0 or d.y1 depending on the link's path)
+ H: drawing a horizontal line back to the outside edge of the source node
+ Z: closing the path, back to the start point
+ */
+
+ const bottomLinkEdge = Math.max(y1, y0) + width / 2;
+ const topLinkEdge = Math.min(y0, y1) - width / 2;
+
+ /* eslint-disable @gitlab/require-i18n-strings */
+ return `
+ M${source.x0}, ${y1}
+ V${Math.max(bottomLinkEdge, y0, y1)}
+ H${target.x1}
+ V${Math.min(topLinkEdge, y0, y1)}
+ H${source.x0}
+ Z
+ `;
+ /* eslint-enable @gitlab/require-i18n-strings */
+};
+
+export const createLinkPath = ({ y0, y1, source, target, width }, idx, nodeWidth) => {
+ /*
+ Creates a series of staggered midpoints for the link paths, so they
+ don't run along one channel and can be distinguished.
+
+ First, get a point staggered by index and link width, modulated by the link box
+ to find a point roughly between the nodes.
+
+ Then offset it by nodeWidth, so it doesn't run under any nodes at the left.
+
+ Determine where it would overlap at the right.
+
+ Finally, select the leftmost of these options:
+ - offset from the source node based on index + fudge;
+ - a fuzzy offset from the right node, using Math.random adds a little blur
+ - a hard offset from the end node, if random pushes it over
+
+ Then draw a line from the start node to the bottom-most point of the midline
+ up to the topmost point in that line and then to the middle of the end node
+ */
+
+ const xValRaw = source.x1 + (((idx + 1) * width) % (target.x1 - source.x0));
+ const xValMin = xValRaw + nodeWidth;
+ const overlapPoint = source.x1 + (target.x0 - source.x1);
+ const xValMax = overlapPoint - nodeWidth * 1.4;
+
+ const midPointX = Math.min(xValMin, target.x0 - nodeWidth * 4 * Math.random(), xValMax);
+
+ return d3.line()([
+ [(source.x0 + source.x1) / 2, y0],
+ [midPointX, y0],
+ [midPointX, y1],
+ [(target.x0 + target.x1) / 2, y1],
+ ]);
+};
+
+/*
+ createSankey calls the d3 layout to generate the relationships and positioning
+ values for the nodes and links in the graph.
+ */
+
+export const createSankey = ({
+ width = 10,
+ height = 10,
+ nodeWidth = 10,
+ nodePadding = 10,
+ paddingForLabels = 1,
+} = {}) => {
+ const sankeyGenerator = sankey()
+ .nodeId(({ name }) => name)
+ .nodeAlign(sankeyLeft)
+ .nodeWidth(nodeWidth)
+ .nodePadding(nodePadding)
+ .extent([
+ [paddingForLabels, paddingForLabels],
+ [width - paddingForLabels, height - paddingForLabels],
+ ]);
+ return ({ nodes, links }) =>
+ sankeyGenerator({
+ nodes: nodes.map(d => ({ ...d })),
+ links: links.map(d => ({ ...d })),
+ });
+};
+
+export const labelPosition = ({ x0, x1, y0, y1 }, viewOptions) => {
+ const { paddingForLabels, labelMargin, nodePadding, width } = viewOptions;
+
+ const firstCol = x0 <= paddingForLabels;
+ const lastCol = x1 >= width - paddingForLabels;
+
+ if (firstCol) {
+ return {
+ x: 0 + labelMargin,
+ y: y0,
+ height: `${y1 - y0}px`,
+ width: paddingForLabels - 2 * labelMargin,
+ textAlign: 'right',
+ };
+ }
+
+ if (lastCol) {
+ return {
+ x: width - paddingForLabels + labelMargin,
+ y: y0,
+ height: `${y1 - y0}px`,
+ width: paddingForLabels - 2 * labelMargin,
+ textAlign: 'left',
+ };
+ }
+
+ return {
+ x: (x1 + x0) / 2,
+ y: y0 - nodePadding,
+ height: `${nodePadding}px`,
+ width: 'max-content',
+ wrapperWidth: paddingForLabels - 2 * labelMargin,
+ textAlign: x0 < width / 2 ? 'left' : 'right',
+ };
+};
diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js
new file mode 100644
index 00000000000..c9008730c90
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/interactions.js
@@ -0,0 +1,134 @@
+import * as d3 from 'd3';
+import { LINK_SELECTOR, NODE_SELECTOR, IS_HIGHLIGHTED } from './constants';
+
+export const highlightIn = 1;
+export const highlightOut = 0.2;
+
+const getCurrent = (idx, collection) => d3.select(collection[idx]);
+const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED);
+const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
+const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
+
+const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut);
+const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2');
+const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn);
+const foregroundNodes = selection => selection.attr('stroke', d => d.color);
+const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity);
+const renewNodes = selection => selection.attr('stroke', d => d.color);
+
+const getAllLinkAncestors = node => {
+ if (node.targetLinks) {
+ return node.targetLinks.flatMap(n => {
+ return [n.uid, ...getAllLinkAncestors(n.source)];
+ });
+ }
+
+ return [];
+};
+
+const getAllNodeAncestors = node => {
+ let allNodes = [];
+
+ if (node.targetLinks) {
+ allNodes = node.targetLinks.flatMap(n => {
+ return getAllNodeAncestors(n.source);
+ });
+ }
+
+ return [...allNodes, node.uid];
+};
+
+export const highlightLinks = (d, idx, collection) => {
+ const currentLink = getCurrent(idx, collection);
+ const currentSourceNode = d3.select(`#${d.source.uid}`);
+ const currentTargetNode = d3.select(`#${d.target.uid}`);
+
+ /* Higlight selected link, de-emphasize others */
+ backgroundLinks(getOtherLinks());
+ foregroundLinks(currentLink);
+
+ /* Do the same to related nodes */
+ backgroundNodes(getNodesNotLive());
+ foregroundNodes(currentSourceNode);
+ foregroundNodes(currentTargetNode);
+};
+
+const highlightPath = (parentLinks, parentNodes) => {
+ /* de-emphasize everything else */
+ backgroundLinks(getOtherLinks());
+ backgroundNodes(getNodesNotLive());
+
+ /* highlight correct links */
+ parentLinks.forEach(id => {
+ foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true);
+ });
+
+ /* highlight correct nodes */
+ parentNodes.forEach(id => {
+ foregroundNodes(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true);
+ });
+};
+
+const restorePath = (parentLinks, parentNodes, baseOpacity) => {
+ parentLinks.forEach(id => {
+ renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false);
+ });
+
+ parentNodes.forEach(id => {
+ d3.select(`#${id}`).classed(IS_HIGHLIGHTED, false);
+ });
+
+ if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) {
+ renewLinks(getOtherLinks(), baseOpacity);
+ renewNodes(getNodesNotLive());
+ return;
+ }
+
+ backgroundLinks(getOtherLinks());
+ backgroundNodes(getNodesNotLive());
+};
+
+export const restoreLinks = (baseOpacity, d, idx, collection) => {
+ /* in this case, it has just been clicked */
+ if (currentIsLive(idx, collection)) {
+ return;
+ }
+
+ /*
+ if there exist live links, reset to highlight out / pale
+ otherwise, reset to base
+ */
+
+ if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) {
+ renewLinks(d3.selectAll(`.${LINK_SELECTOR}`), baseOpacity);
+ renewNodes(d3.selectAll(`.${NODE_SELECTOR}`));
+ return;
+ }
+
+ backgroundLinks(getOtherLinks());
+ backgroundNodes(getNodesNotLive());
+};
+
+export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => {
+ if (currentIsLive(idx, collection)) {
+ restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity);
+ return;
+ }
+
+ highlightPath([d.uid], [d.source.uid, d.target.uid]);
+};
+
+export const togglePathHighlights = (baseOpacity, d, idx, collection) => {
+ const parentLinks = getAllLinkAncestors(d);
+ const parentNodes = getAllNodeAncestors(d);
+ const currentNode = getCurrent(idx, collection);
+
+ /* if this node is already live, make it unlive and reset its path */
+ if (currentIsLive(idx, collection)) {
+ currentNode.classed(IS_HIGHLIGHTED, false);
+ restorePath(parentLinks, parentNodes, baseOpacity);
+ return;
+ }
+
+ highlightPath(parentLinks, parentNodes);
+};
diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
new file mode 100644
index 00000000000..3234f80ee91
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
@@ -0,0 +1,164 @@
+import { uniqWith, isEqual } from 'lodash';
+
+/*
+ The following functions are the main engine in transforming the data as
+ received from the endpoint into the format the d3 graph expects.
+
+ Input is of the form:
+ [stages]
+ stages: {name, groups}
+ groups: [{ name, size, jobs }]
+ name is a group name; in the case that the group has one job, it is
+ also the job name
+ size is the number of parallel jobs
+ jobs: [{ name, needs}]
+ job name is either the same as the group name or group x/y
+
+ Output is of the form:
+ { nodes: [node], links: [link] }
+ node: { name, category }, + unused info passed through
+ link: { source, target, value }, with source & target being node names
+ and value being a constant
+
+ We create nodes, create links, and then dedupe the links, so that in the case where
+ job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link
+ from job 1 to job 2 then another from job 2 to job 4.
+
+ CREATE NODES
+ stage.name -> node.category
+ stage.group.name -> node.name (this is the group name if there are parallel jobs)
+ stage.group.jobs -> node.jobs
+ stage.group.size -> node.size
+
+ CREATE LINKS
+ stages.groups.name -> target
+ stages.groups.needs.each -> source (source is the name of the group, not the parallel job)
+ 10 -> value (constant)
+ */
+
+export const createNodes = data => {
+ return data.flatMap(({ groups, name }) => {
+ return groups.map(group => {
+ return { ...group, category: name };
+ });
+ });
+};
+
+export const createNodeDict = nodes => {
+ return nodes.reduce((acc, node) => {
+ const newNode = {
+ ...node,
+ needs: node.jobs.map(job => job.needs || []).flat(),
+ };
+
+ if (node.size > 1) {
+ node.jobs.forEach(job => {
+ acc[job.name] = newNode;
+ });
+ }
+
+ acc[node.name] = newNode;
+ return acc;
+ }, {});
+};
+
+export const createNodesStructure = data => {
+ const nodes = createNodes(data);
+ const nodeDict = createNodeDict(nodes);
+
+ return { nodes, nodeDict };
+};
+
+export const makeLinksFromNodes = (nodes, nodeDict) => {
+ const constantLinkValue = 10; // all links are the same weight
+ return nodes
+ .map(group => {
+ return group.jobs.map(job => {
+ if (!job.needs) {
+ return [];
+ }
+
+ return job.needs.map(needed => {
+ return {
+ source: nodeDict[needed]?.name,
+ target: group.name,
+ value: constantLinkValue,
+ };
+ });
+ });
+ })
+ .flat(2);
+};
+
+export const getAllAncestors = (nodes, nodeDict) => {
+ const needs = nodes
+ .map(node => {
+ return nodeDict[node].needs || '';
+ })
+ .flat()
+ .filter(Boolean);
+
+ if (needs.length) {
+ return [...needs, ...getAllAncestors(needs, nodeDict)];
+ }
+
+ return [];
+};
+
+export const filterByAncestors = (links, nodeDict) =>
+ links.filter(({ target, source }) => {
+ /*
+
+ for every link, check out it's target
+ for every target, get the target node's needs
+ then drop the current link source from that list
+
+ call a function to get all ancestors, recursively
+ is the current link's source in the list of all parents?
+ then we drop this link
+
+ */
+ const targetNode = target;
+ const targetNodeNeeds = nodeDict[targetNode].needs;
+ const targetNodeNeedsMinusSource = targetNodeNeeds.filter(need => need !== source);
+
+ const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict);
+ return !allAncestors.includes(source);
+ });
+
+export const parseData = data => {
+ const { nodes, nodeDict } = createNodesStructure(data);
+ const allLinks = makeLinksFromNodes(nodes, nodeDict);
+ const filteredLinks = filterByAncestors(allLinks, nodeDict);
+ const links = uniqWith(filteredLinks, isEqual);
+
+ return { nodes, links };
+};
+
+/*
+ The number of nodes in the most populous generation drives the height of the graph.
+*/
+
+export const getMaxNodes = nodes => {
+ const counts = nodes.reduce((acc, { layer }) => {
+ if (!acc[layer]) {
+ acc[layer] = 0;
+ }
+
+ acc[layer] += 1;
+
+ return acc;
+ }, []);
+
+ return Math.max(...counts);
+};
+
+/*
+ Because we cannot know if a node is part of a relationship until after we
+ generate the links with createSankey, this function is used after the first call
+ to find nodes that have no relations.
+*/
+
+export const removeOrphanNodes = sankeyfiedNodes => {
+ return sankeyfiedNodes.filter(node => node.sourceLinks.length || node.targetLinks.length);
+};
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index fc93635bdb5..dbf29b0c29c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -10,7 +10,8 @@ import NavigationControls from './nav_controls.vue';
import { getParameterByName } from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
-import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING } from '../constants';
+import { validateParams } from '../utils';
+import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@@ -86,6 +87,10 @@ export default {
type: String,
required: true,
},
+ params: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -220,10 +225,13 @@ export default {
canFilterPipelines() {
return this.glFeatures.filterPipelinesSearch;
},
+ validatedParams() {
+ return validateParams(this.params);
+ },
},
created() {
this.service = new PipelinesService(this.endpoint);
- this.requestData = { page: this.page, scope: this.scope };
+ this.requestData = { page: this.page, scope: this.scope, ...this.validatedParams };
},
methods: {
successCallback(resp) {
@@ -258,10 +266,18 @@ export default {
filters.forEach(filter => {
// do not add Any for username query param, so we
// can fetch all trigger authors
- if (filter.type && filter.value.data !== ANY_TRIGGER_AUTHOR) {
+ if (
+ filter.type &&
+ filter.value.data !== ANY_TRIGGER_AUTHOR &&
+ filter.type !== FILTER_TAG_IDENTIFIER
+ ) {
this.requestData[filter.type] = filter.value.data;
}
+ if (filter.type === FILTER_TAG_IDENTIFIER) {
+ this.requestData.ref = filter.value.data;
+ }
+
if (!filter.type) {
createFlash(RAW_TEXT_WARNING, 'warning');
}
@@ -304,8 +320,8 @@ export default {
<pipelines-filtered-search
v-if="canFilterPipelines"
- :pipelines="state.pipelines"
:project-id="projectId"
+ :params="validatedParams"
@filterPipelines="filterPipelines"
/>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
index 2212428ced5..59c066b2683 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
@@ -35,7 +35,7 @@ export default {
<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
+ >Download {{ artifact.name }} artifact</gl-link
>
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
index 8f9c3eb70a2..0505a8668d1 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
@@ -3,74 +3,93 @@ import { GlFilteredSearch } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
-import Api from '~/api';
-import createFlash from '~/flash';
-import { FETCH_AUTHOR_ERROR_MESSAGE, FETCH_BRANCH_ERROR_MESSAGE } from '../constants';
+import PipelineStatusToken from './tokens/pipeline_status_token.vue';
+import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue';
+import { map } from 'lodash';
export default {
+ userType: 'username',
+ branchType: 'ref',
+ tagType: 'tag',
+ statusType: 'status',
+ defaultTokensLength: 1,
components: {
GlFilteredSearch,
},
props: {
- pipelines: {
- type: Array,
- required: true,
- },
projectId: {
type: String,
required: true,
},
+ params: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
- projectUsers: null,
- projectBranches: null,
+ internalValue: [],
};
},
computed: {
+ selectedTypes() {
+ return this.value.map(i => i.type);
+ },
tokens() {
return [
{
- type: 'username',
+ type: this.$options.userType,
icon: 'user',
title: s__('Pipeline|Trigger author'),
unique: true,
token: PipelineTriggerAuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
- triggerAuthors: this.projectUsers,
projectId: this.projectId,
},
{
- type: 'ref',
+ type: this.$options.branchType,
icon: 'branch',
title: s__('Pipeline|Branch name'),
unique: true,
token: PipelineBranchNameToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
- branches: this.projectBranches,
projectId: this.projectId,
+ disabled: this.selectedTypes.includes(this.$options.tagType),
+ },
+ {
+ type: this.$options.tagType,
+ icon: 'tag',
+ title: s__('Pipeline|Tag name'),
+ unique: true,
+ token: PipelineTagNameToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ projectId: this.projectId,
+ disabled: this.selectedTypes.includes(this.$options.branchType),
+ },
+ {
+ type: this.$options.statusType,
+ icon: 'status',
+ title: s__('Pipeline|Status'),
+ unique: true,
+ token: PipelineStatusToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
},
];
},
- },
- created() {
- Api.projectUsers(this.projectId)
- .then(users => {
- this.projectUsers = users;
- })
- .catch(err => {
- createFlash(FETCH_AUTHOR_ERROR_MESSAGE);
- throw err;
- });
-
- Api.branches(this.projectId)
- .then(({ data }) => {
- this.projectBranches = data.map(branch => branch.name);
- })
- .catch(err => {
- createFlash(FETCH_BRANCH_ERROR_MESSAGE);
- throw err;
- });
+ parsedParams() {
+ return map(this.params, (val, key) => ({
+ type: key,
+ value: { data: val, operator: '=' },
+ }));
+ },
+ value: {
+ get() {
+ return this.internalValue.length > 0 ? this.internalValue : this.parsedParams;
+ },
+ set(value) {
+ this.internalValue = value;
+ },
+ },
},
methods: {
onSubmit(filters) {
@@ -83,6 +102,7 @@ export default {
<template>
<div class="row-content-block">
<gl-filtered-search
+ v-model="value"
:placeholder="__('Filter pipelines')"
:available-tokens="tokens"
@submit="onSubmit"
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index 80a1c83f171..67646c537bd 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -68,7 +68,7 @@ export default {
<template>
<div>
<div class="row">
- <div class="col-12 d-flex prepend-top-8 align-items-center">
+ <div class="col-12 d-flex gl-mt-3 align-items-center">
<gl-deprecated-button
v-if="showBack"
size="sm"
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
index a7a3f986255..da14bb2d308 100644
--- a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
@@ -23,15 +23,18 @@ export default {
},
data() {
return {
- branches: this.config.branches,
+ branches: null,
loading: true,
};
},
+ created() {
+ this.fetchBranches();
+ },
methods: {
- fetchBranchBySearchTerm(searchTerm) {
- Api.branches(this.config.projectId, searchTerm)
- .then(res => {
- this.branches = res.data.map(branch => branch.name);
+ fetchBranches(searchterm) {
+ Api.branches(this.config.projectId, searchterm)
+ .then(({ data }) => {
+ this.branches = data.map(branch => branch.name);
this.loading = false;
})
.catch(err => {
@@ -41,7 +44,7 @@ export default {
});
},
searchBranches: debounce(function debounceSearch({ data }) {
- this.fetchBranchBySearchTerm(data);
+ this.fetchBranches(data);
}, FILTER_PIPELINES_SEARCH_DELAY),
},
};
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue
new file mode 100644
index 00000000000..dc43d94f4fd
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ statuses() {
+ return [
+ {
+ class: 'ci-status-icon-canceled',
+ icon: 'status_canceled',
+ text: s__('Pipeline|Canceled'),
+ value: 'canceled',
+ },
+ {
+ class: 'ci-status-icon-created',
+ icon: 'status_created',
+ text: s__('Pipeline|Created'),
+ value: 'created',
+ },
+ {
+ class: 'ci-status-icon-failed',
+ icon: 'status_failed',
+ text: s__('Pipeline|Failed'),
+ value: 'failed',
+ },
+ {
+ class: 'ci-status-icon-manual',
+ icon: 'status_manual',
+ text: s__('Pipeline|Manual'),
+ value: 'manual',
+ },
+ {
+ class: 'ci-status-icon-success',
+ icon: 'status_success',
+ text: s__('Pipeline|Passed'),
+ value: 'success',
+ },
+ {
+ class: 'ci-status-icon-pending',
+ icon: 'status_pending',
+ text: s__('Pipeline|Pending'),
+ value: 'pending',
+ },
+ {
+ class: 'ci-status-icon-running',
+ icon: 'status_running',
+ text: s__('Pipeline|Running'),
+ value: 'running',
+ },
+ {
+ class: 'ci-status-icon-skipped',
+ icon: 'status_skipped',
+ text: s__('Pipeline|Skipped'),
+ value: 'skipped',
+ },
+ ];
+ },
+ findActiveStatus() {
+ return this.statuses.find(status => status.value === this.value.data);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
+ <template #view>
+ <div class="gl-display-flex gl-align-items-center">
+ <div :class="findActiveStatus.class">
+ <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" />
+ </div>
+ <span>{{ findActiveStatus.text }}</span>
+ </div>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="(status, index) in statuses"
+ :key="index"
+ :value="status.value"
+ >
+ <div class="gl-display-flex" :class="status.class">
+ <gl-icon :name="status.icon" class="gl-mr-3" />
+ <span>{{ status.text }}</span>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue
new file mode 100644
index 00000000000..7b209c5fa12
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import Api from '~/api';
+import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
+import createFlash from '~/flash';
+import { debounce } from 'lodash';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tags: null,
+ loading: true,
+ };
+ },
+ created() {
+ this.fetchTags();
+ },
+ methods: {
+ fetchTags(searchTerm) {
+ Api.tags(this.config.projectId, searchTerm)
+ .then(({ data }) => {
+ this.tags = data.map(tag => tag.name);
+ this.loading = false;
+ })
+ .catch(err => {
+ createFlash(FETCH_TAG_ERROR_MESSAGE);
+ this.loading = false;
+ throw err;
+ });
+ },
+ searchTags: debounce(function debounceSearch({ data }) {
+ this.fetchTags(data);
+ }, FILTER_PIPELINES_SEARCH_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" @input="searchTags">
+ <template #suggestions>
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion v-for="(tag, index) in tags" :key="index" :value="tag">
+ {{ tag }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
index 83e3558e1a1..4062a3b11bb 100644
--- a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
@@ -36,7 +36,7 @@ export default {
},
data() {
return {
- users: this.config.triggerAuthors,
+ users: [],
loading: true,
};
},
@@ -50,11 +50,14 @@ export default {
});
},
},
+ created() {
+ this.fetchProjectUsers();
+ },
methods: {
- fetchAuthorBySearchTerm(searchTerm) {
+ fetchProjectUsers(searchTerm) {
Api.projectUsers(this.config.projectId, searchTerm)
- .then(res => {
- this.users = res;
+ .then(users => {
+ this.users = users;
this.loading = false;
})
.catch(err => {
@@ -64,7 +67,7 @@ export default {
});
},
searchAuthors: debounce(function debounceSearch({ data }) {
- this.fetchAuthorBySearchTerm(data);
+ this.fetchProjectUsers(data);
}, FILTER_PIPELINES_SEARCH_DELAY),
},
};
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index d694430830b..c709f329728 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -5,6 +5,8 @@ export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
+export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status'];
+export const FILTER_TAG_IDENTIFIER = 'tag';
export const TestStatus = {
FAILED: 'failed',
@@ -14,6 +16,7 @@ export const TestStatus = {
export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
export const FETCH_BRANCH_ERROR_MESSAGE = __('There was a problem fetching project branches.');
+export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project tags.');
export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
);
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 01295874e56..90109598542 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import pipelineGraph from './components/graph/graph_component.vue';
+import Dag from './components/dag/dag.vue';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
@@ -144,6 +145,29 @@ const createTestDetails = detailsEndpoint => {
.catch(() => {});
};
+const createDagApp = () => {
+ if (!window.gon?.features?.dagPipelineTab) {
+ return;
+ }
+
+ const el = document.querySelector('#js-pipeline-dag-vue');
+ const graphUrl = el?.dataset?.pipelineDataPath;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ Dag,
+ },
+ render(createElement) {
+ return createElement('dag', {
+ props: {
+ graphUrl,
+ },
+ });
+ },
+ });
+};
+
export default () => {
const { dataset } = document.querySelector('.js-pipeline-details-vue');
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
@@ -153,4 +177,5 @@ export default () => {
createPipelineHeaderApp(mediator);
createPipelinesTabs(dataset);
createTestDetails(dataset.testReportsCountEndpoint);
+ createDagApp();
};
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index ae94d7a7ca0..0b06bcf243a 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -1,5 +1,6 @@
import axios from '../../lib/utils/axios_utils';
import Api from '~/api';
+import { validateParams } from '../utils';
export default class PipelinesService {
/**
@@ -19,18 +20,10 @@ export default class PipelinesService {
}
getPipelines(data = {}) {
- const { scope, page, username, ref } = data;
+ const { scope, page } = data;
const { CancelToken } = axios;
- const queryParams = { scope, page };
-
- if (username) {
- queryParams.username = username;
- }
-
- if (ref) {
- queryParams.ref = ref;
- }
+ const queryParams = { scope, page, ...validateParams(data) };
this.cancelationSource = CancelToken.source();
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
new file mode 100644
index 00000000000..9dbc8073d3a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -0,0 +1,8 @@
+import { pickBy } from 'lodash';
+import { SUPPORTED_FILTER_PARAMETERS } from './constants';
+
+export const validateParams = params => {
+ return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
+};
+
+export default () => {};
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index eb514b5c070..a8589b50899 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -110,8 +110,8 @@ export default {
<gl-new-dropdown
:text="dropdownText"
:disabled="hasSearchParam"
- toggle-class="gl-py-3"
- class="gl-dropdown w-100 mt-2 mt-sm-0"
+ toggle-class="gl-py-3 gl-border-0"
+ class="w-100 mt-2 mt-sm-0"
>
<gl-new-dropdown-header>
{{ __('Search by author') }}
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index a3a53c2f975..0a52a92ae9d 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -3,6 +3,7 @@ import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
export default {
setInitialData({ commit }, data) {
@@ -16,10 +17,8 @@ export default {
},
fetchAuthors({ dispatch, state }, author = null) {
const { projectId } = state;
- const path = '/autocomplete/users.json';
-
return axios
- .get(path, {
+ .get(joinPaths(gon.relative_url_root || '', '/autocomplete/users.json'), {
params: {
project_id: projectId,
active: true,
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
new file mode 100644
index 00000000000..e553599357c
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
@@ -0,0 +1,160 @@
+<script>
+import WelcomePage from './welcome.vue';
+import LegacyContainer from './legacy_container.vue';
+import { GlBreadcrumb, GlIcon } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+import blankProjectIllustration from '../illustrations/blank-project.svg';
+import createFromTemplateIllustration from '../illustrations/create-from-template.svg';
+import importProjectIllustration from '../illustrations/import-project.svg';
+import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg';
+
+const BLANK_PANEL = 'blank_project';
+const CI_CD_PANEL = 'cicd_for_external_repo';
+const PANELS = [
+ {
+ name: BLANK_PANEL,
+ selector: '#blank-project-pane',
+ title: s__('ProjectsNew|Create blank project'),
+ description: s__(
+ 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.',
+ ),
+ illustration: blankProjectIllustration,
+ },
+ {
+ name: 'create_from_template',
+ selector: '#create-from-template-pane',
+ title: s__('ProjectsNew|Create from template'),
+ description: s__(
+ 'Create a project pre-populated with the necessary files to get you started quickly.',
+ ),
+ illustration: createFromTemplateIllustration,
+ },
+ {
+ name: 'import_project',
+ selector: '#import-project-pane',
+ title: s__('ProjectsNew|Import project'),
+ description: s__(
+ 'Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.',
+ ),
+ illustration: importProjectIllustration,
+ },
+ {
+ name: CI_CD_PANEL,
+ selector: '#ci-cd-project-pane',
+ title: s__('ProjectsNew|Run CI/CD for external repository'),
+ description: s__('ProjectsNew|Connect your external repository to GitLab CI/CD.'),
+ illustration: ciCdProjectIllustration,
+ },
+];
+
+export default {
+ components: {
+ GlBreadcrumb,
+ GlIcon,
+ WelcomePage,
+ LegacyContainer,
+ },
+
+ props: {
+ hasErrors: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isCiCdAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ activeTab: null,
+ };
+ },
+
+ computed: {
+ availablePanels() {
+ if (this.isCiCdAvailable) {
+ return PANELS;
+ }
+
+ return PANELS.filter(p => p.name !== CI_CD_PANEL);
+ },
+
+ activePanel() {
+ return PANELS.find(p => p.name === this.activeTab);
+ },
+
+ breadcrumbs() {
+ if (!this.activeTab || !this.activePanel) {
+ return null;
+ }
+
+ return [
+ { text: __('New project'), href: '#' },
+ { text: this.activePanel.title, href: `#${this.activeTab}` },
+ ];
+ },
+ },
+
+ created() {
+ this.handleLocationHashChange();
+
+ if (this.hasErrors) {
+ this.activeTab = BLANK_PANEL;
+ }
+
+ window.addEventListener('hashchange', () => {
+ this.handleLocationHashChange();
+ this.resetProjectErrors();
+ });
+ this.$root.$on('clicked::link', e => {
+ window.location = e.target.href;
+ });
+ },
+
+ methods: {
+ resetProjectErrors() {
+ const errorsContainer = document.querySelector('.project-edit-errors');
+ if (errorsContainer) {
+ errorsContainer.innerHTML = '';
+ }
+ },
+
+ handleLocationHashChange() {
+ this.activeTab = window.location.hash.substring(1) || null;
+ },
+ },
+
+ PANELS,
+};
+</script>
+
+<template>
+ <welcome-page v-if="activeTab === null" :panels="availablePanels" />
+ <div v-else class="row">
+ <div class="col-lg-3">
+ <div class="text-center" v-html="activePanel.illustration"></div>
+ <h4>{{ activePanel.title }}</h4>
+ <p>{{ activePanel.description }}</p>
+ </div>
+ <div class="col-lg-9">
+ <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs">
+ <template #separator>
+ <gl-icon name="chevron-right" :size="8" />
+ </template>
+ </gl-breadcrumb>
+ <template v-for="panel in $options.PANELS">
+ <legacy-container
+ v-if="activeTab === panel.name"
+ :key="panel.name"
+ class="gl-mt-3"
+ :selector="panel.selector"
+ />
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue
new file mode 100644
index 00000000000..d2fc2c66924
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue
@@ -0,0 +1,31 @@
+<script>
+export default {
+ inheritAttrs: false,
+ props: {
+ selector: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ const legacyEntry = document.querySelector(this.selector);
+ if (legacyEntry.tagName === 'TEMPLATE') {
+ this.$el.innerHTML = legacyEntry.innerHTML;
+ } else {
+ this.source = legacyEntry.parentNode;
+ this.$el.appendChild(legacyEntry);
+ legacyEntry.classList.add('active');
+ }
+ },
+
+ beforeDestroy() {
+ if (this.source) {
+ this.$el.firstChild.classList.remove('active');
+ this.source.appendChild(this.$el.firstChild);
+ }
+ },
+};
+</script>
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
new file mode 100644
index 00000000000..ea22818da0e
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
@@ -0,0 +1,70 @@
+<script>
+import Tracking from '~/tracking';
+import { GlPopover } from '@gitlab/ui';
+import LegacyContainer from './legacy_container.vue';
+
+const trackingMixin = Tracking.mixin(gon.tracking_data);
+
+export default {
+ components: {
+ GlPopover,
+ LegacyContainer,
+ },
+ mixins: [trackingMixin],
+ props: {
+ panels: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="container">
+ <div class="blank-state-welcome">
+ <h2 class="blank-state-welcome-title gl-mt-5! gl-mb-3!">
+ {{ s__('ProjectsNew|Create new project') }}
+ </h2>
+ <p div class="blank-state-text">&nbsp;</p>
+ </div>
+ <div class="row blank-state-row">
+ <a
+ v-for="panel in panels"
+ :key="panel.name"
+ :href="`#${panel.name}`"
+ :data-qa-selector="`${panel.name}_link`"
+ class="blank-state blank-state-link experiment-new-project-page-blank-state"
+ @click="track('click_tab', { label: panel.name })"
+ >
+ <div class="blank-state-icon" v-html="panel.illustration"></div>
+ <div class="blank-state-body gl-pl-4!">
+ <h3 class="blank-state-title experiment-new-project-page-blank-state-title">
+ {{ panel.title }}
+ </h3>
+ <p class="blank-state-text">
+ {{ panel.description }}
+ </p>
+ </div>
+ </a>
+ </div>
+ <div class="blank-state-welcome">
+ <p>
+ {{ __('You can also create a project from the command line.') }}
+ <a
+ id="cli-tip"
+ href="#"
+ click.prevent
+ class="push-new-project-tip"
+ data-title="Push to create a project"
+ rel="noopener noreferrer"
+ >
+ {{ __('Show command') }}
+ </a>
+
+ <gl-popover target="cli-tip" triggers="click blur" placement="top">
+ <legacy-container selector=".push-new-project-tip-template" />
+ </gl-popover>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg
new file mode 100644
index 00000000000..0d8021658d1
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="215px" height="115px" viewBox="0 0 215 115" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 61.2 (89653) - https://sketch.com -->
+ <title>create-new-project-md</title>
+ <desc>Created with Sketch.</desc>
+ <g id="create-new-project-md" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="Group-3" transform="translate(71.000000, 18.000000)" fill-rule="nonzero">
+ <g id="New-Blank1">
+ <path d="M6.11141667,3.90697674 L62.6947849,3.90697674 C65.9485064,3.90697674 68.5891473,6.56494969 68.5891473,9.8400273 L68.5891473,78.0669494 C68.5891473,81.342027 65.9485064,84 62.6947849,84 L6.11141667,84 C2.85769514,84 0.217054264,81.342027 0.217054264,78.0669494 L0.217054264,9.8400273 C0.217054264,6.56494969 2.85769514,3.90697674 6.11141667,3.90697674 Z" id="Path" fill="#F9F9F9"></path>
+ <path d="M8.89436241,1 L65.4777306,1 C68.7314521,1 71.372093,3.65598929 71.372093,6.9286227 L71.372093,74.5132378 C71.372093,77.7858712 68.7314521,80.4418605 65.4777306,80.4418605 L8.89436241,80.4418605 C5.64064088,80.4418605 3,77.7858712 3,74.5132378 L3,6.9286227 C3.00209243,3.65598929 5.64064088,1 8.89436241,1 Z" id="Path" fill="#FFFFFF"></path>
+ <path d="M9.2677971,2.35980136 C6.65357171,2.35980136 4.53489427,4.47043114 4.53489427,7.07940407 L4.53489427,74.3201325 C4.53489427,76.9270116 6.65147193,79.0397352 9.2677971,79.0397352 L66.0500324,79.0397352 C68.6642577,79.0397352 70.7829352,76.9291055 70.7829352,74.3201325 L70.7829352,7.07731019 C70.7829352,4.47043114 68.6663575,2.35770748 66.0500324,2.35770748 L9.2677971,2.35980136 L9.2677971,2.35980136 Z M9.2677971,0 L66.0500324,0 C69.9724203,0 73.1472868,3.16803856 73.1472868,7.07731019 L73.1472868,74.3180386 C73.1472868,78.2294042 69.9703205,81.3953488 66.0500324,81.3953488 L9.2677971,81.3953488 C5.34540913,81.3953488 2.17054264,78.2273103 2.17054264,74.3180386 L2.17054264,7.07731019 C2.17054264,3.17222631 5.34750891,0 9.2677971,0 Z" id="Shape" fill="#EEEEEE"></path>
+ <path d="M21.6234891,28.6511628 L28.9501543,28.6511628 C29.6221266,28.6511628 30.1705426,29.2387129 30.1705426,29.9534884 C30.1705426,30.6682639 29.6199589,31.255814 28.9501543,31.255814 L21.6234891,31.255814 C20.9515168,31.255814 20.4031008,30.6682639 20.4031008,29.9534884 C20.4031008,29.2387129 20.9515168,28.6511628 21.6234891,28.6511628 Z" id="Path" fill="#E1DBF1"></path>
+ <path d="M33.9142229,35.8139535 L36.1943042,35.8139535 C36.8214783,35.8139535 37.3333333,36.4015036 37.3333333,37.1162791 C37.3333333,37.8333678 36.8194552,38.4186047 36.1943042,38.4186047 L33.9142229,38.4186047 C33.2870488,38.4186047 32.7751938,37.8310546 32.7751938,37.1162791 C32.7751938,36.4015036 33.2890719,35.8139535 33.9142229,35.8139535 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M24.200844,42.9767442 L28.9774506,42.9767442 C29.6343929,42.9767442 30.1705426,43.5642943 30.1705426,44.2790698 C30.1705426,44.9961585 29.6322737,45.5813953 28.9774506,45.5813953 L24.200844,45.5813953 C23.5439017,45.5813953 23.0077519,44.9938453 23.0077519,44.2790698 C23.0077519,43.5642943 23.5439017,42.9767442 24.200844,42.9767442 Z" id="Path" fill="#E1DBF1"></path>
+ <path d="M41.0770181,35.8139535 L43.3570964,35.8139535 C43.9842697,35.8139535 44.496124,36.4015036 44.496124,37.1162791 C44.496124,37.8333678 43.9822466,38.4186047 43.3570964,38.4186047 L41.0770181,38.4186047 C40.4498448,38.4186047 39.9379845,37.8310546 39.9379845,37.1162791 C39.9359673,36.4015036 40.4498448,35.8139535 41.0770181,35.8139535 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M33.9372473,28.6511628 L47.89221,28.6511628 C48.5320619,28.6511628 49.0542636,29.2387129 49.0542636,29.9534884 C49.0542636,30.6682639 48.5299978,31.255814 47.89221,31.255814 L33.9372473,31.255814 C33.2973955,31.255814 32.7751938,30.6682639 32.7751938,29.9534884 C32.7751938,29.2387129 33.2994595,28.6511628 33.9372473,28.6511628 Z" id="Path" fill="#C3B8E3"></path>
+ <path d="M33.9142229,42.9767442 L36.1943042,42.9767442 C36.8214783,42.9767442 37.3333333,43.5642943 37.3333333,44.2790698 C37.3333333,44.9961585 36.8194552,45.5813953 36.1943042,45.5813953 L33.9142229,45.5813953 C33.2870488,45.5813953 32.7751938,44.9938453 32.7751938,44.2790698 C32.7751938,43.5642943 33.2890719,42.9767442 33.9142229,42.9767442 Z" id="Path" fill="#6B4FBB"></path>
+ <g id="Group" transform="translate(16.000000, 19.000000)">
+ <circle id="Oval" fill="#FFFFFF" cx="20.8396947" cy="20.8396947" r="20.7533889"></circle>
+ <path d="M20.8396947,41.5930835 C9.3778626,41.5930835 0.0863058062,32.3015267 0.0863058062,20.8396947 C0.0863058062,9.3778626 9.3778626,0.0863058062 20.8396947,0.0863058062 C32.3015267,0.0863058062 41.5930835,9.3778626 41.5930835,20.8396947 C41.5930835,32.3015267 32.3015267,41.5930835 20.8396947,41.5930835 Z M20.8396947,39.2207263 C30.9922045,39.2207263 39.2207263,30.9900995 39.2207263,20.8396947 C39.2207263,10.6892898 30.9900995,2.45866297 20.8396947,2.45866297 C10.6892898,2.45866297 2.45866297,10.6892898 2.45866297,20.8396947 C2.45866297,30.9900995 10.6871848,39.2207263 20.8396947,39.2207263 Z" id="Shape" fill="#EEEEEE"></path>
+ <path d="M13.7647236,19.060953 L27.9967615,19.060953 C28.6493176,19.060953 29.1818876,19.595628 29.1818876,20.2460791 C29.1818876,20.8986352 28.6472126,21.4312052 27.9967615,21.4312052 L13.7647236,21.4312052 C13.1121675,21.4312052 12.5795975,20.8965302 12.5795975,20.2460791 C12.5795975,19.593523 13.1142725,19.060953 13.7647236,19.060953 Z" id="Path" fill="#6B4FBB"></path>
+ <path d="M22.0669211,13.1311127 L22.0669211,27.3631506 C22.0669211,28.0157067 21.5322461,28.5482767 20.881795,28.5482767 C20.231344,28.5482767 19.696669,28.0136017 19.696669,27.3631506 L19.696669,13.1311127 C19.696669,12.4785566 20.231344,11.9459866 20.881795,11.9459866 C21.5322461,11.9459866 22.0669211,12.4785566 22.0669211,13.1311127 Z" id="Path" fill="#6B4FBB"></path>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg
new file mode 100644
index 00000000000..c85e1a245b8
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="215px" height="115px" viewBox="0 0 215 115" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 61.2 (89653) - https://sketch.com -->
+ <title>run-CICD-pipelines-md</title>
+ <desc>Created with Sketch.</desc>
+ <g id="run-CICD-pipelines-md" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="CICD-Repoj1" transform="translate(22.000000, 16.000000)">
+ <g id="Group" transform="translate(100.000000, 0.000000)">
+ <path d="M5.88230137,4.144 L61.8884384,4.144 C65.1195616,4.144 67.7155068,6.804 67.7155068,10.052 L67.7155068,78.064 C67.7155068,81.34 65.0919452,83.972 61.8884384,83.972 L5.88230137,83.972 C2.65117808,83.972 0.0552328767,81.312 0.0552328767,78.064 L0.0552328767,10.052 C0.0552328767,6.804 2.67879452,4.144 5.88230137,4.144 Z" id="Path" fill="#F9F9F9" fill-rule="nonzero"></path>
+ <path d="M8.80964384,1.176 L64.8157808,1.176 C68.0469041,1.176 70.6428493,3.836 70.6428493,7.084 L70.6428493,74.508 C70.6428493,77.784 68.0192877,80.416 64.8157808,80.416 L8.80964384,80.416 C5.57852055,80.416 2.98257534,77.756 2.98257534,74.508 L2.98257534,7.112 C2.98257534,3.836 5.57852055,1.176 8.80964384,1.176 Z" id="Path" fill="#FFFFFF" fill-rule="nonzero"></path>
+ <path d="M8.80964384,2.38 C6.24131507,2.38 4.14246575,4.508 4.14246575,7.112 L4.14246575,74.536 C4.14246575,77.14 6.24131507,79.268 8.80964384,79.268 L64.8157808,79.268 C67.3841096,79.268 69.4829589,77.14 69.4829589,74.536 L69.4829589,7.112 C69.4829589,4.508 67.3841096,2.38 64.8157808,2.38 L8.80964384,2.38 L8.80964384,2.38 Z M8.80964384,0 L64.8157808,0 C68.6820822,0 71.8027397,3.164 71.8027397,7.084 L71.8027397,74.508 C71.8027397,78.428 68.6820822,81.592 64.8157808,81.592 L8.80964384,81.592 C4.94334247,81.592 1.82250462,78.4 1.82250462,74.508 L1.82250462,7.112 C1.79506849,3.192 4.94334247,0 8.80964384,0 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M14.6367123,14.784 L21.6236712,14.784 C22.2588493,14.784 22.7835616,15.316 22.7835616,15.96 C22.7835616,16.604 22.2588493,17.136 21.6236712,17.136 L14.6367123,17.136 C14.0015342,17.136 13.4768219,16.604 13.4768219,15.96 C13.4768219,15.316 14.0015342,14.784 14.6367123,14.784 Z M33.3054247,21.896 L40.2923836,21.896 C40.9275616,21.896 41.452274,22.428 41.452274,23.072 C41.452274,23.716 40.9275616,24.248 40.2923836,24.248 L33.3054247,24.248 C32.6702466,24.248 32.1455342,23.716 32.1455342,23.072 C32.1455342,22.428 32.6702466,21.896 33.3054247,21.896 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M40.32,14.784 L47.3069589,14.784 C47.942137,14.784 48.4668493,15.316 48.4668493,15.96 C48.4668493,16.604 47.942137,17.136 47.3069589,17.136 L40.32,17.136 C39.6848219,17.136 39.1601096,16.604 39.1601096,15.96 C39.1324932,15.316 39.6572055,14.784 40.32,14.784 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M21.6512877,28.98 L28.6382466,28.98 C29.2734247,28.98 29.798137,29.512 29.798137,30.156 C29.798137,30.8 29.2734247,31.332 28.6382466,31.332 L21.6512877,31.332 C21.0161096,31.332 20.4913973,30.8 20.4913973,30.156 C20.4637808,29.512 20.9884932,28.98 21.6512877,28.98 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M26.2908493,14.784 L28.6382466,14.784 C29.2734247,14.784 29.798137,15.316 29.798137,15.96 C29.798137,16.604 29.2734247,17.136 28.6382466,17.136 L26.2908493,17.136 C25.6556712,17.136 25.1309589,16.604 25.1309589,15.96 C25.1309589,15.316 25.6556712,14.784 26.2908493,14.784 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path>
+ <path d="M33.3054247,36.092 L35.6528219,36.092 C36.288,36.092 36.8127123,36.624 36.8127123,37.268 C36.8127123,37.912 36.288,38.444 35.6528219,38.444 L33.3054247,38.444 C32.6702466,38.444 32.1455342,37.912 32.1455342,37.268 C32.1455342,36.624 32.6702466,36.092 33.3054247,36.092 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M44.9595616,21.896 L47.3069589,21.896 C47.942137,21.896 48.4668493,22.428 48.4668493,23.072 C48.4668493,23.716 47.942137,24.248 47.3069589,24.248 L44.9595616,24.248 C44.3243836,24.248 43.7996712,23.716 43.7996712,23.072 C43.7996712,22.428 44.3243836,21.896 44.9595616,21.896 Z M51.974137,14.784 L54.3215342,14.784 C54.9567123,14.784 55.4814247,15.316 55.4814247,15.96 C55.4814247,16.604 54.9567123,17.136 54.3215342,17.136 L51.974137,17.136 C51.3389589,17.136 50.8142466,16.604 50.8142466,15.96 C50.8142466,15.316 51.3389589,14.784 51.974137,14.784 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M23.9710685,43.176 L28.6382466,43.176 C29.2734247,43.176 29.798137,43.708 29.798137,44.352 C29.798137,44.996 29.2734247,45.528 28.6382466,45.528 L23.9710685,45.528 C23.3358904,45.528 22.8111781,44.996 22.8111781,44.352 C22.8111781,43.708 23.3358904,43.176 23.9710685,43.176 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M40.32,36.092 L42.6673973,36.092 C43.3025753,36.092 43.8272877,36.624 43.8272877,37.268 C43.8272877,37.912 43.3025753,38.444 42.6673973,38.444 L40.32,38.444 C39.6848219,38.444 39.1601096,37.912 39.1601096,37.268 C39.1324932,36.624 39.6572055,36.092 40.32,36.092 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M52.2503014,33.712 C53.0511781,33.712 53.7139726,34.384 53.7139726,35.196 C53.7139726,36.008 53.0511781,36.68 52.2503014,36.68 C51.4494247,36.68 50.7866301,36.008 50.7866301,35.196 C50.8142466,34.384 51.4494247,33.712 52.2503014,33.712 Z M58.1049863,50.876 C58.905863,50.876 59.5686575,51.548 59.5686575,52.36 C59.5686575,53.172 58.905863,53.844 58.1049863,53.844 C57.3041096,53.844 56.6413151,53.172 56.6413151,52.36 C56.6413151,51.548 57.3041096,50.876 58.1049863,50.876 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M63.3521096,50.876 C64.1529863,50.876 64.8157808,51.548 64.8157808,52.36 C64.8157808,53.172 64.1529863,53.844 63.3521096,53.844 C62.5512329,53.844 61.8884384,53.172 61.8884384,52.36 C61.8884384,51.548 62.5512329,50.876 63.3521096,50.876 Z M33.3054247,14.784 L35.6528219,14.784 C36.288,14.784 36.8127123,15.316 36.8127123,15.96 C36.8127123,16.604 36.288,17.136 35.6528219,17.136 L33.3054247,17.136 C32.6702466,17.136 32.1455342,16.604 32.1455342,15.96 C32.1455342,15.316 32.6702466,14.784 33.3054247,14.784 Z" id="Shape" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6367123,36.092 L28.6382466,36.092 C29.2734247,36.092 29.798137,36.624 29.798137,37.268 C29.798137,37.912 29.2734247,38.444 28.6382466,38.444 L14.6367123,38.444 C14.0015342,38.444 13.4768219,37.912 13.4768219,37.268 C13.4768219,36.624 14.0015342,36.092 14.6367123,36.092 Z M44.0482192,42 L61.0599452,42 C61.8332055,42 62.4683836,42.672 62.4683836,43.484 C62.4683836,44.296 61.8332055,44.968 61.0599452,44.968 L44.0758356,44.968 C43.3025753,44.968 42.6673973,44.296 42.6673973,43.484 C42.6673973,42.672 43.2749589,42 44.0482192,42 L44.0482192,42 L44.0482192,42 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M35.3214247,50.876 L52.3055342,50.876 C53.0787945,50.876 53.7139726,51.548 53.7139726,52.36 C53.7139726,53.172 53.0787945,53.844 52.3055342,53.844 L35.3214247,53.844 C34.5481644,53.844 33.9129863,53.172 33.9129863,52.36 C33.8853699,51.548 34.5205479,50.876 35.3214247,50.876 L35.3214247,50.876 L35.3214247,50.876 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path>
+ <path d="M14.6367123,21.896 L28.6382466,21.896 C29.2734247,21.896 29.798137,22.428 29.798137,23.072 C29.798137,23.716 29.2734247,24.248 28.6382466,24.248 L14.6367123,24.248 C14.0015342,24.248 13.4768219,23.716 13.4768219,23.072 C13.4768219,22.428 14.0015342,21.896 14.6367123,21.896 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M33.3054247,28.98 L47.3069589,28.98 C47.942137,28.98 48.4668493,29.512 48.4668493,30.156 C48.4668493,30.8 47.942137,31.332 47.3069589,31.332 L33.3054247,31.332 C32.6702466,31.332 32.1455342,30.8 32.1455342,30.156 C32.1455342,29.512 32.6702466,28.98 33.3054247,28.98 Z" id="Path" fill="#C3B8E3" fill-rule="nonzero"></path>
+ <path d="M14.6367123,28.98 L16.9841096,28.98 C17.6192877,28.98 18.144,29.512 18.144,30.156 C18.144,30.8 17.6192877,31.332 16.9841096,31.332 L14.6367123,31.332 C14.0015342,31.332 13.4768219,30.8 13.4768219,30.156 C13.4768219,29.512 14.0015342,28.98 14.6367123,28.98 Z" id="Path" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M33.3054247,43.176 L35.6528219,43.176 C36.288,43.176 36.8127123,43.708 36.8127123,44.352 C36.8127123,44.996 36.288,45.528 35.6528219,45.528 L33.3054247,45.528 C32.6702466,45.528 32.1455342,44.996 32.1455342,44.352 C32.1455342,43.708 32.6702466,43.176 33.3054247,43.176 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M14.6367123,43.176 L19.3038904,43.176 C19.9390685,43.176 20.4637808,43.708 20.4637808,44.352 C20.4637808,44.996 19.9390685,45.528 19.3038904,45.528 L14.6367123,45.528 C14.0015342,45.528 13.4768219,44.996 13.4768219,44.352 C13.4768219,43.708 14.0015342,43.176 14.6367123,43.176 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6367123,50.288 L19.3038904,50.288 C19.9390685,50.288 20.4637808,50.82 20.4637808,51.464 C20.4637808,52.108 19.9390685,52.64 19.3038904,52.64 L14.6367123,52.64 C14.0015342,52.64 13.4768219,52.108 13.4768219,51.464 C13.4768219,50.82 14.0015342,50.288 14.6367123,50.288 Z M23.9710685,50.288 L28.6382466,50.288 C29.2734247,50.288 29.798137,50.82 29.798137,51.464 C29.798137,52.108 29.2734247,52.64 28.6382466,52.64 L23.9710685,52.64 C23.3358904,52.64 22.8111781,52.108 22.8111781,51.464 C22.8111781,50.82 23.3358904,50.288 23.9710685,50.288 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M14.6367123,57.372 L21.6236712,57.372 C22.2588493,57.372 22.7835616,57.904 22.7835616,58.548 C22.7835616,59.192 22.2588493,59.724 21.6236712,59.724 L14.6367123,59.724 C14.0015342,59.724 13.4768219,59.192 13.4768219,58.548 C13.4768219,57.904 14.0015342,57.372 14.6367123,57.372 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path>
+ <path d="M25.3518904,64.484 L33.6644384,64.484 C34.4376986,64.484 35.0452603,65.016 35.0452603,65.66 C35.0452603,66.304 34.4376986,66.836 33.6644384,66.836 L25.3518904,66.836 C24.5786301,66.836 23.9710685,66.304 23.9710685,65.66 C23.9710685,65.016 24.5786301,64.484 25.3518904,64.484 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M44.0206027,59.136 L52.3331507,59.136 C53.106411,59.136 53.7139726,59.808 53.7139726,60.62 C53.7139726,61.432 53.106411,62.104 52.3331507,62.104 L44.0206027,62.104 C43.2473425,62.104 42.6397808,61.432 42.6397808,60.62 C42.6397808,59.808 43.2473425,59.136 44.0206027,59.136 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M26.2908493,57.372 L28.6382466,57.372 C29.2734247,57.372 29.798137,57.904 29.798137,58.548 C29.798137,59.192 29.2734247,59.724 28.6382466,59.724 L26.2908493,59.724 C25.6556712,59.724 25.1309589,59.192 25.1309589,58.548 C25.1309589,57.904 25.6556712,57.372 26.2908493,57.372 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path>
+ <path d="M36.8127123,64.484 L39.1601096,64.484 C39.7952877,64.484 40.32,65.016 40.32,65.66 C40.32,66.304 39.7952877,66.836 39.1601096,66.836 L36.8127123,66.836 C36.1775342,66.836 35.6528219,66.304 35.6528219,65.66 C35.6528219,65.016 36.1775342,64.484 36.8127123,64.484 Z M58.1049863,59.136 L61.0323288,59.136 C61.8332055,59.136 62.496,59.808 62.496,60.62 C62.496,61.432 61.8332055,62.104 61.0323288,62.104 L58.1049863,62.104 C57.3041096,62.104 56.6413151,61.432 56.6413151,60.62 C56.6413151,59.808 57.3041096,59.136 58.1049863,59.136 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M35.3490411,59.136 L38.2763836,59.136 C39.0772603,59.136 39.7400548,59.808 39.7400548,60.62 C39.7400548,61.432 39.0772603,62.104 38.2763836,62.104 L35.3490411,62.104 C34.5481644,62.104 33.8853699,61.432 33.8853699,60.62 C33.8853699,59.808 34.5481644,59.136 35.3490411,59.136 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6367123,64.484 L20.4637808,64.484 C21.0989589,64.484 21.6236712,65.016 21.6236712,65.66 C21.6236712,66.304 21.0989589,66.836 20.4637808,66.836 L14.6367123,66.836 C14.0015342,66.836 13.4768219,66.304 13.4768219,65.66 C13.4768219,65.016 14.0015342,64.484 14.6367123,64.484 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <ellipse id="Oval" fill="#FFFFFF" fill-rule="nonzero" cx="36.7574795" cy="38.612" rx="20.4085479" ry="20.692"></ellipse>
+ <path d="M36.7574795,59.304 C25.4899726,59.304 16.3489315,50.036 16.3489315,38.612 C16.3489315,27.188 25.4899726,17.92 36.7574795,17.92 C48.0249863,17.92 57.1660274,27.188 57.1660274,38.612 C57.1660274,50.036 48.0526027,59.304 36.7574795,59.304 Z M36.7574795,56.952 C46.7546301,56.952 54.8462466,48.748 54.8462466,38.612 C54.8462466,28.476 46.7546301,20.272 36.7574795,20.272 C26.7603288,20.272 18.6687123,28.476 18.6687123,38.612 C18.6687123,48.748 26.7879452,56.952 36.7574795,56.952 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <g transform="translate(26.787945, 29.400000)" id="Path">
+ <path d="M19.7457534,10.528 L18.6410959,7.112 L16.4593973,0.336 C16.3489315,7.97972799e-15 15.8794521,7.97972799e-15 15.7413699,0.336 L13.5872877,7.112 L6.37939726,7.112 L4.19769863,0.336 C4.08723288,7.97972799e-15 3.61775342,7.97972799e-15 3.47967123,0.336 L1.2979726,7.112 L0.193315068,10.528 C0.0828493151,10.836 0.193315068,11.172 0.469479452,11.368 L9.94191781,18.34 L19.4143562,11.368 C19.718137,11.172 19.8286027,10.836 19.7457534,10.528" fill="#FC6D26"></path>
+ <polygon fill="#E24329" points="9.96953425 18.34 13.5596712 7.112 6.35178082 7.112"></polygon>
+ <polygon fill="#FC6D26" points="9.96953425 18.34 6.37939726 7.112 1.32558904 7.112"></polygon>
+ <path d="M1.32558904,7.112 L0.220931507,10.528 C0.110465753,10.836 0.220931507,11.172 0.49709589,11.368 L9.96953425,18.34 L1.32558904,7.112 Z" fill="#FCA326"></path>
+ <path d="M1.32558904,7.112 L6.37939726,7.112 L4.19769863,0.336 C4.08723288,7.9658502e-15 3.61775342,7.9658502e-15 3.47967123,0.336 L1.32558904,7.112 Z" fill="#E24329"></path>
+ <polygon fill="#FC6D26" points="9.96953425 18.34 13.5596712 7.112 18.6134795 7.112"></polygon>
+ <path d="M18.6410959,7.112 L19.7457534,10.528 C19.8562192,10.836 19.7457534,11.172 19.469589,11.368 L9.99715068,18.34 L18.6410959,7.112 Z" fill="#FCA326"></path>
+ <path d="M18.6410959,7.112 L13.5872877,7.112 L15.7689863,0.336 C15.8794521,7.9658502e-15 16.3489315,7.9658502e-15 16.4870137,0.336 L18.6410959,7.112 Z" fill="#E24329"></path>
+ </g>
+ </g>
+ <path d="M76,41.475 C76,40.660967 76.8066938,40 77.8150611,40 L81.4537653,40 C82.4578417,40 83.2688264,40.6540094 83.2688264,41.475 C83.2688264,42.289033 82.4621326,42.95 81.4537653,42.95 L77.8150611,42.95 C76.8152757,42.95 76,42.2959906 76,41.4819575 C76,41.4784788 76,41.4784788 76,41.475 Z M88.7311736,41.475 C88.7311736,40.660967 89.5378674,40 90.5462347,40 L94.1849389,40 C95.1890152,40 96,40.6540094 96,41.475 C96,42.289033 95.1933062,42.95 94.1849389,42.95 L90.5462347,42.95 C89.5464493,42.95 88.7311736,42.2959906 88.7311736,41.4819575 C88.7311736,41.4784788 88.7311736,41.4784788 88.7311736,41.475 Z" id="Shape" fill="#E5E5E5" fill-rule="nonzero"></path>
+ <g id="Group">
+ <g transform="translate(3.686038, 58.800000)">
+ <path d="M6.33328302,0.207529412 L17.1233208,0.207529412 C20.541283,0.207529412 23.2890566,3.04376471 23.2890566,6.57176471 L23.2890566,17.7091765 C23.2890566,21.2025882 20.541283,24.0734118 17.1233208,24.0734118 L6.33328302,24.0734118 C2.94883019,24.0734118 0.16754717,21.2371765 0.16754717,17.7091765 L0.16754717,6.57176471 C0.16754717,3.04376471 2.91532075,0.207529412 6.33328302,0.207529412 Z" id="Path" fill="#FFFFFF"></path>
+ <path d="M6.33328302,3.38964706 C4.62430189,3.38964706 3.25041509,4.80776471 3.25041509,6.57176471 L3.25041509,17.7091765 C3.25041509,19.4731765 4.62430189,20.8912941 6.33328302,20.8912941 L17.1233208,20.8912941 C18.8323019,20.8912941 20.2061887,19.4731765 20.2061887,17.7091765 L20.2061887,6.57176471 C20.2061887,4.80776471 18.8323019,3.38964706 17.1233208,3.38964706 L6.33328302,3.38964706 Z M6.33328302,0.207529412 L17.1233208,0.207529412 C20.541283,0.207529412 23.2890566,3.04376471 23.2890566,6.57176471 L23.2890566,17.7091765 C23.2890566,21.2025882 20.541283,24.0734118 17.1233208,24.0734118 L6.33328302,24.0734118 C2.94883019,24.0734118 0.16754717,21.2371765 0.16754717,17.7091765 L0.16754717,6.57176471 C0.16754717,3.04376471 2.91532075,0.207529412 6.33328302,0.207529412 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M11.7283019,8.12823529 L11.7283019,8.12823529 C13.8393962,8.12823529 15.5818868,9.89223529 15.5818868,12.1058824 L15.5818868,12.1058824 C15.5818868,14.3195294 13.8729057,16.0835294 11.7283019,16.0835294 L11.7283019,16.0835294 C9.61720755,16.0835294 7.87471698,14.3195294 7.87471698,12.1058824 L7.87471698,12.1058824 C7.87471698,9.92682353 9.58369811,8.12823529 11.7283019,8.12823529 Z" id="Path" fill="#6B4FBB"></path>
+ </g>
+ <path d="M4.62430189,3.35505882 C3.78656604,3.35505882 3.08286792,4.08141176 3.08286792,4.94611765 L3.08286792,14.4924706 C3.08286792,15.3571765 3.78656604,16.0835294 4.62430189,16.0835294 L13.8729057,16.0835294 C14.7106415,16.0835294 15.4143396,15.3571765 15.4143396,14.4924706 L15.4143396,4.94611765 C15.4143396,4.08141176 14.7106415,3.35505882 13.8729057,3.35505882 L4.62430189,3.35505882 Z" id="Path" fill="#FFFFFF"></path>
+ <path d="M4.62430189,3.35505882 C3.78656604,3.35505882 3.08286792,4.08141176 3.08286792,4.94611765 L3.08286792,14.4924706 C3.08286792,15.3571765 3.78656604,16.0835294 4.62430189,16.0835294 L13.8729057,16.0835294 C14.7106415,16.0835294 15.4143396,15.3571765 15.4143396,14.4924706 L15.4143396,4.94611765 C15.4143396,4.08141176 14.7106415,3.35505882 13.8729057,3.35505882 L4.62430189,3.35505882 Z M4.62430189,0.172941176 L13.8729057,0.172941176 C16.4196226,0.172941176 18.4972075,2.31741176 18.4972075,4.94611765 L18.4972075,14.4924706 C18.4972075,17.1211765 16.4196226,19.2656471 13.8729057,19.2656471 L4.62430189,19.2656471 C2.07758491,19.2656471 -1.19049424e-15,17.1211765 -1.19049424e-15,14.4924706 L-1.19049424e-15,4.94611765 C-1.19049424e-15,2.31741176 2.07758491,0.172941176 4.62430189,0.172941176 Z" id="Shape" fill="#FDC4A8" fill-rule="nonzero"></path>
+ <path d="M9.24860377,6.53717647 L9.24860377,6.53717647 C10.9575849,6.53717647 12.3314717,7.95529412 12.3314717,9.71929412 L12.3314717,9.71929412 C12.3314717,11.4832941 10.9575849,12.9014118 9.24860377,12.9014118 L9.24860377,12.9014118 C7.53962264,12.9014118 6.16573585,11.4832941 6.16573585,9.71929412 L6.16573585,9.71929412 C6.16573585,7.95529412 7.53962264,6.53717647 9.24860377,6.53717647 Z" id="Path" fill="#FC6D26"></path>
+ <g transform="translate(35.184906, 23.174118)">
+ <path d="M7.94173585,1.62564706 L27.9803774,1.62564706 C32.2360755,1.62564706 35.6875472,5.18823529 35.6875472,9.58094118 L35.6875472,30.2647059 C35.6875472,34.6574118 32.2360755,38.22 27.9803774,38.22 L7.94173585,38.22 C3.68603774,38.22 0.234566038,34.6574118 0.234566038,30.2647059 L0.234566038,9.58094118 C0.234566038,5.18823529 3.68603774,1.62564706 7.94173585,1.62564706 Z" id="Path" fill="#E1DBF1"></path>
+ <path d="M7.94173585,0.0345882353 L27.9803774,0.0345882353 C32.2360755,0.0345882353 35.6875472,3.59717647 35.6875472,7.98988235 L35.6875472,28.6736471 C35.6875472,33.0663529 32.2360755,36.6289412 27.9803774,36.6289412 L7.94173585,36.6289412 C3.68603774,36.6289412 0.234566038,33.0663529 0.234566038,28.6736471 L0.234566038,7.98988235 C0.234566038,3.59717647 3.68603774,0.0345882353 7.94173585,0.0345882353 Z" id="Path" fill="#E1DBF1"></path>
+ <path d="M7.94173585,3.21670588 C5.39501887,3.21670588 3.31743396,5.36117647 3.31743396,7.98988235 L3.31743396,28.6736471 C3.31743396,31.3023529 5.39501887,33.4468235 7.94173585,33.4468235 L27.9803774,33.4468235 C30.5270943,33.4468235 32.6046792,31.3023529 32.6046792,28.6736471 L32.6046792,7.98988235 C32.6046792,5.36117647 30.5270943,3.21670588 27.9803774,3.21670588 C27.9468679,3.21670588 7.94173585,3.21670588 7.94173585,3.21670588 Z M7.94173585,0.0345882353 L27.9803774,0.0345882353 C32.2360755,0.0345882353 35.6875472,3.59717647 35.6875472,7.98988235 L35.6875472,28.6736471 C35.6875472,33.0663529 32.2360755,36.6289412 27.9803774,36.6289412 L7.94173585,36.6289412 C3.68603774,36.6289412 0.234566038,33.0663529 0.234566038,28.6736471 L0.234566038,7.98988235 C0.234566038,3.59717647 3.68603774,0.0345882353 7.94173585,0.0345882353 Z" id="Shape" fill="#C3B8E3" fill-rule="nonzero"></path>
+ <path d="M14.1074717,12.7630588 L21.8146415,12.7630588 C22.6523774,12.7630588 23.3560755,13.4894118 23.3560755,14.3541176 L23.3560755,22.3094118 C23.3560755,23.1741176 22.6523774,23.9004706 21.8146415,23.9004706 L14.1074717,23.9004706 C13.2697358,23.9004706 12.5660377,23.1741176 12.5660377,22.3094118 L12.5660377,14.3541176 C12.5660377,13.4894118 13.2362264,12.7630588 14.1074717,12.7630588 Z" id="Path" fill="#6B4FBB"></path>
+ </g>
+ <path d="M32.6716981,71.4592941 C32.0685283,72.0818824 31.0967547,72.0818824 30.4935849,71.4592941 C29.8904151,70.8367059 29.8904151,69.8336471 30.4935849,69.2110588 L32.1355472,67.5162353 C32.738717,66.8936471 33.7104906,66.8936471 34.3136604,67.5162353 C34.9168302,68.1388235 34.9168302,69.1418824 34.3136604,69.7644706 L32.6716981,71.4592941 Z" id="Path" fill="#C3B8E3" fill-rule="nonzero"></path>
+ <path d="M37.5640755,66.4094118 C36.9609057,67.032 35.9891321,67.032 35.3859623,66.4094118 C34.7827925,65.7868235 34.7827925,64.7837647 35.3859623,64.1611765 L37.0279245,62.4663529 C37.6310943,61.8437647 38.6028679,61.8437647 39.2060377,62.4663529 C39.8092075,63.0889412 39.8092075,64.092 39.2060377,64.7145882 L37.5640755,66.4094118 Z" id="Path" fill="#E1DBF1"></path>
+ <path d="M21.3455094,21.2717647 C20.7088302,20.7183529 20.6083019,19.7152941 21.1444528,19.0235294 C21.6806038,18.3663529 22.6523774,18.2625882 23.322566,18.816 L25.098566,20.3378824 C25.7352453,20.8912941 25.8357736,21.8943529 25.2996226,22.5861176 C24.7634717,23.2432941 23.7916981,23.3470588 23.1215094,22.7936471 L21.3455094,21.2717647 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M26.64,25.872 C26.0033208,25.3185882 25.9027925,24.3155294 26.4389434,23.6237647 C26.9750943,22.9665882 27.9468679,22.8628235 28.6170566,23.4162353 L30.3930566,24.9381176 C31.0297358,25.4915294 31.1302642,26.4945882 30.5941132,27.1863529 C30.0579623,27.8781176 29.0861887,27.9472941 28.416,27.3938824 L26.64,25.872 Z" id="Path" fill="#C3B8E3"></path>
+ </g>
+ </g>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg
new file mode 100644
index 00000000000..e90c354fe65
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="215px" height="115px" viewBox="0 0 215 115" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 61.2 (89653) - https://sketch.com -->
+ <title>create-project-from-template-md</title>
+ <desc>Created with Sketch.</desc>
+ <g id="create-project-from-template-md" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="New-Template1" transform="translate(71.000000, 15.000000)">
+ <g id="Group">
+ <path d="M5.88230137,4.144 L61.8884384,4.144 C65.1195616,4.144 67.7155068,6.804 67.7155068,10.052 L67.7155068,78.064 C67.7155068,81.34 65.0919452,83.972 61.8884384,83.972 L5.88230137,83.972 C2.65117808,83.972 0.0552328767,81.312 0.0552328767,78.064 L0.0552328767,10.052 C0.0552328767,6.804 2.67879452,4.144 5.88230137,4.144 Z" id="Path" fill="#F9F9F9" fill-rule="nonzero"></path>
+ <path d="M8.82706849,1 L64.8332055,1 C68.0643288,1 70.660274,3.66 70.660274,6.908 L70.660274,74.332 C70.660274,77.608 68.0367123,80.24 64.8332055,80.24 L8.82706849,80.24 C5.59594521,80.24 3,77.58 3,74.332 L3,6.936 C3,3.66 5.59594521,1 8.82706849,1 Z" id="Path" fill="#FFFFFF" fill-rule="nonzero"></path>
+ <path d="M8.80964384,2.38 C6.24131507,2.38 4.14246575,4.508 4.14246575,7.112 L4.14246575,74.536 C4.14246575,77.14 6.24131507,79.268 8.80964384,79.268 L64.8157808,79.268 C67.3841096,79.268 69.4829589,77.14 69.4829589,74.536 L69.4829589,7.112 C69.4829589,4.508 67.3841096,2.38 64.8157808,2.38 L8.80964384,2.38 L8.80964384,2.38 Z M8.80964384,0 L64.8157808,0 C68.6820822,0 71.8027397,3.164 71.8027397,7.084 L71.8027397,74.508 C71.8027397,78.428 68.6820822,81.592 64.8157808,81.592 L8.80964384,81.592 C4.94334247,81.592 1.82250462,78.4 1.82250462,74.508 L1.82250462,7.112 C1.79506849,3.192 4.94334247,0 8.80964384,0 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M14.6367123,14.784 L21.6236712,14.784 C22.2588493,14.784 22.7835616,15.316 22.7835616,15.96 C22.7835616,16.604 22.2588493,17.136 21.6236712,17.136 L14.6367123,17.136 C14.0015342,17.136 13.4768219,16.604 13.4768219,15.96 C13.4768219,15.316 14.0015342,14.784 14.6367123,14.784 Z M33.3054247,21.896 L40.2923836,21.896 C40.9275616,21.896 41.452274,22.428 41.452274,23.072 C41.452274,23.716 40.9275616,24.248 40.2923836,24.248 L33.3054247,24.248 C32.6702466,24.248 32.1455342,23.716 32.1455342,23.072 C32.1455342,22.428 32.6702466,21.896 33.3054247,21.896 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M40.32,14.784 L47.3069589,14.784 C47.942137,14.784 48.4668493,15.316 48.4668493,15.96 C48.4668493,16.604 47.942137,17.136 47.3069589,17.136 L40.32,17.136 C39.6848219,17.136 39.1601096,16.604 39.1601096,15.96 C39.1324932,15.316 39.6572055,14.784 40.32,14.784 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M21.6512877,28.98 L28.6382466,28.98 C29.2734247,28.98 29.798137,29.512 29.798137,30.156 C29.798137,30.8 29.2734247,31.332 28.6382466,31.332 L21.6512877,31.332 C21.0161096,31.332 20.4913973,30.8 20.4913973,30.156 C20.4637808,29.512 20.9884932,28.98 21.6512877,28.98 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M26.2908493,14.784 L28.6382466,14.784 C29.2734247,14.784 29.798137,15.316 29.798137,15.96 C29.798137,16.604 29.2734247,17.136 28.6382466,17.136 L26.2908493,17.136 C25.6556712,17.136 25.1309589,16.604 25.1309589,15.96 C25.1309589,15.316 25.6556712,14.784 26.2908493,14.784 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path>
+ <path d="M33.3054247,36.092 L35.6528219,36.092 C36.288,36.092 36.8127123,36.624 36.8127123,37.268 C36.8127123,37.912 36.288,38.444 35.6528219,38.444 L33.3054247,38.444 C32.6702466,38.444 32.1455342,37.912 32.1455342,37.268 C32.1455342,36.624 32.6702466,36.092 33.3054247,36.092 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M44.9595616,21.896 L47.3069589,21.896 C47.942137,21.896 48.4668493,22.428 48.4668493,23.072 C48.4668493,23.716 47.942137,24.248 47.3069589,24.248 L44.9595616,24.248 C44.3243836,24.248 43.7996712,23.716 43.7996712,23.072 C43.7996712,22.428 44.3243836,21.896 44.9595616,21.896 Z M51.974137,14.784 L54.3215342,14.784 C54.9567123,14.784 55.4814247,15.316 55.4814247,15.96 C55.4814247,16.604 54.9567123,17.136 54.3215342,17.136 L51.974137,17.136 C51.3389589,17.136 50.8142466,16.604 50.8142466,15.96 C50.8142466,15.316 51.3389589,14.784 51.974137,14.784 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M23.9710685,43.176 L28.6382466,43.176 C29.2734247,43.176 29.798137,43.708 29.798137,44.352 C29.798137,44.996 29.2734247,45.528 28.6382466,45.528 L23.9710685,45.528 C23.3358904,45.528 22.8111781,44.996 22.8111781,44.352 C22.8111781,43.708 23.3358904,43.176 23.9710685,43.176 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M40.32,36.092 L42.6673973,36.092 C43.3025753,36.092 43.8272877,36.624 43.8272877,37.268 C43.8272877,37.912 43.3025753,38.444 42.6673973,38.444 L40.32,38.444 C39.6848219,38.444 39.1601096,37.912 39.1601096,37.268 C39.1324932,36.624 39.6572055,36.092 40.32,36.092 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M52.2503014,33.712 C53.0511781,33.712 53.7139726,34.384 53.7139726,35.196 C53.7139726,36.008 53.0511781,36.68 52.2503014,36.68 C51.4494247,36.68 50.7866301,36.008 50.7866301,35.196 C50.8142466,34.384 51.4494247,33.712 52.2503014,33.712 Z M58.1049863,50.876 C58.905863,50.876 59.5686575,51.548 59.5686575,52.36 C59.5686575,53.172 58.905863,53.844 58.1049863,53.844 C57.3041096,53.844 56.6413151,53.172 56.6413151,52.36 C56.6413151,51.548 57.3041096,50.876 58.1049863,50.876 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M63.3521096,50.876 C64.1529863,50.876 64.8157808,51.548 64.8157808,52.36 C64.8157808,53.172 64.1529863,53.844 63.3521096,53.844 C62.5512329,53.844 61.8884384,53.172 61.8884384,52.36 C61.8884384,51.548 62.5512329,50.876 63.3521096,50.876 Z M33.3054247,14.784 L35.6528219,14.784 C36.288,14.784 36.8127123,15.316 36.8127123,15.96 C36.8127123,16.604 36.288,17.136 35.6528219,17.136 L33.3054247,17.136 C32.6702466,17.136 32.1455342,16.604 32.1455342,15.96 C32.1455342,15.316 32.6702466,14.784 33.3054247,14.784 Z" id="Shape" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6367123,36.092 L28.6382466,36.092 C29.2734247,36.092 29.798137,36.624 29.798137,37.268 C29.798137,37.912 29.2734247,38.444 28.6382466,38.444 L14.6367123,38.444 C14.0015342,38.444 13.4768219,37.912 13.4768219,37.268 C13.4768219,36.624 14.0015342,36.092 14.6367123,36.092 Z M44.0482192,42 L61.0599452,42 C61.8332055,42 62.4683836,42.672 62.4683836,43.484 C62.4683836,44.296 61.8332055,44.968 61.0599452,44.968 L44.0758356,44.968 C43.3025753,44.968 42.6673973,44.296 42.6673973,43.484 C42.6673973,42.672 43.2749589,42 44.0482192,42 L44.0482192,42 L44.0482192,42 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M35.3214247,50.876 L52.3055342,50.876 C53.0787945,50.876 53.7139726,51.548 53.7139726,52.36 C53.7139726,53.172 53.0787945,53.844 52.3055342,53.844 L35.3214247,53.844 C34.5481644,53.844 33.9129863,53.172 33.9129863,52.36 C33.8853699,51.548 34.5205479,50.876 35.3214247,50.876 L35.3214247,50.876 L35.3214247,50.876 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path>
+ <path d="M14.6367123,21.896 L28.6382466,21.896 C29.2734247,21.896 29.798137,22.428 29.798137,23.072 C29.798137,23.716 29.2734247,24.248 28.6382466,24.248 L14.6367123,24.248 C14.0015342,24.248 13.4768219,23.716 13.4768219,23.072 C13.4768219,22.428 14.0015342,21.896 14.6367123,21.896 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M33.3054247,28.98 L47.3069589,28.98 C47.942137,28.98 48.4668493,29.512 48.4668493,30.156 C48.4668493,30.8 47.942137,31.332 47.3069589,31.332 L33.3054247,31.332 C32.6702466,31.332 32.1455342,30.8 32.1455342,30.156 C32.1455342,29.512 32.6702466,28.98 33.3054247,28.98 Z" id="Path" fill="#C3B8E3" fill-rule="nonzero"></path>
+ <path d="M14.6367123,28.98 L16.9841096,28.98 C17.6192877,28.98 18.144,29.512 18.144,30.156 C18.144,30.8 17.6192877,31.332 16.9841096,31.332 L14.6367123,31.332 C14.0015342,31.332 13.4768219,30.8 13.4768219,30.156 C13.4768219,29.512 14.0015342,28.98 14.6367123,28.98 Z" id="Path" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M33.3054247,43.176 L35.6528219,43.176 C36.288,43.176 36.8127123,43.708 36.8127123,44.352 C36.8127123,44.996 36.288,45.528 35.6528219,45.528 L33.3054247,45.528 C32.6702466,45.528 32.1455342,44.996 32.1455342,44.352 C32.1455342,43.708 32.6702466,43.176 33.3054247,43.176 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M14.6367123,43.176 L19.3038904,43.176 C19.9390685,43.176 20.4637808,43.708 20.4637808,44.352 C20.4637808,44.996 19.9390685,45.528 19.3038904,45.528 L14.6367123,45.528 C14.0015342,45.528 13.4768219,44.996 13.4768219,44.352 C13.4768219,43.708 14.0015342,43.176 14.6367123,43.176 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6367123,50.288 L19.3038904,50.288 C19.9390685,50.288 20.4637808,50.82 20.4637808,51.464 C20.4637808,52.108 19.9390685,52.64 19.3038904,52.64 L14.6367123,52.64 C14.0015342,52.64 13.4768219,52.108 13.4768219,51.464 C13.4768219,50.82 14.0015342,50.288 14.6367123,50.288 Z M23.9710685,50.288 L28.6382466,50.288 C29.2734247,50.288 29.798137,50.82 29.798137,51.464 C29.798137,52.108 29.2734247,52.64 28.6382466,52.64 L23.9710685,52.64 C23.3358904,52.64 22.8111781,52.108 22.8111781,51.464 C22.8111781,50.82 23.3358904,50.288 23.9710685,50.288 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M14.6367123,57.372 L21.6236712,57.372 C22.2588493,57.372 22.7835616,57.904 22.7835616,58.548 C22.7835616,59.192 22.2588493,59.724 21.6236712,59.724 L14.6367123,59.724 C14.0015342,59.724 13.4768219,59.192 13.4768219,58.548 C13.4768219,57.904 14.0015342,57.372 14.6367123,57.372 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path>
+ <path d="M25.3518904,64.484 L33.6644384,64.484 C34.4376986,64.484 35.0452603,65.016 35.0452603,65.66 C35.0452603,66.304 34.4376986,66.836 33.6644384,66.836 L25.3518904,66.836 C24.5786301,66.836 23.9710685,66.304 23.9710685,65.66 C23.9710685,65.016 24.5786301,64.484 25.3518904,64.484 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M44.0206027,59.136 L52.3331507,59.136 C53.106411,59.136 53.7139726,59.808 53.7139726,60.62 C53.7139726,61.432 53.106411,62.104 52.3331507,62.104 L44.0206027,62.104 C43.2473425,62.104 42.6397808,61.432 42.6397808,60.62 C42.6397808,59.808 43.2473425,59.136 44.0206027,59.136 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M26.2908493,57.372 L28.6382466,57.372 C29.2734247,57.372 29.798137,57.904 29.798137,58.548 C29.798137,59.192 29.2734247,59.724 28.6382466,59.724 L26.2908493,59.724 C25.6556712,59.724 25.1309589,59.192 25.1309589,58.548 C25.1309589,57.904 25.6556712,57.372 26.2908493,57.372 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path>
+ <path d="M36.8127123,64.484 L39.1601096,64.484 C39.7952877,64.484 40.32,65.016 40.32,65.66 C40.32,66.304 39.7952877,66.836 39.1601096,66.836 L36.8127123,66.836 C36.1775342,66.836 35.6528219,66.304 35.6528219,65.66 C35.6528219,65.016 36.1775342,64.484 36.8127123,64.484 Z M58.1049863,59.136 L61.0323288,59.136 C61.8332055,59.136 62.496,59.808 62.496,60.62 C62.496,61.432 61.8332055,62.104 61.0323288,62.104 L58.1049863,62.104 C57.3041096,62.104 56.6413151,61.432 56.6413151,60.62 C56.6413151,59.808 57.3041096,59.136 58.1049863,59.136 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M35.3490411,59.136 L38.2763836,59.136 C39.0772603,59.136 39.7400548,59.808 39.7400548,60.62 C39.7400548,61.432 39.0772603,62.104 38.2763836,62.104 L35.3490411,62.104 C34.5481644,62.104 33.8853699,61.432 33.8853699,60.62 C33.8853699,59.808 34.5481644,59.136 35.3490411,59.136 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6367123,64.484 L20.4637808,64.484 C21.0989589,64.484 21.6236712,65.016 21.6236712,65.66 C21.6236712,66.304 21.0989589,66.836 20.4637808,66.836 L14.6367123,66.836 C14.0015342,66.836 13.4768219,66.304 13.4768219,65.66 C13.4768219,65.016 14.0015342,64.484 14.6367123,64.484 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <g id="Group-2" transform="translate(16.000000, 20.000000)">
+ <ellipse id="Oval" fill="#FFFFFF" fill-rule="nonzero" cx="20.4085479" cy="20.692" rx="20.4085479" ry="20.692"></ellipse>
+ <path d="M20.4085479,41.384 C9.1410411,41.384 8.17124146e-14,32.116 8.17124146e-14,20.692 C8.17124146e-14,9.268 9.1410411,1.0658141e-14 20.4085479,1.0658141e-14 C31.6760548,1.0658141e-14 40.8170959,9.268 40.8170959,20.692 C40.8170959,32.116 31.7036712,41.384 20.4085479,41.384 Z M20.4085479,39.032 C30.4056986,39.032 38.4973151,30.828 38.4973151,20.692 C38.4973151,10.556 30.4056986,2.352 20.4085479,2.352 C10.4113973,2.352 2.31978082,10.556 2.31978082,20.692 C2.31978082,30.828 10.4390137,39.032 20.4085479,39.032 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <g id="Group" transform="translate(10.439014, 11.480000)">
+ <path d="M19.7457534,10.528 L18.6410959,7.112 L16.4593973,0.336 C16.3489315,7.97972799e-15 15.8794521,7.97972799e-15 15.7413699,0.336 L13.5872877,7.112 L6.37939726,7.112 L4.19769863,0.336 C4.08723288,7.97972799e-15 3.61775342,7.97972799e-15 3.47967123,0.336 L1.2979726,7.112 L0.193315068,10.528 C0.0828493151,10.836 0.193315068,11.172 0.469479452,11.368 L9.94191781,18.34 L19.4143562,11.368 C19.718137,11.172 19.8286027,10.836 19.7457534,10.528" id="Path" fill="#FC6D26"></path>
+ <polygon id="Path" fill="#E24329" points="9.96953425 18.34 13.5596712 7.112 6.35178082 7.112"></polygon>
+ <polygon id="Path" fill="#FC6D26" points="9.96953425 18.34 6.37939726 7.112 1.32558904 7.112"></polygon>
+ <path d="M1.32558904,7.112 L0.220931507,10.528 C0.110465753,10.836 0.220931507,11.172 0.49709589,11.368 L9.96953425,18.34 L1.32558904,7.112 Z" id="Path" fill="#FCA326"></path>
+ <path d="M1.32558904,7.112 L6.37939726,7.112 L4.19769863,0.336 C4.08723288,7.9658502e-15 3.61775342,7.9658502e-15 3.47967123,0.336 L1.32558904,7.112 Z" id="Path" fill="#E24329"></path>
+ <polygon id="Path" fill="#FC6D26" points="9.96953425 18.34 13.5596712 7.112 18.6134795 7.112"></polygon>
+ <path d="M18.6410959,7.112 L19.7457534,10.528 C19.8562192,10.836 19.7457534,11.172 19.469589,11.368 L9.99715068,18.34 L18.6410959,7.112 Z" id="Path" fill="#FCA326"></path>
+ <path d="M18.6410959,7.112 L13.5872877,7.112 L15.7689863,0.336 C15.8794521,7.9658502e-15 16.3489315,7.9658502e-15 16.4870137,0.336 L18.6410959,7.112 Z" id="Path" fill="#E24329"></path>
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg
new file mode 100644
index 00000000000..c507fb8d73d
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="215px" height="115px" viewBox="0 0 215 115" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 61.2 (89653) - https://sketch.com -->
+ <title>import-project-md</title>
+ <desc>Created with Sketch.</desc>
+ <g id="import-project-md" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="Group-4" transform="translate(14.000000, 15.000000)">
+ <g id="Group-7" transform="translate(0.000000, 0.007864)" fill-rule="nonzero">
+ <path d="M5.84551724,4.12490066 L61.9641379,4.12490066 C65.1917241,4.12490066 67.8096552,6.76450331 67.8096552,10.0188079 L67.8096552,77.8001325 C67.8096552,81.0544371 65.1917241,83.6940397 61.9641379,83.6940397 L5.84551724,83.6940397 C2.61793103,83.6940397 2.84217094e-14,81.0544371 2.84217094e-14,77.8001325 L2.84217094e-14,10.0188079 C2.84217094e-14,6.76450331 2.61793103,4.12490066 5.84551724,4.12490066 Z" id="Path" fill="#F9F9F9"></path>
+ <path d="M8.76965517,1.17933775 L64.8882759,1.17933775 C68.1158621,1.17933775 70.7337931,3.8189404 70.7337931,7.07324503 L70.7337931,74.2649007 C70.7337931,77.5192053 68.1158621,80.1588079 64.8882759,80.1588079 L8.76965517,80.1588079 C5.54206897,80.1588079 2.92413793,77.5192053 2.92413793,74.2649007 L2.92413793,7.07324503 C2.92413793,3.81615894 5.53931034,1.17933775 8.76965517,1.17933775 Z" id="Path" fill="#FFFFFF"></path>
+ <path d="M64.8882759,2.20268248e-13 C68.7613793,2.20268248e-13 71.9034483,3.16807947 71.9034483,7.07324503 L71.9024828,19.847 L69.5634828,19.847 L69.5641379,7.07324503 C69.5641379,4.46980132 67.4703448,2.3586755 64.8882759,2.3586755 L8.76965517,2.3586755 L8.76965517,2.35589404 C6.18758621,2.35589404 4.0937931,4.46701987 4.0937931,7.07046358 L4.0937931,74.2621192 C4.0937931,76.8655629 6.18758621,78.9766887 8.76965517,78.9766887 L64.8882759,78.9766887 C67.4703448,78.9766887 69.5641379,76.8655629 69.5641379,74.2621192 L69.5624828,54.847 L71.9014828,54.847 L71.9034483,74.2649007 C71.9034483,78.1700662 68.7613793,81.3381457 64.8882759,81.3381457 L8.76965517,81.3381457 C4.89655172,81.3381457 1.75448276,78.1700662 1.75448276,74.2649007 L1.75448276,7.07324503 C1.75448276,3.16529801 4.8937931,2.20268248e-13 8.76965517,2.20268248e-13 L64.8882759,2.20268248e-13 Z M71.9014828,44.847 L71.9004828,48.847 L69.5614828,48.847 L69.5624828,44.847 L71.9014828,44.847 Z M71.9024828,26.847 L71.9014828,31.847 L69.5624828,31.847 L69.5634828,26.847 L71.9024828,26.847 Z" id="Combined-Shape" fill="#EEEEEE"></path>
+ <path d="M14.6151724,14.7333775 L21.6275862,14.7333775 C22.2731034,14.7333775 22.7972414,15.2618543 22.7972414,15.9127152 C22.7972414,16.5635762 22.2731034,17.092053 21.6275862,17.092053 L14.6151724,17.092053 C13.9696552,17.092053 13.4455172,16.5635762 13.4455172,15.9127152 C13.4455172,15.2618543 13.9696552,14.7333775 14.6151724,14.7333775 Z M33.3213793,21.8066225 L40.3365517,21.8066225 C40.982069,21.8066225 41.5062069,22.3350993 41.5062069,22.9859603 C41.5062069,23.6368212 40.982069,24.165298 40.3365517,24.165298 L33.3213793,24.165298 C32.6758621,24.165298 32.1517241,23.6368212 32.1517241,22.9859603 C32.1517241,22.3350993 32.6731034,21.8066225 33.3213793,21.8066225 Z" id="Shape" fill="#E1DBF1"></path>
+ <path d="M40.3337931,14.7333775 L47.3489655,14.7333775 C47.9944828,14.7333775 48.5186207,15.2618543 48.5186207,15.9127152 C48.5186207,16.5635762 47.9944828,17.092053 47.3489655,17.092053 L40.3337931,17.092053 C39.6882759,17.092053 39.1641379,16.5635762 39.1641379,15.9127152 C39.1668966,15.2618543 39.6882759,14.7333775 40.3337931,14.7333775 Z" id="Path" fill="#EEEEEE"></path>
+ <path d="M21.6275862,28.8798675 L28.6427586,28.8798675 C29.2882759,28.8798675 29.8124138,29.4083444 29.8124138,30.0592053 C29.8124138,30.7100662 29.2882759,31.238543 28.6427586,31.238543 L21.6275862,31.238543 C20.982069,31.238543 20.457931,30.7100662 20.457931,30.0592053 C20.4606897,29.4083444 20.982069,28.8798675 21.6275862,28.8798675 Z" id="Path" fill="#E1DBF1"></path>
+ <path d="M26.3062069,14.7333775 L28.6455172,14.7333775 C29.2910345,14.7333775 29.8151724,15.2618543 29.8151724,15.9127152 C29.8151724,16.5635762 29.2910345,17.092053 28.6455172,17.092053 L26.3062069,17.092053 C25.6606897,17.092053 25.1365517,16.5635762 25.1365517,15.9127152 C25.1365517,15.2618543 25.6606897,14.7333775 26.3062069,14.7333775 Z" id="Path" fill="#FEE1D3"></path>
+ <path d="M33.3213793,35.9531126 L35.6606897,35.9531126 C36.3062069,35.9531126 36.8303448,36.4815894 36.8303448,37.1324503 C36.8303448,37.7833113 36.3062069,38.3117881 35.6606897,38.3117881 L33.3213793,38.3117881 C32.6758621,38.3117881 32.1517241,37.7833113 32.1517241,37.1324503 C32.1517241,36.4815894 32.6731034,35.9531126 33.3213793,35.9531126 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M52.0248276,14.7333775 L54.3641379,14.7333775 C55.0096552,14.7333775 55.5337931,15.2618543 55.5337931,15.9127152 C55.5337931,16.5635762 55.0096552,17.092053 54.3641379,17.092053 L52.0248276,17.092053 C51.3793103,17.092053 50.8551724,16.5635762 50.8551724,15.9127152 C50.857931,15.2618543 51.3793103,14.7333775 52.0248276,14.7333775 Z" id="Shape" fill="#FEF0E8"></path>
+ <path d="M23.9668966,43.0263576 L28.6427586,43.0263576 C29.2882759,43.0263576 29.8124138,43.5548344 29.8124138,44.2056954 C29.8124138,44.8565563 29.2882759,45.3850331 28.6427586,45.3850331 L23.9668966,45.3850331 C23.3213793,45.3850331 22.7972414,44.8565563 22.7972414,44.2056954 C22.7972414,43.5548344 23.3213793,43.0263576 23.9668966,43.0263576 Z" id="Path" fill="#E1DBF1"></path>
+ <path d="M40.3337931,35.9531126 L42.6731034,35.9531126 C43.3186207,35.9531126 43.8427586,36.4815894 43.8427586,37.1324503 C43.8427586,37.7833113 43.3186207,38.3117881 42.6731034,38.3117881 L40.3337931,38.3117881 C39.6882759,38.3117881 39.1641379,37.7833113 39.1641379,37.1324503 C39.1641379,36.4815894 39.6882759,35.9531126 40.3337931,35.9531126 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M52.3172414,33.5944371 C53.1255172,33.5944371 53.7793103,34.2536424 53.7793103,35.0686093 C53.7793103,35.8835762 53.1255172,36.5427815 52.3172414,36.5427815 C51.5089655,36.5427815 50.8551724,35.8835762 50.8551724,35.0686093 C50.8551724,34.2536424 51.5117241,33.5944371 52.3172414,33.5944371 Z M58.1627586,50.6892715 C58.9710345,50.6892715 59.6248276,51.3484768 59.6248276,52.1634437 C59.6248276,52.9784106 58.9710345,53.6376159 58.1627586,53.6376159 C57.3544828,53.6376159 56.7006897,52.9784106 56.7006897,52.1634437 C56.7006897,51.3484768 57.3572414,50.6892715 58.1627586,50.6892715 Z" id="Shape" fill="#E1DBF1"></path>
+ <path d="M63.4262069,50.6892715 C64.2344828,50.6892715 64.8882759,51.3484768 64.8882759,52.1634437 C64.8882759,52.9784106 64.2344828,53.6376159 63.4262069,53.6376159 C62.617931,53.6376159 61.9641379,52.9784106 61.9641379,52.1634437 C61.9641379,51.3484768 62.617931,50.6892715 63.4262069,50.6892715 Z M33.3213793,14.7333775 L35.6606897,14.7333775 C36.3062069,14.7333775 36.8303448,15.2618543 36.8303448,15.9127152 C36.8303448,16.5635762 36.3062069,17.092053 35.6606897,17.092053 L33.3213793,17.092053 C32.6758621,17.092053 32.1517241,16.5635762 32.1517241,15.9127152 C32.1517241,15.2618543 32.6731034,14.7333775 33.3213793,14.7333775 Z" id="Shape" fill="#FC6D26"></path>
+ <path d="M59.1696552,33.8470199 L66.182069,33.8470199 C66.8275862,33.8470199 67.3517241,34.3754967 67.3517241,35.0263576 C67.3517241,35.6772185 66.8275862,36.2056954 66.182069,36.2056954 L59.1696552,36.2056954 C58.5241379,36.2056954 58,35.6772185 58,35.0263576 C58,34.3754967 58.5241379,33.8470199 59.1696552,33.8470199 Z" id="Shape" fill="#E1DBF1"></path>
+ <path d="M70.8606897,33.8470199 L73.2,33.8470199 C73.8455172,33.8470199 74.3696552,34.3754967 74.3696552,35.0263576 C74.3696552,35.6772185 73.8455172,36.2056954 73.2,36.2056954 L70.8606897,36.2056954 C70.2151724,36.2056954 69.6910345,35.6772185 69.6910345,35.0263576 C69.6910345,34.3754967 70.2151724,33.8470199 70.8606897,33.8470199 Z" id="Path" fill="#FEE1D3"></path>
+ <path d="M77.8758621,33.8470199 L80.2151724,33.8470199 C80.8606897,33.8470199 81.3848276,34.3754967 81.3848276,35.0263576 C81.3848276,35.6772185 80.8606897,36.2056954 80.2151724,36.2056954 L77.8758621,36.2056954 C77.2303448,36.2056954 76.7062069,35.6772185 76.7062069,35.0263576 C76.7062069,34.3754967 77.2275862,33.8470199 77.8758621,33.8470199 Z" id="Shape" fill="#FC6D26"></path>
+ <path d="M14.6151724,35.9531126 L28.6455172,35.9531126 C29.2910345,35.9531126 29.8151724,36.4815894 29.8151724,37.1324503 C29.8151724,37.7833113 29.2910345,38.3117881 28.6455172,38.3117881 L14.6151724,38.3117881 C13.9696552,38.3117881 13.4455172,37.7833113 13.4455172,37.1324503 C13.4455172,36.4815894 13.9696552,35.9531126 14.6151724,35.9531126 Z M44.0937931,41.8470199 L61.1282759,41.8470199 C61.9117241,41.8470199 62.5489655,42.5062252 62.5489655,43.3211921 C62.5489655,44.1361589 61.9144828,44.7953642 61.1282759,44.7953642 L44.0937931,44.7953642 C43.3103448,44.7953642 42.6731034,44.1361589 42.6731034,43.3211921 C42.6731034,42.5062252 43.3103448,41.8470199 44.0937931,41.8470199 L44.0937931,41.8470199 L44.0937931,41.8470199 Z" id="Shape" fill="#EEEEEE"></path>
+ <path d="M35.3241379,50.6892715 L52.3586207,50.6892715 C53.142069,50.6892715 53.7793103,51.3484768 53.7793103,52.1634437 C53.7793103,52.9784106 53.1448276,53.6376159 52.3586207,53.6376159 L35.3241379,53.6376159 C34.5406897,53.6376159 33.9034483,52.9784106 33.9034483,52.1634437 C33.9034483,51.3484768 34.5406897,50.6892715 35.3241379,50.6892715 L35.3241379,50.6892715 L35.3241379,50.6892715 Z" id="Path" fill="#EFEDF8"></path>
+ <path d="M14.6151724,21.8066225 L28.6455172,21.8066225 C29.2910345,21.8066225 29.8151724,22.3350993 29.8151724,22.9859603 C29.8151724,23.6368212 29.2910345,24.165298 28.6455172,24.165298 L14.6151724,24.165298 C13.9696552,24.165298 13.4455172,23.6368212 13.4455172,22.9859603 C13.4455172,22.3350993 13.9696552,21.8066225 14.6151724,21.8066225 Z" id="Path" fill="#6B4FBB"></path>
+ <path d="M33.3213793,28.8798675 L47.3517241,28.8798675 C47.9972414,28.8798675 48.5213793,29.4083444 48.5213793,30.0592053 C48.5213793,30.7100662 47.9972414,31.238543 47.3517241,31.238543 L33.3213793,31.238543 C32.6758621,31.238543 32.1517241,30.7100662 32.1517241,30.0592053 C32.1517241,29.4083444 32.6731034,28.8798675 33.3213793,28.8798675 Z" id="Path" fill="#C3B8E3"></path>
+ <path d="M14.6151724,28.8798675 L16.9544828,28.8798675 C17.6,28.8798675 18.1241379,29.4083444 18.1241379,30.0592053 C18.1241379,30.7100662 17.6,31.238543 16.9544828,31.238543 L14.6151724,31.238543 C13.9696552,31.238543 13.4455172,30.7100662 13.4455172,30.0592053 C13.4455172,29.4083444 13.9696552,28.8798675 14.6151724,28.8798675 Z" id="Path" fill="#FEF0E8"></path>
+ <path d="M75.182069,50.8470199 L82.1972414,50.8470199 C82.8427586,50.8470199 83.3668966,51.3754967 83.3668966,52.0263576 C83.3668966,52.6772185 82.8427586,53.2056954 82.1972414,53.2056954 L75.182069,53.2056954 C74.5365517,53.2056954 74.0124138,52.6772185 74.0124138,52.0263576 C74.0151724,51.3754967 74.5365517,50.8470199 75.182069,50.8470199 Z" id="Path" fill="#E1DBF1"></path>
+ <path d="M86.8758621,50.8470199 L100.906207,50.8470199 C101.551724,50.8470199 102.075862,51.5079868 102.075862,52.3220199 C102.075862,53.1360529 101.551724,53.7970199 100.906207,53.7970199 L86.8758621,53.7970199 C86.2303448,53.7970199 85.7062069,53.1360529 85.7062069,52.3220199 C85.7062069,51.5079868 86.2275862,50.8470199 86.8758621,50.8470199 Z" id="Path" fill="#C3B8E3"></path>
+ <path d="M68.1696552,50.8470199 L70.5089655,50.8470199 C71.1544828,50.8470199 71.6786207,51.3754967 71.6786207,52.0263576 C71.6786207,52.6772185 71.1544828,53.2056954 70.5089655,53.2056954 L68.1696552,53.2056954 C67.5241379,53.2056954 67,52.6772185 67,52.0263576 C67,51.3754967 67.5241379,50.8470199 68.1696552,50.8470199 Z" id="Path" fill="#FEF0E8"></path>
+ <path d="M33.3213793,43.0263576 L35.6606897,43.0263576 C36.3062069,43.0263576 36.8303448,43.5548344 36.8303448,44.2056954 C36.8303448,44.8565563 36.3062069,45.3850331 35.6606897,45.3850331 L33.3213793,45.3850331 C32.6758621,45.3850331 32.1517241,44.8565563 32.1517241,44.2056954 C32.1517241,43.5548344 32.6731034,43.0263576 33.3213793,43.0263576 Z" id="Path" fill="#6B4FBB"></path>
+ <path d="M14.6151724,43.0263576 L19.2910345,43.0263576 C19.9365517,43.0263576 20.4606897,43.5548344 20.4606897,44.2056954 C20.4606897,44.8565563 19.9365517,45.3850331 19.2910345,45.3850331 L14.6151724,45.3850331 C13.9696552,45.3850331 13.4455172,44.8565563 13.4455172,44.2056954 C13.4455172,43.5548344 13.9696552,43.0263576 14.6151724,43.0263576 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M14.6151724,50.0996026 L19.2910345,50.0996026 C19.9365517,50.0996026 20.4606897,50.6280795 20.4606897,51.2789404 C20.4606897,51.9298013 19.9365517,52.4582781 19.2910345,52.4582781 L14.6151724,52.4582781 C13.9696552,52.4582781 13.4455172,51.9298013 13.4455172,51.2789404 C13.4455172,50.625298 13.9696552,50.0996026 14.6151724,50.0996026 Z M23.9668966,50.0996026 L28.6427586,50.0996026 C29.2882759,50.0996026 29.8124138,50.6280795 29.8124138,51.2789404 C29.8124138,51.9298013 29.2882759,52.4582781 28.6427586,52.4582781 L23.9668966,52.4582781 C23.3213793,52.4582781 22.7972414,51.9298013 22.7972414,51.2789404 C22.7972414,50.625298 23.3213793,50.0996026 23.9668966,50.0996026 Z" id="Shape" fill="#FEF0E8"></path>
+ <path d="M88.7172414,21.8029139 C89.5255172,21.8029139 90.1793103,22.4621192 90.1793103,23.2770861 C90.1793103,24.092053 89.5255172,24.7512583 88.7172414,24.7512583 C87.9089655,24.7512583 87.2551724,24.092053 87.2551724,23.2770861 C87.2551724,22.4621192 87.9117241,21.8029139 88.7172414,21.8029139 Z" id="Shape" fill="#FEE1D3"></path>
+ <path d="M93.9806897,21.8029139 C94.7889655,21.8029139 95.4427586,22.4621192 95.4427586,23.2770861 C95.4427586,24.092053 94.7889655,24.7512583 93.9806897,24.7512583 C93.1724138,24.7512583 92.5186207,24.092053 92.5186207,23.2770861 C92.5186207,22.4621192 93.1724138,21.8029139 93.9806897,21.8029139 Z" id="Shape" fill="#6B4FBB"></path>
+ <path d="M65.8786207,21.8029139 L82.9131034,21.8029139 C83.6965517,21.8029139 84.3337931,22.4624894 84.3337931,23.2779139 C84.3337931,24.0933384 83.6993103,24.7529139 82.9131034,24.7529139 L65.8786207,24.7529139 C65.0951724,24.7529139 64.457931,24.0933384 64.457931,23.2779139 C64.457931,22.4624894 65.0951724,21.8029139 65.8786207,21.8029139 L65.8786207,21.8029139 L65.8786207,21.8029139 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M54.5213793,21.213245 L59.1972414,21.213245 C59.8427586,21.213245 60.3668966,21.7417219 60.3668966,22.3925828 C60.3668966,23.0434437 59.8427586,23.5719205 59.1972414,23.5719205 L54.5213793,23.5719205 C53.8758621,23.5719205 53.3517241,23.0434437 53.3517241,22.3925828 C53.3517241,21.7389404 53.8758621,21.213245 54.5213793,21.213245 Z" id="Shape" fill="#FEF0E8"></path>
+ <path d="M45.1696552,21.213245 L49.8455172,21.213245 C50.4910345,21.213245 51.0151724,21.7417219 51.0151724,22.3925828 C51.0151724,23.0434437 50.4910345,23.5719205 49.8455172,23.5719205 L45.1696552,23.5719205 C44.5241379,23.5719205 44,23.0434437 44,22.3925828 C44,21.7389404 44.5241379,21.213245 45.1696552,21.213245 Z" id="Path" fill="#EEEEEE"></path>
+ <path d="M14.6151724,57.1728477 L21.6275862,57.1728477 C22.2731034,57.1728477 22.7972414,57.7013245 22.7972414,58.3521854 C22.7972414,59.0030464 22.2731034,59.5315232 21.6275862,59.5315232 L14.6151724,59.5315232 C13.9696552,59.5315232 13.4455172,59.0030464 13.4455172,58.3521854 C13.4455172,57.698543 13.9696552,57.1728477 14.6151724,57.1728477 Z" id="Path" fill="#EFEDF8"></path>
+ <path d="M25.3544828,64.2433113 L33.6855172,64.2433113 C34.4524138,64.2433113 35.0731034,64.7717881 35.0731034,65.422649 C35.0731034,66.0735099 34.4524138,66.6019868 33.6855172,66.6019868 L25.3544828,66.6019868 C24.5875862,66.6019868 23.9668966,66.0735099 23.9668966,65.422649 C23.9668966,64.7717881 24.5875862,64.2433113 25.3544828,64.2433113 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M44.0606897,58.9390728 L52.3917241,58.9390728 C53.1586207,58.9390728 53.7793103,59.5982781 53.7793103,60.413245 C53.7793103,61.2254305 53.1586207,61.8874172 52.3917241,61.8874172 L44.0606897,61.8874172 C43.2937931,61.8874172 42.6731034,61.2282119 42.6731034,60.413245 C42.6731034,59.6010596 43.2937931,58.9390728 44.0606897,58.9390728 Z" id="Path" fill="#6B4FBB"></path>
+ <path d="M26.3062069,57.1728477 L28.6455172,57.1728477 C29.2910345,57.1728477 29.8151724,57.7013245 29.8151724,58.3521854 C29.8151724,59.0030464 29.2910345,59.5315232 28.6455172,59.5315232 L26.3062069,59.5315232 C25.6606897,59.5315232 25.1365517,59.0030464 25.1365517,58.3521854 C25.1365517,57.698543 25.6606897,57.1728477 26.3062069,57.1728477 Z" id="Path" fill="#FEE1D3"></path>
+ <path d="M36.8275862,64.2433113 L39.1668966,64.2433113 C39.8124138,64.2433113 40.3365517,64.7717881 40.3365517,65.422649 C40.3365517,66.0735099 39.8124138,66.6019868 39.1668966,66.6019868 L36.8275862,66.6019868 C36.182069,66.6019868 35.657931,66.0735099 35.657931,65.422649 C35.657931,64.7717881 36.182069,64.2433113 36.8275862,64.2433113 Z M58.1627586,58.9390728 L61.0868966,58.9390728 C61.8951724,58.9390728 62.5489655,59.5982781 62.5489655,60.413245 C62.5489655,61.2282119 61.8951724,61.8874172 61.0868966,61.8874172 L58.1627586,61.8874172 C57.3544828,61.8874172 56.7006897,61.2282119 56.7006897,60.413245 C56.7034483,59.5982781 57.3572414,58.9390728 58.1627586,58.9390728 Z" id="Shape" fill="#FEF0E8"></path>
+ <path d="M35.3655172,58.9390728 L38.2896552,58.9390728 C39.097931,58.9390728 39.7517241,59.5982781 39.7517241,60.413245 C39.7517241,61.2282119 39.097931,61.8874172 38.2896552,61.8874172 L35.3655172,61.8874172 C34.5572414,61.8874172 33.9034483,61.2282119 33.9034483,60.413245 C33.9034483,59.5982781 34.56,58.9390728 35.3655172,58.9390728 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M66.1696552,40.8470199 L73.182069,40.8470199 C73.8275862,40.8470199 74.3517241,41.3754967 74.3517241,42.0263576 C74.3517241,42.6772185 73.8275862,43.2056954 73.182069,43.2056954 L66.1696552,43.2056954 C65.5241379,43.2056954 65,42.6772185 65,42.0263576 C65,41.3727152 65.5241379,40.8470199 66.1696552,40.8470199 Z" id="Path" fill="#EFEDF8"></path>
+ <path d="M95.6151724,42.613245 L103.946207,42.613245 C104.713103,42.613245 105.333793,43.1409054 105.333793,43.793245 C105.333793,44.4433582 104.713103,44.973245 103.946207,44.973245 L95.6151724,44.973245 C94.8482759,44.973245 94.2275862,44.4455847 94.2275862,43.793245 C94.2275862,43.1431318 94.8482759,42.613245 95.6151724,42.613245 Z" id="Path" fill="#6B4FBB"></path>
+ <path d="M77.8606897,40.8470199 L80.2,40.8470199 C80.8455172,40.8470199 81.3696552,41.3754967 81.3696552,42.0263576 C81.3696552,42.6772185 80.8455172,43.2056954 80.2,43.2056954 L77.8606897,43.2056954 C77.2151724,43.2056954 76.6910345,42.6772185 76.6910345,42.0263576 C76.6910345,41.3727152 77.2151724,40.8470199 77.8606897,40.8470199 Z" id="Path" fill="#FEE1D3"></path>
+ <path d="M86.92,42.613245 L89.8441379,42.613245 C90.6524138,42.613245 91.3062069,43.1409054 91.3062069,43.793245 C91.3062069,44.4455847 90.6524138,44.973245 89.8441379,44.973245 L86.92,44.973245 C86.1117241,44.973245 85.457931,44.4455847 85.457931,43.793245 C85.457931,43.1409054 86.1144828,42.613245 86.92,42.613245 Z" id="Path" fill="#FC6D26"></path>
+ <path d="M14.6151724,64.2433113 L20.4606897,64.2433113 C21.1062069,64.2433113 21.6303448,64.7717881 21.6303448,65.422649 C21.6303448,66.0735099 21.1062069,66.6019868 20.4606897,66.6019868 L14.6151724,66.6019868 C13.9696552,66.6019868 13.4455172,66.0735099 13.4455172,65.422649 C13.4455172,64.7717881 13.9696552,64.2433113 14.6151724,64.2433113 Z" id="Path" fill="#EEEEEE"></path>
+ </g>
+ <g id="Group-12" transform="translate(112.058152, -0.000000)">
+ <path d="M5.84861758,4.12465116 L62.0003099,4.12465116 C65.229233,4.12465116 67.8489253,6.76465116 67.8489253,10.0186047 L67.8489253,77.8046512 C67.8489253,81.0586047 65.229233,83.6986047 62.0003099,83.6986047 L5.84861758,83.6986047 C2.6196945,83.6986047 1.42108547e-14,81.0586047 1.42108547e-14,77.8046512 L1.42108547e-14,10.0213953 C-0.00276703963,6.76744186 2.6196945,4.12465116 5.84861758,4.12465116 Z" id="Path" fill="#F9F9F9" fill-rule="nonzero"></path>
+ <path d="M8.77292527,1.17767442 L64.9246176,1.17767442 C68.1535407,1.17767442 70.773233,3.81767442 70.773233,7.07162791 L70.773233,74.2688372 C70.773233,77.5227907 68.1535407,80.1627907 64.9246176,80.1627907 L8.77292527,80.1627907 C5.54400219,80.1627907 2.92430988,77.5227907 2.92430988,74.2688372 L2.92430988,7.07162791 C2.92430988,3.81767442 5.54400219,1.17767442 8.77292527,1.17767442 Z" id="Path" fill="#FFFFFF" fill-rule="nonzero"></path>
+ <path d="M8.77292527,2.35813953 C6.18646373,2.35813953 4.09292527,4.46790698 4.09292527,7.0744186 L4.09292527,74.2716279 C4.09292527,76.8781395 6.18646373,78.987907 8.77292527,78.987907 L64.9246176,78.987907 C67.5110791,78.987907 69.6046176,76.8781395 69.6046176,74.2716279 L69.6046176,7.07162791 C69.6046176,4.46511628 67.5110791,2.35534884 64.9246176,2.35534884 L8.77292527,2.35813953 L8.77292527,2.35813953 Z M8.77292527,-4.19220214e-13 L64.9246176,-4.19220214e-13 C68.8043099,-4.19220214e-13 71.9418483,3.16744186 71.9418483,7.07162791 L71.9418483,74.2688372 C71.9418483,78.1786047 68.7987714,81.3404651 64.9246176,81.3404651 L8.77292527,81.3404651 C4.89323296,81.3404651 1.75569267,78.1730233 1.75569267,74.2688372 L1.75569267,7.07162791 C1.75292527,3.17023256 4.89600219,-4.19220214e-13 8.77292527,-4.19220214e-13 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M14.6215407,14.7348837 L21.6387714,14.7348837 C22.281233,14.7348837 22.8073868,15.2651163 22.8073868,15.9125581 C22.8073868,16.56 22.281233,17.0902326 21.6387714,17.0902326 L14.6215407,17.0902326 C13.9790791,17.0902326 13.4529253,16.56 13.4529253,15.9125581 C13.4529253,15.2651163 13.9763099,14.7348837 14.6215407,14.7348837 Z M33.3387714,21.8093023 L40.3560022,21.8093023 C40.9984637,21.8093023 41.5246176,22.3395349 41.5246176,22.9869767 C41.5246176,23.6344186 40.9984637,24.1646512 40.3560022,24.1646512 L33.3387714,24.1646512 C32.6963099,24.1646512 32.170156,23.6344186 32.170156,22.9869767 C32.170156,22.3395349 32.6963099,21.8093023 33.3387714,21.8093023 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M40.3587714,14.7348837 L47.3760022,14.7348837 C48.0184637,14.7348837 48.5446176,15.2651163 48.5446176,15.9125581 C48.5446176,16.56 48.0184637,17.0902326 47.3760022,17.0902326 L40.3587714,17.0902326 C39.7163099,17.0902326 39.1901452,16.56 39.1901452,15.9125581 C39.1873868,15.267907 39.7163099,14.7348837 40.3587714,14.7348837 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M21.6415407,28.8837209 L28.6587714,28.8837209 C29.301233,28.8837209 29.8273868,29.4139535 29.8273868,30.0613953 C29.8273868,30.7088372 29.301233,31.2390698 28.6587714,31.2390698 L21.6415407,31.2390698 C20.9990791,31.2390698 20.4729253,30.7088372 20.4729253,30.0613953 C20.4729253,29.4139535 20.9990791,28.8837209 21.6415407,28.8837209 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M26.3187714,14.7348837 L28.6587714,14.7348837 C29.301233,14.7348837 29.8273868,15.2651163 29.8273868,15.9125581 C29.8273868,16.56 29.301233,17.0902326 28.6587714,17.0902326 L26.3187714,17.0902326 C25.6763099,17.0902326 25.150156,16.56 25.150156,15.9125581 C25.150156,15.2651163 25.6763099,14.7348837 26.3187714,14.7348837 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path>
+ <path d="M33.3387714,35.9553488 L35.6787714,35.9553488 C36.321233,35.9553488 36.8473868,36.4855814 36.8473868,37.1330233 C36.8473868,37.7804651 36.321233,38.3106977 35.6787714,38.3106977 L33.3387714,38.3106977 C32.6963099,38.3106977 32.170156,37.7804651 32.170156,37.1330233 C32.170156,36.4855814 32.6963099,35.9553488 33.3387714,35.9553488 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M45.0360022,21.8093023 L47.3760022,21.8093023 C48.0184637,21.8093023 48.5446176,22.3395349 48.5446176,22.9869767 C48.5446176,23.6344186 48.0184637,24.1646512 47.3760022,24.1646512 L45.0360022,24.1646512 C44.3935407,24.1646512 43.8673868,23.6344186 43.8673868,22.9869767 C43.8673868,22.3395349 44.3935407,21.8093023 45.0360022,21.8093023 Z M52.0560022,14.7348837 L54.3960022,14.7348837 C55.0384637,14.7348837 55.5646176,15.2651163 55.5646176,15.9125581 C55.5646176,16.56 55.0384637,17.0902326 54.3960022,17.0902326 L52.0560022,17.0902326 C51.4135407,17.0902326 50.8873868,16.56 50.8873868,15.9125581 C50.8873868,15.2651163 51.4135407,14.7348837 52.0560022,14.7348837 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M23.9787714,43.0297674 L28.6587714,43.0297674 C29.301233,43.0297674 29.8273868,43.56 29.8273868,44.2074419 C29.8273868,44.8548837 29.301233,45.3851163 28.6587714,45.3851163 L23.9787714,45.3851163 C23.3363099,45.3851163 22.810156,44.8548837 22.810156,44.2074419 C22.810156,43.56 23.3363099,43.0297674 23.9787714,43.0297674 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M40.3587714,35.9553488 L42.6987714,35.9553488 C43.341233,35.9553488 43.8673868,36.4855814 43.8673868,37.1330233 C43.8673868,37.7804651 43.341233,38.3106977 42.6987714,38.3106977 L40.3587714,38.3106977 C39.7163099,38.3106977 39.1901452,37.7804651 39.1901452,37.1330233 C39.1873868,36.4883721 39.7163099,35.9553488 40.3587714,35.9553488 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M52.3495407,33.5972093 C53.158156,33.5972093 53.8116945,34.255814 53.8116945,35.0706977 C53.8116945,35.8855814 53.158156,36.544186 52.3495407,36.544186 C51.5409253,36.544186 50.8873868,35.8855814 50.8873868,35.0706977 C50.8873868,34.2586047 51.5436945,33.5972093 52.3495407,33.5972093 Z M58.198156,50.6930233 C59.0067714,50.6930233 59.6603099,51.3516279 59.6603099,52.1665116 C59.6603099,52.9813953 59.0067714,53.64 58.198156,53.64 C57.3895407,53.64 56.7360022,52.9813953 56.7360022,52.1665116 C56.7360022,51.3516279 57.3895407,50.6930233 58.198156,50.6930233 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path>
+ <path d="M63.4624637,50.6930233 C64.2710791,50.6930233 64.9246176,51.3516279 64.9246176,52.1665116 C64.9246176,52.9813953 64.2710791,53.64 63.4624637,53.64 C62.6538483,53.64 62.0003099,52.9813953 62.0003099,52.1665116 C62.0003099,51.3516279 62.6566176,50.6930233 63.4624637,50.6930233 Z M33.3387714,14.7348837 L35.6787714,14.7348837 C36.321233,14.7348837 36.8473868,15.2651163 36.8473868,15.9125581 C36.8473868,16.56 36.321233,17.0902326 35.6787714,17.0902326 L33.3387714,17.0902326 C32.6963099,17.0902326 32.170156,16.56 32.170156,15.9125581 C32.170156,15.2651163 32.6963099,14.7348837 33.3387714,14.7348837 Z" id="Shape" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6215407,35.9553488 L28.6615407,35.9553488 C29.3040022,35.9553488 29.830156,36.4855814 29.830156,37.1330233 C29.830156,37.7804651 29.3040022,38.3106977 28.6615407,38.3106977 L14.6215407,38.3106977 C13.9790791,38.3106977 13.4529253,37.7804651 13.4529253,37.1330233 C13.4529253,36.4855814 13.9763099,35.9553488 14.6215407,35.9553488 Z M44.1193868,41.8493023 L61.1640022,41.8493023 C61.9476945,41.8493023 62.5873868,42.507907 62.5873868,43.3227907 C62.5873868,44.1376744 61.9504637,44.7962791 61.1640022,44.7962791 L44.1193868,44.7962791 C43.3356945,44.7962791 42.6960022,44.1376744 42.6960022,43.3227907 C42.6960022,42.507907 43.3356945,41.8493023 44.1193868,41.8493023 L44.1193868,41.8493023 L44.1193868,41.8493023 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M35.3464637,50.6930233 L52.3910791,50.6930233 C53.1747714,50.6930233 53.8144637,51.3516279 53.8144637,52.1665116 C53.8144637,52.9813953 53.1775407,53.64 52.3910791,53.64 L35.3464637,53.64 C34.5627714,53.64 33.9230791,52.9813953 33.9230791,52.1665116 C33.9230791,51.3516279 34.5600022,50.6930233 35.3464637,50.6930233 L35.3464637,50.6930233 L35.3464637,50.6930233 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path>
+ <path d="M14.6215407,21.8093023 L28.6615407,21.8093023 C29.3040022,21.8093023 29.830156,22.3395349 29.830156,22.9869767 C29.830156,23.6344186 29.3040022,24.1646512 28.6615407,24.1646512 L14.6215407,24.1646512 C13.9790791,24.1646512 13.4529253,23.6344186 13.4529253,22.9869767 C13.4529253,22.3395349 13.9763099,21.8093023 14.6215407,21.8093023 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M33.3387714,28.8837209 L47.3787714,28.8837209 C48.021233,28.8837209 48.5473868,29.4139535 48.5473868,30.0613953 C48.5473868,30.7088372 48.021233,31.2390698 47.3787714,31.2390698 L33.3387714,31.2390698 C32.6963099,31.2390698 32.170156,30.7088372 32.170156,30.0613953 C32.170156,29.4139535 32.6963099,28.8837209 33.3387714,28.8837209 Z" id="Path" fill="#C3B8E3" fill-rule="nonzero"></path>
+ <path d="M14.6215407,28.8837209 L16.9615407,28.8837209 C17.6040022,28.8837209 18.130156,29.4139535 18.130156,30.0613953 C18.130156,30.7088372 17.6040022,31.2390698 16.9615407,31.2390698 L14.6215407,31.2390698 C13.9790791,31.2390698 13.4529253,30.7088372 13.4529253,30.0613953 C13.4529253,29.4139535 13.9763099,28.8837209 14.6215407,28.8837209 Z" id="Path" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M33.3387714,43.0297674 L35.6787714,43.0297674 C36.321233,43.0297674 36.8473868,43.56 36.8473868,44.2074419 C36.8473868,44.8548837 36.321233,45.3851163 35.6787714,45.3851163 L33.3387714,45.3851163 C32.6963099,45.3851163 32.170156,44.8548837 32.170156,44.2074419 C32.170156,43.56 32.6963099,43.0297674 33.3387714,43.0297674 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M14.6215407,43.0297674 L19.3015407,43.0297674 C19.9440022,43.0297674 20.470156,43.56 20.470156,44.2074419 C20.470156,44.8548837 19.9440022,45.3851163 19.3015407,45.3851163 L14.6215407,45.3851163 C13.9790791,45.3851163 13.4529253,44.8548837 13.4529253,44.2074419 C13.4529253,43.56 13.9763099,43.0297674 14.6215407,43.0297674 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6215407,50.104186 L19.3015407,50.104186 C19.9440022,50.104186 20.470156,50.6344186 20.470156,51.2818605 C20.470156,51.9293023 19.9440022,52.4595349 19.3015407,52.4595349 L14.6215407,52.4595349 C13.9790791,52.4595349 13.4529253,51.9293023 13.4529253,51.2818605 C13.4529253,50.6344186 13.9763099,50.104186 14.6215407,50.104186 Z M23.9787714,50.104186 L28.6587714,50.104186 C29.301233,50.104186 29.8273868,50.6344186 29.8273868,51.2818605 C29.8273868,51.9293023 29.301233,52.4595349 28.6587714,52.4595349 L23.9787714,52.4595349 C23.3363099,52.4595349 22.810156,51.9293023 22.810156,51.2818605 C22.810156,50.6344186 23.3363099,50.104186 23.9787714,50.104186 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M14.6215407,57.175814 L21.6387714,57.175814 C22.281233,57.175814 22.8073868,57.7060465 22.8073868,58.3534884 C22.8073868,59.0009302 22.281233,59.5311628 21.6387714,59.5311628 L14.6215407,59.5311628 C13.9790791,59.5311628 13.4529253,59.0009302 13.4529253,58.3534884 C13.4529253,57.7060465 13.9763099,57.175814 14.6215407,57.175814 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path>
+ <path d="M25.366156,64.2502326 L33.7015407,64.2502326 C34.4686176,64.2502326 35.0916945,64.7748837 35.0916945,65.427907 C35.0916945,66.0753488 34.4713868,66.6055814 33.7015407,66.6055814 L25.366156,66.6055814 C24.5990791,66.6055814 23.9760022,66.0753488 23.9760022,65.427907 C23.9787714,64.7804651 24.6018483,64.2502326 25.366156,64.2502326 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M44.0833868,58.9451163 L52.4187714,58.9451163 C53.1858483,58.9451163 53.8089253,59.6037209 53.8089253,60.4186047 C53.8089253,61.2334884 53.1886176,61.892093 52.4187714,61.892093 L44.0833868,61.892093 C43.3163099,61.892093 42.693233,61.2334884 42.693233,60.4186047 C42.6960022,59.6065116 43.3190791,58.9451163 44.0833868,58.9451163 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path>
+ <path d="M26.3187714,57.175814 L28.6587714,57.175814 C29.301233,57.175814 29.8273868,57.7060465 29.8273868,58.3534884 C29.8273868,59.0009302 29.301233,59.5311628 28.6587714,59.5311628 L26.3187714,59.5311628 C25.6763099,59.5311628 25.150156,59.0009302 25.150156,58.3534884 C25.150156,57.7060465 25.6763099,57.175814 26.3187714,57.175814 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path>
+ <path d="M36.850156,64.2502326 L39.190156,64.2502326 C39.8326176,64.2502326 40.3587714,64.7804651 40.3587714,65.427907 C40.3587714,66.0753488 39.8326176,66.6055814 39.190156,66.6055814 L36.850156,66.6055814 C36.2076945,66.6055814 35.6815407,66.0753488 35.6815407,65.427907 C35.6815407,64.7804651 36.2049253,64.2502326 36.850156,64.2502326 Z M58.198156,58.9451163 L61.1224637,58.9451163 C61.9310791,58.9451163 62.5846176,59.6037209 62.5846176,60.4186047 C62.5846176,61.2334884 61.9310791,61.892093 61.1224637,61.892093 L58.198156,61.892093 C57.3895407,61.892093 56.7360022,61.2334884 56.7360022,60.4186047 C56.7360022,59.6065116 57.3895407,58.9451163 58.198156,58.9451163 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path>
+ <path d="M35.385233,58.9451163 L38.3095407,58.9451163 C39.118156,58.9451163 39.7716945,59.6037209 39.7716945,60.4186047 C39.7716945,61.2334884 39.118156,61.892093 38.3095407,61.892093 L35.385233,61.892093 C34.5766176,61.892093 33.9230791,61.2334884 33.9230791,60.4186047 C33.9230791,59.6065116 34.5793868,58.9451163 35.385233,58.9451163 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path>
+ <path d="M14.6215407,64.2502326 L20.470156,64.2502326 C21.1126176,64.2502326 21.6387714,64.7804651 21.6387714,65.427907 C21.6387714,66.0753488 21.1126176,66.6055814 20.470156,66.6055814 L14.6215407,66.6055814 C13.9790791,66.6055814 13.4529253,66.0753488 13.4529253,65.427907 C13.4529253,64.7804651 13.9763099,64.2502326 14.6215407,64.2502326 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <ellipse id="Oval" fill="#FFFFFF" fill-rule="nonzero" cx="36.814156" cy="38.4725581" rx="20.4729231" ry="20.6316279"></ellipse>
+ <path d="M36.814156,59.104186 C25.5073868,59.104186 16.341233,49.8669767 16.341233,38.4725581 C16.341233,27.0781395 25.5073868,17.8409302 36.814156,17.8409302 C48.1209253,17.8409302 57.2870791,27.0781395 57.2870791,38.4725581 C57.2870791,49.8669767 48.1209253,59.104186 36.814156,59.104186 Z M36.814156,56.7460465 C46.8276945,56.7460465 54.9470791,48.5637209 54.9470791,38.4725581 C54.9470791,28.3813953 46.8276945,20.1990698 36.814156,20.1990698 C26.8006176,20.1990698 18.681233,28.3813953 18.681233,38.4725581 C18.681233,48.5637209 26.8006176,56.7460465 36.814156,56.7460465 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path>
+ <path d="M46.5895407,39.7813953 L45.490156,36.3739535 L43.3135407,29.6260465 C43.2027714,29.28 42.718156,29.28 42.5990791,29.6260465 L40.4280022,36.3739535 L33.2030791,36.3739535 L31.0264637,29.6260465 C30.9156945,29.28 30.4310791,29.28 30.3120022,29.6260465 L28.1353868,36.3739535 L27.0360022,39.7813953 C26.9363099,40.0939535 27.0470791,40.4288372 27.3046176,40.624186 L36.8086176,47.5786047 L46.3126176,40.624186 C46.581233,40.4288372 46.689233,40.0883721 46.5895407,39.7813953" id="Path" fill="#FC6D26"></path>
+ <polygon id="Path" fill="#E24329" points="36.814156 47.5813953 40.4280022 36.3767442 33.2030791 36.3767442"></polygon>
+ <polygon id="Path" fill="#FC6D26" points="36.814156 47.5813953 33.2003099 36.3767442 28.1353868 36.3767442"></polygon>
+ <path d="M28.138156,36.3739535 L27.0387714,39.7813953 C26.9390791,40.0939535 27.0498483,40.4288372 27.3073868,40.624186 L36.8113868,47.5786047 L28.138156,36.3739535 Z" id="Path" fill="#FCA326"></path>
+ <path d="M28.138156,36.3739535 L33.2030791,36.3739535 L31.0264637,29.6260465 C30.9156945,29.28 30.4310791,29.28 30.3120022,29.6260465 L28.138156,36.3739535 Z" id="Path" fill="#E24329"></path>
+ <polygon id="Path" fill="#FC6D26" points="36.814156 47.5813953 40.4280022 36.3767442 45.4929253 36.3767442"></polygon>
+ <path d="M45.4929253,36.3739535 L46.5923099,39.7813953 C46.6920022,40.0939535 46.581233,40.4288372 46.3236945,40.624186 L36.8196945,47.5786047 L45.4929253,36.3739535 Z" id="Path" fill="#FCA326"></path>
+ <path d="M45.4929253,36.3739535 L40.4280022,36.3739535 L42.6046176,29.6260465 C42.7153868,29.28 43.2000022,29.28 43.3190791,29.6260465 L45.4929253,36.3739535 Z" id="Path" fill="#E24329"></path>
+ </g>
+ </g>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/index.js b/app/assets/javascripts/projects/experiment_new_project_creation/index.js
new file mode 100644
index 00000000000..3715c52b6b9
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import NewProjectCreationApp from './components/app.vue';
+
+export default function(el, props) {
+ return new Vue({
+ el,
+ components: {
+ NewProjectCreationApp,
+ },
+ render(h) {
+ return h(NewProjectCreationApp, { props });
+ },
+ });
+}
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue
new file mode 100644
index 00000000000..8bdf043a106
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
+
+import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlAlert,
+ GlLink,
+ },
+ model: {
+ prop: 'deleteAlertType',
+ event: 'change',
+ },
+ props: {
+ deleteAlertType: {
+ type: String,
+ default: null,
+ required: false,
+ validator(value) {
+ return !value || ALERT_MESSAGES[value] !== undefined;
+ },
+ },
+ garbageCollectionHelpPagePath: { type: String, required: false, default: '' },
+ isAdmin: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ deleteAlertConfig() {
+ const config = {
+ title: '',
+ message: '',
+ type: 'success',
+ };
+ if (this.deleteAlertType) {
+ [config.type] = this.deleteAlertType.split('_');
+
+ config.message = ALERT_MESSAGES[this.deleteAlertType];
+
+ if (this.isAdmin && config.type === 'success') {
+ config.title = config.message;
+ config.message = ADMIN_GARBAGE_COLLECTION_TIP;
+ }
+ }
+ return config;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ v-if="deleteAlertType"
+ :variant="deleteAlertConfig.type"
+ :title="deleteAlertConfig.title"
+ @dismiss="$emit('change', null)"
+ >
+ <gl-sprintf :message="deleteAlertConfig.message">
+ <template #docLink="{content}">
+ <gl-link :href="garbageCollectionHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue
new file mode 100644
index 00000000000..96f221bf71d
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import { REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAGS_CONFIRMATION_TEXT } from '../../constants/index';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ props: {
+ itemsToBeDeleted: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ modalAction() {
+ return n__(
+ 'ContainerRegistry|Remove tag',
+ 'ContainerRegistry|Remove tags',
+ this.itemsToBeDeleted.length,
+ );
+ },
+ modalDescription() {
+ if (this.itemsToBeDeleted.length > 1) {
+ return {
+ message: REMOVE_TAGS_CONFIRMATION_TEXT,
+ item: this.itemsToBeDeleted.length,
+ };
+ }
+ const [first] = this.itemsToBeDeleted;
+
+ return {
+ message: REMOVE_TAG_CONFIRMATION_TEXT,
+ item: first?.path,
+ };
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.deleteModal.show();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="deleteModal"
+ modal-id="delete-tag-modal"
+ ok-variant="danger"
+ @ok="$emit('confirmDelete')"
+ @cancel="$emit('cancelDelete')"
+ >
+ <template #modal-title>{{ modalAction }}</template>
+ <template #modal-ok>{{ modalAction }}</template>
+ <p v-if="modalDescription" data-testid="description">
+ <gl-sprintf :message="modalDescription.message">
+ <template #item
+ ><b>{{ modalDescription.item }}</b></template
+ >
+ </gl-sprintf>
+ </p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
new file mode 100644
index 00000000000..c254dd05aa4
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { DETAILS_PAGE_TITLE } from '../../constants/index';
+
+export default {
+ components: { GlSprintf },
+ props: {
+ imageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ i18n: {
+ DETAILS_PAGE_TITLE,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-my-2 gl-align-items-center">
+ <h4>
+ <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
+ <template #imageName>
+ {{ imageName }}
+ </template>
+ </gl-sprintf>
+ </h4>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue
new file mode 100644
index 00000000000..0c684d124d5
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue
@@ -0,0 +1,33 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import {
+ EMPTY_IMAGE_REPOSITORY_TITLE,
+ EMPTY_IMAGE_REPOSITORY_MESSAGE,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlEmptyState,
+ },
+ props: {
+ noContainersImage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ i18n: {
+ EMPTY_IMAGE_REPOSITORY_TITLE,
+ EMPTY_IMAGE_REPOSITORY_MESSAGE,
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
+ :svg-path="noContainersImage"
+ :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
+ class="gl-mx-auto gl-my-0"
+ />
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue
new file mode 100644
index 00000000000..b7afa5fba33
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+ loader: {
+ repeat: 10,
+ width: 1000,
+ height: 40,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-skeleton-loader
+ v-for="index in $options.loader.repeat"
+ :key="index"
+ :width="$options.loader.width"
+ :height="$options.loader.height"
+ preserve-aspect-ratio="xMinYMax meet"
+ >
+ <rect width="15" x="0" y="12.5" height="15" rx="4" />
+ <rect width="250" x="25" y="10" height="20" rx="4" />
+ <circle cx="290" cy="20" r="10" />
+ <rect width="100" x="315" y="10" height="20" rx="4" />
+ <rect width="100" x="500" y="10" height="20" rx="4" />
+ <rect width="100" x="630" y="10" height="20" rx="4" />
+ <rect x="960" y="0" width="40" height="40" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue
new file mode 100644
index 00000000000..81be778e1e5
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue
@@ -0,0 +1,210 @@
+<script>
+import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import {
+ LIST_KEY_TAG,
+ LIST_KEY_IMAGE_ID,
+ LIST_KEY_SIZE,
+ LIST_KEY_LAST_UPDATED,
+ LIST_KEY_ACTIONS,
+ LIST_KEY_CHECKBOX,
+ LIST_LABEL_TAG,
+ LIST_LABEL_IMAGE_ID,
+ LIST_LABEL_SIZE,
+ LIST_LABEL_LAST_UPDATED,
+ REMOVE_TAGS_BUTTON_TITLE,
+ REMOVE_TAG_BUTTON_TITLE,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlTable,
+ GlFormCheckbox,
+ GlButton,
+ ClipboardButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ tags: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isDesktop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ i18n: {
+ REMOVE_TAGS_BUTTON_TITLE,
+ REMOVE_TAG_BUTTON_TITLE,
+ },
+ data() {
+ return {
+ selectedItems: [],
+ };
+ },
+ computed: {
+ fields() {
+ const tagClass = this.isDesktop ? 'w-25' : '';
+ const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
+ return [
+ { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
+ {
+ key: LIST_KEY_TAG,
+ label: LIST_LABEL_TAG,
+ class: `${tagClass} js-tag-column`,
+ innerClass: tagInnerClass,
+ },
+ { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
+ { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
+ { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
+ { key: LIST_KEY_ACTIONS, label: '' },
+ ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
+ },
+ tagsNames() {
+ return this.tags.map(t => t.name);
+ },
+ selectAllChecked() {
+ return this.selectedItems.length === this.tags.length && this.tags.length > 0;
+ },
+ },
+ watch: {
+ tagsNames: {
+ immediate: false,
+ handler(tagsNames) {
+ this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t));
+ },
+ },
+ },
+ methods: {
+ formatSize(size) {
+ return numberToHumanSize(size);
+ },
+ layers(layers) {
+ return layers ? n__('%d layer', '%d layers', layers) : '';
+ },
+ onSelectAllChange() {
+ if (this.selectAllChecked) {
+ this.selectedItems = [];
+ } else {
+ this.selectedItems = this.tags.map(x => x.name);
+ }
+ },
+ updateSelectedItems(name) {
+ const delIndex = this.selectedItems.findIndex(x => x === name);
+
+ if (delIndex > -1) {
+ this.selectedItems.splice(delIndex, 1);
+ } else {
+ this.selectedItems.push(name);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading">
+ <template v-if="isDesktop" #head(checkbox)>
+ <gl-form-checkbox
+ data-testid="mainCheckbox"
+ :checked="selectAllChecked"
+ @change="onSelectAllChange"
+ />
+ </template>
+ <template #head(actions)>
+ <span class="gl-display-flex gl-justify-content-end">
+ <gl-button
+ v-gl-tooltip
+ data-testid="bulkDeleteButton"
+ :disabled="!selectedItems || selectedItems.length === 0"
+ icon="remove"
+ variant="danger"
+ :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
+ :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
+ @click="$emit('delete', selectedItems)"
+ />
+ </span>
+ </template>
+
+ <template #cell(checkbox)="{item}">
+ <gl-form-checkbox
+ data-testid="rowCheckbox"
+ :checked="selectedItems.includes(item.name)"
+ @change="updateSelectedItems(item.name)"
+ />
+ </template>
+ <template #cell(name)="{item, field}">
+ <div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']">
+ <span
+ v-gl-tooltip
+ data-testid="rowNameText"
+ :title="item.name"
+ class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
+ >
+ {{ item.name }}
+ </span>
+ <clipboard-button
+ v-if="item.location"
+ data-testid="rowClipboardButton"
+ :title="item.location"
+ :text="item.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </div>
+ </template>
+ <template #cell(short_revision)="{value}">
+ <span data-testid="rowShortRevision">
+ {{ value }}
+ </span>
+ </template>
+ <template #cell(total_size)="{item}">
+ <span data-testid="rowSize">
+ {{ formatSize(item.total_size) }}
+ <template v-if="item.total_size && item.layers">
+ &middot;
+ </template>
+ {{ layers(item.layers) }}
+ </span>
+ </template>
+ <template #cell(created_at)="{value}">
+ <span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)">
+ {{ timeFormatted(value) }}
+ </span>
+ </template>
+ <template #cell(actions)="{item}">
+ <span class="gl-display-flex gl-justify-content-end">
+ <gl-button
+ data-testid="singleDeleteButton"
+ :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
+ :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
+ :disabled="!item.destroy_path"
+ variant="danger"
+ icon="remove"
+ category="secondary"
+ @click="$emit('delete', [item.name])"
+ />
+ </span>
+ </template>
+
+ <template #empty>
+ <slot name="empty"></slot>
+ </template>
+ <template #table-busy>
+ <slot name="loader"></slot>
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/image_list.vue b/app/assets/javascripts/registry/explorer/components/image_list.vue
deleted file mode 100644
index bc209b12738..00000000000
--- a/app/assets/javascripts/registry/explorer/components/image_list.vue
+++ /dev/null
@@ -1,124 +0,0 @@
-<script>
-import { GlPagination, GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-
-import {
- ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
- LIST_DELETE_BUTTON_DISABLED,
- REMOVE_REPOSITORY_LABEL,
- ROW_SCHEDULED_FOR_DELETION,
-} from '../constants';
-
-export default {
- name: 'ImageList',
- components: {
- GlPagination,
- ClipboardButton,
- GlDeprecatedButton,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- images: {
- type: Array,
- required: true,
- },
- pagination: {
- type: Object,
- required: true,
- },
- },
- i18n: {
- LIST_DELETE_BUTTON_DISABLED,
- REMOVE_REPOSITORY_LABEL,
- ROW_SCHEDULED_FOR_DELETION,
- ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
- },
- computed: {
- currentPage: {
- get() {
- return this.pagination.page;
- },
- set(page) {
- this.$emit('pageChange', page);
- },
- },
- },
- methods: {
- encodeListItem(item) {
- const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
- return window.btoa(params);
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-display-flex gl-flex-direction-column">
- <div
- v-for="(listItem, index) in images"
- :key="index"
- v-gl-tooltip="{
- placement: 'left',
- disabled: !listItem.deleting,
- title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
- }"
- data-testid="rowItem"
- >
- <div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 border-bottom"
- :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
- >
- <div class="gl-display-flex gl-align-items-center">
- <router-link
- data-testid="detailsLink"
- :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
- >
- {{ listItem.path }}
- </router-link>
- <clipboard-button
- v-if="listItem.location"
- :disabled="listItem.deleting"
- :text="listItem.location"
- :title="listItem.location"
- css-class="btn-default btn-transparent btn-clipboard"
- />
- <gl-icon
- v-if="listItem.failedDelete"
- v-gl-tooltip
- :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE"
- name="warning"
- class="text-warning align-middle"
- />
- </div>
- <div
- v-gl-tooltip="{ disabled: listItem.destroy_path }"
- class="d-none d-sm-block"
- :title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
- >
- <gl-deprecated-button
- v-gl-tooltip
- data-testid="deleteImageButton"
- :disabled="!listItem.destroy_path || listItem.deleting"
- :title="$options.i18n.REMOVE_REPOSITORY_LABEL"
- :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
- class="btn-inverted"
- variant="danger"
- @click="$emit('delete', listItem)"
- >
- <gl-icon name="remove" />
- </gl-deprecated-button>
- </div>
- </div>
- </div>
- <gl-pagination
- v-model="currentPage"
- :per-page="pagination.perPage"
- :total-items="pagination.total"
- align="center"
- class="w-100 gl-mt-2"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
index 96455496239..8b06797c0ae 100644
--- a/app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
@@ -11,7 +11,7 @@ import {
COPY_BUILD_TITLE,
PUSH_COMMAND_LABEL,
COPY_PUSH_TITLE,
-} from '../constants';
+} from '../../constants/index';
export default {
components: {
diff --git a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
index a29a9bd23c3..a29a9bd23c3 100644
--- a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
new file mode 100644
index 00000000000..9d48769cbad
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlPagination } from '@gitlab/ui';
+import ImageListRow from './image_list_row.vue';
+
+export default {
+ name: 'ImageList',
+ components: {
+ GlPagination,
+ ImageListRow,
+ },
+ props: {
+ images: {
+ type: Array,
+ required: true,
+ },
+ pagination: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ currentPage: {
+ get() {
+ return this.pagination.page;
+ },
+ set(page) {
+ this.$emit('pageChange', page);
+ },
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <image-list-row
+ v-for="(listItem, index) in images"
+ :key="index"
+ :item="listItem"
+ :show-top-border="index === 0"
+ @delete="$emit('delete', $event)"
+ />
+
+ <gl-pagination
+ v-model="currentPage"
+ :per-page="pagination.perPage"
+ :total-items="pagination.total"
+ align="center"
+ class="w-100 gl-mt-3"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
new file mode 100644
index 00000000000..cd878c38081
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -0,0 +1,136 @@
+<script>
+import { GlTooltipDirective, GlButton, GlIcon, GlSprintf } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+import {
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ ROW_SCHEDULED_FOR_DELETION,
+} from '../../constants/index';
+
+export default {
+ name: 'ImageListrow',
+ components: {
+ ClipboardButton,
+ GlButton,
+ GlSprintf,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ showTopBorder: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ i18n: {
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ ROW_SCHEDULED_FOR_DELETION,
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ },
+ computed: {
+ encodedItem() {
+ const params = JSON.stringify({
+ name: this.item.path,
+ tags_path: this.item.tags_path,
+ id: this.item.id,
+ });
+ return window.btoa(params);
+ },
+ disabledDelete() {
+ return !this.item.destroy_path || this.item.deleting;
+ },
+ tagsCountText() {
+ return n__(
+ 'ContainerRegistry|%{count} Tag',
+ 'ContainerRegistry|%{count} Tags',
+ this.item.tags_count,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-gl-tooltip="{
+ placement: 'left',
+ disabled: !item.deleting,
+ title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
+ }"
+ >
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-py-4 "
+ :class="{
+ 'gl-border-t-solid gl-border-t-1': showTopBorder,
+ 'disabled-content': item.deleting,
+ }"
+ >
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div class="gl-display-flex gl-align-items-center">
+ <router-link
+ class="gl-text-black-normal gl-font-weight-bold"
+ data-testid="detailsLink"
+ :to="{ name: 'details', params: { id: encodedItem } }"
+ >
+ {{ item.path }}
+ </router-link>
+ <clipboard-button
+ v-if="item.location"
+ :disabled="item.deleting"
+ :text="item.location"
+ :title="item.location"
+ css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500"
+ />
+ <gl-icon
+ v-if="item.failedDelete"
+ v-gl-tooltip
+ :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE"
+ name="warning"
+ class="text-warning"
+ />
+ </div>
+ <div class="gl-font-sm gl-text-gray-500">
+ <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount">
+ <gl-icon name="tag" class="gl-mr-2" />
+ <gl-sprintf :message="tagsCountText">
+ <template #count>
+ {{ item.tags_count }}
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ </div>
+ <div
+ v-gl-tooltip="{
+ disabled: item.destroy_path,
+ title: $options.i18n.LIST_DELETE_BUTTON_DISABLED,
+ }"
+ class="d-none d-sm-block"
+ data-testid="deleteButtonWrapper"
+ >
+ <gl-button
+ v-gl-tooltip
+ data-testid="deleteImageButton"
+ :disabled="disabledDelete"
+ :title="$options.i18n.REMOVE_REPOSITORY_LABEL"
+ :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ @click="$emit('delete', item)"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
index 0ce38c4a9ec..c27d53f4351 100644
--- a/app/assets/javascripts/registry/explorer/components/project_empty_state.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
@@ -3,7 +3,12 @@ import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import { COPY_LOGIN_TITLE, COPY_BUILD_TITLE, COPY_PUSH_TITLE, QUICK_START } from '../constants';
+import {
+ COPY_LOGIN_TITLE,
+ COPY_BUILD_TITLE,
+ COPY_PUSH_TITLE,
+ QUICK_START,
+} from '../../constants/index';
export default {
name: 'ProjectEmptyState',
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
new file mode 100644
index 00000000000..d4ff84447bb
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
@@ -0,0 +1,138 @@
+<script>
+import { GlSprintf, GlLink, GlIcon } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
+
+import {
+ CONTAINER_REGISTRY_TITLE,
+ LIST_INTRO_TEXT,
+ EXPIRATION_POLICY_WILL_RUN_IN,
+ EXPIRATION_POLICY_DISABLED_TEXT,
+ EXPIRATION_POLICY_DISABLED_MESSAGE,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlIcon,
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ expirationPolicy: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ imagesCount: {
+ type: Number,
+ default: 0,
+ required: false,
+ },
+ helpPagePath: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ expirationPolicyHelpPagePath: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ hideExpirationPolicyData: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ loader: {
+ repeat: 10,
+ width: 1000,
+ height: 40,
+ },
+ i18n: {
+ CONTAINER_REGISTRY_TITLE,
+ LIST_INTRO_TEXT,
+ EXPIRATION_POLICY_DISABLED_MESSAGE,
+ },
+ computed: {
+ imagesCountText() {
+ return n__(
+ 'ContainerRegistry|%{count} Image repository',
+ 'ContainerRegistry|%{count} Image repositories',
+ this.imagesCount,
+ );
+ },
+ timeTillRun() {
+ const difference = calculateRemainingMilliseconds(this.expirationPolicy?.next_run_at);
+ return approximateDuration(difference / 1000);
+ },
+ expirationPolicyEnabled() {
+ return this.expirationPolicy?.enabled;
+ },
+ expirationPolicyText() {
+ return this.expirationPolicyEnabled
+ ? EXPIRATION_POLICY_WILL_RUN_IN
+ : EXPIRATION_POLICY_DISABLED_TEXT;
+ },
+ showExpirationPolicyTip() {
+ return (
+ !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center"
+ data-testid="header"
+ >
+ <h4 data-testid="title">{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4>
+ <div class="gl-display-none d-sm-block" data-testid="commands-slot">
+ <slot name="commands"></slot>
+ </div>
+ </div>
+ <div
+ v-if="imagesCount"
+ class="gl-display-flex gl-align-items-center gl-mt-1 gl-mb-3 gl-text-gray-700"
+ data-testid="subheader"
+ >
+ <span class="gl-mr-3" data-testid="images-count">
+ <gl-icon class="gl-mr-1" name="container-image" />
+ <gl-sprintf :message="imagesCountText">
+ <template #count>
+ {{ imagesCount }}
+ </template>
+ </gl-sprintf>
+ </span>
+ <span v-if="!hideExpirationPolicyData" data-testid="expiration-policy">
+ <gl-icon class="gl-mr-1" name="expire" />
+ <gl-sprintf :message="expirationPolicyText">
+ <template #time>
+ {{ timeTillRun }}
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ <div data-testid="info-area">
+ <p>
+ <span data-testid="default-intro">
+ <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT">
+ <template #docLink="{content}">
+ <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message">
+ <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE">
+ <template #docLink="{content}">
+ <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue
deleted file mode 100644
index 88a0710574f..00000000000
--- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { mapState } from 'vuex';
-import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
-import {
- EXPIRATION_POLICY_ALERT_TITLE,
- EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON,
- EXPIRATION_POLICY_ALERT_FULL_MESSAGE,
- EXPIRATION_POLICY_ALERT_SHORT_MESSAGE,
-} from '../constants';
-
-export default {
- components: {
- GlAlert,
- GlSprintf,
- GlLink,
- },
-
- computed: {
- ...mapState(['config', 'images', 'isLoading']),
- isEmpty() {
- return !this.images || this.images.length === 0;
- },
- showAlert() {
- return this.config.expirationPolicy?.enabled;
- },
- timeTillRun() {
- const difference = calculateRemainingMilliseconds(this.config.expirationPolicy?.next_run_at);
- return approximateDuration(difference / 1000);
- },
- alertConfiguration() {
- if (this.isEmpty || this.isLoading) {
- return {
- title: null,
- primaryButton: null,
- message: EXPIRATION_POLICY_ALERT_SHORT_MESSAGE,
- };
- }
- return {
- title: EXPIRATION_POLICY_ALERT_TITLE,
- primaryButton: EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON,
- message: EXPIRATION_POLICY_ALERT_FULL_MESSAGE,
- };
- },
- },
-};
-</script>
-
-<template>
- <gl-alert
- v-if="showAlert"
- :dismissible="false"
- :primary-button-text="alertConfiguration.primaryButton"
- :primary-button-link="config.settingsPath"
- :title="alertConfiguration.title"
- >
- <gl-sprintf :message="alertConfiguration.message">
- <template #days>
- <strong>{{ timeTillRun }}</strong>
- </template>
- <template #link="{content}">
- <gl-link :href="config.expirationPolicyHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
-</template>
diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js
deleted file mode 100644
index 7cbe657bfc0..00000000000
--- a/app/assets/javascripts/registry/explorer/constants.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import { s__ } from '~/locale';
-
-// List page
-
-export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
-export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
-export const CONNECTION_ERROR_MESSAGE = s__(
- `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`,
-);
-export const LIST_INTRO_TEXT = s__(
- `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
-);
-
-export const LIST_DELETE_BUTTON_DISABLED = s__(
- 'ContainerRegistry|Missing or insufficient permission, delete button disabled',
-);
-export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
-export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
- 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
-);
-export const ROW_SCHEDULED_FOR_DELETION = s__(
- `ContainerRegistry|This image repository is scheduled for deletion`,
-);
-export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while fetching the repository list.',
-);
-export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while fetching the tags list.',
-);
-export const DELETE_IMAGE_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.',
-);
-export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__(
- `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`,
-);
-export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
- 'ContainerRegistry|%{title} was successfully scheduled for deletion',
-);
-
-export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories');
-
-export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name');
-
-export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.');
-export const EMPTY_RESULT_MESSAGE = s__(
- 'ContainerRegistry|To widen your search, change or remove the filters above.',
-);
-
-// Image details page
-
-export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
-
-export const DELETE_TAG_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while marking the tag for deletion.',
-);
-export const DELETE_TAG_SUCCESS_MESSAGE = s__(
- 'ContainerRegistry|Tag successfully marked for deletion.',
-);
-export const DELETE_TAGS_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while marking the tags for deletion.',
-);
-export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
- 'ContainerRegistry|Tags successfully marked for deletion.',
-);
-
-export const DEFAULT_PAGE = 1;
-export const DEFAULT_PAGE_SIZE = 10;
-
-export const GROUP_PAGE_TYPE = 'groups';
-
-export const LIST_KEY_TAG = 'name';
-export const LIST_KEY_IMAGE_ID = 'short_revision';
-export const LIST_KEY_SIZE = 'total_size';
-export const LIST_KEY_LAST_UPDATED = 'created_at';
-export const LIST_KEY_ACTIONS = 'actions';
-export const LIST_KEY_CHECKBOX = 'checkbox';
-
-export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag');
-export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
-export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
-export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
-
-export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
-export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags');
-
-export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
- `ContainerRegistry|You are about to remove %{item}. Are you sure?`,
-);
-export const REMOVE_TAGS_CONFIRMATION_TEXT = s__(
- `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`,
-);
-
-export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags');
-export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__(
- `ContainerRegistry|The last tag related to this image was recently removed.
-This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
-If you have any questions, contact your administrator.`,
-);
-
-export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
- 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
-);
-
-// Expiration policies
-
-export const EXPIRATION_POLICY_ALERT_TITLE = s__(
- 'ContainerRegistry|Retention policy has been Enabled',
-);
-export const EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON = s__('ContainerRegistry|Edit Settings');
-export const EXPIRATION_POLICY_ALERT_FULL_MESSAGE = s__(
- 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled and will run in %{days}. For more information visit the %{linkStart}documentation%{linkEnd}',
-);
-export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__(
- 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}',
-);
-
-// Quick Start
-
-export const QUICK_START = s__('ContainerRegistry|Quick Start');
-export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
-export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
-export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
-export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
-export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
-export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
-
-// Image state
-
-export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
-export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
new file mode 100644
index 00000000000..a1fa995c17f
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -0,0 +1,60 @@
+import { s__ } from '~/locale';
+
+// Translations strings
+export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
+export const DELETE_TAG_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while marking the tag for deletion.',
+);
+export const DELETE_TAG_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Tag successfully marked for deletion.',
+);
+export const DELETE_TAGS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while marking the tags for deletion.',
+);
+export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Tags successfully marked for deletion.',
+);
+export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag');
+export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
+export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
+export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
+export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
+export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags');
+export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
+ `ContainerRegistry|You are about to remove %{item}. Are you sure?`,
+);
+export const REMOVE_TAGS_CONFIRMATION_TEXT = s__(
+ `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`,
+);
+export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags');
+export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__(
+ `ContainerRegistry|The last tag related to this image was recently removed.
+This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
+If you have any questions, contact your administrator.`,
+);
+export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
+ 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
+);
+
+// Parameters
+
+export const DEFAULT_PAGE = 1;
+export const DEFAULT_PAGE_SIZE = 10;
+export const GROUP_PAGE_TYPE = 'groups';
+export const LIST_KEY_TAG = 'name';
+export const LIST_KEY_IMAGE_ID = 'short_revision';
+export const LIST_KEY_SIZE = 'total_size';
+export const LIST_KEY_LAST_UPDATED = 'created_at';
+export const LIST_KEY_ACTIONS = 'actions';
+export const LIST_KEY_CHECKBOX = 'checkbox';
+export const ALERT_SUCCESS_TAG = 'success_tag';
+export const ALERT_DANGER_TAG = 'danger_tag';
+export const ALERT_SUCCESS_TAGS = 'success_tags';
+export const ALERT_DANGER_TAGS = 'danger_tags';
+
+export const ALERT_MESSAGES = {
+ [ALERT_SUCCESS_TAG]: DELETE_TAG_SUCCESS_MESSAGE,
+ [ALERT_DANGER_TAG]: DELETE_TAG_ERROR_MESSAGE,
+ [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE,
+ [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE,
+};
diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
new file mode 100644
index 00000000000..8af25ca6ecc
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
@@ -0,0 +1,11 @@
+import { s__ } from '~/locale';
+
+export const EXPIRATION_POLICY_WILL_RUN_IN = s__(
+ 'ContainerRegistry|Expiration policy will run in %{time}',
+);
+export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
+ 'ContainerRegistry|Expiration policy is disabled',
+);
+export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__(
+ 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}',
+);
diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/registry/explorer/constants/index.js
new file mode 100644
index 00000000000..10816e12ead
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/constants/index.js
@@ -0,0 +1,4 @@
+export * from './expiration_policies';
+export * from './quick_start';
+export * from './list';
+export * from './details';
diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js
new file mode 100644
index 00000000000..39f63d2a153
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/constants/list.js
@@ -0,0 +1,48 @@
+import { s__ } from '~/locale';
+
+// Translations strings
+
+export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
+export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
+export const CONNECTION_ERROR_MESSAGE = s__(
+ `ContainerRegistry|We are having trouble connecting to the Registry, which could be due to an issue with your project name or path. %{docLinkStart}More information%{docLinkEnd}`,
+);
+export const LIST_INTRO_TEXT = s__(
+ `ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`,
+);
+export const LIST_DELETE_BUTTON_DISABLED = s__(
+ 'ContainerRegistry|Missing or insufficient permission, delete button disabled',
+);
+export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
+export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
+ 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
+);
+export const ROW_SCHEDULED_FOR_DELETION = s__(
+ `ContainerRegistry|This image repository is scheduled for deletion`,
+);
+export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the repository list.',
+);
+export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the tags list.',
+);
+export const DELETE_IMAGE_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.',
+);
+export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__(
+ `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`,
+);
+export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|%{title} was successfully scheduled for deletion',
+);
+export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories');
+export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name');
+export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.');
+export const EMPTY_RESULT_MESSAGE = s__(
+ 'ContainerRegistry|To widen your search, change or remove the filters above.',
+);
+
+// Parameters
+
+export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
+export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
diff --git a/app/assets/javascripts/registry/explorer/constants/quick_start.js b/app/assets/javascripts/registry/explorer/constants/quick_start.js
new file mode 100644
index 00000000000..6a39c07eba2
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/constants/quick_start.js
@@ -0,0 +1,9 @@
+import { s__ } from '~/locale';
+
+export const QUICK_START = s__('ContainerRegistry|CLI Commands');
+export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
+export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
+export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
+export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
+export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
+export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index cc2dc531dc8..598e643ce1a 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -1,139 +1,56 @@
<script>
-import { mapState, mapActions, mapGetters } from 'vuex';
-import {
- GlTable,
- GlFormCheckbox,
- GlDeprecatedButton,
- GlIcon,
- GlTooltipDirective,
- GlPagination,
- GlModal,
- GlSprintf,
- GlAlert,
- GlLink,
- GlEmptyState,
- GlResizeObserverDirective,
- GlSkeletonLoader,
-} from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { n__ } from '~/locale';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
import Tracking from '~/tracking';
+import DeleteAlert from '../components/details_page/delete_alert.vue';
+import DeleteModal from '../components/details_page/delete_modal.vue';
+import DetailsHeader from '../components/details_page/details_header.vue';
+import TagsTable from '../components/details_page/tags_table.vue';
+import TagsLoader from '../components/details_page/tags_loader.vue';
+import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
+
import { decodeAndParse } from '../utils';
import {
- LIST_KEY_TAG,
- LIST_KEY_IMAGE_ID,
- LIST_KEY_SIZE,
- LIST_KEY_LAST_UPDATED,
- LIST_KEY_ACTIONS,
- LIST_KEY_CHECKBOX,
- LIST_LABEL_TAG,
- LIST_LABEL_IMAGE_ID,
- LIST_LABEL_SIZE,
- LIST_LABEL_LAST_UPDATED,
- DELETE_TAG_SUCCESS_MESSAGE,
- DELETE_TAG_ERROR_MESSAGE,
- DELETE_TAGS_SUCCESS_MESSAGE,
- DELETE_TAGS_ERROR_MESSAGE,
- REMOVE_TAG_CONFIRMATION_TEXT,
- REMOVE_TAGS_CONFIRMATION_TEXT,
- DETAILS_PAGE_TITLE,
- REMOVE_TAGS_BUTTON_TITLE,
- REMOVE_TAG_BUTTON_TITLE,
- EMPTY_IMAGE_REPOSITORY_TITLE,
- EMPTY_IMAGE_REPOSITORY_MESSAGE,
- ADMIN_GARBAGE_COLLECTION_TIP,
-} from '../constants';
+ ALERT_SUCCESS_TAG,
+ ALERT_DANGER_TAG,
+ ALERT_SUCCESS_TAGS,
+ ALERT_DANGER_TAGS,
+} from '../constants/index';
export default {
components: {
- GlTable,
- GlFormCheckbox,
- GlDeprecatedButton,
- GlIcon,
- ClipboardButton,
+ DeleteAlert,
+ DetailsHeader,
GlPagination,
- GlModal,
- GlSkeletonLoader,
- GlSprintf,
- GlEmptyState,
- GlAlert,
- GlLink,
+ DeleteModal,
+ TagsTable,
+ TagsLoader,
+ EmptyTagsState,
},
directives: {
- GlTooltip: GlTooltipDirective,
GlResizeObserver: GlResizeObserverDirective,
},
- mixins: [timeagoMixin, Tracking.mixin()],
- loader: {
- repeat: 10,
- width: 1000,
- height: 40,
- },
- i18n: {
- DETAILS_PAGE_TITLE,
- REMOVE_TAGS_BUTTON_TITLE,
- REMOVE_TAG_BUTTON_TITLE,
- EMPTY_IMAGE_REPOSITORY_TITLE,
- EMPTY_IMAGE_REPOSITORY_MESSAGE,
- },
- alertMessages: {
- success_tag: DELETE_TAG_SUCCESS_MESSAGE,
- danger_tag: DELETE_TAG_ERROR_MESSAGE,
- success_tags: DELETE_TAGS_SUCCESS_MESSAGE,
- danger_tags: DELETE_TAGS_ERROR_MESSAGE,
- },
+ mixins: [Tracking.mixin()],
data() {
return {
- selectedItems: [],
itemsToBeDeleted: [],
- selectAllChecked: false,
- modalDescription: null,
isDesktop: true,
- deleteAlertType: false,
+ deleteAlertType: null,
};
},
computed: {
- ...mapGetters(['tags']),
- ...mapState(['tagsPagination', 'isLoading', 'config']),
+ ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']),
imageName() {
const { name } = decodeAndParse(this.$route.params.id);
return name;
},
- fields() {
- const tagClass = this.isDesktop ? 'w-25' : '';
- const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
- return [
- { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
- {
- key: LIST_KEY_TAG,
- label: LIST_LABEL_TAG,
- class: `${tagClass} js-tag-column`,
- innerClass: tagInnerClass,
- },
- { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
- { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
- { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
- { key: LIST_KEY_ACTIONS, label: '' },
- ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
- },
- isMultiDelete() {
- return this.itemsToBeDeleted.length > 1;
- },
tracking() {
return {
- label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ label:
+ this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
- modalAction() {
- return n__(
- 'ContainerRegistry|Remove tag',
- 'ContainerRegistry|Remove tags',
- this.isMultiDelete ? this.itemsToBeDeleted.length : 1,
- );
- },
currentPage: {
get() {
return this.tagsPagination.page;
@@ -142,132 +59,51 @@ export default {
this.requestTagsList({ pagination: { page }, params: this.$route.params.id });
},
},
- deleteAlertConfig() {
- const config = {
- title: '',
- message: '',
- type: 'success',
- };
- if (this.deleteAlertType) {
- [config.type] = this.deleteAlertType.split('_');
-
- const defaultMessage = this.$options.alertMessages[this.deleteAlertType];
-
- if (this.config.isAdmin && config.type === 'success') {
- config.title = defaultMessage;
- config.message = ADMIN_GARBAGE_COLLECTION_TIP;
- } else {
- config.message = defaultMessage;
- }
- }
- return config;
- },
},
mounted() {
this.requestTagsList({ params: this.$route.params.id });
},
methods: {
...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
- setModalDescription(itemIndex = -1) {
- if (itemIndex === -1) {
- this.modalDescription = {
- message: REMOVE_TAGS_CONFIRMATION_TEXT,
- item: this.itemsToBeDeleted.length,
- };
- } else {
- const { path } = this.tags[itemIndex];
-
- this.modalDescription = {
- message: REMOVE_TAG_CONFIRMATION_TEXT,
- item: path,
- };
- }
- },
- formatSize(size) {
- return numberToHumanSize(size);
- },
- layers(layers) {
- return layers ? n__('%d layer', '%d layers', layers) : '';
- },
- onSelectAllChange() {
- if (this.selectAllChecked) {
- this.deselectAll();
- } else {
- this.selectAll();
- }
- },
- selectAll() {
- this.selectedItems = this.tags.map((x, index) => index);
- this.selectAllChecked = true;
- },
- deselectAll() {
- this.selectedItems = [];
- this.selectAllChecked = false;
- },
- updateSelectedItems(index) {
- const delIndex = this.selectedItems.findIndex(x => x === index);
-
- if (delIndex > -1) {
- this.selectedItems.splice(delIndex, 1);
- this.selectAllChecked = false;
- } else {
- this.selectedItems.push(index);
-
- if (this.selectedItems.length === this.tags.length) {
- this.selectAllChecked = true;
- }
- }
- },
- deleteSingleItem(index) {
- this.setModalDescription(index);
- this.itemsToBeDeleted = [index];
+ deleteTags(toBeDeletedList) {
+ this.itemsToBeDeleted = toBeDeletedList.map(name => ({
+ ...this.tags.find(t => t.name === name),
+ }));
this.track('click_button');
this.$refs.deleteModal.show();
},
- deleteMultipleItems() {
- this.itemsToBeDeleted = [...this.selectedItems];
- if (this.selectedItems.length === 1) {
- this.setModalDescription(this.itemsToBeDeleted[0]);
- } else if (this.selectedItems.length > 1) {
- this.setModalDescription();
- }
- this.track('click_button');
- this.$refs.deleteModal.show();
- },
- handleSingleDelete(index) {
- const itemToDelete = this.tags[index];
+ handleSingleDelete() {
+ const [itemToDelete] = this.itemsToBeDeleted;
this.itemsToBeDeleted = [];
- this.selectedItems = this.selectedItems.filter(i => i !== index);
return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id })
.then(() => {
- this.deleteAlertType = 'success_tag';
+ this.deleteAlertType = ALERT_SUCCESS_TAG;
})
.catch(() => {
- this.deleteAlertType = 'danger_tag';
+ this.deleteAlertType = ALERT_DANGER_TAG;
});
},
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
- this.selectedItems = [];
return this.requestDeleteTags({
- ids: itemsToBeDeleted.map(x => this.tags[x].name),
+ ids: itemsToBeDeleted.map(x => x.name),
params: this.$route.params.id,
})
.then(() => {
- this.deleteAlertType = 'success_tags';
+ this.deleteAlertType = ALERT_SUCCESS_TAGS;
})
.catch(() => {
- this.deleteAlertType = 'danger_tags';
+ this.deleteAlertType = ALERT_DANGER_TAGS;
});
},
onDeletionConfirmed() {
this.track('confirm_delete');
- if (this.isMultiDelete) {
+ if (this.itemsToBeDeleted.length > 1) {
this.handleMultipleDelete();
} else {
- this.handleSingleDelete(this.itemsToBeDeleted[0]);
+ this.handleSingleDelete();
}
},
handleResize() {
@@ -279,141 +115,23 @@ export default {
<template>
<div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element">
- <gl-alert
- v-if="deleteAlertType"
- :variant="deleteAlertConfig.type"
- :title="deleteAlertConfig.title"
+ <delete-alert
+ v-model="deleteAlertType"
+ :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
+ :is-admin="config.isAdmin"
class="my-2"
- @dismiss="deleteAlertType = null"
- >
- <gl-sprintf :message="deleteAlertConfig.message">
- <template #docLink="{content}">
- <gl-link :href="config.garbageCollectionHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
- <div class="d-flex my-3 align-items-center">
- <h4>
- <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
- <template #imageName>
- {{ imageName }}
- </template>
- </gl-sprintf>
- </h4>
- </div>
-
- <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty>
- <template v-if="isDesktop" #head(checkbox)>
- <gl-form-checkbox
- ref="mainCheckbox"
- :checked="selectAllChecked"
- @change="onSelectAllChange"
- />
- </template>
- <template #head(actions)>
- <gl-deprecated-button
- ref="bulkDeleteButton"
- v-gl-tooltip
- :disabled="!selectedItems || selectedItems.length === 0"
- class="float-right"
- variant="danger"
- :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
- :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
- @click="deleteMultipleItems()"
- >
- <gl-icon name="remove" />
- </gl-deprecated-button>
- </template>
+ />
- <template #cell(checkbox)="{index}">
- <gl-form-checkbox
- ref="rowCheckbox"
- class="js-row-checkbox"
- :checked="selectedItems.includes(index)"
- @change="updateSelectedItems(index)"
- />
- </template>
- <template #cell(name)="{item, field}">
- <div ref="rowName" :class="[field.innerClass, 'gl-display-flex']">
- <span
- v-gl-tooltip
- data-testid="rowNameText"
- :title="item.name"
- class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
- >
- {{ item.name }}
- </span>
- <clipboard-button
- v-if="item.location"
- ref="rowClipboardButton"
- :title="item.location"
- :text="item.location"
- css-class="btn-default btn-transparent btn-clipboard"
- />
- </div>
- </template>
- <template #cell(short_revision)="{value}">
- <span ref="rowShortRevision">
- {{ value }}
- </span>
- </template>
- <template #cell(total_size)="{item}">
- <span ref="rowSize">
- {{ formatSize(item.total_size) }}
- <template v-if="item.total_size && item.layers">
- &middot;
- </template>
- {{ layers(item.layers) }}
- </span>
- </template>
- <template #cell(created_at)="{value}">
- <span ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)">
- {{ timeFormatted(value) }}
- </span>
- </template>
- <template #cell(actions)="{index, item}">
- <gl-deprecated-button
- ref="singleDeleteButton"
- :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
- :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
- :disabled="!item.destroy_path"
- variant="danger"
- class="js-delete-registry float-right btn-inverted btn-border-color btn-icon"
- @click="deleteSingleItem(index)"
- >
- <gl-icon name="remove" />
- </gl-deprecated-button>
- </template>
+ <details-header :image-name="imageName" />
+ <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags">
<template #empty>
- <template v-if="isLoading">
- <gl-skeleton-loader
- v-for="index in $options.loader.repeat"
- :key="index"
- :width="$options.loader.width"
- :height="$options.loader.height"
- preserve-aspect-ratio="xMinYMax meet"
- >
- <rect width="15" x="0" y="12.5" height="15" rx="4" />
- <rect width="250" x="25" y="10" height="20" rx="4" />
- <circle cx="290" cy="20" r="10" />
- <rect width="100" x="315" y="10" height="20" rx="4" />
- <rect width="100" x="500" y="10" height="20" rx="4" />
- <rect width="100" x="630" y="10" height="20" rx="4" />
- <rect x="960" y="0" width="40" height="40" rx="4" />
- </gl-skeleton-loader>
- </template>
- <gl-empty-state
- v-else
- :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
- :svg-path="config.noContainersImage"
- :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
- class="mx-auto my-0"
- />
+ <empty-tags-state :no-containers-image="config.noContainersImage" />
</template>
- </gl-table>
+ <template #loader>
+ <tags-loader v-once />
+ </template>
+ </tags-table>
<gl-pagination
v-if="!isLoading"
@@ -425,22 +143,11 @@ export default {
class="w-100"
/>
- <gl-modal
+ <delete-modal
ref="deleteModal"
- modal-id="delete-tag-modal"
- ok-variant="danger"
- @ok="onDeletionConfirmed"
+ :items-to-be-deleted="itemsToBeDeleted"
+ @confirmDelete="onDeletionConfirmed"
@cancel="track('cancel_delete')"
- >
- <template #modal-title>{{ modalAction }}</template>
- <template #modal-ok>{{ modalAction }}</template>
- <p v-if="modalDescription">
- <gl-sprintf :message="modalDescription.message">
- <template #item>
- <b>{{ modalDescription.item }}</b>
- </template>
- </gl-sprintf>
- </p>
- </gl-modal>
+ />
</div>
</template>
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 4efa6f08d84..e8a26dc58f2 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -12,26 +12,24 @@ import {
} from '@gitlab/ui';
import Tracking from '~/tracking';
-import ProjectEmptyState from '../components/project_empty_state.vue';
-import GroupEmptyState from '../components/group_empty_state.vue';
-import ProjectPolicyAlert from '../components/project_policy_alert.vue';
-import QuickstartDropdown from '../components/quickstart_dropdown.vue';
-import ImageList from '../components/image_list.vue';
+import ProjectEmptyState from '../components/list_page/project_empty_state.vue';
+import GroupEmptyState from '../components/list_page/group_empty_state.vue';
+import RegistryHeader from '../components/list_page/registry_header.vue';
+import ImageList from '../components/list_page/image_list.vue';
+import CliCommands from '../components/list_page/cli_commands.vue';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
- CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
- LIST_INTRO_TEXT,
REMOVE_REPOSITORY_MODAL_TEXT,
REMOVE_REPOSITORY_LABEL,
SEARCH_PLACEHOLDER_TEXT,
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
-} from '../constants';
+} from '../constants/index';
export default {
name: 'RegistryListApp',
@@ -39,8 +37,6 @@ export default {
GlEmptyState,
ProjectEmptyState,
GroupEmptyState,
- ProjectPolicyAlert,
- QuickstartDropdown,
ImageList,
GlModal,
GlSprintf,
@@ -48,6 +44,8 @@ export default {
GlAlert,
GlSkeletonLoader,
GlSearchBoxByClick,
+ RegistryHeader,
+ CliCommands,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -59,10 +57,8 @@ export default {
height: 40,
},
i18n: {
- CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
- LIST_INTRO_TEXT,
REMOVE_REPOSITORY_MODAL_TEXT,
REMOVE_REPOSITORY_LABEL,
SEARCH_PLACEHOLDER_TEXT,
@@ -85,7 +81,7 @@ export default {
label: 'registry_repository_delete',
};
},
- showQuickStartDropdown() {
+ showCommands() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
showDeleteAlert() {
@@ -149,8 +145,6 @@ export default {
</gl-sprintf>
</gl-alert>
- <project-policy-alert v-if="!config.isGroupPage" class="mt-2" />
-
<gl-empty-state
v-if="config.characterError"
:title="$options.i18n.CONNECTION_ERROR_TITLE"
@@ -170,21 +164,17 @@ export default {
</gl-empty-state>
<template v-else>
- <div>
- <div class="d-flex justify-content-between align-items-center">
- <h4>{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4>
- <quickstart-dropdown v-if="showQuickStartDropdown" class="d-none d-sm-block" />
- </div>
- <p>
- <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT">
- <template #docLink="{content}">
- <gl-link :href="config.helpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </div>
+ <registry-header
+ :images-count="pagination.total"
+ :expiration-policy="config.expirationPolicy"
+ :help-page-path="config.helpPagePath"
+ :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath"
+ :hide-expiration-policy-data="config.isGroupPage"
+ >
+ <template #commands>
+ <cli-commands v-if="showCommands" />
+ </template>
+ </registry-header>
<div v-if="isLoading" class="mt-2">
<gl-skeleton-loader
@@ -201,7 +191,7 @@ export default {
</div>
<template v-else>
<template v-if="!isEmpty">
- <div class="gl-display-flex gl-p-1" data-testid="listHeader">
+ <div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader">
<div class="gl-flex-fill-1">
<h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
</div>
diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js
index 478eaca1a68..f570987023b 100644
--- a/app/assets/javascripts/registry/explorer/router.js
+++ b/app/assets/javascripts/registry/explorer/router.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
-import { s__ } from '~/locale';
import List from './pages/list.vue';
import Details from './pages/details.vue';
import { decodeAndParse } from './utils';
+import { CONTAINER_REGISTRY_TITLE } from './constants/index';
Vue.use(VueRouter);
@@ -17,7 +17,7 @@ export default function createRouter(base) {
path: '/',
component: List,
meta: {
- nameGenerator: () => s__('ContainerRegistry|Container Registry'),
+ nameGenerator: () => CONTAINER_REGISTRY_TITLE,
root: true,
},
},
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
index 7f80bc21d6e..3d73ffbd23f 100644
--- a/app/assets/javascripts/registry/explorer/stores/actions.js
+++ b/app/assets/javascripts/registry/explorer/stores/actions.js
@@ -6,7 +6,7 @@ import {
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
FETCH_TAGS_LIST_ERROR_MESSAGE,
-} from '../constants';
+} from '../constants/index';
import { decodeAndParse } from '../utils';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
diff --git a/app/assets/javascripts/registry/explorer/stores/getters.js b/app/assets/javascripts/registry/explorer/stores/getters.js
index a371d0e6356..7b5d1bd6da3 100644
--- a/app/assets/javascripts/registry/explorer/stores/getters.js
+++ b/app/assets/javascripts/registry/explorer/stores/getters.js
@@ -1,9 +1,3 @@
-export const tags = state => {
- // to show the loader inside the table we need to pass an empty array to gl-table whenever the table is loading
- // this is to take in account isLoading = true and state.tags =[1,2,3] during pagination and delete
- return state.isLoading ? [] : state.tags;
-};
-
export const dockerBuildCommand = state => {
/* eslint-disable @gitlab/require-i18n-strings */
return `docker build -t ${state.config.repositoryUrl} .`;
diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js
index 153032e37d3..54a8e0e1c1c 100644
--- a/app/assets/javascripts/registry/explorer/stores/index.js
+++ b/app/assets/javascripts/registry/explorer/stores/index.js
@@ -7,6 +7,7 @@ import state from './state';
Vue.use(Vuex);
+// eslint-disable-next-line import/prefer-default-export
export const createStore = () =>
new Vuex.Store({
state,
@@ -14,6 +15,3 @@ export const createStore = () =>
actions,
mutations,
});
-
-// Deprecated and to be removed
-export default createStore();
diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js
index b25a0221dc1..706f6489287 100644
--- a/app/assets/javascripts/registry/explorer/stores/mutations.js
+++ b/app/assets/javascripts/registry/explorer/stores/mutations.js
@@ -1,14 +1,14 @@
import * as types from './mutation_types';
-import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
-import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants';
+import { parseIntPagination, normalizeHeaders, parseBoolean } from '~/lib/utils/common_utils';
+import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants/index';
export default {
[types.SET_INITIAL_STATE](state, config) {
state.config = {
...config,
expirationPolicy: config.expirationPolicy ? JSON.parse(config.expirationPolicy) : undefined,
- isGroupPage: config.isGroupPage !== undefined,
- isAdmin: config.isAdmin !== undefined,
+ isGroupPage: parseBoolean(config.isGroupPage),
+ isAdmin: parseBoolean(config.isAdmin),
};
},
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index 0698ca5e31f..d0d1485d8e7 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -8,12 +8,25 @@ import {
GlIcon,
GlTooltipDirective,
GlFormInput,
+ GlFormSelect,
} from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { DEFAULT_ASSET_LINK_TYPE, ASSET_LINK_TYPE } from '../constants';
+import { s__ } from '~/locale';
export default {
name: 'AssetLinksForm',
- components: { GlSprintf, GlLink, GlFormGroup, GlButton, GlIcon, GlFormInput },
+ components: {
+ GlSprintf,
+ GlLink,
+ GlFormGroup,
+ GlButton,
+ GlIcon,
+ GlFormInput,
+ GlFormSelect,
+ },
directives: { GlTooltip: GlTooltipDirective },
+ mixins: [glFeatureFlagsMixin()],
computed: {
...mapState('detail', ['release', 'releaseAssetsDocsPath']),
...mapGetters('detail', ['validationErrors']),
@@ -26,6 +39,7 @@ export default {
'addEmptyAssetLink',
'updateAssetLinkUrl',
'updateAssetLinkName',
+ 'updateAssetLinkType',
'removeAssetLink',
]),
onAddAnotherClicked() {
@@ -35,12 +49,6 @@ export default {
this.removeAssetLink(linkId);
this.ensureAtLeastOneLink();
},
- onUrlInput(linkIdToUpdate, newUrl) {
- this.updateAssetLinkUrl({ linkIdToUpdate, newUrl });
- },
- onLinkTitleInput(linkIdToUpdate, newName) {
- this.updateAssetLinkName({ linkIdToUpdate, newName });
- },
hasDuplicateUrl(link) {
return Boolean(this.getLinkErrors(link).isDuplicate);
},
@@ -73,6 +81,13 @@ export default {
}
},
},
+ typeOptions: [
+ { value: ASSET_LINK_TYPE.IMAGE, text: s__('ReleaseAssetLinkType|Image') },
+ { value: ASSET_LINK_TYPE.PACKAGE, text: s__('ReleaseAssetLinkType|Package') },
+ { value: ASSET_LINK_TYPE.RUNBOOK, text: s__('ReleaseAssetLinkType|Runbook') },
+ { value: ASSET_LINK_TYPE.OTHER, text: s__('ReleaseAssetLinkType|Other') },
+ ],
+ defaultTypeOptionValue: DEFAULT_ASSET_LINK_TYPE,
};
</script>
@@ -109,10 +124,10 @@ export default {
<div
v-for="(link, index) in release.assets.links"
:key="link.id"
- class="row flex-column flex-sm-row align-items-stretch align-items-sm-start"
+ class="row flex-column flex-sm-row align-items-stretch align-items-sm-start no-gutters"
>
<gl-form-group
- class="url-field form-group col"
+ class="url-field form-group col pr-sm-2"
:label="__('URL')"
:label-for="`asset-url-${index}`"
>
@@ -123,7 +138,7 @@ export default {
type="text"
class="form-control"
:state="isUrlValid(link)"
- @change="onUrlInput(link.id, $event)"
+ @change="updateAssetLinkUrl({ linkIdToUpdate: link.id, newUrl: $event })"
/>
<template #invalid-feedback>
<span v-if="hasEmptyUrl(link)" class="invalid-feedback d-inline">
@@ -149,7 +164,7 @@ export default {
</gl-form-group>
<gl-form-group
- class="link-title-field col"
+ class="link-title-field col px-sm-2"
:label="__('Link title')"
:label-for="`asset-link-name-${index}`"
>
@@ -160,7 +175,7 @@ export default {
type="text"
class="form-control"
:state="isNameValid(link)"
- @change="onLinkTitleInput(link.id, $event)"
+ @change="updateAssetLinkName({ linkIdToUpdate: link.id, newName: $event })"
/>
<template #invalid-feedback>
<span v-if="hasEmptyName(link)" class="invalid-feedback d-inline">
@@ -169,16 +184,34 @@ export default {
</template>
</gl-form-group>
- <div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto">
+ <gl-form-group
+ v-if="glFeatures.releaseAssetLinkType"
+ class="link-type-field col-auto px-sm-2"
+ :label="__('Type')"
+ :label-for="`asset-type-${index}`"
+ >
+ <gl-form-select
+ :id="`asset-type-${index}`"
+ ref="typeSelect"
+ :value="link.linkType || $options.defaultTypeOptionValue"
+ class="form-control pr-4"
+ :options="$options.typeOptions"
+ @change="updateAssetLinkType({ linkIdToUpdate: link.id, newType: $event })"
+ />
+ </gl-form-group>
+
+ <div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto pl-sm-2">
<gl-button
v-gl-tooltip
- class="remove-button w-100"
+ class="remove-button w-100 form-control"
:aria-label="__('Remove asset link')"
:title="__('Remove asset link')"
@click="onRemoveClicked(link.id)"
>
- <gl-icon class="mr-1 mr-sm-0 mb-1" :size="16" name="remove" />
- <span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span>
+ <div class="d-flex">
+ <gl-icon class="mr-1 mr-sm-0" :size="16" name="remove" />
+ <span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span>
+ </div>
</gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index acae6fda533..2cc15777343 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -71,7 +71,7 @@ export default {
:download="evidenceTitle(index)"
:href="evidenceUrl(index)"
>
- <gl-icon name="review-list" class="align-middle append-right-8" />
+ <gl-icon name="review-list" class="align-middle gl-mr-3" />
<span>{{ evidenceTitle(index) }}</span>
</gl-link>
@@ -96,7 +96,7 @@ export default {
<gl-icon
v-gl-tooltip
name="clock"
- class="align-middle append-right-8"
+ class="align-middle gl-mr-3"
:title="collectedAt(index)"
/>
<span>{{ timeSummary(index) }}</span>
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index 58045b57d80..adb0e69b786 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -109,7 +109,7 @@ export default {
<evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" />
<div ref="gfm-content" class="card-text prepend-top-default">
- <div v-html="release.descriptionHtml"></div>
+ <div class="md" v-html="release.descriptionHtml"></div>
</div>
</div>
diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue
index f4b92416e47..e07646e9a9f 100644
--- a/app/assets/javascripts/releases/components/release_block_assets.vue
+++ b/app/assets/javascripts/releases/components/release_block_assets.vue
@@ -1,65 +1,190 @@
<script>
-import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ASSET_LINK_TYPE } from '../constants';
+import { __, s__, sprintf } from '~/locale';
+import { difference } from 'lodash';
export default {
name: 'ReleaseBlockAssets',
components: {
GlLink,
+ GlButton,
+ GlCollapse,
+ GlIcon,
Icon,
+ GlBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
assets: {
type: Object,
required: true,
},
},
+ data() {
+ return {
+ isAssetsExpanded: true,
+ };
+ },
computed: {
hasAssets() {
return Boolean(this.assets.count);
},
+ imageLinks() {
+ return this.linksForType(ASSET_LINK_TYPE.IMAGE);
+ },
+ packageLinks() {
+ return this.linksForType(ASSET_LINK_TYPE.PACKAGE);
+ },
+ runbookLinks() {
+ return this.linksForType(ASSET_LINK_TYPE.RUNBOOK);
+ },
+ otherLinks() {
+ return difference(this.assets.links, [
+ ...this.imageLinks,
+ ...this.packageLinks,
+ ...this.runbookLinks,
+ ]);
+ },
+ sections() {
+ return [
+ {
+ links: this.assets.sources.map(s => ({
+ url: s.url,
+ name: sprintf(__('Source code (%{fileExtension})'), { fileExtension: s.format }),
+ })),
+ iconName: 'doc-code',
+ },
+ {
+ title: s__('ReleaseAssetLinkType|Images'),
+ links: this.imageLinks,
+ iconName: 'container-image',
+ },
+ {
+ title: s__('ReleaseAssetLinkType|Packages'),
+ links: this.packageLinks,
+ iconName: 'package',
+ },
+ {
+ title: s__('ReleaseAssetLinkType|Runbooks'),
+ links: this.runbookLinks,
+ iconName: 'book',
+ },
+ {
+ title: s__('ReleaseAssetLinkType|Other'),
+ links: this.otherLinks,
+ iconName: 'link',
+ },
+ ].filter(section => section.links.length > 0);
+ },
+ },
+ methods: {
+ toggleAssetsExpansion() {
+ this.isAssetsExpanded = !this.isAssetsExpanded;
+ },
+ linksForType(type) {
+ return this.assets.links.filter(l => l.linkType === type);
+ },
},
+ externalLinkTooltipText: __('This link points to external content'),
};
</script>
<template>
<div class="card-text prepend-top-default">
- <b>
- {{ __('Assets') }}
- <span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
- </b>
-
- <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
- <li v-for="link in assets.links" :key="link.name" class="append-bottom-8">
- <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl">
- <icon name="package" class="align-middle append-right-4 align-text-bottom" />
- {{ link.name }}
- <span v-if="link.external">{{ __('(external source)') }}</span>
- </gl-link>
- </li>
- </ul>
-
- <div v-if="hasAssets" class="dropdown">
- <button
- type="button"
- class="btn btn-link"
- data-toggle="dropdown"
- aria-haspopup="true"
- aria-expanded="false"
+ <template v-if="glFeatures.releaseAssetLinkType">
+ <gl-button
+ data-testid="accordion-button"
+ variant="link"
+ class="gl-font-weight-bold"
+ @click="toggleAssetsExpansion"
>
- <icon name="doc-code" class="align-top append-right-4" />
- {{ __('Source code') }}
- <icon name="chevron-down" />
- </button>
+ <gl-icon
+ name="chevron-right"
+ class="gl-transition-medium"
+ :class="{ 'gl-rotate-90': isAssetsExpanded }"
+ />
+ {{ __('Assets') }}
+ <gl-badge size="sm" variant="neutral" class="gl-display-inline-block">{{
+ assets.count
+ }}</gl-badge>
+ </gl-button>
+ <gl-collapse v-model="isAssetsExpanded">
+ <div class="gl-pl-6 gl-pt-3 js-assets-list">
+ <template v-for="(section, index) in sections">
+ <h5 v-if="section.title" :key="`section-header-${index}`" class="gl-mb-2">
+ {{ section.title }}
+ </h5>
+ <ul :key="`section-body-${index}`" class="list-unstyled gl-m-0">
+ <li v-for="link in section.links" :key="link.url">
+ <gl-link
+ :href="link.directAssetUrl || link.url"
+ class="gl-display-flex gl-align-items-center gl-line-height-24"
+ >
+ <gl-icon
+ :name="section.iconName"
+ class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0"
+ />
+ {{ link.name }}
+ <gl-icon
+ v-if="link.external"
+ v-gl-tooltip
+ name="external-link"
+ :aria-label="$options.externalLinkTooltipText"
+ :title="$options.externalLinkTooltipText"
+ data-testid="external-link-indicator"
+ class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-600"
+ />
+ </gl-link>
+ </li>
+ </ul>
+ </template>
+ </div>
+ </gl-collapse>
+ </template>
- <div class="js-sources-dropdown dropdown-menu">
- <li v-for="asset in assets.sources" :key="asset.url">
- <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
+ <template v-else>
+ <b>
+ {{ __('Assets') }}
+ <span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
+ </b>
+
+ <ul v-if="assets.links.length" class="pl-0 mb-0 gl-mt-3 list-unstyled js-assets-list">
+ <li v-for="link in assets.links" :key="link.name" class="gl-mb-3">
+ <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl">
+ <icon name="package" class="align-middle append-right-4 align-text-bottom" />
+ {{ link.name }}
+ <span v-if="link.external" data-testid="external-link-indicator">{{
+ __('(external source)')
+ }}</span>
+ </gl-link>
</li>
+ </ul>
+
+ <div v-if="hasAssets" class="dropdown">
+ <button
+ type="button"
+ class="btn btn-link"
+ data-toggle="dropdown"
+ aria-haspopup="true"
+ aria-expanded="false"
+ >
+ <icon name="doc-code" class="align-top append-right-4" />
+ {{ __('Source code') }}
+ <icon name="chevron-down" />
+ </button>
+
+ <div class="js-sources-dropdown dropdown-menu">
+ <li v-for="asset in assets.sources" :key="asset.url">
+ <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
+ </li>
+ </div>
</div>
- </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/release_block_author.vue b/app/assets/javascripts/releases/components/release_block_author.vue
index 0432d45b2dc..94f2b1795f0 100644
--- a/app/assets/javascripts/releases/components/release_block_author.vue
+++ b/app/assets/javascripts/releases/components/release_block_author.vue
@@ -30,7 +30,7 @@ export default {
<gl-sprintf :message="__('by %{user}')">
<template #user>
<user-avatar-link
- class="prepend-left-4"
+ class="gl-ml-2"
:link-href="author.webUrl"
:img-src="author.avatarUrl"
:img-alt="userImageAltDescription"
diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue
index 40133941011..a3377ce044a 100644
--- a/app/assets/javascripts/releases/components/release_block_metadata.vue
+++ b/app/assets/javascripts/releases/components/release_block_metadata.vue
@@ -57,7 +57,7 @@ export default {
<template>
<div class="card-subtitle d-flex flex-wrap text-secondary">
- <div class="append-right-8">
+ <div class="gl-mr-3">
<icon name="commit" class="align-middle" />
<gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
{{ commit.shortId }}
@@ -65,7 +65,7 @@ export default {
<span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.shortId }}</span>
</div>
- <div class="append-right-8">
+ <div class="gl-mr-3">
<icon name="tag" class="align-middle" />
<gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl">
{{ release.tagName }}
diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
index d9fbd2884b7..4f75e15a149 100644
--- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
@@ -144,7 +144,7 @@ export default {
<div class="d-flex flex-column align-items-start flex-shrink-0 mr-4 mb-3 js-issues-container">
<span class="mb-1">
{{ __('Issues') }}
- <gl-badge pill variant="light" class="font-weight-bold">{{ totalIssuesCount }}</gl-badge>
+ <gl-badge variant="muted" size="sm">{{ totalIssuesCount }}</gl-badge>
</span>
<div class="d-flex">
<gl-link v-if="openIssuesPath" ref="openIssuesLink" :href="openIssuesPath">
diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js
index 1db93323a87..361cee70747 100644
--- a/app/assets/javascripts/releases/constants.js
+++ b/app/assets/javascripts/releases/constants.js
@@ -1,3 +1,12 @@
export const MAX_MILESTONES_TO_DISPLAY = 5;
export const BACK_URL_PARAM = 'back_url';
+
+export const ASSET_LINK_TYPE = Object.freeze({
+ OTHER: 'other',
+ IMAGE: 'image',
+ PACKAGE: 'package',
+ RUNBOOK: 'runbook',
+});
+
+export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER;
diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js
index 3bc427dfa16..2026eeba880 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js
@@ -3,7 +3,10 @@ import api from '~/api';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import {
+ convertObjectPropsToCamelCase,
+ convertObjectPropsToSnakeCase,
+} from '~/lib/utils/common_utils';
export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE);
export const receiveReleaseSuccess = ({ commit }, data) =>
@@ -54,13 +57,18 @@ export const updateRelease = ({ dispatch, state, getters }) => {
const { release } = state;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
+ const updatedRelease = convertObjectPropsToSnakeCase(
+ {
+ name: release.name,
+ description: release.description,
+ milestones,
+ },
+ { deep: true },
+ );
+
return (
api
- .updateRelease(state.projectId, state.tagName, {
- name: release.name,
- description: release.description,
- milestones,
- })
+ .updateRelease(state.projectId, state.tagName, updatedRelease)
/**
* Currently, we delete all existing links and then
@@ -91,7 +99,11 @@ export const updateRelease = ({ dispatch, state, getters }) => {
// Create a new link for each link in the form
return Promise.all(
getters.releaseLinksToCreate.map(l =>
- api.createReleaseLink(state.projectId, release.tagName, l),
+ api.createReleaseLink(
+ state.projectId,
+ release.tagName,
+ convertObjectPropsToSnakeCase(l, { deep: true }),
+ ),
),
);
})
@@ -118,6 +130,10 @@ export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) =>
commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
};
+export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
+ commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType });
+};
+
export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
index 1d6356990ce..7b694120126 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
@@ -13,4 +13,5 @@ export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR';
export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK';
export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL';
export const UPDATE_ASSET_LINK_NAME = 'UPDATE_ASSET_LINK_NAME';
+export const UPDATE_ASSET_LINK_TYPE = 'UPDATE_ASSET_LINK_TYPE';
export const REMOVE_ASSET_LINK = 'REMOVE_ASSET_LINK';
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
index 5c29b402cba..ca544151323 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
@@ -1,5 +1,6 @@
import * as types from './mutation_types';
import { uniqueId, cloneDeep } from 'lodash';
+import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants';
const findReleaseLink = (release, id) => {
return release.assets.links.find(l => l.id === id);
@@ -49,6 +50,7 @@ export default {
id: uniqueId('new-link-'),
url: '',
name: '',
+ linkType: DEFAULT_ASSET_LINK_TYPE,
});
},
@@ -62,6 +64,11 @@ export default {
linkToUpdate.name = newName;
},
+ [types.UPDATE_ASSET_LINK_TYPE](state, { linkIdToUpdate, newType }) {
+ const linkToUpdate = findReleaseLink(state.release, linkIdToUpdate);
+ linkToUpdate.linkType = newType;
+ },
+
[types.REMOVE_ASSET_LINK](state, linkIdToRemove) {
state.release.assets.links = state.release.assets.links.filter(l => l.id !== linkIdToRemove);
},
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
index 0f7a0e60dc0..a670cad5f9f 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -98,25 +98,27 @@ export default {
:has-issues="reports.length > 0"
class="mr-widget-section grouped-security-reports mr-report"
>
- <div slot="body" class="mr-widget-grouped-section report-block">
- <template v-for="(report, i) in reports">
- <summary-row
- :key="`summary-row-${i}`"
- :summary="reportText(report)"
- :status-icon="getReportIcon(report)"
- />
- <issues-list
- v-if="shouldRenderIssuesList(report)"
- :key="`issues-list-${i}`"
- :unresolved-issues="unresolvedIssues(report)"
- :new-issues="newIssues(report)"
- :resolved-issues="resolvedIssues(report)"
- :component="$options.componentNames.TestIssueBody"
- class="report-block-group-list"
- />
- </template>
+ <template #body>
+ <div class="mr-widget-grouped-section report-block">
+ <template v-for="(report, i) in reports">
+ <summary-row
+ :key="`summary-row-${i}`"
+ :summary="reportText(report)"
+ :status-icon="getReportIcon(report)"
+ />
+ <issues-list
+ v-if="shouldRenderIssuesList(report)"
+ :key="`issues-list-${i}`"
+ :unresolved-issues="unresolvedIssues(report)"
+ :new-issues="newIssues(report)"
+ :resolved-issues="resolvedIssues(report)"
+ :component="$options.componentNames.TestIssueBody"
+ class="report-block-group-list"
+ />
+ </template>
- <modal :title="modalTitle" :modal-data="modalData" />
- </div>
+ <modal :title="modalTitle" :modal-data="modalData" />
+ </div>
+ </template>
</report-section>
</template>
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 9cbe2a690a0..b9fc902cd3a 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -54,7 +54,10 @@ export default {
<ci-icon v-else :status="iconStatus" :size="24" />
</div>
<div class="report-block-list-issue-description">
- <div class="report-block-list-issue-description-text">
+ <div
+ class="report-block-list-issue-description-text"
+ data-testid="test-summary-row-description"
+ >
{{ summary
}}<span v-if="popoverOptions" class="text-nowrap"
>&nbsp;<popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
index 7700f49bf7d..c41238070b1 100644
--- a/app/assets/javascripts/reports/components/test_issue_body.vue
+++ b/app/assets/javascripts/reports/components/test_issue_body.vue
@@ -26,7 +26,7 @@ export default {
</script>
<template>
<div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
- <div class="report-block-list-issue-description-text">
+ <div class="report-block-list-issue-description-text" data-testid="test-issue-body-description">
<button
type="button"
class="btn-link btn-blank text-left break-link vulnerability-name-button"
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 010fc9a5d1a..c5c99d56e2a 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -149,7 +149,7 @@ export default {
<pre
v-if="commit.description"
:class="{ 'd-block': showDescription }"
- class="commit-row-description append-bottom-8"
+ class="commit-row-description gl-mb-3"
>{{ commit.description }}</pre
>
</div>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 34424121390..d5363016335 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -147,8 +147,11 @@ export default {
class="mr-1 position-relative text-secondary"
/><span class="position-relative">{{ fullPath }}</span>
</component>
- <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
- <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">LFS</gl-badge>
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-qa-selector="label-lfs"
+ >LFS</gl-badge
+ >
+ <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
<template v-if="isSubmodule">
@ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link>
</template>
diff --git a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql
index 3c3d14881da..eb21c1e73d8 100644
--- a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql
+++ b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql
@@ -1,3 +1,3 @@
-query getProjectShortPath {
+query getVueFileListLfsBadge {
vueFileListLfsBadge @client
}
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index bbafdd7f8f1..aac7c46a295 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -71,7 +71,7 @@ export default {
</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" />
+ <url :uri="targetUrl" class="gl-mt-3 no-expand" />
</div>
</li>
</template>
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 2757d64bd7d..fd1f9eae152 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
@@ -82,7 +82,8 @@ export default {
})
.catch(() => createFlash(__('Failed to load emoji list.')));
},
- showEmojiMenu() {
+ showEmojiMenu(e) {
+ e.stopPropagation();
this.isEmojiMenuVisible = true;
this.emojiMenu.showEmojiMenu($(this.$refs.toggleEmojiMenuButton));
},
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index 51cd5810ac0..67abde0c22a 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -1,17 +1,12 @@
<script>
import { sprintf, s__ } from '../../../locale';
+import { joinPaths } from '~/lib/utils/url_utility';
export default {
name: 'TimeTrackingHelpState',
- props: {
- rootPath: {
- type: String,
- required: true,
- },
- },
computed: {
href() {
- return `${this.rootPath}help/workflow/time_tracking.md`;
+ return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md');
},
estimateText() {
return sprintf(
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 1e8a31fff81..5cf574e1387 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -42,10 +42,6 @@ export default {
default: false,
required: false,
},
- rootPath: {
- type: String,
- required: true,
- },
},
data() {
return {
@@ -137,7 +133,7 @@ export default {
:limit-to-hours="limitToHours"
/>
<transition name="help-state-toggle">
- <time-tracking-help-state v-if="showHelpState" :root-path="rootPath" />
+ <time-tracking-help-state v-if="showHelpState" />
</transition>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index d934463382f..0f5f8f2b53b 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -24,7 +24,6 @@ export default class SidebarMilestone {
humanTimeEstimate,
humanTimeSpent,
limitToHours: parseBoolean(limitToHours),
- rootPath: '/',
},
}),
});
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index e8d6c005435..a6651515e47 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -11,7 +11,11 @@ import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
import { getSnippetMixin } from '../mixins/snippets';
-import { SNIPPET_VISIBILITY_PRIVATE } from '../constants';
+import {
+ SNIPPET_VISIBILITY_PRIVATE,
+ SNIPPET_CREATE_MUTATION_ERROR,
+ SNIPPET_UPDATE_MUTATION_ERROR,
+} from '../constants';
import SnippetBlobEdit from './snippet_blob_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
import SnippetDescriptionEdit from './snippet_description_edit.vue';
@@ -98,7 +102,11 @@ export default {
this.fileName = newName;
},
flashAPIFailure(err) {
- Flash(sprintf(__("Can't update snippet: %{err}"), { err }));
+ const defaultErrorMsg = this.newSnippet
+ ? SNIPPET_CREATE_MUTATION_ERROR
+ : SNIPPET_UPDATE_MUTATION_ERROR;
+ Flash(sprintf(defaultErrorMsg, { err }));
+ this.isUpdating = false;
},
onNewSnippetFetched() {
this.newSnippet = true;
@@ -129,29 +137,45 @@ export default {
this.onExistingSnippetFetched();
}
},
+ getAttachedFiles() {
+ const fileInputs = Array.from(this.$el.querySelectorAll('[name="files[]"]'));
+ return fileInputs.map(node => node.value);
+ },
+ createMutation() {
+ return {
+ mutation: CreateSnippetMutation,
+ variables: {
+ input: {
+ ...this.apiData,
+ uploadedFiles: this.getAttachedFiles(),
+ projectPath: this.projectPath,
+ },
+ },
+ };
+ },
+ updateMutation() {
+ return {
+ mutation: UpdateSnippetMutation,
+ variables: {
+ input: this.apiData,
+ },
+ };
+ },
handleFormSubmit() {
this.isUpdating = true;
this.$apollo
- .mutate({
- mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation,
- variables: {
- input: {
- ...this.apiData,
- projectPath: this.newSnippet ? this.projectPath : undefined,
- },
- },
- })
+ .mutate(this.newSnippet ? this.createMutation() : this.updateMutation())
.then(({ data }) => {
const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
const errors = baseObj?.errors;
if (errors.length) {
this.flashAPIFailure(errors[0]);
+ } else {
+ redirectTo(baseObj.snippet.webUrl);
}
- redirectTo(baseObj.snippet.webUrl);
})
.catch(e => {
- this.isUpdating = false;
this.flashAPIFailure(e);
});
},
@@ -168,6 +192,8 @@ export default {
<form
class="snippet-form js-requires-input js-quick-submit common-note-form"
:data-snippet-type="isProjectSnippet ? 'project' : 'personal'"
+ data-testid="snippet-edit-form"
+ @submit.prevent="handleFormSubmit"
>
<gl-loading-icon
v-if="isLoading"
@@ -179,7 +205,7 @@ export default {
<title-field
:id="titleFieldId"
v-model="snippet.title"
- data-qa-selector="snippet_title"
+ data-qa-selector="snippet_title_field"
required
:autofocus="true"
/>
@@ -203,17 +229,17 @@ export default {
<form-footer-actions>
<template #prepend>
<gl-button
- type="submit"
category="primary"
+ type="submit"
variant="success"
:disabled="updatePrevented"
data-qa-selector="submit_button"
- @click="handleFormSubmit"
+ data-testid="snippet-submit-btn"
>{{ saveButtonLabel }}</gl-button
>
</template>
<template #append>
- <gl-button data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{
+ <gl-button type="cancel" data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{
__('Cancel')
}}</gl-button>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index dd03902417d..62c29b0c7cd 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -35,7 +35,7 @@ export default {
<div class="file-holder snippet">
<blob-header-edit
:value="fileName"
- data-qa-selector="snippet_file_name"
+ data-qa-selector="file_name_field"
@input="$emit('name-change', $event)"
/>
<gl-loading-icon
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index 6b218b21e56..7472aff3318 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -74,6 +74,9 @@ export default {
canBeCloned() {
return this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo;
},
+ hasRenderError() {
+ return Boolean(this.viewer.renderError);
+ },
},
methods: {
switchViewer(newViewer) {
@@ -92,7 +95,12 @@ export default {
<div>
<blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" />
<article class="file-holder snippet-file-content">
- <blob-header :blob="blob" :active-viewer-type="viewer.type" @viewer-changed="switchViewer">
+ <blob-header
+ :blob="blob"
+ :active-viewer-type="viewer.type"
+ :has-render-error="hasRenderError"
+ @viewer-changed="switchViewer"
+ >
<template #actions>
<clone-dropdown-button
v-if="canBeCloned"
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index 0fe539a5de7..737845d09b8 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -30,7 +30,7 @@ export default {
</script>
<template>
<div class="form-group js-description-input">
- <label>{{ s__('Snippets|Description (optional)') }}</label>
+ <label for="snippet-description">{{ s__('Snippets|Description (optional)') }}</label>
<div class="js-collapsible-input">
<div class="js-collapsed" :class="{ 'd-none': value }">
<gl-form-input
@@ -46,22 +46,26 @@ export default {
<markdown-field
class="js-expanded"
:class="{ 'd-none': !value }"
+ :add-spacing-classes="false"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
>
- <textarea
- slot="textarea"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- dir="auto"
- data-qa-selector="snippet_description_field"
- data-supports-quick-actions="false"
- :value="value"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
- v-bind="$attrs"
- @input="$emit('input', $event.target.value)"
- >
- </textarea>
+ <template #textarea>
+ <textarea
+ id="snippet-description"
+ ref="textarea"
+ :value="value"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-qa-selector="snippet_description_field"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ v-bind="$attrs"
+ @input="$emit('input', $event.target.value)"
+ >
+ </textarea>
+ </template>
</markdown-field>
</div>
</div>
diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue
index 72afcc30be6..a5107f09fc7 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue
@@ -15,7 +15,7 @@ export default {
};
</script>
<template>
- <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_field">
+ <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_content">
<div class="md js-snippet-description" v-html="description"></div>
</markdown-field-view>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index c0967e9093c..2a06296cb15 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -163,7 +163,8 @@ export default {
<div class="detail-page-header">
<div class="detail-page-header-body">
<div
- class="snippet-box qa-snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1"
+ class="snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1"
+ data-qa-selector="snippet_container"
:title="snippetVisibilityLevelDescription"
data-container="body"
>
diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue
index 5267c3748ca..2cf7a1e267b 100644
--- a/app/assets/javascripts/snippets/components/snippet_title.vue
+++ b/app/assets/javascripts/snippets/components/snippet_title.vue
@@ -20,7 +20,7 @@ export default {
</script>
<template>
<div class="snippet-header limited-header-width">
- <h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title">
+ <h2 class="snippet-title gl-mt-0 mb-3" data-qa-selector="snippet_title_content">
{{ snippet.title }}
</h2>
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
index 7fd5e5b8ee4..b3abc73557c 100644
--- a/app/assets/javascripts/snippets/constants.js
+++ b/app/assets/javascripts/snippets/constants.js
@@ -22,3 +22,6 @@ export const SNIPPET_VISIBILITY = {
description: __('The snippet can be accessed without any authentication.'),
},
};
+
+export const SNIPPET_CREATE_MUTATION_ERROR = __("Can't create snippet: %{err}");
+export const SNIPPET_UPDATE_MUTATION_ERROR = __("Can't update snippet: %{err}");
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue
index dff21d919a9..e9efef40632 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue
@@ -2,12 +2,16 @@
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import PublishToolbar from './publish_toolbar.vue';
import EditHeader from './edit_header.vue';
+import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue';
+import parseSourceFile from '~/static_site_editor/services/parse_source_file';
+import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants';
export default {
components: {
RichContentEditor,
PublishToolbar,
EditHeader,
+ UnsavedChangesConfirmDialog,
},
props: {
title: {
@@ -30,26 +34,57 @@ export default {
},
data() {
return {
- editableContent: this.content,
saveable: false,
+ parsedSource: parseSourceFile(this.content),
+ editorMode: EDITOR_TYPES.wysiwyg,
};
},
computed: {
+ editableContent() {
+ return this.parsedSource.editable;
+ },
+ editableKey() {
+ return this.isWysiwygMode ? 'body' : 'raw';
+ },
+ isWysiwygMode() {
+ return this.editorMode === EDITOR_TYPES.wysiwyg;
+ },
modified() {
- return this.content !== this.editableContent;
+ return this.isWysiwygMode
+ ? this.parsedSource.isModifiedBody()
+ : this.parsedSource.isModifiedRaw();
},
},
methods: {
+ syncSource() {
+ if (this.isWysiwygMode) {
+ this.parsedSource.syncBody();
+ return;
+ }
+
+ this.parsedSource.syncRaw();
+ },
+ onModeChange(mode) {
+ this.editorMode = mode;
+ this.syncSource();
+ },
onSubmit() {
- this.$emit('submit', { content: this.editableContent });
+ this.syncSource();
+ this.$emit('submit', { content: this.editableContent.raw });
},
},
};
</script>
<template>
- <div class="d-flex flex-grow-1 flex-column">
+ <div class="d-flex flex-grow-1 flex-column h-100">
<edit-header class="py-2" :title="title" />
- <rich-content-editor v-model="editableContent" class="mb-9" />
+ <rich-content-editor
+ v-model="editableContent[editableKey]"
+ :initial-edit-type="editorMode"
+ class="mb-9 h-100"
+ @modeChange="onModeChange"
+ />
+ <unsaved-changes-confirm-dialog :modified="modified" />
<publish-toolbar
class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
:return-url="returnUrl"
diff --git a/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue b/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue
new file mode 100644
index 00000000000..255f029bd27
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue
@@ -0,0 +1,27 @@
+<script>
+export default {
+ props: {
+ modified: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ created() {
+ window.addEventListener('beforeunload', this.requestConfirmation);
+ },
+ destroyed() {
+ window.removeEventListener('beforeunload', this.requestConfirmation);
+ },
+ methods: {
+ requestConfirmation(e) {
+ if (this.modified) {
+ e.preventDefault();
+ // eslint-disable-next-line no-param-reassign
+ e.returnValue = '';
+ }
+ },
+ },
+ render: () => null,
+};
+</script>
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index 4794cf5eead..947347922f2 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -17,3 +17,5 @@ export const LOAD_CONTENT_ERROR = __(
export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
+export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request';
+export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor';
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index f65b648acd6..a1314c8a478 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -7,7 +7,8 @@ import appDataQuery from '../graphql/queries/app_data.query.graphql';
import sourceContentQuery from '../graphql/queries/source_content.query.graphql';
import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql';
import createFlash from '~/flash';
-import { LOAD_CONTENT_ERROR } from '../constants';
+import Tracking from '~/tracking';
+import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants';
import { SUCCESS_ROUTE } from '../router/constants';
export default {
@@ -59,6 +60,9 @@ export default {
return Boolean(this.sourceContent);
},
},
+ mounted() {
+ Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR);
+ },
methods: {
onDismissError() {
this.submitChangesError = null;
diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
new file mode 100644
index 00000000000..f32c693411f
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
@@ -0,0 +1,55 @@
+const parseSourceFile = raw => {
+ const frontMatterRegex = /(^---$[\s\S]*?^---$)/m;
+ const preGroupedRegex = /([\s\S]*?)(^---$[\s\S]*?^---$)(\s*)([\s\S]*)/m; // preFrontMatter, frontMatter, spacing, and content
+ let initial;
+ let editable;
+
+ const hasFrontMatter = source => frontMatterRegex.test(source);
+
+ const buildPayload = (source, header, spacing, body) => {
+ return { raw: source, header, spacing, body };
+ };
+
+ const parse = source => {
+ if (hasFrontMatter(source)) {
+ const match = source.match(preGroupedRegex);
+ const [, preFrontMatter, frontMatter, spacing, content] = match;
+ const header = preFrontMatter + frontMatter;
+
+ return buildPayload(source, header, spacing, content);
+ }
+
+ return buildPayload(source, '', '', source);
+ };
+
+ const computedRaw = () => `${editable.header}${editable.spacing}${editable.body}`;
+
+ const syncBody = () => {
+ /*
+ We re-parse as markdown editing could have added non-body changes (preFrontMatter, frontMatter, or spacing).
+ Re-parsing additionally gets us the desired body that was extracted from the mutated editable.raw
+ Additionally we intentionally mutate the existing editable's key values as opposed to reassigning the object itself so consumers of the potentially reactive property stay in sync.
+ */
+ Object.assign(editable, parse(editable.raw));
+ };
+
+ const syncRaw = () => {
+ editable.raw = computedRaw();
+ };
+
+ const isModifiedRaw = () => initial.raw !== editable.raw;
+ const isModifiedBody = () => initial.raw !== computedRaw();
+
+ initial = parse(raw);
+ editable = parse(raw);
+
+ return {
+ editable,
+ isModifiedRaw,
+ isModifiedBody,
+ syncRaw,
+ syncBody,
+ };
+};
+
+export default parseSourceFile;
diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
index 49135d2141b..fce7c1f918f 100644
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
@@ -10,6 +10,7 @@ import {
SUBMIT_CHANGES_COMMIT_ERROR,
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
TRACKING_ACTION_CREATE_COMMIT,
+ TRACKING_ACTION_CREATE_MERGE_REQUEST,
} from '../constants';
const createBranch = (projectId, branch) =>
@@ -41,8 +42,15 @@ const commitContent = (projectId, message, branch, sourcePath, content) => {
});
};
-const createMergeRequest = (projectId, title, sourceBranch, targetBranch = DEFAULT_TARGET_BRANCH) =>
- Api.createProjectMergeRequest(
+const createMergeRequest = (
+ projectId,
+ title,
+ sourceBranch,
+ targetBranch = DEFAULT_TARGET_BRANCH,
+) => {
+ Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST);
+
+ return Api.createProjectMergeRequest(
projectId,
convertObjectPropsToSnakeCase({
title,
@@ -52,6 +60,7 @@ const createMergeRequest = (projectId, title, sourceBranch, targetBranch = DEFAU
).catch(() => {
throw new Error(SUBMIT_CHANGES_MERGE_REQUEST_ERROR);
});
+};
const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
const branch = generateBranchName(username);
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index 96dfff77859..df00f38dd70 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -21,16 +21,17 @@ export default class UserCallout {
dismissCallout(e) {
const $currentTarget = $(e.currentTarget);
+ const cookieOptions = {};
+ if (!$currentTarget.hasClass('js-close-session')) {
+ cookieOptions.expires = 365;
+ }
if (this.options.setCalloutPerProject) {
- Cookies.set(this.cookieName, 'true', {
- expires: 365,
- path: this.userCalloutBody.data('projectPath'),
- });
- } else {
- Cookies.set(this.cookieName, 'true', { expires: 365 });
+ cookieOptions.path = this.userCalloutBody.data('projectPath');
}
+ Cookies.set(this.cookieName, 'true', cookieOptions);
+
if ($currentTarget.hasClass('close') || $currentTarget.hasClass('js-close')) {
this.userCalloutBody.remove();
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
index 996e54a1183..7d74d5531b4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
@@ -64,7 +64,7 @@ export default {
:title="buttonTitle"
:loading="isLoading"
:disabled="isActionInProgress"
- :class="`btn btn-default btn-sm inline prepend-left-4 ${containerClasses}`"
+ :class="`btn btn-default btn-sm inline gl-ml-2 ${containerClasses}`"
@click="$emit('click')"
>
<span class="d-inline-flex align-items-baseline">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
index 5dabd9fe5fe..bce25ca20ec 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
@@ -75,11 +75,11 @@ export default {
rel="noopener noreferrer nofollow"
class="js-deploy-url-menu-item menu-item"
>
- <strong class="str-truncated-100 append-bottom-0 d-block">
+ <strong class="str-truncated-100 gl-mb-0 d-block">
{{ slotProps.result.path }}
</strong>
- <p class="text-secondary str-truncated-100 append-bottom-0 d-block">
+ <p class="text-secondary str-truncated-100 gl-mb-0 d-block">
{{ slotProps.result.external_url }}
</p>
</gl-link>
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 2433ba879aa..0464c4b9c15 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
@@ -117,7 +117,7 @@ export default {
:href="webIdePath"
:title="ideButtonTitle"
:class="{ disabled: !mr.canPushToSourceBranch }"
- class="btn btn-default js-web-ide d-none d-md-inline-block append-right-8"
+ class="btn btn-default js-web-ide d-none d-md-inline-block gl-mr-3"
data-placement="bottom"
tabindex="0"
role="button"
@@ -129,7 +129,7 @@ export default {
:disabled="mr.sourceBranchRemoved"
data-target="#modal_merge_info"
data-toggle="modal"
- class="btn btn-default js-check-out-branch append-right-8"
+ class="btn btn-default js-check-out-branch gl-mr-3"
type="button"
>
{{ s__('mrWidget|Check out branch') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue
index 8313b8afb1b..2ef5e81b36b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue
@@ -1,16 +1,15 @@
<script>
import { __ } from '~/locale';
-import { GlIcon, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import CiIcon from '../../vue_shared/components/ci_icon.vue';
import flash from '~/flash';
import Poll from '~/lib/utils/poll';
export default {
name: 'MRWidgetTerraformPlan',
components: {
- CiIcon,
GlIcon,
+ GlLink,
GlLoadingIcon,
GlSprintf,
},
@@ -36,17 +35,12 @@ export default {
deleteNum() {
return Number(this.plan.delete);
},
- iconStatusObj() {
- return {
- group: 'warning',
- icon: 'status_warning',
- };
- },
logUrl() {
return this.plan.job_path;
},
plan() {
- return this.plans['tfplan.json'] || {};
+ const firstPlanKey = Object.keys(this.plans)[0];
+ return this.plans[firstPlanKey] ?? {};
},
validPlanValues() {
return this.addNum + this.changeNum + this.deleteNum >= 0;
@@ -90,7 +84,7 @@ export default {
<section class="mr-widget-section">
<div class="mr-widget-body media d-flex flex-row">
<span class="append-right-default align-self-start align-self-lg-center">
- <ci-icon :status="iconStatusObj" :size="24" />
+ <gl-icon name="status_warning" :size="24" />
</span>
<div class="d-flex flex-fill flex-column flex-md-row">
@@ -125,7 +119,7 @@ export default {
</div>
<div class="terraform-mr-plan-actions">
- <a
+ <gl-link
v-if="logUrl"
:href="logUrl"
target="_blank"
@@ -137,7 +131,7 @@ export default {
>
{{ __('View full log') }}
<gl-icon name="external-link" />
- </a>
+ </gl-link>
</div>
</div>
</div>
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 6aad2a26a53..a0e76b151f7 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
@@ -14,7 +14,7 @@ export default {
</script>
<template>
- <p v-once class="mr-info-list mr-links append-bottom-0">
+ <p v-once class="mr-info-list mr-links gl-mb-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/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index b1fb377e47a..c3cc30a1a6f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -127,7 +127,7 @@ export default {
</button>
<span v-if="!rebasingError" class="bold">{{
__(
- 'Fast-forward merge is not possible. Rebase the source branch onto the target branch or merge target branch into source branch to allow this merge request to be merged.',
+ 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
)
}}</span>
<span v-else class="bold danger">{{ rebasingError }}</span>
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 60e41a16854..7431b7e9ed4 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -1,7 +1,7 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
-import { getCommitIconMap } from '~/ide/utils';
+import getCommitIconMap from '~/ide/commit_icon';
import { __ } from '~/locale';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
index e80cb06edfb..47231c4ad39 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -52,7 +52,7 @@ export default {
:download="fileName"
target="_blank"
>
- <icon :size="16" name="download" class="float-left append-right-8" />
+ <icon :size="16" name="download" class="float-left gl-mr-3" />
{{ __('Download') }}
</gl-link>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
index 07748482204..ddbb474bab6 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -1,20 +1,17 @@
<script>
-import { GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
+import { GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
-import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
defaultTimeRanges,
defaultTimeRange,
- isValidDate,
- stringToISODate,
- ISODateToString,
- truncateZerosInDateTime,
- isDateTimePickerInputValid,
+ isValidInputString,
+ inputStringToIsoDate,
+ isoDateToInputString,
} from './date_time_picker_lib';
const events = {
@@ -24,13 +21,13 @@ const events = {
export default {
components: {
- Icon,
- TooltipOnTruncate,
- DateTimePickerInput,
- GlFormGroup,
+ GlIcon,
GlDeprecatedButton,
GlDropdown,
GlDropdownItem,
+ GlFormGroup,
+ TooltipOnTruncate,
+ DateTimePickerInput,
},
props: {
value: {
@@ -48,20 +45,41 @@ export default {
required: false,
default: true,
},
+ utc: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
timeRange: this.value,
- startDate: '',
- endDate: '',
+
+ /**
+ * Valid start iso date string, null if not valid value
+ */
+ startDate: null,
+ /**
+ * Invalid start date string as input by the user
+ */
+ startFallbackVal: '',
+
+ /**
+ * Valid end iso date string, null if not valid value
+ */
+ endDate: null,
+ /**
+ * Invalid end date string as input by the user
+ */
+ endFallbackVal: '',
};
},
computed: {
startInputValid() {
- return isValidDate(this.startDate);
+ return isValidInputString(this.startDate);
},
endInputValid() {
- return isValidDate(this.endDate);
+ return isValidInputString(this.endDate);
},
isValid() {
return this.startInputValid && this.endInputValid;
@@ -69,21 +87,31 @@ export default {
startInput: {
get() {
- return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
+ return this.dateToInput(this.startDate) || this.startFallbackVal;
},
set(val) {
- // Attempt to set a formatted date if possible
- this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
+ try {
+ this.startDate = this.inputToDate(val);
+ this.startFallbackVal = null;
+ } catch (e) {
+ this.startDate = null;
+ this.startFallbackVal = val;
+ }
this.timeRange = null;
},
},
endInput: {
get() {
- return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
+ return this.dateToInput(this.endDate) || this.endFallbackVal;
},
set(val) {
- // Attempt to set a formatted date if possible
- this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
+ try {
+ this.endDate = this.inputToDate(val);
+ this.endFallbackVal = null;
+ } catch (e) {
+ this.endDate = null;
+ this.endFallbackVal = val;
+ }
this.timeRange = null;
},
},
@@ -96,10 +124,10 @@ export default {
}
const { start, end } = convertToFixedRange(this.value);
- if (isValidDate(start) && isValidDate(end)) {
+ if (isValidInputString(start) && isValidInputString(end)) {
return sprintf(__('%{start} to %{end}'), {
- start: this.formatDate(start),
- end: this.formatDate(end),
+ start: this.stripZerosInDateTime(this.dateToInput(start)),
+ end: this.stripZerosInDateTime(this.dateToInput(end)),
});
}
} catch {
@@ -107,6 +135,13 @@ export default {
}
return '';
},
+
+ customLabel() {
+ if (this.utc) {
+ return __('Custom range (UTC)');
+ }
+ return __('Custom range');
+ },
},
watch: {
value(newValue) {
@@ -132,8 +167,17 @@ export default {
}
},
methods: {
- formatDate(date) {
- return truncateZerosInDateTime(ISODateToString(date));
+ dateToInput(date) {
+ if (date === null) {
+ return null;
+ }
+ return isoDateToInputString(date, this.utc);
+ },
+ inputToDate(value) {
+ return inputStringToIsoDate(value, this.utc);
+ },
+ stripZerosInDateTime(str = '') {
+ return str.replace(' 00:00:00', '');
},
closeDropdown() {
this.$refs.dropdown.hide();
@@ -169,10 +213,16 @@ export default {
menu-class="date-time-picker-menu"
toggle-class="date-time-picker-toggle text-truncate"
>
+ <template #button-content>
+ <span class="gl-flex-grow-1 text-truncate">{{ timeWindowText }}</span>
+ <span v-if="utc" class="text-muted gl-font-weight-bold gl-font-sm">{{ __('UTC') }}</span>
+ <gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" />
+ </template>
+
<div class="d-flex justify-content-between gl-p-2-deprecated-no-really-do-not-use-me">
<gl-form-group
v-if="customEnabled"
- :label="__('Custom range')"
+ :label="customLabel"
label-for="custom-from-time"
label-class="gl-pb-1-deprecated-no-really-do-not-use-me"
class="custom-time-range-form-group col-md-7 gl-pl-1-deprecated-no-really-do-not-use-me gl-pr-0 m-0"
@@ -214,7 +264,7 @@ export default {
active-class="active"
@click="setQuickRange(option)"
>
- <icon
+ <gl-icon
name="mobile-issue-close"
class="align-bottom"
:class="{ invisible: !isOptionActive(option) }"
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue
index f19f8bd46b3..32a24844d71 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue
@@ -6,9 +6,9 @@ import { dateFormats } from './date_time_picker_lib';
const inputGroupText = {
invalidFeedback: sprintf(__('Format: %{dateFormat}'), {
- dateFormat: dateFormats.stringDate,
+ dateFormat: dateFormats.inputFormat,
}),
- placeholder: dateFormats.stringDate,
+ placeholder: dateFormats.inputFormat,
};
export default {
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
index 673d981cf07..40708453d79 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
@@ -2,12 +2,6 @@ import dateformat from 'dateformat';
import { __ } from '~/locale';
/**
- * Valid strings for this regex are
- * 2019-10-01 and 2019-10-01 01:02:03
- */
-const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/;
-
-/**
* Default time ranges for the date picker.
* @see app/assets/javascripts/lib/utils/datetime_range.js
*/
@@ -34,23 +28,33 @@ export const defaultTimeRanges = [
export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default);
export const dateFormats = {
- ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
- stringDate: 'yyyy-mm-dd HH:MM:ss',
+ /**
+ * Format used by users to input dates
+ *
+ * Note: Should be a format that can be parsed by Date.parse.
+ */
+ inputFormat: 'yyyy-mm-dd HH:MM:ss',
+ /**
+ * Format used to strip timezone from inputs
+ */
+ stripTimezoneFormat: "yyyy-mm-dd'T'HH:MM:ss'Z'",
};
/**
- * The URL params start and end need to be validated
- * before passing them down to other components.
+ * Returns true if the date can be parsed succesfully after
+ * being typed by a user.
*
- * @param {string} dateString
- * @returns true if the string is a valid date, false otherwise
+ * It allows some ambiguity so validation is not strict.
+ *
+ * @param {string} value - Value as typed by the user
+ * @returns true if the value can be parsed as a valid date, false otherwise
*/
-export const isValidDate = dateString => {
+export const isValidInputString = value => {
try {
// dateformat throws error that can be caught.
// This is better than using `new Date()`
- if (dateString && dateString.trim()) {
- dateformat(dateString, 'isoDateTime');
+ if (value && value.trim()) {
+ dateformat(value, 'isoDateTime');
return true;
}
return false;
@@ -60,25 +64,30 @@ export const isValidDate = dateString => {
};
/**
- * Convert the input in Time picker component to ISO date.
+ * Convert the input in time picker component to an ISO date.
*
- * @param {string} val
- * @returns {string}
+ * @param {string} value
+ * @param {Boolean} utc - If true, it forces the date to by
+ * formatted using UTC format, ignoring the local time.
+ * @returns {Date}
*/
-export const stringToISODate = val =>
- dateformat(new Date(val.replace(/-/g, '/')), dateFormats.ISODate, true);
+export const inputStringToIsoDate = (value, utc = false) => {
+ let date = new Date(value);
+ if (utc) {
+ // Forces date to be interpreted as UTC by stripping the timezone
+ // by formatting to a string with 'Z' and skipping timezone
+ date = dateformat(date, dateFormats.stripTimezoneFormat);
+ }
+ return dateformat(date, 'isoUtcDateTime');
+};
/**
- * Convert the ISO date received from the URL to string
- * for the Time picker component.
+ * Converts a iso date string to a formatted string for the Time picker component.
*
- * @param {Date} date
+ * @param {String} ISO Formatted date
* @returns {string}
*/
-export const ISODateToString = date => dateformat(date, dateFormats.stringDate);
-
-export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', '');
-
-export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val);
+export const isoDateToInputString = (date, utc = false) =>
+ dateformat(date, dateFormats.inputFormat, utc);
export default {};
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 bf3c3666300..a2fe19f9672 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
@@ -7,6 +7,10 @@ import ModeChanged from './viewers/mode_changed.vue';
export default {
props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
diffMode: {
type: String,
required: true,
@@ -92,6 +96,7 @@ export default {
<div v-if="viewer" class="diff-file preview-container">
<component
:is="viewer"
+ :diff-file="diffFile"
:diff-mode="diffMode"
:new-path="fullNewPath"
:old-path="fullOldPath"
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index 5c1ea59b471..eba6dd4d14c 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -1,3 +1,108 @@
+<script>
+import { mapActions } from 'vuex';
+import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import {
+ TRANSITION_LOAD_START,
+ TRANSITION_LOAD_ERROR,
+ TRANSITION_LOAD_SUCCEED,
+ TRANSITION_ACKNOWLEDGE_ERROR,
+ STATE_IDLING,
+ STATE_LOADING,
+ STATE_ERRORED,
+ RENAMED_DIFF_TRANSITIONS,
+} from '~/diffs/constants';
+import { truncateSha } from '~/lib/utils/text_utility';
+
+export default {
+ STATE_LOADING,
+ STATE_ERRORED,
+ TRANSITIONS: RENAMED_DIFF_TRANSITIONS,
+ uiText: {
+ showLink: __('Show file contents'),
+ commitLink: __('View file @ %{commitSha}'),
+ description: __('File renamed with no changes.'),
+ loadError: __('Unable to load file contents. Try again later.'),
+ },
+ components: {
+ GlAlert,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ },
+ props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ },
+ data: () => ({
+ state: STATE_IDLING,
+ }),
+ computed: {
+ shortSha() {
+ return truncateSha(this.diffFile.content_sha);
+ },
+ canLoadFullDiff() {
+ return this.diffFile.alternate_viewer.name === 'text';
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['switchToFullDiffFromRenamedFile']),
+ transition(transitionEvent) {
+ const key = `${this.state}:${transitionEvent}`;
+
+ if (this.$options.TRANSITIONS[key]) {
+ this.state = this.$options.TRANSITIONS[key];
+ }
+ },
+ is(state) {
+ return this.state === state;
+ },
+ switchToFull() {
+ this.transition(TRANSITION_LOAD_START);
+
+ this.switchToFullDiffFromRenamedFile({ diffFile: this.diffFile })
+ .then(() => {
+ this.transition(TRANSITION_LOAD_SUCCEED);
+ })
+ .catch(() => {
+ this.transition(TRANSITION_LOAD_ERROR);
+ });
+ },
+ clickLink(event) {
+ if (this.canLoadFullDiff) {
+ event.preventDefault();
+
+ this.switchToFull();
+ }
+ },
+ dismissError() {
+ this.transition(TRANSITION_ACKNOWLEDGE_ERROR);
+ },
+ },
+};
+</script>
+
<template>
- <div class="nothing-here-block">{{ __('File moved') }}</div>
+ <div class="nothing-here-block">
+ <gl-loading-icon v-if="is($options.STATE_LOADING)" />
+ <template v-else>
+ <gl-alert
+ v-show="is($options.STATE_ERRORED)"
+ class="gl-mb-5 gl-text-left"
+ variant="danger"
+ @dismiss="dismissError"
+ >{{ $options.uiText.loadError }}</gl-alert
+ >
+ <span test-id="plaintext">{{ $options.uiText.description }}</span>
+ <gl-link :href="diffFile.view_path" @click="clickLink">
+ <span v-if="canLoadFullDiff">{{ $options.uiText.showLink }}</span>
+ <gl-sprintf v-else :message="$options.uiText.commitLink">
+ <template #commitSha>{{ shortSha }}</template>
+ </gl-sprintf>
+ </gl-link>
+ </template>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index b57455adaad..9f6f3d2d63a 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -261,7 +261,7 @@ export default {
</li>
</template>
<li v-else class="dropdown-menu-empty-item">
- <div class="append-right-default prepend-left-default prepend-top-8 append-bottom-8">
+ <div class="append-right-default prepend-left-default gl-mt-3 gl-mb-3">
<template v-if="loading">
{{ __('Loading...') }}
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 018e3a84c39..590501a975a 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -75,12 +75,8 @@ export default {
@mouseover="mouseOverRow"
@mousemove="mouseMove"
>
- <file-icon
- :file-name="file.name"
- :size="16"
- css-classes="diff-file-changed-icon append-right-8"
- />
- <span class="diff-changed-file-content append-right-8">
+ <file-icon :file-name="file.name" :size="16" css-classes="diff-file-changed-icon gl-mr-3" />
+ <span class="diff-changed-file-content gl-mr-3">
<strong class="diff-changed-file-name">
<span
v-for="(char, charIndex) in file.name.split('')"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
new file mode 100644
index 00000000000..6665a5754b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -0,0 +1,8 @@
+export const ANY_AUTHOR = 'Any';
+
+export const DEBOUNCE_DELAY = 200;
+
+export const SortDirection = {
+ descending: 'descending',
+ ascending: 'ascending',
+};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
new file mode 100644
index 00000000000..a858ffdbed5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -0,0 +1,253 @@
+<script>
+import {
+ GlFilteredSearch,
+ GlButtonGroup,
+ GlButton,
+ GlNewDropdown as GlDropdown,
+ GlNewDropdownItem as GlDropdownItem,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import createFlash from '~/flash';
+
+import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
+import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
+
+import { SortDirection } from './constants';
+
+export default {
+ components: {
+ GlFilteredSearch,
+ GlButtonGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ namespace: {
+ type: String,
+ required: true,
+ },
+ recentSearchesStorageKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tokens: {
+ type: Array,
+ required: true,
+ },
+ sortOptions: {
+ type: Array,
+ required: true,
+ },
+ initialFilterValue: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ initialSortBy: {
+ type: String,
+ required: false,
+ default: '',
+ validator: value => value === '' || /(_desc)|(_asc)/g.test(value),
+ },
+ searchInputPlaceholder: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ let selectedSortOption = this.sortOptions[0].sortDirection.descending;
+ let selectedSortDirection = SortDirection.descending;
+
+ // Extract correct sortBy value based on initialSortBy
+ if (this.initialSortBy) {
+ selectedSortOption = this.sortOptions
+ .filter(
+ sortBy =>
+ sortBy.sortDirection.ascending === this.initialSortBy ||
+ sortBy.sortDirection.descending === this.initialSortBy,
+ )
+ .pop();
+ selectedSortDirection = this.initialSortBy.endsWith('_desc')
+ ? SortDirection.descending
+ : SortDirection.ascending;
+ }
+
+ return {
+ initialRender: true,
+ recentSearchesPromise: null,
+ filterValue: this.initialFilterValue,
+ selectedSortOption,
+ selectedSortDirection,
+ };
+ },
+ computed: {
+ tokenSymbols() {
+ return this.tokens.reduce(
+ (tokenSymbols, token) => ({
+ ...tokenSymbols,
+ [token.type]: token.symbol,
+ }),
+ {},
+ );
+ },
+ sortDirectionIcon() {
+ return this.selectedSortDirection === SortDirection.ascending
+ ? 'sort-lowest'
+ : 'sort-highest';
+ },
+ sortDirectionTooltip() {
+ return this.selectedSortDirection === SortDirection.ascending
+ ? __('Sort direction: Ascending')
+ : __('Sort direction: Descending');
+ },
+ },
+ watch: {
+ /**
+ * GlFilteredSearch currently doesn't emit any event when
+ * search field is cleared, but we still want our parent
+ * component to know that filters were cleared and do
+ * necessary data refetch, so this watcher is basically
+ * a dirty hack/workaround to identify if filter input
+ * was cleared. :(
+ */
+ filterValue(value) {
+ const [firstVal] = value;
+ if (
+ !this.initialRender &&
+ value.length === 1 &&
+ firstVal.type === 'filtered-search-term' &&
+ !firstVal.value.data
+ ) {
+ this.$emit('onFilter', []);
+ }
+
+ // Set initial render flag to false
+ // as we don't want to emit event
+ // on initial load when value is empty already.
+ this.initialRender = false;
+ },
+ },
+ created() {
+ if (this.recentSearchesStorageKey) this.setupRecentSearch();
+ },
+ methods: {
+ /**
+ * Initialize service and store instances for
+ * getting Recent Search functional.
+ */
+ setupRecentSearch() {
+ this.recentSearchesService = new RecentSearchesService(
+ `${this.namespace}-${RecentSearchesStorageKeys[this.recentSearchesStorageKey]}`,
+ );
+
+ this.recentSearchesStore = new RecentSearchesStore({
+ isLocalStorageAvailable: RecentSearchesService.isAvailable(),
+ allowedKeys: this.tokens.map(token => token.type),
+ });
+
+ this.recentSearchesPromise = this.recentSearchesService
+ .fetch()
+ .catch(error => {
+ if (error.name === 'RecentSearchesServiceError') return undefined;
+
+ createFlash(__('An error occurred while parsing recent searches'));
+
+ // Gracefully fail to empty array
+ return [];
+ })
+ .then(searches => {
+ if (!searches) return;
+
+ // Put any searches that may have come in before
+ // we fetched the saved searches ahead of the already saved ones
+ const resultantSearches = this.recentSearchesStore.setRecentSearches(
+ this.recentSearchesStore.state.recentSearches.concat(searches),
+ );
+ this.recentSearchesService.save(resultantSearches);
+ });
+ },
+ getRecentSearches() {
+ return this.recentSearchesStore?.state.recentSearches;
+ },
+ handleSortOptionClick(sortBy) {
+ this.selectedSortOption = sortBy;
+ this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
+ },
+ handleSortDirectionClick() {
+ this.selectedSortDirection =
+ this.selectedSortDirection === SortDirection.ascending
+ ? SortDirection.descending
+ : SortDirection.ascending;
+ this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
+ },
+ handleFilterSubmit(filters) {
+ if (this.recentSearchesStorageKey) {
+ this.recentSearchesPromise
+ .then(() => {
+ if (filters.length) {
+ const searchTokens = filters.map(filter => {
+ // check filter was plain text search
+ if (typeof filter === 'string') {
+ return filter;
+ }
+ // filter was a token.
+ return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${
+ filter.value.data
+ }`;
+ });
+
+ const resultantSearches = this.recentSearchesStore.addRecentSearch(
+ searchTokens.join(' '),
+ );
+ this.recentSearchesService.save(resultantSearches);
+ }
+ })
+ .catch(() => {
+ // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
+ });
+ }
+ this.$emit('onFilter', filters);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="vue-filtered-search-bar-container d-md-flex">
+ <gl-filtered-search
+ v-model="filterValue"
+ :placeholder="searchInputPlaceholder"
+ :available-tokens="tokens"
+ :history-items="getRecentSearches()"
+ class="flex-grow-1"
+ @submit="handleFilterSubmit"
+ />
+ <gl-button-group class="sort-dropdown-container d-flex">
+ <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
+ <gl-dropdown-item
+ v-for="sortBy in sortOptions"
+ :key="sortBy.id"
+ :is-check-item="true"
+ :is-checked="sortBy.id === selectedSortOption.id"
+ @click="handleSortOptionClick(sortBy)"
+ >{{ sortBy.title }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ <gl-button
+ v-gl-tooltip
+ :title="sortDirectionTooltip"
+ :icon="sortDirectionIcon"
+ class="flex-shrink-1"
+ @click="handleSortDirectionClick"
+ />
+ </gl-button-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
new file mode 100644
index 00000000000..412bfa5aa7f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -0,0 +1,114 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants';
+
+export default {
+ anyAuthor: ANY_AUTHOR,
+ components: {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ authors: this.config.initialAuthors || [],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeAuthor() {
+ return this.authors.find(author => author.username.toLowerCase() === this.currentValue);
+ },
+ },
+ methods: {
+ fetchAuthorBySearchTerm(searchTerm) {
+ const fetchPromise = this.config.fetchPath
+ ? this.config.fetchAuthors(this.config.fetchPath, searchTerm)
+ : this.config.fetchAuthors(searchTerm);
+
+ fetchPromise
+ .then(res => {
+ // We'd want to avoid doing this check but
+ // users.json and /groups/:id/members & /projects/:id/users
+ // return response differently.
+ this.authors = Array.isArray(res) ? res : res.data;
+ })
+ .catch(() => createFlash(__('There was a problem fetching users.')))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchAuthors: debounce(function debouncedSearch({ data }) {
+ this.fetchAuthorBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchAuthors"
+ >
+ <template #view="{ inputValue }">
+ <gl-avatar
+ v-if="activeAuthor"
+ :size="16"
+ :src="activeAuthor.avatar_url"
+ shape="circle"
+ class="gl-mr-2"
+ />
+ <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion :value="$options.anyAuthor">{{
+ __('Any')
+ }}</gl-filtered-search-suggestion>
+ <gl-dropdown-divider />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="author in authors"
+ :key="author.username"
+ :value="author.username"
+ >
+ <div class="d-flex">
+ <gl-avatar :size="32" :src="author.avatar_url" />
+ <div>
+ <div>{{ author.name }}</div>
+ <div>@{{ author.username }}</div>
+ </div>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index 508f43afe61..a7fba5e760b 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -1,6 +1,5 @@
<script>
-import escape from 'lodash/escape';
-import sanitize from 'sanitize-html';
+import { escape } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
@@ -11,11 +10,11 @@ import { spriteIcon } from '~/lib/utils/common_utils';
* @param original An object from the array returned from the `autocomplete_sources/members` API
* @returns {string} An HTML template
*/
-function createMenuItemTemplate({ original }) {
+function menuItemTemplate({ original }) {
const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
- align-items-center d-inline-flex justify-content-center`;
+ gl-display-inline-flex gl-align-items-center gl-justify-content-center`;
const avatarTag = original.avatar_url
? `<img
@@ -24,42 +23,20 @@ function createMenuItemTemplate({ original }) {
class="${avatarClasses}"/>`
: `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
- const name = escape(sanitize(original.name));
+ const name = escape(original.name);
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
const icon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5')
+ ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
: '';
return `${avatarTag}
${original.username}
- <small class="small font-weight-normal gl-reset-color">${name}${count}</small>
+ <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
${icon}`;
}
-/**
- * Creates the list of users to show in the mentions dropdown.
- *
- * @param inputText The text entered by the user in the mentions input field
- * @param processValues Callback function to set the list of users to show in the mentions dropdown
- */
-function getMembers(inputText, processValues) {
- if (this.members) {
- processValues(this.members);
- } else if (this.dataSources.members) {
- axios
- .get(this.dataSources.members)
- .then(response => {
- this.members = response.data;
- processValues(response.data);
- })
- .catch(() => {});
- } else {
- processValues([]);
- }
-}
-
export default {
name: 'GlMentions',
props: {
@@ -72,30 +49,49 @@ export default {
data() {
return {
members: undefined,
- options: {
- trigger: '@',
- fillAttr: 'username',
- lookup(value) {
- return value.name + value.username;
- },
- menuItemTemplate: createMenuItemTemplate.bind(this),
- values: getMembers.bind(this),
- },
};
},
mounted() {
+ this.tribute = new Tribute({
+ trigger: '@',
+ fillAttr: 'username',
+ lookup: value => value.name + value.username,
+ menuItemTemplate,
+ values: this.getMembers,
+ });
+
const input = this.$slots.default[0].elm;
- this.tribute = new Tribute(this.options);
this.tribute.attach(input);
},
beforeDestroy() {
const input = this.$slots.default[0].elm;
- if (this.tribute) {
- this.tribute.detach(input);
- }
+ this.tribute.detach(input);
+ },
+ methods: {
+ /**
+ * Creates the list of users to show in the mentions dropdown.
+ *
+ * @param inputText - The text entered by the user in the mentions input field
+ * @param processValues - Callback function to set the list of users to show in the mentions dropdown
+ */
+ getMembers(inputText, processValues) {
+ if (this.members) {
+ processValues(this.members);
+ } else if (this.dataSources.members) {
+ axios
+ .get(this.dataSources.members)
+ .then(response => {
+ this.members = response.data;
+ processValues(response.data);
+ })
+ .catch(() => {});
+ } else {
+ processValues([]);
+ }
+ },
},
- render(h) {
- return h('div', this.$slots.default);
+ render(createElement) {
+ return createElement('div', this.$slots.default);
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index 4f1b1c758b2..63de1e009fd 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -85,7 +85,7 @@ export default {
class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
/>
- <a :href="computedPath" class="sortable-link">{{ title }}</a>
+ <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a>
</div>
<!-- Info area: meta, path, and assignees -->
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 8007ccb91d5..0e05f4a4622 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -134,7 +134,7 @@ export default {
addMultipleToDiscussionWarning() {
return sprintf(
__(
- '%{icon}You are about to add %{usersTag} people to the discussion. Proceed with caution.',
+ '%{icon}You are about to add %{usersTag} people to the discussion. They will all receive a notification.',
),
{
icon: '<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>',
@@ -245,11 +245,11 @@ export default {
<div class="zen-backdrop">
<slot name="textarea"></slot>
<a
- class="zen-control zen-control-leave js-zen-leave"
+ class="zen-control zen-control-leave js-zen-leave gl-text-gray-700"
href="#"
- :aria-label="__('Enter zen mode')"
+ :aria-label="__('Leave zen mode')"
>
- <icon :size="32" name="screen-normal" />
+ <icon :size="16" name="screen-normal" />
</a>
<markdown-toolbar
:markdown-docs-path="markdownDocsPath"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 665637f3b9e..aa1abb5adb6 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -158,7 +158,7 @@ export default {
<div class="d-inline-block ml-md-2 ml-0">
<toolbar-button
:prepend="true"
- tag="* "
+ tag="- "
:button-title="__('Add a bullet list')"
icon="list-bulleted"
/>
@@ -170,7 +170,7 @@ export default {
/>
<toolbar-button
:prepend="true"
- tag="* [ ] "
+ tag="- [ ] "
:button-title="__('Add a task list')"
icon="list-task"
/>
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 a7cd292e01d..6dac448d5de 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -13,6 +13,11 @@ export default {
type: Object,
required: true,
},
+ batchSuggestionsInfo: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
disabled: {
type: Boolean,
required: false,
@@ -24,6 +29,14 @@ export default {
},
},
computed: {
+ batchSuggestionsCount() {
+ return this.batchSuggestionsInfo.length;
+ },
+ isBatched() {
+ return Boolean(
+ this.batchSuggestionsInfo.find(({ suggestionId }) => suggestionId === this.suggestion.id),
+ );
+ },
lines() {
return selectDiffLines(this.suggestion.diff_lines);
},
@@ -32,6 +45,15 @@ export default {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
},
+ applySuggestionBatch() {
+ this.$emit('applyBatch');
+ },
+ addSuggestionToBatch() {
+ this.$emit('addToBatch', this.suggestion.id);
+ },
+ removeSuggestionFromBatch() {
+ this.$emit('removeFromBatch', this.suggestion.id);
+ },
},
};
</script>
@@ -42,8 +64,14 @@ export default {
class="qa-suggestion-diff-header js-suggestion-diff-header"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
+ :is-batched="isBatched"
+ :is-applying-batch="suggestion.is_applying_batch"
+ :batch-suggestions-count="batchSuggestionsCount"
:help-page-path="helpPagePath"
@apply="applySuggestion"
+ @applyBatch="applySuggestionBatch"
+ @addToBatch="addSuggestionToBatch"
+ @removeFromBatch="removeSuggestionFromBatch"
/>
<table class="mb-3 md-suggestion-diff js-syntax-highlight code">
<tbody>
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 af438ce5619..e26ff51e01e 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,11 +1,19 @@
<script>
import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: { Icon, GlDeprecatedButton, GlLoadingIcon },
directives: { 'gl-tooltip': GlTooltipDirective },
+ mixins: [glFeatureFlagsMixin()],
props: {
+ batchSuggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
canApply: {
type: Boolean,
required: false,
@@ -16,6 +24,16 @@ export default {
required: true,
default: false,
},
+ isBatched: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isApplyingBatch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
helpPagePath: {
type: String,
required: true,
@@ -23,17 +41,54 @@ export default {
},
data() {
return {
- isApplying: false,
+ isApplyingSingle: false,
};
},
+ computed: {
+ canBeBatched() {
+ return Boolean(this.glFeatures.batchSuggestions);
+ },
+ isApplying() {
+ return this.isApplyingSingle || this.isApplyingBatch;
+ },
+ tooltipMessage() {
+ return this.canApply
+ ? __('This also resolves the discussion')
+ : __("Can't apply as this line has changed or the suggestion already matches its content.");
+ },
+ tooltipMessageBatch() {
+ return !this.canBeBatched
+ ? __("Suggestions that change line count can't be added to batches, yet.")
+ : this.tooltipMessage;
+ },
+ isDisableButton() {
+ return this.isApplying || !this.canApply;
+ },
+ applyingSuggestionsMessage() {
+ if (this.isApplyingSingle || this.batchSuggestionsCount < 2) {
+ return __('Applying suggestion...');
+ }
+ return __('Applying suggestions...');
+ },
+ },
methods: {
applySuggestion() {
if (!this.canApply) return;
- this.isApplying = true;
+ this.isApplyingSingle = true;
this.$emit('apply', this.applySuggestionCallback);
},
applySuggestionCallback() {
- this.isApplying = false;
+ this.isApplyingSingle = false;
+ },
+ applySuggestionBatch() {
+ if (!this.canApply) return;
+ this.$emit('applyBatch');
+ },
+ addSuggestionToBatch() {
+ this.$emit('addToBatch');
+ },
+ removeSuggestionFromBatch() {
+ this.$emit('removeFromBatch');
},
},
};
@@ -47,20 +102,52 @@ export default {
<icon name="question-o" css-classes="link-highlight" />
</a>
</div>
- <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
- <div v-if="isApplying" class="d-flex align-items-center text-secondary">
+ <div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div>
+ <div v-else-if="isApplying" class="d-flex align-items-center text-secondary">
<gl-loading-icon class="d-flex-center mr-2" />
- <span>{{ __('Applying suggestion') }}</span>
+ <span>{{ applyingSuggestionsMessage }}</span>
+ </div>
+ <div v-else-if="canApply && canBeBatched && isBatched" class="d-flex align-items-center">
+ <gl-deprecated-button
+ class="btn-inverted js-remove-from-batch-btn btn-grouped"
+ :disabled="isApplying"
+ @click="removeSuggestionFromBatch"
+ >
+ {{ __('Remove from batch') }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ v-gl-tooltip.viewport="__('This also resolves all related threads')"
+ class="btn-inverted js-apply-batch-btn btn-grouped"
+ :disabled="isApplying"
+ variant="success"
+ @click="applySuggestionBatch"
+ >
+ {{ __('Apply suggestions') }}
+ <span class="badge badge-pill badge-pill-success">
+ {{ batchSuggestionsCount }}
+ </span>
+ </gl-deprecated-button>
+ </div>
+ <div v-else class="d-flex align-items-center">
+ <span v-if="canBeBatched" v-gl-tooltip.viewport="tooltipMessageBatch" tabindex="0">
+ <gl-deprecated-button
+ class="btn-inverted js-add-to-batch-btn btn-grouped"
+ :disabled="isDisableButton"
+ @click="addSuggestionToBatch"
+ >
+ {{ __('Add suggestion to batch') }}
+ </gl-deprecated-button>
+ </span>
+ <span v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
+ <gl-deprecated-button
+ class="btn-inverted js-apply-btn btn-grouped"
+ :disabled="isDisableButton"
+ variant="success"
+ @click="applySuggestion"
+ >
+ {{ __('Apply suggestion') }}
+ </gl-deprecated-button>
+ </span>
</div>
- <gl-deprecated-button
- v-else-if="canApply"
- v-gl-tooltip.viewport="__('This also resolves the discussion')"
- class="btn-inverted js-apply-btn"
- :disabled="isApplying"
- variant="success"
- @click="applySuggestion"
- >
- {{ __('Apply suggestion') }}
- </gl-deprecated-button>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 20a14d78f9b..9527c5114f2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -16,6 +16,11 @@ export default {
required: false,
default: () => [],
},
+ batchSuggestionsInfo: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
noteHtml: {
type: String,
required: true,
@@ -68,18 +73,30 @@ export default {
this.isRendered = true;
},
generateDiff(suggestionIndex) {
- const { suggestions, disabled, helpPagePath } = this;
+ const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
- propsData: { disabled, suggestion, helpPagePath },
+ propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
});
+ suggestionDiff.$on('applyBatch', () => {
+ this.$emit('applyBatch', { flashContainer: this.$el });
+ });
+
+ suggestionDiff.$on('addToBatch', suggestionId => {
+ this.$emit('addToBatch', suggestionId);
+ });
+
+ suggestionDiff.$on('removeFromBatch', suggestionId => {
+ this.$emit('removeFromBatch', suggestionId);
+ });
+
return suggestionDiff;
},
reset() {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 486d4f6b609..330785c9319 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,11 +1,13 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
export default {
components: {
+ GlButton,
GlLink,
GlLoadingIcon,
+ GlSprintf,
+ GlIcon,
},
props: {
markdownDocsPath: {
@@ -35,45 +37,69 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
+ <gl-link :href="markdownDocsPath" target="_blank">{{
__('Markdown is supported')
}}</gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
- __('Markdown')
- }}</gl-link>
- and
- <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">{{
- __('quick actions')
- }}</gl-link>
- are supported
+ <gl-sprintf
+ :message="
+ __(
+ '%{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd} and %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd} are supported',
+ )
+ "
+ >
+ <template #markdownDocsLink="{content}">
+ <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #quickActionsDocsLink="{content}">
+ <gl-link :href="quickActionsDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
+ <template>
+ <gl-icon name="media" :size="16" />
+ </template>
<span class="attaching-file-message"></span>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<span class="uploading-progress">0%</span>
<gl-loading-icon inline class="align-text-bottom" />
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
+ <template>
+ <gl-icon name="media" :size="16" />
+ </template>
</span>
<span class="uploading-error-message"></span>
- <button class="retry-uploading-link" type="button">{{ __('Try again') }}</button> or
- <button class="attach-new-file markdown-selector" type="button">
- {{ __('attach a new file') }}
- </button>
+
+ <gl-sprintf
+ :message="
+ __(
+ '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}',
+ )
+ "
+ >
+ <template #retryButton="{content}">
+ <button class="retry-uploading-link" type="button">{{ content }}</button>
+ </template>
+ <template #newFileButton="{content}">
+ <button class="attach-new-file markdown-selector" type="button">{{ content }}</button>
+ </template>
+ </gl-sprintf>
</span>
- <button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i
- ><span class="text-attach-file">{{ __('Attach a file') }}</span>
- </button>
- <button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button">
+ <gl-button class="markdown-selector button-attach-file" variant="link">
+ <template>
+ <gl-icon name="media" :size="16" />
+ </template>
+ <span class="text-attach-file">{{ __('Attach a file') }}</span>
+ </gl-button>
+ <gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link">
{{ __('Cancel') }}
- </button>
+ </gl-button>
</span>
</div>
</template>
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 ec7d7e94e5c..b6271a95008 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -132,7 +132,7 @@ export default {
</pre>
<pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre>
<gl-deprecated-button
- v-if="canDeleteDescriptionVersion"
+ v-if="displayDeleteButton"
ref="deleteDescriptionVersionButton"
v-gl-tooltip
:title="__('Remove description history')"
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
index fd45ac52647..15a5ce85046 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -1,6 +1,7 @@
<script>
import { debounce } from 'lodash';
import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
+import { __, n__, sprintf } from '~/locale';
import ProjectListItem from './project_list_item.vue';
const SEARCH_INPUT_TIMEOUT_MS = 500;
@@ -24,28 +25,23 @@ export default {
},
showNoResultsMessage: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
showMinimumSearchQueryMessage: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
showLoadingIndicator: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
showSearchErrorMessage: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
totalResults: {
type: Number,
- required: false,
- default: 0,
+ required: true,
},
},
data() {
@@ -53,6 +49,20 @@ export default {
searchQuery: '',
};
},
+ computed: {
+ legendText() {
+ const count = this.projectSearchResults.length;
+ const total = this.totalResults;
+
+ if (total > 0) {
+ return sprintf(__('Showing %{count} of %{total} projects'), { count, total });
+ }
+
+ return sprintf(n__('Showing %{count} project', 'Showing %{count} projects', count), {
+ count,
+ });
+ },
+ },
methods: {
projectClicked(project) {
this.$emit('projectClicked', project);
@@ -87,17 +97,23 @@ export default {
:total-items="totalResults"
@bottomReached="bottomReached"
>
- <div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column">
- <project-list-item
- v-for="project in projectSearchResults"
- :key="project.id"
- :selected="isSelected(project)"
- :project="project"
- :matcher="searchQuery"
- class="js-project-list-item"
- @click="projectClicked(project)"
- />
- </div>
+ <template v-if="!showLoadingIndicator" #items>
+ <div 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>
+ </template>
+
+ <template #default>
+ {{ legendText }}
+ </template>
</gl-infinite-scroll>
<div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
{{ __('Sorry, no projects matched your search') }}
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
index 457f1806452..1566c2c784b 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -1,5 +1,9 @@
import { __ } from '~/locale';
-import { generateToolbarItem } from './toolbar_service';
+import { generateToolbarItem } from './editor_service';
+
+export const CUSTOM_EVENTS = {
+ openAddImageModal: 'gl_openAddImageModal',
+};
/* eslint-disable @gitlab/require-i18n-strings */
const TOOLBAR_ITEM_CONFIGS = [
@@ -10,7 +14,6 @@ const TOOLBAR_ITEM_CONFIGS = [
{ isDivider: true },
{ icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') },
{ icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') },
- { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
{ isDivider: true },
{ icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') },
{ icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') },
@@ -20,8 +23,10 @@ const TOOLBAR_ITEM_CONFIGS = [
{ isDivider: true },
{ icon: 'dash', command: 'HR', tooltip: __('Add a line') },
{ icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
+ { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
{ isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
+ { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
];
export const EDITOR_OPTIONS = {
@@ -29,6 +34,7 @@ export const EDITOR_OPTIONS = {
};
export const EDITOR_TYPES = {
+ markdown: 'markdown',
wysiwyg: 'wysiwyg',
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js
new file mode 100644
index 00000000000..278cd50a947
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import ToolbarItem from './toolbar_item.vue';
+
+const buildWrapper = propsData => {
+ const instance = new Vue({
+ render(createElement) {
+ return createElement(ToolbarItem, propsData);
+ },
+ });
+
+ instance.$mount();
+ return instance.$el;
+};
+
+export const generateToolbarItem = config => {
+ const { icon, classes, event, command, tooltip, isDivider } = config;
+
+ if (isDivider) {
+ return 'divider';
+ }
+
+ return {
+ type: 'button',
+ options: {
+ el: buildWrapper({ props: { icon, tooltip }, class: classes }),
+ event,
+ command,
+ },
+ };
+};
+
+export const addCustomEventListener = (editorApi, event, handler) => {
+ editorApi.eventManager.addEventType(event);
+ editorApi.eventManager.listen(event, handler);
+};
+
+export const removeCustomEventListener = (editorApi, event, handler) =>
+ editorApi.eventManager.removeEventHandler(event, handler);
+
+export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
+
+export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
new file mode 100644
index 00000000000..40063065926
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
@@ -0,0 +1,74 @@
+<script>
+import { isSafeURL } from '~/lib/utils/url_utility';
+import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ },
+ data() {
+ return {
+ error: null,
+ imageUrl: null,
+ altText: null,
+ modalTitle: __('Image Details'),
+ okTitle: __('Insert'),
+ urlLabel: __('Image URL'),
+ descriptionLabel: __('Description'),
+ };
+ },
+ methods: {
+ show() {
+ this.error = null;
+ this.imageUrl = null;
+ this.altText = null;
+
+ this.$refs.modal.show();
+ },
+ onOk(event) {
+ if (!this.isValid()) {
+ event.preventDefault();
+ return;
+ }
+
+ const { imageUrl, altText } = this;
+
+ this.$emit('addImage', { imageUrl, altText: altText || __('image') });
+ },
+ isValid() {
+ if (!isSafeURL(this.imageUrl)) {
+ this.error = __('Please provide a valid URL');
+ this.$refs.urlInput.$el.focus();
+ return false;
+ }
+
+ return true;
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="add-image-modal"
+ :title="modalTitle"
+ :ok-title="okTitle"
+ @ok="onOk"
+ >
+ <gl-form-group
+ :label="urlLabel"
+ label-for="url-input"
+ :state="!Boolean(error)"
+ :invalid-feedback="error"
+ >
+ <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
+ </gl-form-group>
+
+ <gl-form-group :label="descriptionLabel" label-for="description-input">
+ <gl-form-input id="description-input" ref="descriptionInput" v-model="altText" />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
index ba3696c8ad1..5c310fc059b 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
@@ -2,7 +2,21 @@
import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
-import { EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE } from './constants';
+import AddImageModal from './modals/add_image_modal.vue';
+import {
+ EDITOR_OPTIONS,
+ EDITOR_TYPES,
+ EDITOR_HEIGHT,
+ EDITOR_PREVIEW_STYLE,
+ CUSTOM_EVENTS,
+} from './constants';
+
+import {
+ addCustomEventListener,
+ removeCustomEventListener,
+ addImage,
+ getMarkdown,
+} from './editor_service';
export default {
components: {
@@ -10,6 +24,7 @@ export default {
import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
toast => toast.Editor,
),
+ AddImageModal,
},
props: {
value: {
@@ -37,29 +52,85 @@ export default {
default: EDITOR_PREVIEW_STYLE,
},
},
+ data() {
+ return {
+ editorApi: null,
+ previousMode: null,
+ };
+ },
computed: {
editorOptions() {
return { ...EDITOR_OPTIONS, ...this.options };
},
+ editorInstance() {
+ return this.$refs.editor;
+ },
+ },
+ watch: {
+ value(newVal) {
+ const isSameMode = this.previousMode === this.editorApi.currentMode;
+ if (!isSameMode) {
+ /*
+ The ToastUI Editor consumes its content via the `initial-value` prop and then internally
+ manages changes. If we desire the `v-model` to work as expected, we need to manually call
+ `setMarkdown`. However, if we do this in each v-model change we'll continually prevent
+ the editor from internally managing changes. Thus we use the `previousMode` flag as
+ confirmation to actually update its internals. This is initially designed so that front
+ matter is excluded from editing in wysiwyg mode, but included in markdown mode.
+ */
+ this.editorInstance.invoke('setMarkdown', newVal);
+ this.previousMode = this.editorApi.currentMode;
+ }
+ },
+ },
+ beforeDestroy() {
+ removeCustomEventListener(
+ this.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ this.onOpenAddImageModal,
+ );
+
+ this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
},
methods: {
onContentChanged() {
- this.$emit('input', this.getMarkdown());
+ this.$emit('input', getMarkdown(this.editorInstance));
+ },
+ onLoad(editorApi) {
+ this.editorApi = editorApi;
+
+ addCustomEventListener(
+ this.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ this.onOpenAddImageModal,
+ );
+
+ this.editorApi.eventManager.listen('changeMode', this.onChangeMode);
+ },
+ onOpenAddImageModal() {
+ this.$refs.addImageModal.show();
+ },
+ onAddImage(image) {
+ addImage(this.editorInstance, image);
},
- getMarkdown() {
- return this.$refs.editor.invoke('getMarkdown');
+ onChangeMode(newMode) {
+ this.$emit('modeChange', newMode);
},
},
};
</script>
<template>
- <toast-editor
- ref="editor"
- :initial-value="value"
- :options="editorOptions"
- :preview-style="previewStyle"
- :initial-edit-type="initialEditType"
- :height="height"
- @change="onContentChanged"
- />
+ <div>
+ <toast-editor
+ ref="editor"
+ :initial-value="value"
+ :options="editorOptions"
+ :preview-style="previewStyle"
+ :initial-edit-type="initialEditType"
+ :height="height"
+ @change="onContentChanged"
+ @load="onLoad"
+ />
+ <add-image-modal ref="addImageModal" @addImage="onAddImage" />
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
index 58aaeef45f2..4271f6053ed 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
@@ -1,20 +1,27 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
icon: {
type: String,
required: true,
},
+ tooltip: {
+ type: String,
+ required: true,
+ },
},
};
</script>
<template>
- <button class="p-0 gl-display-flex toolbar-button">
- <gl-icon class="gl-mx-auto" :name="icon" />
+ <button v-gl-tooltip="{ title: tooltip }" class="p-0 gl-display-flex toolbar-button">
+ <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
deleted file mode 100644
index fff90f3e3fb..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import ToolbarItem from './toolbar_item.vue';
-
-const buildWrapper = propsData => {
- const instance = new Vue({
- render(createElement) {
- return createElement(ToolbarItem, propsData);
- },
- });
-
- instance.$mount();
- return instance.$el;
-};
-
-// eslint-disable-next-line import/prefer-default-export
-export const generateToolbarItem = config => {
- const { icon, classes, event, command, tooltip, isDivider } = config;
-
- if (isDivider) {
- return 'divider';
- }
-
- return {
- type: 'button',
- options: {
- el: buildWrapper({ props: { icon }, class: classes }),
- event,
- command,
- tooltip,
- },
- };
-};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
index ab652c9356a..e94e7d46f85 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
@@ -1,5 +1,6 @@
-// eslint-disable-next-line import/prefer-default-export
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
};
+
+export const LIST_BUFFER_SIZE = 5;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index 1ef2e8b3bed..af16088b6b9 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -3,15 +3,20 @@ import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import LabelItem from './label_item.vue';
+import { LIST_BUFFER_SIZE } from './constants';
+
export default {
+ LIST_BUFFER_SIZE,
components: {
GlLoadingIcon,
GlButton,
GlSearchBoxByType,
GlLink,
+ SmartVirtualList,
LabelItem,
},
data() {
@@ -139,10 +144,18 @@ export default {
<gl-search-box-by-type v-model="searchKey" :autofocus="true" />
</div>
<div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
- <ul class="list-unstyled mb-0">
+ <smart-virtual-list
+ :length="visibleLabels.length"
+ :remain="$options.LIST_BUFFER_SIZE"
+ :size="$options.LIST_BUFFER_SIZE"
+ wclass="list-unstyled mb-0"
+ wtag="ul"
+ class="h-100"
+ >
<li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left">
<label-item
:label="label"
+ :is-label-set="label.set"
:highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)"
/>
@@ -150,7 +163,7 @@ export default {
<li v-show="!visibleLabels.length" class="p-2 text-center">
{{ __('No matching results') }}
</li>
- </ul>
+ </smart-virtual-list>
</div>
<div v-if="isDropdownVariantSidebar" class="dropdown-footer">
<ul class="list-unstyled">
@@ -162,9 +175,9 @@ export default {
>
</li>
<li>
- <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">{{
- footerManageLabelTitle
- }}</gl-link>
+ <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">
+ {{ footerManageLabelTitle }}
+ </gl-link>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
index c95221d71b5..002e741ab96 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
@@ -11,6 +11,10 @@ export default {
type: Object,
required: true,
},
+ isLabelSet: {
+ type: Boolean,
+ required: true,
+ },
highlight: {
type: Boolean,
required: false,
@@ -19,7 +23,7 @@ export default {
},
data() {
return {
- isSet: this.label.set,
+ isSet: this.isLabelSet,
};
},
computed: {
@@ -29,6 +33,16 @@ export default {
};
},
},
+ watch: {
+ /**
+ * This watcher assures that if user used
+ * `Enter` key to set/unset label, changes
+ * are reflected here too.
+ */
+ isLabelSet(value) {
+ this.isSet = value;
+ },
+ },
methods: {
handleClick() {
this.isSet = !this.isSet;
diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue
new file mode 100644
index 00000000000..389d42f0829
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/url_sync.vue
@@ -0,0 +1,25 @@
+<script>
+import { historyPushState } from '~/lib/utils/common_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+
+export default {
+ props: {
+ query: {
+ type: Object,
+ required: true,
+ },
+ },
+ watch: {
+ query: {
+ immediate: true,
+ deep: true,
+ handler(newQuery) {
+ historyPushState(setUrlParams(newQuery, window.location.href, true));
+ },
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
index c93b3d37a63..a740a3fa6b9 100644
--- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
@@ -5,6 +5,7 @@
* Components need to have `scope`, `page` and `requestData`
*/
import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils';
+import { validateParams } from '~/pipelines/utils';
export default {
methods: {
@@ -35,18 +36,7 @@ export default {
},
onChangeWithFilter(params) {
- const { username, ref } = this.requestData;
- const paramsData = params;
-
- if (username) {
- paramsData.username = username;
- }
-
- if (ref) {
- paramsData.ref = ref;
- }
-
- return paramsData;
+ return { ...params, ...validateParams(this.requestData) };
},
updateInternalState(parameters) {
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index 4fad34d22d8..c628a67f7f5 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -144,7 +144,9 @@ const mixins = {
return 'merge-request-status closed issue-token-state-icon-closed';
}
- return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
+ return this.isOpen
+ ? 'issue-token-state-icon-open gl-text-green-500'
+ : 'issue-token-state-icon-closed gl-text-blue-500';
},
computedLinkElementType() {
return this.path.length > 0 ? 'a' : 'span';
diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss
new file mode 100644
index 00000000000..72196d71969
--- /dev/null
+++ b/app/assets/stylesheets/application_dark.scss
@@ -0,0 +1,3 @@
+@import "./themes/dark";
+
+@import "./application";
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index 1c15400542a..a6d56819140 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -111,7 +111,7 @@ kbd {
code {
padding: 2px 4px;
color: $code-color;
- background-color: $gray-100;
+ background-color: $gray-50;
border-radius: $border-radius-default;
.code > & {
@@ -187,7 +187,7 @@ h3.popover-header {
// Add to .label so that old system notes that are saved to the db
// will still receive the correct styling
-.badge,
+.badge:not(.gl-badge),
.label {
padding: 4px 5px;
font-size: 12px;
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
index 312123aeef9..6bb7e9d215e 100644
--- a/app/assets/stylesheets/components/avatar.scss
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -70,7 +70,7 @@ $avatar-sizes: (
$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal,
$identicon-orange, $gray-darker;
-.avatar-circle {
+%avatar-circle {
float: left;
margin-right: $gl-padding;
border-radius: $avatar-radius;
@@ -84,7 +84,7 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
}
.avatar {
- @extend .avatar-circle;
+ @extend %avatar-circle;
transition-property: none;
width: 40px;
@@ -100,10 +100,7 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
margin-left: 2px;
flex-shrink: 0;
- &.s16 {
- margin-right: 4px;
- }
-
+ &.s16,
&.s24 {
margin-right: 4px;
}
@@ -154,7 +151,7 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
}
.avatar-container {
- @extend .avatar-circle;
+ @extend %avatar-circle;
overflow: hidden;
display: flex;
diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss
index ce33aa94df3..64091201221 100644
--- a/app/assets/stylesheets/components/dashboard_skeleton.scss
+++ b/app/assets/stylesheets/components/dashboard_skeleton.scss
@@ -67,10 +67,10 @@
background-repeat: no-repeat;
background-size: cover;
background-image: linear-gradient(to right,
- $gray-100 0%,
+ $gray-50 0%,
$gray-10 20%,
- $gray-100 40%,
- $gray-100 100%);
+ $gray-50 40%,
+ $gray-50 100%);
border-radius: $gl-padding;
height: $gl-padding;
margin-top: -$gl-padding-8;
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
index 1061aae2bbb..380b2280490 100644
--- a/app/assets/stylesheets/components/design_management/design.scss
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -1,3 +1,7 @@
+.layout-page.design-detail-layout {
+ max-height: 100vh;
+}
+
.design-detail {
background-color: rgba($black, 0.9);
@@ -5,8 +9,30 @@
top: 35px;
}
- .inactive {
- opacity: 0.5;
+ .design-pin {
+ transition: opacity 0.5s ease;
+
+ &.inactive {
+ @include gl-opacity-5;
+
+ &:hover {
+ @include gl-opacity-10;
+ }
+ }
+ }
+
+ .badge.badge-pill {
+ display: flex;
+ height: 28px;
+ width: 28px;
+ background-color: $blue-400;
+ color: $white;
+ border: $white 1px solid;
+ border-radius: 50%;
+
+ &.resolved {
+ background-color: $gray-700;
+ }
}
}
@@ -40,14 +66,31 @@
min-width: 400px;
flex-basis: 28%;
+ .link-inherit-color {
+ &:hover,
+ &:active,
+ &:focus {
+ color: inherit;
+ text-decoration: none;
+ }
+ }
+
+ .toggle-comments {
+ line-height: 20px;
+ border-top: 1px solid $border-color;
+
+ &.expanded {
+ border-bottom: 1px solid $border-color;
+ }
+
+ .toggle-comments-button:focus {
+ text-decoration: none;
+ color: $blue-600;
+ }
+ }
+
.badge.badge-pill {
margin-left: $gl-padding;
- background-color: $blue-400;
- color: $white;
- border: $white 1px solid;
- min-height: 28px;
- padding: 7px 10px;
- border-radius: $gl-padding;
}
.design-discussion {
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
index fcaa1b054ed..1e78781f4b8 100644
--- a/app/assets/stylesheets/components/popover.scss
+++ b/app/assets/stylesheets/components/popover.scss
@@ -1,7 +1,7 @@
.popover {
max-width: $popover-max-width;
border: 1px solid $gray-200;
- box-shadow: 0 2px 3px 1px $gray-200;
+ box-shadow: $popover-box-shadow;
font-size: $gl-font-size-small;
/**
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 61f971a3185..956f34f7a8b 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -23,20 +23,17 @@ $item-remove-button-space: 42px;
.sortable-link {
white-space: normal;
}
+
+ .item-assignees .avatar {
+ height: $gl-padding;
+ width: $gl-padding;
+ }
}
.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;
}
@@ -68,7 +65,6 @@ $item-remove-button-space: 42px;
.sortable-link {
color: $gray-900;
- font-weight: normal;
}
}
@@ -276,10 +272,6 @@ $item-remove-button-space: 42px;
/* Small devices (landscape phones, 768px and up) */
@include media-breakpoint-up(md) {
- .item-body .item-contents {
- max-width: 95%;
- }
-
.related-items-tree .item-contents,
.item-body .item-title {
max-width: 100%;
@@ -348,6 +340,11 @@ $item-remove-button-space: 42px;
}
.item-assignees {
+ .avatar {
+ height: $gl-padding-24;
+ width: $gl-padding-24;
+ }
+
.avatar-counter {
height: $gl-padding-24;
min-width: $gl-padding-24;
@@ -366,6 +363,10 @@ $item-remove-button-space: 42px;
.sortable-link {
line-height: 1.3;
}
+
+ .item-info-area {
+ flex-basis: auto;
+ }
}
@media only screen and (min-width: 1500px) {
diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss
index eca0f1114af..bedd06ec9a1 100644
--- a/app/assets/stylesheets/components/rich_content_editor.scss
+++ b/app/assets/stylesheets/components/rich_content_editor.scss
@@ -1,4 +1,8 @@
-// Overrides styles from ToastUI editor
+/**
+* Overrides styles from ToastUI editor
+*/
+
+// Toolbar buttons
.tui-editor-defaultUI-toolbar .toolbar-button {
color: $gl-gray-600;
border: 0;
@@ -9,3 +13,19 @@
border: 0;
}
}
+
+// Contextual menu's & popups
+.tui-editor-defaultUI .tui-popup-wrapper {
+ @include gl-overflow-hidden;
+ @include gl-rounded-base;
+ @include gl-border-gray-400;
+
+ hr {
+ @include gl-m-0;
+ @include gl-bg-gray-400;
+ }
+
+ button {
+ @include gl-text-gray-800;
+ }
+}
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 13174687e5d..136ff82e0f8 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -93,7 +93,6 @@
}
.dropdown-menu-toggle,
-.avatar-circle,
.header-user-avatar {
@include transition(border-color);
}
@@ -177,7 +176,7 @@ a {
[class^='skeleton-line-'] {
position: relative;
- background-color: $gray-100;
+ background-color: $gray-50;
height: 10px;
overflow: hidden;
@@ -192,10 +191,10 @@ a {
background-repeat: no-repeat;
background-size: cover;
background-image: linear-gradient(to right,
- $gray-100 0%,
+ $gray-50 0%,
$gray-10 20%,
- $gray-100 40%,
- $gray-100 100%);
+ $gray-50 40%,
+ $gray-50 100%);
height: 10px;
}
}
diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss
index c036267a7c8..5b8a4bf964e 100644
--- a/app/assets/stylesheets/framework/badges.scss
+++ b/app/assets/stylesheets/framework/badges.scss
@@ -1,6 +1,14 @@
-.badge.badge-pill {
+.badge.badge-pill:not(.gl-badge) {
font-weight: $gl-font-weight-normal;
background-color: $badge-bg;
color: $gray-800;
vertical-align: baseline;
+
+ // Do not use this!
+ // This is a temporary workaround until the new GlBadge component
+ // is available: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/481
+ &.badge-pill-success {
+ background-color: rgba($green-500, 0.2);
+ color: $green;
+ }
}
diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss
index 7dd7ab339dd..a1e757afe56 100644
--- a/app/assets/stylesheets/framework/blank.scss
+++ b/app/assets/stylesheets/framework/blank.scss
@@ -116,3 +116,17 @@
}
}
}
+
+.experiment-new-project-page-blank-state {
+ @include media-breakpoint-down(md) {
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+ }
+}
+
+$experiment-new-project-indigo-700: #41419f;
+
+.experiment-new-project-page-blank-state-title {
+ color: $experiment-new-project-indigo-700;
+}
diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss
index 9903d10d27c..534ada08b85 100644
--- a/app/assets/stylesheets/framework/broadcast_messages.scss
+++ b/app/assets/stylesheets/framework/broadcast_messages.scss
@@ -14,8 +14,6 @@
}
.broadcast-banner-message {
- @extend .broadcast-message;
- @extend .alert-warning;
text-align: center;
.broadcast-message-dismiss {
@@ -24,8 +22,6 @@
}
.broadcast-notification-message {
- @extend .broadcast-message;
-
position: fixed;
bottom: $gl-padding;
right: $gl-padding;
@@ -42,7 +38,6 @@
}
.broadcast-message-dismiss {
- height: 100%;
color: $gray-800;
}
}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 93361c21642..849ca4a79f8 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -111,7 +111,7 @@ pre {
hr {
margin: 24px 0;
- border-top: 1px solid darken($gray-normal, 8%);
+ border-top: 1px solid $gray-darker;
}
.str-truncated {
@@ -135,7 +135,7 @@ hr {
text-overflow: ellipsis;
white-space: nowrap;
- > div:not(.block),
+ > div:not(.block):not(.select2-display-none),
.str-truncated {
display: inline;
}
@@ -396,21 +396,13 @@ img.emoji {
🚨 Do not use these classes — they are deprecated and being removed. 🚨
See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details.
**/
-.prepend-top-0 { margin-top: 0; }
-.prepend-top-2 { margin-top: 2px; }
-.prepend-top-4 { margin-top: $gl-padding-4; }
.prepend-top-5 { margin-top: 5px; }
-.prepend-top-8 { margin-top: $grid-size; }
.prepend-top-10 { margin-top: 10px; }
.prepend-top-15 { margin-top: 15px; }
.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-2 { margin-left: 2px; }
-.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; }
@@ -420,7 +412,6 @@ img.emoji {
.append-right-2 { margin-right: 2px; }
.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; }
@@ -428,11 +419,7 @@ img.emoji {
.append-right-32 { margin-right: 32px; }
.append-right-48 { margin-right: 48px; }
.prepend-right-32 { margin-right: 32px; }
-.append-bottom-0 { margin-bottom: 0; }
-.append-bottom-2 { margin-bottom: 2px; }
-.append-bottom-4 { margin-bottom: $gl-padding-4; }
.append-bottom-5 { margin-bottom: 5px; }
-.append-bottom-8 { margin-bottom: $grid-size; }
.append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
@@ -521,31 +508,6 @@ img.emoji {
}
/**
- The zero-indexed classes will not change and do not need to be updated.
- These can be removed when the Gitlab UI class include is merged.
-**/
-
-.gl-p-0 {
- padding: 0;
-}
-
-.gl-pl-0 {
- padding-left: 0;
-}
-
-.gl-pr-0 {
- padding-right: 0;
-}
-
-.gl-pt-0 {
- padding-top: 0;
-}
-
-.gl-pb-0 {
- padding-bottom: 0;
-}
-
-/**
* 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.
@@ -602,7 +564,7 @@ img.emoji {
bottom: 40px;
right: 40px;
font-size: $gl-font-size-small;
- background: $gray-100;
+ background: $gray-50;
width: 200px;
border-radius: 24px;
box-shadow: 0 2px 4px $issue-boards-card-shadow;
@@ -637,8 +599,6 @@ img.emoji {
.gl-font-lg { font-size: $gl-font-size-large; }
.gl-font-base { font-size: $gl-font-size-14; }
-.gl-line-height-24 { line-height: $gl-line-height-24; }
-
.gl-font-size-0 { font-size: 0; }
.gl-font-size-28 { font-size: $gl-font-size-28; }
.gl-font-size-42 { font-size: $gl-font-size-42; }
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 11064f18418..e4bee01f61f 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -137,7 +137,7 @@
.badge.badge-pill:not(.fly-out-badge),
.sidebar-context-title,
.nav-item-name {
- display: none;
+ @include gl-sr-only;
}
.sidebar-top-level-items > li > a {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 1df9818a877..485a4879c43 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -657,6 +657,7 @@
.dropdown-input-field,
.default-dropdown-input {
+ background-color: $input-bg;
display: block;
width: 100%;
min-height: 30px;
@@ -940,7 +941,7 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
position: absolute;
top: 13px;
right: 25px;
- color: $gray-100;
+ color: $gray-50;
}
}
@@ -979,7 +980,7 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
&:hover {
.frequent-items-item-avatar-container .avatar {
- border-color: $gray-100;
+ border-color: $gray-50;
}
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 7ee3e68ceea..eef6d9031f8 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -480,7 +480,7 @@ span.idiff {
padding-bottom: $gl-padding;
.discussion-reply-holder {
- border-bottom: 1px solid $gray-100;
+ border-bottom: 1px solid $gray-50;
border-radius: 0;
}
}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 5f6a26d0a14..9bba5c0614a 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -96,7 +96,7 @@
}
.name {
- background-color: $white-normal;
+ background-color: $gray-50;
color: $gl-text-color-secondary;
border-radius: 2px 0 0 2px;
margin-right: 1px;
@@ -259,6 +259,7 @@
flex: 1;
position: relative;
min-width: 0;
+ background-color: $input-bg;
}
.filtered-search-input-dropdown-menu {
@@ -449,3 +450,17 @@
font-size: 13px;
}
}
+
+.vue-filtered-search-bar-container {
+ @include media-breakpoint-up(md) {
+ .sort-dropdown-container {
+ margin-left: 10px;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ .sort-dropdown-container {
+ margin-top: 10px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
index 6a2f36d2509..8d5afe1d312 100644
--- a/app/assets/stylesheets/framework/gitlab_theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -314,12 +314,12 @@ body {
$gray-800,
$gray-700,
$gray-700,
- $gray-100,
+ $gray-50,
$gray-700
);
.navbar-gitlab {
- background-color: $gray-100;
+ background-color: $gray-50;
box-shadow: 0 1px 0 0 $border-color;
.logo-text svg {
@@ -388,4 +388,49 @@ body {
color: $gray-900;
}
}
+
+ &.gl-dark {
+ .logo-text svg {
+ fill: $gl-text-color;
+ }
+
+ .navbar-gitlab {
+ background-color: $gray-50;
+
+ .navbar-sub-nav,
+ .navbar-nav {
+ li {
+ > a:hover,
+ > a:focus,
+ > button:hover,
+ > button:focus {
+ color: $gl-text-color;
+ background-color: $gray-200;
+ }
+ }
+
+ li.active,
+ li.dropdown.show {
+ > a,
+ > button {
+ color: $gl-text-color;
+ background-color: $gray-200;
+ }
+ }
+ }
+
+ .search {
+ form {
+ background-color: $gray-100;
+ box-shadow: inset 0 0 0 1px $border-color;
+
+ &:active,
+ &:hover {
+ background-color: $gray-100;
+ box-shadow: inset 0 0 0 1px $blue-200;
+ }
+ }
+ }
+ }
+ }
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 97698fefbee..2a97009e605 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -123,7 +123,7 @@
.markdown-area {
border-radius: 0;
background: $white;
- border: 1px solid $gray-100;
+ border: 1px solid $gray-50;
min-height: 140px;
max-height: 500px;
padding: 5px;
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 3c428cc352f..c2ab6f5b8c5 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -12,6 +12,7 @@
.select2-container.select2-drop-above {
.select2-choice {
background: $white;
+ color: $gl-text-color;
border-color: $input-border;
height: 34px;
padding: $gl-vert-padding $gl-input-padding;
@@ -58,6 +59,42 @@
}
}
+ // Essentially we’re doing @include form-control-focus here (from
+ // bootstrap/scss/mixins/_forms.scss), except that the bootstrap mixin adds a
+ // `&:focus` selector and we’re never actually focusing the .select2-choice
+ // link nor the .select2-container, the Select2 library focuses an off-screen
+ // .select2-focusser element instead.
+ &.select2-container-active:not(.select2-dropdown-open) {
+ .select2-choice {
+ color: $input-focus-color;
+ background-color: $input-focus-bg;
+ border-color: $input-focus-border-color;
+ outline: 0;
+ }
+
+ // Reusable focus “glow” box-shadow
+ @mixin form-control-focus-glow {
+ @if $enable-shadows {
+ box-shadow: $input-box-shadow, $input-focus-box-shadow;
+ } @else {
+ box-shadow: $input-focus-box-shadow;
+ }
+ }
+
+ // Apply the focus “glow” shadow to the .select2-container if it also has
+ // the .block-truncated class as that applies an overflow: hidden, thereby
+ // hiding the glow of the nested .select2-choice element.
+ &.block-truncated {
+ @include form-control-focus-glow;
+ }
+
+ // Apply the glow directly to the .select2-choice link if we’re not
+ // block-truncating the container.
+ &:not(.block-truncated) .select2-choice {
+ @include form-control-focus-glow;
+ }
+ }
+
&.is-invalid {
~ .invalid-feedback {
display: block;
@@ -72,6 +109,7 @@
.select2-drop,
.select2-drop.select2-drop-above {
+ background: $white;
box-shadow: 0 2px 4px $dropdown-shadow-color;
border-radius: $border-radius-base;
border: 1px solid $border-color;
@@ -166,7 +204,8 @@
input {
padding: $grid-size;
- background: $white image-url('select2.png');
+ background: transparent image-url('select2.png');
+ color: $gl-text-color;
background-clip: content-box;
background-origin: content-box;
background-repeat: no-repeat;
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
index 5c298d5a588..4f66d6bf354 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -83,12 +83,20 @@
// right sidebar eg: mr page
.nav-sidebar,
.right-sidebar,
- // navless pages' footer eg: login page
- // navless pages' footer border eg: login page
+ // 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;
}
+
+ .content-wrapper {
+ margin-bottom: 16px;
+ }
+
+ .boards-list {
+ height: calc(100vh - #{$header-height + $breadcrumb-min-height + $performance-bar-height + $system-footer-height + $gl-padding-32});
+ }
}
.fullscreen-layout {
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 5739f048e86..5bc2874ea05 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -47,7 +47,7 @@ table {
}
th {
- @include gl-bg-gray-100;
+ @include gl-bg-gray-50;
border-bottom: 0;
&.wide {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 1afcbc6d514..6e07a2b5de1 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -545,6 +545,24 @@
}
}
}
+
+ /* AsciiDoc(tor) built-in alignment roles */
+
+ .text-left {
+ text-align: left !important;
+ }
+
+ .text-right {
+ text-align: right !important;
+ }
+
+ .text-center {
+ text-align: center !important;
+ }
+
+ .text-justify {
+ text-align: justify !important;
+ }
}
/**
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index ac4d431ea57..1536c5c3022 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -73,108 +73,106 @@ $size-scale: (
'xl': #{70 * $grid-size}
);
-/*
- * Color schema
- */
-$darken-normal-factor: 7%;
-$darken-dark-factor: 10%;
-$darken-border-factor: 5%;
-$darken-border-dashed-factor: 25%;
-
-$white: #fff;
-$white-normal: #f0f0f0;
-$white-dark: #eaeaea;
-$white-transparent: rgba(255, 255, 255, 0.8);
-
-$gray-lightest: #fdfdfd;
-$gray-light: #fafafa;
-$gray-lighter: #f9f9f9;
-$gray-normal: #f5f5f5;
-$gray-dark: darken($gray-light, $darken-dark-factor);
-$gray-darker: #eee;
-$gray-darkest: #c4c4c4;
-
-$purple: #6d49cb;
-$purple-light: #ede8fb;
-
-$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;
-$gl-gray-800: #333;
-
-$green-50: #f1fdf6;
-$green-100: #dcf5e7;
-$green-200: #b3e6c8;
-$green-300: #75d09b;
-$green-400: #37b96d;
-$green-500: #1aaa55;
-$green-600: #168f48;
-$green-700: #12753a;
-$green-800: #0e5a2d;
-$green-900: #0a4020;
-$green-950: #072b15;
-
-$blue-50: #f6fafe;
-$blue-100: #e4f0fb;
-$blue-200: #b8d6f4;
-$blue-300: #73afea;
-$blue-400: #418cd8;
-$blue-500: #1f78d1;
-$blue-600: #1b69b6;
-$blue-700: #17599c;
-$blue-800: #134a81;
-$blue-900: #0f3b66;
-$blue-950: #0a2744;
-
-$orange-50: #fffaf4;
-$orange-100: #fff1de;
-$orange-200: #fed69f;
-$orange-300: #fdbc60;
-$orange-400: #fca429;
-$orange-500: #fc9403;
-$orange-600: #de7e00;
-$orange-700: #c26700;
-$orange-800: #a35200;
-$orange-900: #853c00;
-$orange-950: #592800;
-
-$red-50: #fef6f5;
-$red-100: #fbe5e1;
-$red-200: #f2b4a9;
-$red-300: #ea8271;
-$red-400: #e05842;
-$red-500: #db3b21;
-$red-600: #c0341d;
-$red-700: #a62d19;
-$red-800: #8b2615;
-$red-900: #711e11;
-$red-950: #4b140b;
-
-$gray-10: #fafafa;
-$gray-50: #f0f0f0;
-$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;
+// Color schema
+$darken-normal-factor: 7% !default;
+$darken-dark-factor: 10% !default;
+$darken-border-factor: 5% !default;
+$darken-border-dashed-factor: 25% !default;
+
+$white: #fff !default;
+$white-normal: #f0f0f0 !default;
+$white-dark: #eaeaea !default;
+$white-transparent: rgba(255, 255, 255, 0.8) !default;
+
+$gray-lightest: #fdfdfd !default;
+$gray-light: #fafafa !default;
+$gray-lighter: #f9f9f9 !default;
+$gray-normal: #f5f5f5 !default;
+$gray-dark: darken($gray-light, $darken-dark-factor) !default;
+$gray-darker: #eee !default;
+$gray-darkest: #c4c4c4 !default;
+
+$purple: #6d49cb !default;
+$purple-light: #ede8fb !default;
+
+$black: #000 !default;
+$black-transparent: rgba(0, 0, 0, 0.3) !default;
+$almost-black: #242424 !default;
+
+$t-gray-a-02: rgba($black, 0.02) !default;
+$t-gray-a-04: rgba($black, 0.04) !default;
+$t-gray-a-06: rgba($black, 0.06) !default;
+$t-gray-a-08: rgba($black, 0.08) !default;
+
+$gl-gray-100: #ddd !default;
+$gl-gray-200: #ccc !default;
+$gl-gray-350: #aaa !default;
+$gl-gray-400: #999 !default;
+$gl-gray-500: #777 !default;
+$gl-gray-600: #666 !default;
+$gl-gray-700: #555 !default;
+$gl-gray-800: #333 !default;
+
+$green-50: #f1fdf6 !default;
+$green-100: #dcf5e7 !default;
+$green-200: #263a2e !default;
+$green-300: #75d09b !default;
+$green-400: #37b96d !default;
+$green-500: #1aaa55 !default;
+$green-600: #168f48 !default;
+$green-700: #12753a !default;
+$green-800: #0e5a2d !default;
+$green-900: #0a4020 !default;
+$green-950: #072b15 !default;
+
+$blue-50: #f6fafe !default;
+$blue-100: #e4f0fb !default;
+$blue-200: #b8d6f4 !default;
+$blue-300: #73afea !default;
+$blue-400: #418cd8 !default;
+$blue-500: #1f78d1 !default;
+$blue-600: #1b69b6 !default;
+$blue-700: #17599c !default;
+$blue-800: #134a81 !default;
+$blue-900: #0f3b66 !default;
+$blue-950: #0a2744 !default;
+
+$orange-50: #fffaf4 !default;
+$orange-100: #fff1de !default;
+$orange-200: #fed69f !default;
+$orange-300: #fdbc60 !default;
+$orange-400: #fca429 !default;
+$orange-500: #fc9403 !default;
+$orange-600: #de7e00 !default;
+$orange-700: #c26700 !default;
+$orange-800: #a35200 !default;
+$orange-900: #853c00 !default;
+$orange-950: #592800 !default;
+
+$red-50: #fcf1ef !default;
+$red-100: #fdd4cd !default;
+$red-200: #fcb5aa !default;
+$red-300: #f57f6c !default;
+$red-400: #ec5941 !default;
+$red-500: #dd2b0e !default;
+$red-600: #c91c00 !default;
+$red-700: #ae1800 !default;
+$red-800: #8d1300 !default;
+$red-900: #660e00 !default;
+$red-950: #4d0a00 !default;
+
+$gray-10: #fafafa !default;
+$gray-50: #f0f0f0 !default;
+$gray-100: #dbdbdb !default;
+$gray-200: #dfdfdf !default;
+$gray-300: #ccc !default;
+$gray-400: #bababa !default;
+$gray-500: #a7a7a7 !default;
+$gray-600: #919191 !default;
+$gray-700: #707070 !default;
+$gray-800: #4f4f4f !default;
+$gray-900: #2e2e2e !default;
+$gray-950: #1f1f1f !default;
$greens: (
'50': $green-50,
@@ -325,8 +323,8 @@ $theme-light-red-500: #c24b38;
$theme-light-red-600: #b03927;
$theme-light-red-700: #a62e21;
-$border-white-light: darken($white, $darken-border-factor);
-$border-white-normal: darken($white-normal, $darken-border-factor);
+$border-white-light: darken($white, $darken-border-factor) !default;
+$border-white-normal: darken($white-normal, $darken-border-factor) !default;
$border-gray-light: darken($gray-light, $darken-border-factor);
$border-gray-normal: darken($gray-normal, $darken-border-factor);
@@ -335,7 +333,7 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor);
/*
* UI elements
*/
-$border-color: #e5e5e5;
+$border-color: $gray-200;
$shadow-color: $t-gray-a-08;
$well-expand-item: #e8f2f7;
$well-inner-border: #eef0f2;
@@ -356,7 +354,7 @@ $gl-text-color-secondary: $gray-700;
$gl-text-color-tertiary: $gray-600;
$gl-text-color-quaternary: #d6d6d6;
$gl-text-color-inverted: $white;
-$gl-text-color-secondary-inverted: rgba(255, 255, 255, 0.85);
+$gl-text-color-secondary-inverted: rgba($white, 0.85);
$gl-text-color-disabled: $gray-600;
$gl-grayish-blue: #7f8fa4;
$gl-gray-dark: #313236;
@@ -435,7 +433,6 @@ $layout-link-gray: #7e7c7c;
$btn-side-margin: 10px;
$btn-sm-side-margin: 7px;
$btn-margin-5: 5px;
-$sidebar-block-hover-color: #ebebeb;
$count-arrow-border: #dce0e5;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
@@ -491,8 +488,8 @@ $line-number-select: #fbf2da;
$line-target-blue: $blue-50;
$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);
+$dark-diff-match-bg: rgba($white, 0.3);
+$dark-diff-match-color: rgba($white, 0.1);
$diff-image-info-color: #808080;
$diff-view-modes-color: #808080;
$diff-view-modes-border: #c1c1c1;
@@ -520,7 +517,7 @@ $dropdown-shadow-color: rgba(#000, 0.1);
$dropdown-title-btn-color: #bfbfbf;
$dropdown-input-fa-color: #c7c7c7;
$dropdown-input-focus-shadow: rgba($blue-300, 0.4);
-$dropdown-loading-bg: rgba(#fff, 0.6);
+$dropdown-loading-bg: rgba($white, 0.6);
$dropdown-chevron-size: 10px;
$dropdown-toggle-active-border-color: darken($border-color, 14%);
$dropdown-fade-mask-height: 32px;
@@ -534,9 +531,9 @@ $filtered-search-term-shadow-color: rgba(0, 0, 0, 0.09);
/*
* Contextual Sidebar
*/
-$link-active-background: rgba(0, 0, 0, 0.04);
-$link-hover-background: rgba(0, 0, 0, 0.06);
-$inactive-badge-background: rgba(0, 0, 0, 0.08);
+$link-active-background: rgba($black, 0.04);
+$link-hover-background: rgba($gray-900, 0.06);
+$inactive-badge-background: rgba($black, 0.08);
$sidebar-toggle-height: 60px;
$sidebar-toggle-width: 40px;
$sidebar-milestone-toggle-bottom-margin: 10px;
@@ -544,8 +541,8 @@ $sidebar-milestone-toggle-bottom-margin: 10px;
/*
* Buttons
*/
-$btn-active-gray: #ececec;
-$btn-active-gray-light: #e4e7ed;
+$btn-active-gray: $gray-50;
+$btn-active-gray-light: $gray-50;
$btn-white-active: #848484;
$gl-btn-padding: 10px;
$gl-btn-line-height: 16px;
@@ -602,12 +599,12 @@ $note-icon-gutter-width: 55px;
/*
* Identicon
*/
-$identicon-red: #ffebee;
-$identicon-purple: #f3e5f5;
-$identicon-indigo: #e8eaf6;
-$identicon-blue: #e3f2fd;
-$identicon-teal: #e0f2f1;
-$identicon-orange: #fbe9e7;
+$identicon-red: #ffebee !default;
+$identicon-purple: #f3e5f5 !default;
+$identicon-indigo: #e8eaf6 !default;
+$identicon-blue: #e3f2fd !default;
+$identicon-teal: #e0f2f1 !default;
+$identicon-orange: #fbe9e7 !default;
/*
* Calendar
@@ -719,8 +716,8 @@ $accepting-mr-label-color: #69d100;
/*
* Issues
*/
-$issues-today-bg: #f3fff2;
-$issues-today-border: #e1e8d5;
+$issues-today-bg: #f3fff2 !default;
+$issues-today-border: #e1e8d5 !default;
$compare-display-color: #888;
/*
@@ -871,6 +868,7 @@ $priority-label-empty-state-width: 114px;
Popovers
*/
$popover-max-width: 384px;
+$popover-box-shadow: 0 2px 3px 1px $gray-200;
/*
Issues Analytics
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index ef75dabbda4..c7a50bdb5a3 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -55,3 +55,26 @@ $tooltip-padding-y: 0.5rem;
$tooltip-padding-x: 0.75rem;
$tooltip-arrow-height: 0.5rem;
$tooltip-arrow-width: 1rem;
+$b-table-sort-icon-bg-descending: url('data:image/svg+xml, <svg \
+ xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"> \
+ <path style="fill: #666;" fill-rule="evenodd" d="M11.707085,11.7071 \
+ L7.999975,15.4142 L4.292875,11.7071 C3.902375,11.3166 3.902375, \
+ 10.6834 4.292875,10.2929 C4.683375,9.90237 \
+ 5.316575,9.90237 5.707075,10.2929 L6.999975, \
+ 11.5858 L6.999975,2 C6.999975,1.44771 \
+ 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 \
+ 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 C10.683395 \
+ ,9.90237 11.316555,9.90237 11.707085,10.2929 \
+ C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 Z"/> \
+ </svg>') !default;
+$b-table-sort-icon-bg-ascending: url('data:image/svg+xml,<svg \
+ xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"> \
+ <path style="fill: #666;" fill-rule="evenodd" d="M4.29289,4.2971 L8,0.59 \
+ L11.7071,4.2971 C12.0976,4.6876 \
+ 12.0976,5.3208 11.7071,5.7113 C11.3166,6.10183 10.6834, \
+ 6.10183 10.2929,5.7113 L9,4.4184 L9,14.0042 C9,14.55649 \
+ 8.55228,15.0042 8,15.0042 C7.44772,15.0042 7,14.55649 \
+ 7,14.0042 L7,4.4184 L5.70711,5.7113 C5.31658,6.10183 4.68342,6.10183 4.29289,5.7113 \
+ C3.90237,5.3208 3.90237,4.6876 4.29289,4.2971 Z"/> \
+ </svg> ') !default;
+$b-table-sort-icon-bg-not-sorted: '';
diff --git a/app/assets/stylesheets/notify.scss b/app/assets/stylesheets/notify.scss
index 2bf823993d7..6320c10fb51 100644
--- a/app/assets/stylesheets/notify.scss
+++ b/app/assets/stylesheets/notify.scss
@@ -36,5 +36,5 @@ pre.commit-message {
}
.gl-label-text-dark {
- color: $gl-gray-800;
+ color: $gl-text-color;
}
diff --git a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
index 5675835a622..0b847902525 100644
--- a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
@@ -27,8 +27,7 @@
z-index: 2;
}
- .is-readonly,
- .editor.original {
+ .is-readonly .editor.original {
.view-lines {
cursor: default;
}
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
index e4c01c2bd6c..2b82b2226c6 100644
--- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -1,9 +1,15 @@
// -------
// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
// -------
-.ide.theme-dark {
- a:not(.btn) {
- color: var(--ide-link-color);
+.ide {
+ $bs-input-focus-border: #80bdff;
+ $bs-input-focus-box-shadow: rgba(0, 123, 255, 0.25);
+
+ a:not(.btn),
+ .btn-link:hover,
+ .btn-link:focus,
+ .btn-link:active {
+ color: var(--ide-link-color, $blue-600);
}
h1,
@@ -19,156 +25,207 @@
.context-header > a,
input,
textarea,
- .md-area.is-focused,
.dropdown-menu li button,
.dropdown-menu-selectable li a.is-active,
.dropdown-menu-inner-title,
- .dropdown-menu-inner-content,
- .nav-links:not(.quick-links) li:not(.md-header-toolbar) a,
- .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover,
- .nav-links:not(.quick-links) li:not(.md-header-toolbar) a.active .badge.badge-pill,
- .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover .badge.badge-pill,
- .badge.badge-pill,
.bs-callout,
.ide-pipeline .top-bar,
- .ide-pipeline .top-bar .controllers .controllers-buttons {
- color: var(--ide-text-color);
+ .ide-pipeline .top-bar .controllers .controllers-buttons,
+ .controllers-buttons svg,
+ .nav-links li a.active,
+ .md-area.is-focused {
+ color: var(--ide-text-color, $gl-text-color);
}
- .drag-handle:hover,
- .card-header .badge.badge-pill {
- background-color: var(--ide-dropdown-hover-background);
+ .badge.badge-pill {
+ color: var(--ide-text-color, $gray-800);
+ background-color: var(--ide-background, $badge-bg);
}
+ .nav-links:not(.quick-links) li:not(.md-header-toolbar) a,
+ .dropdown-menu-inner-content,
.file-row .file-row-icon svg,
- .file-row:hover .file-row-icon svg,
- .controllers-buttons svg {
- color: var(--ide-text-color-secondary);
+ .file-row:hover .file-row-icon svg {
+ color: var(--ide-text-color-secondary, $gl-text-color-secondary);
+ }
+
+ .nav-links:not(.quick-links) li:not(.md-header-toolbar) {
+ &:hover a,
+ &.active a,
+ a:hover,
+ a.active {
+ &,
+ .badge.badge-pill {
+ color: var(--ide-text-color, $black);
+ border-color: var(--ide-input-border, $gray-darkest);
+ }
+ }
+ }
+
+ .drag-handle:hover {
+ background-color: var(--ide-dropdown-hover-background, $white-normal);
+ }
+
+ .card-header {
+ background-color: var(--ide-background, $white);
+
+ .badge.badge-pill {
+ background-color: var(--ide-dropdown-hover-background, $badge-bg);
+ }
}
.text-secondary {
- color: var(--ide-text-color-secondary) !important;
+ color: var(--ide-text-color-secondary, $gl-text-color-secondary) !important;
}
input[type='search']::placeholder,
input[type='text']::placeholder,
- textarea::placeholder,
+ textarea::placeholder {
+ color: var(--ide-input-border, $gl-text-color-tertiary);
+ }
+
.dropdown-input .fa {
- color: var(--ide-input-border);
+ color: var(--ide-input-border, $dropdown-input-fa-color);
}
.ide-nav-form .input-icon {
- color: var(--ide-input-border);
+ color: var(--ide-input-border, $dropdown-input-fa-color);
+ }
+
+ code {
+ background-color: var(--ide-background, $gray-100);
}
- code,
- .badge.badge-pill,
- .card-header,
.bs-callout,
.ide-pipeline .top-bar,
.ide-terminal .top-bar {
- background-color: var(--ide-background);
+ background-color: var(--ide-background, $gray-light);
}
.bs-callout {
- border-color: var(--ide-dropdown-background);
+ border-color: var(--ide-dropdown-background, $border-color);
code {
- background-color: var(--ide-dropdown-background);
+ background-color: var(--ide-dropdown-background, $gray-100);
}
}
- .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover {
- border-color: var(--ide-dropdown-hover-background);
+ .common-note-form .md-area {
+ border-color: var(--ide-input-border, $border-color);
}
- .common-note-form .md-area {
- border-color: var(--ide-input-border);
+ .md table:not(.code) tr th {
+ background-color: var(--ide-highlight-background, $gray-100);
}
&,
- .md table:not(.code) tr th,
- .common-note-form .md-area,
- .card {
- background-color: var(--ide-highlight-background);
+ .card,
+ .common-note-form .md-area {
+ background-color: var(--ide-highlight-background, $white);
}
.card,
.card-header,
.ide-terminal .top-bar,
.ide-pipeline .top-bar {
- border-color: var(--ide-border-color);
+ border-color: var(--ide-border-color, $border-color);
+ }
+
+ hr {
+ border-color: var(--ide-border-color, darken($gray-normal, 8%));
}
- hr,
.md h1,
.md h2,
.md blockquote,
- pre,
.md table:not(.code) tbody td,
.md table:not(.code) tr th,
- .nav-links:not(.quick-links) {
- border-color: var(--ide-border-color-alt);
+ .nav-links:not(.quick-links),
+ .common-note-form .md-area.is-focused .nav-links {
+ border-color: var(--ide-border-color-alt, $white-dark);
}
- .ide-sidebar-link.active {
- color: var(--ide-highlight-accent);
- box-shadow: inset 3px 0 var(--ide-highlight-accent);
+ pre {
+ border-color: var(--ide-border-color-alt, $gray-200);
- &.is-right {
- box-shadow: inset -3px 0 var(--ide-highlight-accent);
+ code {
+ background-color: var(--ide-border-color, inherit);
}
}
- .nav-links li.active a,
- .nav-links li a.active {
- border-color: var(--ide-highlight-accent);
- color: var(--ide-text-color);
- }
+ // highlight accents (based on navigation theme) should only apply
+ // in the default white theme and "none" theme.
+ &:not(.theme-white):not(.theme-none) {
+ .ide-sidebar-link.active {
+ color: var(--ide-highlight-accent, $gl-text-color);
+ box-shadow: inset 3px 0 var(--ide-highlight-accent, $gl-text-color);
+
+ &.is-right {
+ box-shadow: inset -3px 0 var(--ide-highlight-accent, $gl-text-color);
+ }
+ }
+
+ .nav-links li.active a,
+ .nav-links li a.active {
+ border-color: var(--ide-highlight-accent, $gl-text-color);
+ }
- .avatar-container {
- &,
- .avatar {
- color: var(--ide-text-color);
- background-color: var(--ide-highlight-background);
- border-color: var(--ide-highlight-background);
+ .dropdown-menu .nav-links li a.active {
+ border-color: var(--ide-highlight-accent, $gl-text-color);
+ }
+
+ // for other themes, suppress different avatar default colors for simplicity
+ .avatar-container {
+ &,
+ .avatar {
+ color: var(--ide-text-color, $gl-text-color);
+ background-color: var(--ide-highlight-background, $white);
+ border-color: var(--ide-highlight-background, rgba($black, $gl-avatar-border-opacity));
+ }
}
}
input[type='text'],
input[type='search'],
.filtered-search-box {
- border-color: var(--ide-input-border);
- background: var(--ide-input-background) !important;
+ border-color: var(--ide-input-border, $border-color);
+ background: var(--ide-input-background, $white) !important;
+ }
+
+ input[type='text']:not([disabled]):not([readonly]):focus,
+ .md-area.is-focused {
+ border-color: var(--ide-input-border, $bs-input-focus-border);
+ box-shadow: 0 0 0 3px var(--ide-dropdown-background, $bs-input-focus-box-shadow);
}
input[type='text'],
input[type='search'],
.filtered-search-box,
textarea {
- color: var(--ide-input-color) !important;
+ color: var(--ide-input-color, $gl-text-color) !important;
}
.filtered-search-box input[type='search'] {
- border-color: transparent;
+ border-color: transparent !important;
+ box-shadow: none !important;
}
.filtered-search-token .value-container,
.filtered-search-term .value-container {
- background-color: var(--ide-dropdown-hover-background);
- color: var(--ide-text-color);
+ background-color: var(--ide-dropdown-hover-background, $white-normal);
+ color: var(--ide-text-color, $gl-text-color);
&:hover {
- background-color: var(--ide-input-border);
+ background-color: var(--ide-input-border, $gray-200);
}
}
@function calc-btn-hover-padding($original-padding, $original-border: 1px) {
- @return calc(#{$original-padding + $original-border} - var(--ide-btn-hover-border-width));
+ @return calc(#{$original-padding + $original-border} - var(--ide-btn-hover-border-width, #{$original-border}));
}
.btn:not(.btn-link):not([disabled]):hover {
- border-width: var(--ide-btn-hover-border-width);
+ border-width: var(--ide-btn-hover-border-width, 1px);
padding: calc-btn-hover-padding(6px) calc-btn-hover-padding(10px);
}
@@ -180,53 +237,71 @@
padding: calc-btn-hover-padding(6px) 0;
}
- .btn-inverted,
.btn-default,
.dropdown,
.dropdown-menu-toggle {
- background-color: var(--ide-input-background) !important;
- color: var(--ide-input-color) !important;
- border-color: var(--ide-btn-default-border);
+ background-color: var(--ide-input-background, $white) !important;
+ color: var(--ide-input-color, $gl-text-color) !important;
+ border-color: var(--ide-btn-default-border, $border-color);
}
- .btn-inverted,
- .btn-default {
+ .dropdown-menu-toggle {
+ border-color: var(--ide-btn-default-border, $gray-darkest);
+
&:hover,
&:focus {
- border-color: var(--ide-btn-default-hover-border) !important;
+ background-color: var(--ide-dropdown-btn-hover-background, $gray-darker) !important;
+ border-color: var(--ide-dropdown-btn-hover-border, $gray-darkest) !important;
}
}
- .dropdown,
- .dropdown-menu-toggle {
+ // In IDE, the only inverted buttons are `.btn-remove`
+ .btn-inverted.btn-remove {
+ color: var(--ide-input-color, $red-500) !important;
+ background-color: var(--ide-input-background, $white) !important;
+ border-color: var(--ide-btn-default-border, $red-500);
+
&:hover,
&:focus {
- background-color: var(--ide-dropdown-btn-hover-background) !important;
- border-color: var(--ide-dropdown-btn-hover-border) !important;
+ color: var(--ide-input-color, $red-700) !important;
+ background-color: var(--ide-input-background, $red-100) !important;
+ border-color: var(--ide-btn-default-hover-border, $red-500) !important;
}
- }
- .dropdown-menu {
- color: var(--ide-text-color);
- border-color: var(--ide-background);
- background-color: var(--ide-dropdown-background);
+ &:active {
+ color: var(--ide-input-color, $red-800) !important;
+ background-color: var(--ide-input-background, $red-200) !important;
+ border-color: var(--ide-btn-default-hover-border, $red-600) !important;
+ }
+ }
- .divider,
- .nav-links:not(.quick-links) {
- background-color: var(--ide-dropdown-hover-background);
- border-color: var(--ide-dropdown-hover-background);
+ .btn-default {
+ &:hover,
+ &:focus {
+ border-color: var(--ide-btn-default-hover-border, $border-white-normal) !important;
+ background-color: var(--ide-input-background, $white-normal) !important;
}
- .nav-links li a.active {
- border-color: var(--ide-highlight-accent);
+ &:active,
+ .active {
+ border-color: var(--ide-btn-default-hover-border, $border-white-normal) !important;
+ background-color: var(--ide-input-background, $white-dark) !important;
}
+ }
- .nav-links:not(.quick-links) li:not(.md-header-toolbar) a {
- color: var(--ide-text-color);
+ .dropdown-menu {
+ color: var(--ide-text-color, $gl-text-color);
+ border-color: var(--ide-background, $border-color);
+ background-color: var(--ide-dropdown-background, $white);
- &.active {
- color: var(--ide-text-color);
- }
+ .nav-links:not(.quick-links) {
+ background-color: var(--ide-dropdown-hover-background, $white);
+ border-color: var(--ide-dropdown-hover-background, $border-color);
+ }
+
+ .divider {
+ background-color: var(--ide-dropdown-hover-background, $gray-200);
+ border-color: var(--ide-dropdown-hover-background, $gray-200);
}
li > a:not(.disable-hover):hover,
@@ -234,75 +309,88 @@
li button:not(.disable-hover):hover,
li button:not(.disable-hover):focus,
li button.is-focused {
- background-color: var(--ide-dropdown-hover-background);
- color: var(--ide-text-color);
+ background-color: var(--ide-dropdown-hover-background, $gray-darker);
+ color: var(--ide-text-color, $gl-text-color);
}
}
.dropdown-title,
.dropdown-input {
- border-color: var(--ide-dropdown-hover-background) !important;
+ border-color: var(--ide-dropdown-hover-background, $gray-200) !important;
}
.btn-primary,
.btn-info {
- background-color: var(--ide-btn-primary-background);
- border-color: var(--ide-btn-primary-border) !important;
+ background-color: var(--ide-btn-primary-background, $blue-500);
+ border-color: var(--ide-btn-primary-border, $blue-600) !important;
&:hover,
&:focus {
- border-color: var(--ide-btn-primary-hover-border) !important;
+ background-color: var(--ide-btn-primary-background, $blue-600);
+ border-color: var(--ide-btn-primary-hover-border, $blue-700) !important;
+ }
+
+ &:active,
+ &.active {
+ background-color: var(--ide-btn-primary-background, $blue-700);
+ border-color: var(--ide-btn-primary-hover-border, $blue-800) !important;
}
}
.btn-success {
- background-color: var(--ide-btn-success-background);
- border-color: var(--ide-btn-success-border) !important;
+ background-color: var(--ide-btn-success-background, $green-500);
+ border-color: var(--ide-btn-success-border, $green-600) !important;
&:hover,
&:focus {
- border-color: var(--ide-btn-success-hover-border) !important;
+ background-color: var(--ide-btn-success-background, $green-600);
+ border-color: var(--ide-btn-success-hover-border, $green-700) !important;
+ }
+
+ &:active,
+ &.active {
+ background-color: var(--ide-btn-success-background, $green-700);
+ border-color: var(--ide-btn-success-hover-border, $green-800) !important;
}
}
.btn[disabled] {
- background: var(--ide-btn-default-background) !important;
- border: 1px solid var(--ide-btn-disabled-border) !important;
- color: var(--ide-btn-disabled-color) !important;
+ background-color: var(--ide-btn-default-background, $gray-light) !important;
+ border: 1px solid var(--ide-btn-disabled-border, $gray-200) !important;
+ color: var(--ide-btn-disabled-color, $gl-text-color-disabled) !important;
}
- pre code,
.md table:not(.code) tbody {
- background-color: var(--ide-border-color);
+ background-color: var(--ide-border-color, $white);
}
.animation-container {
[class^='skeleton-line-'] {
- background-color: var(--ide-animation-gradient-1);
+ background-color: var(--ide-animation-gradient-1, $gray-100);
&::after {
background-image: linear-gradient(to right,
- var(--ide-animation-gradient-1) 0%,
- var(--ide-animation-gradient-2) 20%,
- var(--ide-animation-gradient-1) 40%,
- var(--ide-animation-gradient-1) 100%);
+ var(--ide-animation-gradient-1, $gray-100) 0%,
+ var(--ide-animation-gradient-2, $gray-10) 20%,
+ var(--ide-animation-gradient-1, $gray-100) 40%,
+ var(--ide-animation-gradient-1, $gray-100) 100%);
}
}
}
.idiff.addition {
- background-color: var(--ide-diff-insert);
+ background-color: var(--ide-diff-insert, $line-added-dark);
}
.idiff.deletion {
- background-color: var(--ide-diff-remove);
+ background-color: var(--ide-diff-remove, $line-removed-dark);
}
-}
-.navbar.theme-dark {
- border-bottom-color: transparent;
+ ~ .popover {
+ box-shadow: none;
+ }
}
-.theme-dark ~ .popover {
- box-shadow: none;
+.navbar:not(.theme-white):not(.theme-none) {
+ border-bottom-color: transparent;
}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 61914740ac0..9c92f891834 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -5,6 +5,7 @@
@import './ide_theme_overrides';
@import './ide_themes/dark';
+@import './ide_themes/solarized-dark';
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
@@ -24,6 +25,13 @@ $ide-commit-header-height: 48px;
@include str-truncated(250px);
}
+.ide-layout {
+ // Fix for iOS 13+, the height of the page is actually less than
+ // 100vh because of the presence of the bottom bar
+ max-height: 100%;
+ position: fixed;
+}
+
.ide-view {
position: relative;
margin-top: 0;
@@ -65,6 +73,7 @@ $ide-commit-header-height: 48px;
flex-direction: column;
flex: 1;
border-left: 1px solid var(--ide-border-color, $white-dark);
+ border-right: 1px solid var(--ide-border-color, $white-dark);
overflow: hidden;
}
@@ -88,7 +97,7 @@ $ide-commit-header-height: 48px;
&.active {
background-color: var(--ide-highlight-background, $white);
- border-bottom-color: var(--ide-border-color, $white);
+ border-bottom-color: transparent;
}
&:not(.disabled) {
@@ -281,7 +290,6 @@ $ide-commit-header-height: 48px;
.multi-file-commit-panel {
display: flex;
position: relative;
- width: 340px;
padding: 0;
background-color: var(--ide-background, $gray-light);
@@ -386,7 +394,7 @@ $ide-commit-header-height: 48px;
&:hover,
&:focus {
- background: var(--ide-background, $gray-100);
+ background: var(--ide-background, $gray-50);
outline: 0;
}
@@ -558,7 +566,7 @@ $ide-commit-header-height: 48px;
&:hover {
color: var(--ide-text-color, $gl-text-color);
- background-color: var(--ide-background-hover, $gray-100);
+ background-color: var(--ide-background-hover, $gray-50);
}
&:focus {
@@ -584,14 +592,15 @@ $ide-commit-header-height: 48px;
background: var(--ide-highlight-background, $white);
}
- &.is-right {
- padding-right: $gl-padding;
- padding-left: $gl-padding + 1px;
+ }
- &::after {
- right: auto;
- left: -1px;
- }
+ &.is-right {
+ padding-right: $gl-padding;
+ padding-left: $gl-padding + 1px;
+
+ &::after {
+ right: auto;
+ left: -1px;
}
}
}
@@ -872,26 +881,21 @@ $ide-commit-header-height: 48px;
}
.ide-sidebar {
- width: auto;
min-width: 60px;
}
.ide-right-sidebar {
- .ide-activity-bar {
- border-left: 1px solid var(--ide-border-color, $white-dark);
- }
-
.multi-file-commit-panel-inner {
- width: 350px;
padding: $grid-size 0;
background-color: var(--ide-highlight-background, $white);
- border-left: 1px solid var(--ide-border-color, $white-dark);
+ border-right: 1px solid var(--ide-border-color, $white-dark);
}
.ide-right-sidebar-jobs-detail {
padding-bottom: 0;
}
+ .ide-right-sidebar-terminal,
.ide-right-sidebar-clientside {
padding: 0;
}
@@ -901,7 +905,7 @@ $ide-commit-header-height: 48px;
@include ide-trace-view();
svg {
- --svg-status-bg: var(--ide-background, $white);
+ --svg-status-bg: var(--ide-background, #{$white});
}
.empty-state {
@@ -1043,7 +1047,7 @@ $ide-commit-header-height: 48px;
.ide-entry-dropdown-toggle {
padding: $gl-padding-4;
color: var(--ide-text-color, $gl-text-color);
- background-color: var(--ide-background, $gray-100);
+ background-color: var(--ide-background, $gray-50);
&:hover {
background-color: var(--ide-file-row-btn-hover-background, $gray-200);
@@ -1144,12 +1148,12 @@ $ide-commit-header-height: 48px;
}
.file-row.is-active {
- background: var(--ide-background, $gray-100);
+ background: var(--ide-background, $gray-50);
}
.file-row:hover,
.file-row:focus {
- background: var(--ide-background, $gray-100);
+ background: var(--ide-background, $gray-50);
.ide-new-btn {
display: block;
@@ -1159,3 +1163,22 @@ $ide-commit-header-height: 48px;
fill: var(--ide-text-color-secondary, $gl-text-color-secondary);
}
}
+
+.ide-terminal {
+ @include ide-trace-view();
+
+ .terminal-wrapper {
+ background: $black;
+ color: $gray-darkest;
+ overflow: hidden;
+ }
+
+ .xterm {
+ height: 100%;
+ padding: $grid-size;
+ }
+
+ .xterm-viewport {
+ overflow-y: auto;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/README.md b/app/assets/stylesheets/page_bundles/ide_themes/README.md
index 535179cc4c2..82e89aef49b 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/README.md
+++ b/app/assets/stylesheets/page_bundles/ide_themes/README.md
@@ -32,19 +32,7 @@ To add a new theme, follow the following steps:
3. Copy over all the CSS variables from `_dark.scss` to `_solarized_dark.scss` and assign them your own values.
Put them under the selector `.ide.theme-solarized-dark`.
4. Import this newly created SCSS file in `ide.scss` file in the parent directory.
-5. To make sure the variables apply to to your theme, add the selector `.ide.theme-solarized-dark` to the top
- of `_ide_theme_overrides.scss` file. The file should now look like this:
-
- ```scss
- .ide.theme-dark,
- .ide.theme-solarized-dark {
- /* file contents */
- }
- ```
-
- This step is temporary until all CSS variables in that file have their
- default values assigned.
-6. That's it! Raise a merge request with your newly added theme.
+5. That's it! Raise a merge request with your newly added theme.
## Modifying Monaco Themes
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
new file mode 100644
index 00000000000..a58a0ed9475
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
@@ -0,0 +1,50 @@
+// -------
+// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
+// -------
+.ide.theme-solarized-dark {
+ --ide-border-color: #002c38;
+ --ide-border-color-alt: var(--ide-background);
+ --ide-highlight-accent: #fff;
+ --ide-text-color: #ddd;
+ --ide-text-color-secondary: #ddd;
+ --ide-background: #004152;
+ --ide-background-hover: #003b4d;
+ --ide-highlight-background: #003240;
+ --ide-link-color: #73b9ff;
+ --ide-footer-background: var(--ide-highlight-background);
+
+ --ide-input-border: #d8d8d8;
+ --ide-input-background: transparent;
+ --ide-input-color: #fff;
+
+ --ide-btn-default-background: transparent;
+ --ide-btn-default-border: var(--ide-input-border);
+ --ide-btn-default-hover-border: #d8d8d8;
+
+ --ide-btn-primary-background: #1068bf;
+ --ide-btn-primary-border: #428fdc;
+ --ide-btn-primary-hover-border: #63a6e9;
+
+ --ide-btn-success-background: #217645;
+ --ide-btn-success-border: #108548;
+ --ide-btn-success-hover-border: #2da160;
+
+ --ide-btn-disabled-border: rgba(223, 223, 223, 0.24);
+ --ide-btn-disabled-color: rgba(145, 145, 145, 0.48);
+
+ --ide-btn-hover-border-width: 2px;
+
+ --ide-dropdown-background: #004c61;
+ --ide-dropdown-hover-background: #00617a;
+
+ --ide-dropdown-btn-hover-border: #e9ecef;
+ --ide-dropdown-btn-hover-background: var(--ide-background-hover);
+
+ --ide-file-row-btn-hover-background: #005a73;
+
+ --ide-diff-insert: rgba(155, 185, 85, 0.2);
+ --ide-diff-remove: rgba(255, 0, 0, 0.2);
+
+ --ide-animation-gradient-1: var(--ide-file-row-btn-hover-background);
+ --ide-animation-gradient-2: var(--ide-dropdown-hover-background);
+ }
diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss
index 89219e41644..591a26e5941 100644
--- a/app/assets/stylesheets/pages/alert_management/details.scss
+++ b/app/assets/stylesheets/pages/alert_management/details.scss
@@ -35,8 +35,39 @@
}
@include media-breakpoint-down(xs) {
- .alert-details-create-issue-button {
+ .alert-details-issue-button {
width: 100%;
}
}
+
+ .toggle-sidebar-mobile-button {
+ right: 0;
+ }
+
+ .dropdown-menu-toggle {
+ &:hover {
+ background-color: $white;
+ }
+ }
+
+ .assignee-dropdown-item {
+ .dropdown-item {
+ display: flex;
+ align-items: center;
+
+ &::before {
+ top: 50% !important;
+ }
+
+ &.is-active {
+ &:last-child {
+ border-bottom: 1px solid $gray-200;
+ }
+ }
+ }
+ }
+
+ .note-header-info {
+ margin-top: 1px;
+ }
}
diff --git a/app/assets/stylesheets/pages/alert_management/list.scss b/app/assets/stylesheets/pages/alert_management/list.scss
index dc181342def..c1ea9b7604a 100644
--- a/app/assets/stylesheets/pages/alert_management/list.scss
+++ b/app/assets/stylesheets/pages/alert_management/list.scss
@@ -1,22 +1,4 @@
.alert-management-list {
- // consider adding these stateful variants to @gitlab-ui
- // https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1178
- .hover-bg-blue-50:hover {
- background-color: $blue-50;
- }
-
- .hover-gl-cursor-pointer:hover {
- cursor: pointer;
- }
-
- .hover-gl-border-b-solid:hover {
- @include gl-border-b-solid;
- }
-
- .hover-gl-border-blue-200:hover {
- border-color: $blue-200;
- }
-
// these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui
table {
color: $gray-700;
@@ -26,25 +8,52 @@
outline: none;
}
+ > :not([aria-sort='none']).b-table-sort-icon-left:hover::before {
+ content: '' !important;
+ }
+
td,
th {
- @include gl-p-5;
- border: 0; // Remove cell border styling so that we can set border styling per row
-
- &.event-count {
- @include gl-pr-9;
- }
+ // TODO: There is no gl-pl-9 utlity for this padding, to be done and then removed.
+ padding-left: 1.25rem;
+ @include gl-py-5;
+ @include gl-outline-none;
+ @include gl-relative;
}
th {
background-color: transparent;
font-weight: $gl-font-weight-bold;
color: $gl-gray-600;
+
+ &:hover::before {
+ left: 3%;
+ top: 34%;
+ @include gl-absolute;
+ content: url("data:image/svg+xml,%3Csvg \
+ xmlns='http://www.w3.org/2000/svg' \
+ width='14' height='14' viewBox='0 0 16 \
+ 16'%3E%3Cpath fill='%23BABABA' fill-rule='evenodd' \
+ d='M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 \
+ C3.902375,11.3166 3.902375,10.6834 \
+ 4.292875,10.2929 C4.683375,9.90237 \
+ 5.316575,9.90237 5.707075,10.2929 \
+ L6.999975,11.5858 L6.999975,2 C6.999975,1.44771 \
+ 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 \
+ 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 \
+ C10.683395,9.90237 11.316555,9.90237 11.707085,10.2929 \
+ C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 \
+ Z'/%3E%3C/svg%3E%0A");
+ }
}
+ }
- &:last-child {
- td {
- @include gl-border-0;
+ @include media-breakpoint-up(md) {
+ tr {
+ &:last-child {
+ td {
+ @include gl-border-0;
+ }
}
}
}
@@ -52,21 +61,31 @@
@include media-breakpoint-down(sm) {
.alert-management-table {
- .table-col {
- min-height: 68px;
+ tr {
+ border-top: 0;
- &:last-child {
- background-color: $gray-10;
+ .table-col {
+ min-height: 68px;
- &::before {
- content: none !important;
- }
+ &:last-child {
+ background-color: $gray-10;
+
+ &::before {
+ content: none !important;
+ }
- div {
- width: 100% !important;
- padding: 0 !important;
+ div {
+ width: 100% !important;
+ padding: 0 !important;
+ }
}
}
+
+ &:hover {
+ background-color: $white;
+ border-color: $white;
+ border-bottom-style: none;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index d755170fe1f..3e680c59910 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -84,17 +84,22 @@
.board-title-caret {
cursor: pointer;
border-radius: $border-radius-default;
- padding: 4px;
+ line-height: $gl-spacing-scale-5;
+ height: $gl-spacing-scale-5;
+
+ &.btn svg {
+ top: 0;
+ }
&:hover {
- background-color: $gray-dark;
+ background-color: $gray-50;
transition: background-color 0.1s linear;
}
}
&:not(.is-collapsed) {
.board-title-caret {
- margin: 0 $gl-padding-4 0 -10px;
+ margin-right: $gl-padding-4;
}
}
@@ -155,7 +160,7 @@
.board-inner {
font-size: $issue-boards-font-size;
background: $gray-light;
- border: 1px solid $border-color;
+ border: 1px solid $gray-100;
}
.board-header {
@@ -186,8 +191,8 @@
.board-title {
align-items: center;
font-size: 1em;
- border-bottom: 1px solid $border-color;
- padding: $gl-padding-8 $gl-padding;
+ border-bottom: 1px solid $gray-100;
+ padding: $gl-padding-8;
.js-max-issue-size::before {
content: '/';
@@ -199,13 +204,13 @@
}
.board-delete {
- margin-right: 10px;
color: $gray-darkest;
background-color: transparent;
outline: 0;
&:hover {
color: $blue-600;
+ box-shadow: none;
}
}
@@ -246,7 +251,7 @@
.board-card {
background: $white;
- border: 1px solid $gray-200;
+ border: 1px solid $gray-100;
box-shadow: 0 1px 2px $issue-boards-card-shadow;
line-height: $gl-padding;
list-style: none;
@@ -541,7 +546,8 @@
cursor: help;
}
-.board-labels-toggle-wrapper {
+.board-labels-toggle-wrapper,
+.board-swimlanes-toggle-wrapper {
/**
* Make the wrapper the same height as a button so it aligns properly when the
* filtered-search-box input element increases in size on Linux smaller breakpoints
@@ -572,3 +578,8 @@
top: 0;
}
}
+
+.board-epics-swimlanes {
+ overflow-x: auto;
+ min-height: 600px;
+}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index ddd1a373e2a..98d74a9aaa2 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -446,7 +446,7 @@ table.code {
vertical-align: top;
span {
- white-space: pre-wrap;
+ white-space: break-spaces;
&.context-cell {
display: inline-block;
diff --git a/app/assets/stylesheets/pages/experience_level.scss b/app/assets/stylesheets/pages/experience_level.scss
new file mode 100644
index 00000000000..e57ad6321a5
--- /dev/null
+++ b/app/assets/stylesheets/pages/experience_level.scss
@@ -0,0 +1,29 @@
+.signup-page[data-page^='registrations:experience_levels'] {
+ $card-shadow-color: rgba($black, 0.2);
+
+ .page-wrap {
+ background-color: $white;
+ }
+
+ .card-deck {
+ max-width: 828px;
+ }
+
+ .card {
+ transition: box-shadow 0.3s ease-in-out;
+ }
+
+ .card:hover {
+ box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color;
+ }
+
+ @media (min-width: $breakpoint-sm) {
+ .card-deck .card {
+ margin: 0 $gl-spacing-scale-3;
+ }
+ }
+
+ .stretched-link:hover {
+ text-decoration: none;
+ }
+}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index beb0ea27de0..c309c8d157a 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -14,17 +14,12 @@
flex-direction: column;
margin: 0;
- .group-row-contents .controls > .btn:last-child {
- margin: 0;
- }
-
li {
.title {
font-weight: 600;
}
a {
- color: $gray-900;
text-decoration: none;
&:hover {
@@ -42,8 +37,6 @@
}
.group-row {
- @include basic-list-stats;
-
.description p {
margin-bottom: 0;
color: $gl-text-color-secondary;
@@ -56,6 +49,12 @@
}
}
+.save-group-loader {
+ margin-top: $gl-padding-50;
+ margin-bottom: $gl-padding-50;
+ color: $gl-gray-700;
+}
+
.group-nav-container .nav-controls {
.group-filter-form {
flex: 1 1 auto;
@@ -454,29 +453,10 @@ table.pipeline-project-metrics tr td {
min-width: 30px;
}
- > span:last-child {
- margin-right: 0;
- }
-
.stat-value {
margin: 2px 0 0 5px;
}
}
-
- .controls {
- flex-basis: 90px;
-
- > .btn {
- margin: 0 $btn-side-margin 0 0;
- color: $gl-text-color-secondary;
- }
- }
-
- .metadata {
- @include media-breakpoint-up(md) {
- flex-basis: 240px;
- }
- }
}
.project-row-contents .stats {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index b241d0a2bdc..b1e849143b0 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -396,7 +396,7 @@
overflow: hidden;
&:hover {
- background-color: $sidebar-block-hover-color;
+ background-color: $gray-200;
}
&.issuable-sidebar-header {
@@ -754,7 +754,8 @@
margin-right: 10px;
min-width: 0;
- .issue-weight-icon {
+ .issue-weight-icon,
+ .issue-estimate-icon {
vertical-align: sub;
}
}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 0dd25ec5360..0c349ab73a3 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -304,6 +304,72 @@ ul.related-merge-requests > li {
}
}
+.issue-sticky-header {
+ @include gl-left-0;
+ @include gl-w-full;
+ top: $header-height;
+
+ // collapsed right sidebar
+ @include media-breakpoint-up(sm) {
+ width: calc(100% - #{$gutter-collapsed-width});
+ }
+
+ .issue-sticky-header-text {
+ max-width: $limited-layout-width;
+ }
+}
+
+.with-performance-bar .issue-sticky-header {
+ top: $header-height + $performance-bar-height;
+}
+
+@include media-breakpoint-up(md) {
+ // collapsed left sidebar + collapsed right sidebar
+ .issue-sticky-header {
+ left: $contextual-sidebar-collapsed-width;
+ width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
+ }
+
+ // collapsed left sidebar + expanded right sidebar
+ .right-sidebar-expanded .issue-sticky-header {
+ width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
+ }
+}
+
+@include media-breakpoint-up(xl) {
+ // expanded left sidebar + collapsed right sidebar
+ .issue-sticky-header {
+ left: $contextual-sidebar-width;
+ width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width});
+ }
+
+ // collapsed left sidebar + collapsed right sidebar
+ .page-with-icon-sidebar .issue-sticky-header {
+ left: $contextual-sidebar-collapsed-width;
+ width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
+ }
+
+ // expanded left sidebar + expanded right sidebar
+ .right-sidebar-expanded .issue-sticky-header {
+ width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width});
+ }
+
+ // collapsed left sidebar + expanded right sidebar
+ .right-sidebar-expanded.page-with-icon-sidebar .issue-sticky-header {
+ width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
+ }
+}
+
+.issuable-header-slide-enter-active,
+.issuable-header-slide-leave-active {
+ @include gl-transition-slow;
+}
+
+.issuable-header-slide-enter,
+.issuable-header-slide-leave-to {
+ transform: translateY(-100%);
+}
+
.issuable-list-root {
.gl-label-link {
text-decoration: none;
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 22c1cb127cd..c3bac053a0a 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -86,7 +86,7 @@
justify-content: space-between;
padding: $gl-padding;
border-radius: $border-radius-default;
- border: 1px solid $gray-100;
+ border: 1px solid $gray-50;
&:last-child {
margin-bottom: 0;
@@ -276,7 +276,7 @@
}
.label-badge-gray {
- background-color: $gray-100;
+ background-color: $gray-50;
}
.label-links {
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 67a8f689e9d..81a70470c65 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -96,14 +96,21 @@
margin: 0;
}
- .omniauth-btn {
- margin-bottom: $gl-padding;
+ form {
width: 48%;
- padding: $gl-padding-8;
+ padding: 0;
+ border: 0;
+ background: none;
+ margin-bottom: $gl-padding;
@include media-breakpoint-down(md) {
width: 100%;
}
+ }
+
+ .omniauth-btn {
+ width: 100%;
+ padding: $gl-padding-8;
img {
width: $default-icon-size;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 57afe45a74b..c3f3dbc223b 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -253,11 +253,11 @@ table {
background-color: $gray-light;
border-radius: 0 0 3px 3px;
padding: $gl-padding;
- border-top: 1px solid $gray-100;
+ border-top: 1px solid $gray-50;
+ .new-note {
background-color: $gray-light;
- border-top: 1px solid $gray-100;
+ border-top: 1px solid $gray-50;
}
&.is-replying {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index bed147aa3a7..e8cdfd717c0 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 $gray-100;
+ border-left: 2px solid $gray-50;
position: absolute;
top: 0;
bottom: 0;
@@ -83,8 +83,8 @@ $note-form-margin-left: 72px;
.replies-toggle {
background-color: $gray-light;
padding: $gl-padding-8 $gl-padding;
- border-top: 1px solid $gray-100;
- border-bottom: 1px solid $gray-100;
+ border-top: 1px solid $gray-50;
+ border-bottom: 1px solid $gray-50;
.collapse-replies-btn:hover {
color: $blue-600;
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 154717f9776..43d766db9e0 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -669,7 +669,8 @@
.ci-action-icon-container {
position: absolute;
right: 5px;
- top: 5px;
+ top: 50%;
+ transform: translateY(-50%);
// Action Icons in big pipeline-graph nodes
&.ci-action-icon-wrapper {
@@ -920,7 +921,7 @@ button.mini-pipeline-graph-dropdown-toggle {
.ci-status-icon {
- @extend .append-right-8;
+ @include gl-mr-3;
position: relative;
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss
index 45e62913f37..3bab84af492 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/pages/profiles/preferences.scss
@@ -15,6 +15,10 @@
}
.application-theme {
+ $ui-dark-bg: #2e2e2e;
+ $ui-light-bg: #dfdfdf;
+ $ui-dark-mode-bg: #1f1f1f;
+
label {
margin: 0 $gl-padding-32 $gl-padding 0;
text-align: center;
@@ -60,11 +64,17 @@
}
&.ui-dark {
- background-color: $gray-900;
+ background-color: $ui-dark-bg;
+ border: solid 1px $border-color;
}
&.ui-light {
- background-color: $gray-200;
+ background-color: $ui-light-bg;
+ }
+
+ &.gl-dark {
+ background-color: $ui-dark-mode-bg;
+ border: solid 1px $border-color;
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index c0a1cf10fe4..438f6c2546e 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -396,7 +396,7 @@
margin-right: $gl-padding-4;
margin-bottom: $gl-padding-4;
color: $gl-text-color-secondary;
- background-color: $gray-100;
+ background-color: $gray-50;
line-height: $gl-btn-line-height;
&:hover {
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index 0f56b98a78d..26db1fb9f58 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -21,6 +21,14 @@
}
}
}
+
+ .links-section {
+ .gl-hover-text-blue-600-children:hover {
+ * {
+ @include gl-text-blue-600;
+ }
+ }
+ }
}
.draggable {
diff --git a/app/assets/stylesheets/pages/storage_quota.scss b/app/assets/stylesheets/pages/storage_quota.scss
new file mode 100644
index 00000000000..347bd1316c0
--- /dev/null
+++ b/app/assets/stylesheets/pages/storage_quota.scss
@@ -0,0 +1,17 @@
+.storage-type-usage {
+ &:first-child {
+ @include gl-rounded-top-left-base;
+ @include gl-rounded-bottom-left-base;
+ }
+
+ &:last-child {
+ @include gl-rounded-top-right-base;
+ @include gl-rounded-bottom-right-base;
+ }
+
+ &:not(:last-child) {
+ @include gl-border-r-2;
+ @include gl-border-r-solid;
+ @include gl-border-white;
+ }
+}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
new file mode 100644
index 00000000000..1f2a7645495
--- /dev/null
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -0,0 +1,134 @@
+$gray-10: #1f1f1f;
+$gray-50: #2e2e2e;
+$gray-100: #4f4f4f;
+$gray-200: #707070;
+$gray-300: #919191;
+$gray-400: #a7a7a7;
+$gray-500: #bababa;
+$gray-600: #ccc;
+$gray-700: #dfdfdf;
+$gray-800: #f2f2f2;
+$gray-900: #fafafa;
+$gray-950: #fff;
+
+$gl-gray-100: #333;
+$gl-gray-200: #555;
+$gl-gray-350: #666;
+$gl-gray-400: #777;
+$gl-gray-500: #999;
+$gl-gray-600: #aaa;
+$gl-gray-700: #ccc;
+$gl-gray-800: #ddd;
+
+$green-50: #072b15;
+$green-100: #0a4020;
+$green-200: #0e5a2d;
+$green-300: #12753a;
+$green-400: #168f48;
+$green-500: #1aaa55;
+$green-600: #37b96d;
+$green-700: #75d09b;
+$green-800: #b3e6c8;
+$green-900: #dcf5e7;
+$green-950: #f1fdf6;
+
+$blue-50: #0a2744;
+$blue-100: #0f3b66;
+$blue-200: #134a81;
+$blue-300: #17599c;
+$blue-400: #1b69b6;
+$blue-500: #1f78d1;
+$blue-600: #418cd8;
+$blue-700: #73afea;
+$blue-800: #b8d6f4;
+$blue-900: #e4f0fb;
+$blue-950: #f6fafe;
+
+$orange-50: #592800;
+$orange-100: #853c00;
+$orange-200: #a35200;
+$orange-300: #c26700;
+$orange-400: #de7e00;
+$orange-500: #fc9403;
+$orange-600: #fca429;
+$orange-700: #fdbc60;
+$orange-800: #fed69f;
+$orange-900: #fff1de;
+$orange-950: #fffaf4;
+
+$red-50: #4b140b;
+$red-100: #711e11;
+$red-200: #8b2615;
+$red-300: #a62d19;
+$red-400: #c0341d;
+$red-500: #db3b21;
+$red-600: #e05842;
+$red-700: #ea8271;
+$red-800: #f2b4a9;
+$red-900: #fbe5e1;
+$red-950: #fef6f5;
+
+$indigo-50: #1a1a40;
+$indigo-100: #292961;
+$indigo-200: #393982;
+$indigo-300: #4b4ba3;
+$indigo-400: #5b5bbd;
+$indigo-500: #6666c4;
+$indigo-600: #7c7ccc;
+$indigo-700: #a6a6de;
+$indigo-800: #d1d1f0;
+$indigo-900: #ebebfa;
+$indigo-950: #f7f7ff;
+
+$gray-lightest: #222;
+$gray-light: $gray-50;
+$gray-lighter: #303030;
+$gray-normal: #333;
+$gray-dark: $gray-100;
+$gray-darker: #4f4f4f;
+$gray-darkest: #c4c4c4;
+
+$black: #fff;
+$white: #333;
+$white-light: #2b2b2b;
+$white-normal: #333;
+$white-dark: #444;
+
+$border-white-light: $gray-900;
+$border-white-normal: $gray-900;
+
+$body-bg: $gray-50;
+$input-bg: $gray-100;
+$input-focus-bg: $gray-100;
+$input-color: $gray-900;
+$input-group-addon-bg: $gray-900;
+
+$tooltip-bg: $gray-800;
+$tooltip-color: $gray-10;
+
+$popover-color: $gray-950;
+$popover-box-shadow: 0 2px 3px 1px $gray-700;
+$popover-arrow-outer-color: $gray-800;
+
+$secondary: $gray-600;
+
+$issues-today-bg: #333838;
+$issues-today-border: #333a40;
+
+.gl-label {
+ filter: brightness(0.9) contrast(1.1);
+}
+
+// white-ish text for light labels
+// and for scoped label value (the right section)
+.gl-label-text-light.gl-label-text-light,
+.gl-label-text-dark + .gl-label-text-dark {
+ color: $gray-900;
+}
+
+// duplicated class as the original .atwho-view style is added later
+.atwho-view.atwho-view {
+ background-color: $white;
+ color: $gray-900;
+ border-color: $gray-800;
+}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 8cf5c533f1f..176d64272c2 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -81,68 +81,22 @@
.gl-h-32 { height: px-to-rem($grid-size * 4); }
.gl-h-64 { height: px-to-rem($grid-size * 8); }
-.gl-shim-h-2 {
- height: px-to-rem(4px);
-}
-
-.gl-shim-w-5 {
- width: px-to-rem(16px);
-}
-
-.gl-shim-pb-3 {
- padding-bottom: 8px;
-}
-
-.gl-shim-pt-5 {
- padding-top: 16px;
-}
-
-.gl-shim-mx-2 {
- margin-left: 4px;
- margin-right: 4px;
+.d-sm-table-column {
+ @include media-breakpoint-up(sm) {
+ display: table-column !important;
+ }
}
.gl-text-purple { color: $purple; }
-.gl-text-gray-800 { color: $gray-800; }
.gl-bg-purple-light { background-color: $purple-light; }
-// Classes using mixins coming from @gitlab-ui
-// can be removed once the mixins are added.
-// See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details.
-.gl-bg-blue-50 { @include gl-bg-blue-50; }
-.gl-bg-red-100 { @include gl-bg-red-100; }
-.gl-bg-orange-100 { @include gl-bg-orange-100; }
-.gl-bg-gray-50 { @include gl-bg-gray-50; }
-.gl-bg-gray-100 { @include gl-bg-gray-100; }
-.gl-bg-green-100 { @include gl-bg-green-100;}
-.gl-bg-blue-500 { @include gl-bg-blue-500; }
-.gl-bg-green-500 { @include gl-bg-green-500; }
-.gl-bg-theme-indigo-500 { @include gl-bg-theme-indigo-500; }
-.gl-bg-red-500 { @include gl-bg-red-500; }
-.gl-bg-orange-500 { @include gl-bg-orange-500; }
-
-.gl-text-blue-500 { @include gl-text-blue-500; }
-.gl-text-gray-500 { @include gl-text-gray-500; }
-.gl-text-gray-700 { @include gl-text-gray-700; }
-.gl-text-gray-900 { @include gl-text-gray-900; }
-.gl-text-red-700 { @include gl-text-red-700; }
-.gl-text-red-900 { @include gl-text-red-900; }
-.gl-text-orange-400 { @include gl-text-orange-400; }
-.gl-text-orange-500 { @include gl-text-orange-500; }
-.gl-text-orange-600 { @include gl-text-orange-600; }
-.gl-text-orange-700 { @include gl-text-orange-700; }
-.gl-text-green-500 { @include gl-text-green-500; }
-.gl-text-green-700 { @include gl-text-green-700; }
-
-.gl-align-items-center { @include gl-align-items-center; }
-
-.d-sm-table-column {
- @include media-breakpoint-up(sm) {
- display: table-column !important;
- }
+// move this to GitLab UI once onboarding experiment is considered a success
+.gl-py-8 {
+ padding-top: $gl-spacing-scale-8;
+ padding-bottom: $gl-spacing-scale-8;
}
-.gl-white-space-normal { @include gl-white-space-normal; }
-.gl-word-break-all { @include gl-word-break-all; }
-.gl-reset-line-height { @include gl-reset-line-height; }
-.gl-reset-text-align { @include gl-reset-text-align; }
+// move this to GitLab UI once onboarding experiment is considered a success
+.gl-pl-7 {
+ padding-left: $gl-spacing-scale-7;
+}
diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
index 9aec2305390..0de2b0185b5 100644
--- a/app/channels/application_cable/channel.rb
+++ b/app/channels/application_cable/channel.rb
@@ -2,5 +2,16 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
+ include Logging
+
+ private
+
+ def notification_payload(_)
+ super.merge!(params: params.except(:channel))
+ end
+
+ def request
+ connection.request
+ end
end
end
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
index 87c833f3593..1361269f2a2 100644
--- a/app/channels/application_cable/connection.rb
+++ b/app/channels/application_cable/connection.rb
@@ -2,8 +2,12 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
+ include Logging
+
identified_by :current_user
+ public :request
+
def connect
self.current_user = find_user_from_session_store
end
@@ -18,5 +22,9 @@ module ApplicationCable
def session_id
Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]])
end
+
+ def notification_payload(_)
+ super.merge!(params: request.params)
+ end
end
end
diff --git a/app/channels/application_cable/logging.rb b/app/channels/application_cable/logging.rb
new file mode 100644
index 00000000000..4152f8c779f
--- /dev/null
+++ b/app/channels/application_cable/logging.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ApplicationCable
+ module Logging
+ private
+
+ def notification_payload(_)
+ super.merge!(
+ Labkit::Correlation::CorrelationId::LOG_KEY => request.request_id,
+ user_id: current_user&.id,
+ username: current_user&.username,
+ remote_ip: request.remote_ip,
+ ua: request.env['HTTP_USER_AGENT']
+ )
+ end
+ end
+end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 709834a2bec..94c82c25357 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -12,6 +12,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action :whitelist_query_limiting, only: [:usage_data]
+ before_action only: [:ci_cd] do
+ push_frontend_feature_flag(:ci_instance_variables_ui, default_enabled: true)
+ end
+
VALID_SETTING_PANELS = %w(general integrations repository
ci_cd reporting metrics_and_profiling
network preferences).freeze
@@ -216,6 +220,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
[
*::ApplicationSettingsHelper.visible_attributes,
*::ApplicationSettingsHelper.external_authorization_service_attributes,
+ *ApplicationSetting.repository_storages_weighted_attributes,
:lets_encrypt_notification_email,
:lets_encrypt_terms_of_service_accepted,
:domain_blacklist_file,
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 4639d8adfe0..2449fa3128c 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -4,7 +4,7 @@ class Admin::RunnersController < Admin::ApplicationController
before_action :runner, except: [:index, :tag_list]
def index
- finder = Admin::RunnersFinder.new(params: params)
+ finder = Ci::RunnersFinder.new(current_user: current_user, params: params)
@runners = finder.execute
@active_runners_count = Ci::Runner.online.count
@sort = finder.sort_key
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index ee42baa8326..fc0acd8f99a 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -241,7 +241,8 @@ class Admin::UsersController < Admin::ApplicationController
:theme_id,
:twitter,
:username,
- :website_url
+ :website_url,
+ :note
]
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 54e3275662b..79a164a5574 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
include WithPerformanceBar
+ include Gitlab::SearchContext::ControllerConcern
include SessionlessAuthentication
include SessionsHelper
include ConfirmEmailWarning
diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb
index 8c13cc67be2..6b83400971d 100644
--- a/app/controllers/clusters/base_controller.rb
+++ b/app/controllers/clusters/base_controller.rb
@@ -7,7 +7,7 @@ class Clusters::BaseController < ApplicationController
before_action :authorize_read_cluster!
before_action do
- push_frontend_feature_flag(:managed_apps_local_tiller)
+ push_frontend_feature_flag(:managed_apps_local_tiller, clusterable)
end
helper_method :clusterable
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index aa39d430b24..46dec5f3287 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -23,6 +23,7 @@ class Clusters::ClustersController < Clusters::BaseController
respond_to do |format|
format.html
format.json do
+ Gitlab::PollingInterval.set_header(response, interval: STATUS_POLLING_INTERVAL)
serializer = ClusterSerializer.new(current_user: current_user)
render json: {
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index d486d734db8..6c443611a60 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -23,8 +23,7 @@ module EnforcesTwoFactorAuthentication
def two_factor_authentication_required?
Gitlab::CurrentSettings.require_two_factor_authentication? ||
- current_user.try(:require_two_factor_authentication_from_group?) ||
- current_user.try(:ultraauth_user?)
+ current_user.try(:require_two_factor_authentication_from_group?)
end
def current_user_requires_two_factor?
diff --git a/app/controllers/concerns/find_snippet.rb b/app/controllers/concerns/find_snippet.rb
new file mode 100644
index 00000000000..d51f1a1b3ad
--- /dev/null
+++ b/app/controllers/concerns/find_snippet.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module FindSnippet
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ private
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ def snippet
+ strong_memoize(:snippet) do
+ snippet_klass.inc_relations_for_view.find_by(id: snippet_id)
+ end
+ end
+ # rubocop:enable CodeReuse/ActiveRecord
+
+ def snippet_klass
+ raise NotImplementedError
+ end
+
+ def snippet_id
+ params[:id]
+ end
+end
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index ff283f9bb62..cc9db7936e8 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -16,10 +16,12 @@ module IntegrationsActions
def update
saved = integration.update(service_params[:service])
+ overwrite = Gitlab::Utils.to_boolean(params[:overwrite])
respond_to do |format|
format.html do
if saved
+ PropagateIntegrationWorker.perform_async(integration.id, overwrite)
redirect_to scoped_edit_integration_path(integration), notice: success_message
else
render 'shared/integrations/edit'
@@ -34,6 +36,10 @@ module IntegrationsActions
end
end
+ def custom_integration_projects
+ Project.with_custom_integration_compared_to(integration).page(params[:page]).per(20)
+ end
+
def test
render json: {}, status: :ok
end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 0b1b3f2bcba..98fa8202e25 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -16,19 +16,6 @@ module IssuableActions
end
end
- def permitted_keys
- [
- :issuable_ids,
- :assignee_id,
- :milestone_id,
- :state_event,
- :subscription_event,
- label_ids: [],
- add_label_ids: [],
- remove_label_ids: []
- ]
- end
-
def show
respond_to do |format|
format.html do
@@ -221,10 +208,20 @@ module IssuableActions
end
def bulk_update_params
- permitted_keys_array = permitted_keys.dup
- permitted_keys_array << { assignee_ids: [] }
+ params.require(:update).permit(bulk_update_permitted_keys)
+ end
- params.require(:update).permit(permitted_keys_array)
+ def bulk_update_permitted_keys
+ [
+ :issuable_ids,
+ :assignee_id,
+ :milestone_id,
+ :state_event,
+ :subscription_event,
+ assignee_ids: [],
+ add_label_ids: [],
+ remove_label_ids: []
+ ]
end
def resource_name
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 5aa00af8910..9ef067e8797 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -5,7 +5,6 @@ module IssuableCollections
include PaginatedCollection
include SortingHelper
include SortingPreference
- include Gitlab::IssuableMetadata
include Gitlab::Utils::StrongMemoize
included do
@@ -44,7 +43,7 @@ module IssuableCollections
def set_pagination
@issuables = @issuables.page(params[:page])
@issuables = per_page_for_relative_position if params[:sort] == 'relative_position'
- @issuable_meta_data = issuable_meta_data(@issuables, collection_type, current_user)
+ @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @issuables).data
@total_pages = issuable_page_count(@issuables)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index 78b3c6771b3..e3ac117660b 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -11,7 +11,7 @@ module IssuableCollectionsAction
.non_archived
.page(params[:page])
- @issuable_meta_data = issuable_meta_data(@issues, collection_type, current_user)
+ @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @issues).data
respond_to do |format|
format.html
@@ -22,7 +22,7 @@ module IssuableCollectionsAction
def merge_requests
@merge_requests = issuables_collection.page(params[:page])
- @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type, current_user)
+ @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @merge_requests).data
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/concerns/known_sign_in.rb b/app/controllers/concerns/known_sign_in.rb
index 97883d8d08c..c0b9605de58 100644
--- a/app/controllers/concerns/known_sign_in.rb
+++ b/app/controllers/concerns/known_sign_in.rb
@@ -26,6 +26,6 @@ module KnownSignIn
end
def notify_user
- current_user.notification_service.unknown_sign_in(current_user, request.remote_ip)
+ current_user.notification_service.unknown_sign_in(current_user, request.remote_ip, current_user.current_sign_in_at)
end
end
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index dbc575a1487..29138e7b014 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -51,13 +51,7 @@ module MilestoneActions
}
end
- # rubocop:disable Gitlab/ModuleWithInstanceVariables
def milestone_redirect_path
- if @milestone.global_milestone?
- url_for(action: :show, title: @milestone.title)
- else
- url_for(action: :show)
- end
+ url_for(action: :show)
end
- # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index d4b0d3b2674..d3dfb1813e4 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -13,9 +13,7 @@ module NotesActions
end
def index
- current_fetched_at = Time.current.to_i
-
- notes_json = { notes: [], last_fetched_at: current_fetched_at }
+ notes_json = { notes: [], last_fetched_at: Time.current.to_i }
notes = notes_finder
.execute
@@ -24,7 +22,7 @@ module NotesActions
if notes_filter != UserPreference::NOTES_FILTERS[:only_comments]
notes =
ResourceEvents::MergeIntoNotesService
- .new(noteable, current_user, last_fetched_at: current_fetched_at)
+ .new(noteable, current_user, last_fetched_at: last_fetched_at)
.execute(notes)
end
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index ba15d611c0d..2916762e31f 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -32,7 +32,7 @@ module PreviewMarkdown
def markdown_context_params
case controller_name
- when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
+ when 'wikis' then { pipeline: :wiki, wiki: wiki, page_slug: params[:id] }
when 'snippets' then { skip_project_check: true }
when 'groups' then { group: group }
when 'projects' then projects_filter_params
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index e2c83f9a069..e78fa8f8250 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -6,6 +6,7 @@ module ServiceParams
ALLOWED_PARAMS_CE = [
:active,
:add_pusher,
+ :alert_events,
:api_key,
:api_url,
:api_version,
@@ -28,6 +29,8 @@ module ServiceParams
:drone_url,
:enable_ssl_verification,
:external_wiki_url,
+ :google_iap_service_account_json,
+ :google_iap_audience_client_id,
# We're using `issues_events` and `merge_requests_events`
# in the view so we still need to explicitly state them
# here. `Service#event_names` would only give
diff --git a/app/controllers/concerns/snippet_authorizations.rb b/app/controllers/concerns/snippet_authorizations.rb
new file mode 100644
index 00000000000..9bbb0cc6faa
--- /dev/null
+++ b/app/controllers/concerns/snippet_authorizations.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module SnippetAuthorizations
+ extend ActiveSupport::Concern
+
+ private
+
+ def authorize_read_snippet!
+ return render_404 unless can?(current_user, :read_snippet, snippet)
+ end
+
+ def authorize_update_snippet!
+ return render_404 unless can?(current_user, :update_snippet, snippet)
+ end
+
+ def authorize_admin_snippet!
+ return render_404 unless can?(current_user, :admin_snippet, snippet)
+ end
+
+ def authorize_create_snippet!
+ return render_404 unless can?(current_user, :create_snippet)
+ end
+end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index e78723bdda2..51fc12398d9 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -3,9 +3,18 @@
module SnippetsActions
extend ActiveSupport::Concern
include SendsBlob
+ include RendersNotes
+ include RendersBlob
+ include PaginatedCollection
+ include Gitlab::NoteableMetadata
included do
+ skip_before_action :verify_authenticity_token,
+ if: -> { action_name == 'show' && js_request? }
+
before_action :redirect_if_binary, only: [:edit, :update]
+
+ respond_to :html
end
def edit
@@ -43,6 +52,58 @@ module SnippetsActions
request.format.js?
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def show
+ conditionally_expand_blob(blob)
+
+ respond_to do |format|
+ format.html do
+ @note = Note.new(noteable: @snippet, project: @snippet.project)
+ @noteable = @snippet
+
+ @discussions = @snippet.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
+ render 'show'
+ end
+
+ format.json do
+ render_blob_json(blob)
+ end
+
+ format.js do
+ if @snippet.embeddable?
+ render 'shared/snippets/show'
+ else
+ head :not_found
+ end
+ end
+ end
+ end
+
+ def update
+ update_params = snippet_params.merge(spammable_params)
+
+ service_response = Snippets::UpdateService.new(@snippet.project, current_user, update_params).execute(@snippet)
+ @snippet = service_response.payload[:snippet]
+
+ handle_repository_error(:edit)
+ end
+
+ def destroy
+ service_response = Snippets::DestroyService.new(current_user, @snippet).execute
+
+ if service_response.success?
+ redirect_to gitlab_dashboard_snippets_path(@snippet), status: :found
+ elsif service_response.http_status == 403
+ access_denied!
+ else
+ redirect_to gitlab_snippet_path(@snippet),
+ status: :found,
+ alert: service_response.message
+ end
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
private
def content_disposition
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
new file mode 100644
index 00000000000..b4b4fd84c37
--- /dev/null
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -0,0 +1,232 @@
+# frozen_string_literal: true
+
+module WikiActions
+ include SendsBlob
+ include Gitlab::Utils::StrongMemoize
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :authorize_read_wiki!
+ before_action :authorize_create_wiki!, only: [:edit, :create]
+ before_action :authorize_admin_wiki!, only: :destroy
+
+ before_action :wiki
+ before_action :page, only: [:show, :edit, :update, :history, :destroy]
+ before_action :load_sidebar, except: [:pages]
+
+ before_action only: [:show, :edit, :update] do
+ @valid_encoding = valid_encoding?
+ end
+
+ before_action only: [:edit, :update], unless: :valid_encoding? do
+ redirect_to wiki_page_path(wiki, page)
+ end
+ end
+
+ def new
+ redirect_to wiki_page_path(wiki, SecureRandom.uuid, random_title: true)
+ end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def pages
+ @wiki_pages = Kaminari.paginate_array(
+ wiki.list_pages(sort: params[:sort], direction: params[:direction])
+ ).page(params[:page])
+
+ @wiki_entries = WikiPage.group_by_directory(@wiki_pages)
+
+ render 'shared/wikis/pages'
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ # `#show` handles a number of scenarios:
+ #
+ # - If `id` matches a WikiPage, then show the wiki page.
+ # - If `id` is a file in the wiki repository, then send the file.
+ # - If we know the user wants to create a new page with the given `id`,
+ # then display a create form.
+ # - Otherwise show the empty wiki page and invite the user to create a page.
+ #
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def show
+ if page
+ set_encoding_error unless valid_encoding?
+
+ # Assign vars expected by MarkupHelper
+ @ref = params[:version_id]
+ @path = page.path
+
+ render 'shared/wikis/show'
+ elsif file_blob
+ send_blob(wiki.repository, file_blob, allow_caching: container.public?)
+ elsif show_create_form?
+ # Assign a title to the WikiPage unless `id` is a randomly generated slug from #new
+ title = params[:id] unless params[:random_title].present?
+
+ @page = build_page(title: title)
+
+ render 'shared/wikis/edit'
+ else
+ render 'shared/wikis/empty'
+ end
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def edit
+ render 'shared/wikis/edit'
+ end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def update
+ return render('shared/wikis/empty') unless can?(current_user, :create_wiki, container)
+
+ @page = WikiPages::UpdateService.new(container: container, current_user: current_user, params: wiki_params).execute(page)
+
+ if page.valid?
+ redirect_to(
+ wiki_page_path(wiki, page),
+ notice: _('Wiki was successfully updated.')
+ )
+ else
+ render 'shared/wikis/edit'
+ end
+ rescue WikiPage::PageChangedError, WikiPage::PageRenameError, Gitlab::Git::Wiki::OperationError => e
+ @error = e
+ render 'shared/wikis/edit'
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def create
+ @page = WikiPages::CreateService.new(container: container, current_user: current_user, params: wiki_params).execute
+
+ if page.persisted?
+ redirect_to(
+ wiki_page_path(wiki, page),
+ notice: _('Wiki was successfully updated.')
+ )
+ else
+ render 'shared/wikis/edit'
+ end
+ rescue Gitlab::Git::Wiki::OperationError => e
+ @page = build_page(wiki_params)
+ @error = e
+ render 'shared/wikis/edit'
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def history
+ if page
+ @page_versions = Kaminari.paginate_array(page.versions(page: params[:page].to_i),
+ total_count: page.count_versions)
+ .page(params[:page])
+
+ render 'shared/wikis/history'
+ else
+ redirect_to(
+ wiki_path(wiki),
+ notice: _("Page not found")
+ )
+ end
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def destroy
+ WikiPages::DestroyService.new(container: container, current_user: current_user).execute(page)
+
+ redirect_to wiki_path(wiki),
+ status: :found,
+ notice: _("Page was successfully deleted")
+ rescue Gitlab::Git::Wiki::OperationError => e
+ @error = e
+ render 'shared/wikis/edit'
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ private
+
+ def container
+ raise NotImplementedError
+ end
+
+ def show_create_form?
+ can?(current_user, :create_wiki, container) &&
+ page.nil? &&
+ # Always show the create form when the wiki has had at least one page created.
+ # Otherwise, we only show the form when the user has navigated from
+ # the 'empty wiki' page
+ (wiki.exists? || params[:view] == 'create')
+ end
+
+ def wiki
+ strong_memoize(:wiki) do
+ wiki = Wiki.for_container(container, current_user)
+
+ # Call #wiki to make sure the Wiki Repo is initialized
+ wiki.wiki
+
+ wiki
+ end
+ rescue Wiki::CouldNotCreateWikiError
+ flash[:notice] = _("Could not create Wiki Repository at this time. Please try again later.")
+ redirect_to container
+ false
+ end
+
+ def page
+ strong_memoize(:page) do
+ wiki.find_page(*page_params)
+ end
+ end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def load_sidebar
+ @sidebar_page = wiki.find_sidebar(params[:version_id])
+
+ unless @sidebar_page # Fallback to default sidebar
+ @sidebar_wiki_entries, @sidebar_limited = wiki.sidebar_entries
+ end
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def wiki_params
+ params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha)
+ end
+
+ def build_page(args = {})
+ WikiPage.new(wiki).tap do |page|
+ page.update_attributes(args) # rubocop:disable Rails/ActiveRecordAliases
+ end
+ end
+
+ def page_params
+ keys = [:id]
+ keys << :version_id if params[:action] == 'show'
+
+ params.values_at(*keys)
+ end
+
+ def valid_encoding?
+ page_encoding == Encoding::UTF_8
+ end
+
+ def page_encoding
+ strong_memoize(:page_encoding) { page&.content&.encoding }
+ 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.")
+ end
+
+ def file_blob
+ strong_memoize(:file_blob) do
+ commit = wiki.repository.commit(wiki.default_branch)
+
+ next unless commit
+
+ wiki.repository.blob_at(commit.id, params[:id])
+ end
+ end
+end
diff --git a/app/controllers/concerns/workhorse_import_export_upload.rb b/app/controllers/concerns/workhorse_import_export_upload.rb
new file mode 100644
index 00000000000..3c52f4d7adf
--- /dev/null
+++ b/app/controllers/concerns/workhorse_import_export_upload.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module WorkhorseImportExportUpload
+ extend ActiveSupport::Concern
+ include WorkhorseRequest
+
+ included do
+ skip_before_action :verify_authenticity_token, only: %i[authorize]
+ before_action :verify_workhorse_api!, only: %i[authorize]
+ end
+
+ def authorize
+ set_workhorse_internal_api_content_type
+
+ authorized = ImportExportUploader.workhorse_authorize(
+ has_length: false,
+ maximum_size: Gitlab::CurrentSettings.max_import_size.megabytes
+ )
+
+ render json: authorized
+ rescue SocketError
+ render json: _("Error uploading file"), status: :internal_server_error
+ end
+
+ private
+
+ def file_is_valid?(file)
+ return false unless file.is_a?(::UploadedFile)
+
+ ImportExportUploader::EXTENSION_WHITELIST
+ .include?(File.extname(file.original_filename).delete('.'))
+ end
+end
diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb
index d34a07324da..14f9a026688 100644
--- a/app/controllers/dashboard/milestones_controller.rb
+++ b/app/controllers/dashboard/milestones_controller.rb
@@ -1,48 +1,32 @@
# frozen_string_literal: true
class Dashboard::MilestonesController < Dashboard::ApplicationController
- include MilestoneActions
-
before_action :projects
before_action :groups, only: :index
- before_action :milestone, only: [:show, :merge_requests, :participants, :labels]
def index
respond_to do |format|
format.html do
- @milestone_states = Milestone.states_count(@projects.select(:id), @groups.select(:id))
- @milestones = Kaminari.paginate_array(milestones).page(params[:page])
+ @milestone_states = Milestone.states_count(@projects.select(:id), groups.select(:id))
+ @milestones = milestones.page(params[:page])
end
format.json do
- render json: milestones
+ render json: milestones.to_json(only: [:id, :title], methods: :name)
end
end
end
- def show
- end
-
private
- def group_milestones
- DashboardGroupMilestone.build_collection(groups, params)
- end
-
- # See [#39545](https://gitlab.com/gitlab-org/gitlab-foss/issues/39545) for info about the deprecation of dynamic milestones
- def dynamic_milestones
- DashboardMilestone.build_collection(@projects, params)
- end
-
def milestones
- @milestones = group_milestones + dynamic_milestones
- end
-
- def milestone
- @milestone = DashboardMilestone.build(@projects, params[:title])
- render_404 unless @milestone
+ MilestonesFinder.new(search_params).execute
end
def groups
@groups ||= GroupsFinder.new(current_user, all_available: false).execute
end
+
+ def search_params
+ params.permit(:state, :search_title).merge(group_ids: groups, project_ids: projects)
+ end
end
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index ebee8e9094e..8a8064b24c2 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -17,7 +17,9 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def destroy
- TodoService.new.mark_todos_as_done_by_ids(params[:id], current_user)
+ todo = current_user.todos.find(params[:id])
+
+ TodoService.new.resolve_todo(todo, current_user, resolved_by_action: :mark_done)
respond_to do |format|
format.html do
@@ -31,7 +33,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def destroy_all
- updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user)
+ updated_ids = TodoService.new.resolve_todos(@todos, current_user, resolved_by_action: :mark_all_done)
respond_to do |format|
format.html { redirect_to dashboard_todos_path, status: :found, notice: _('Everything on your to-do list is marked as done.') }
@@ -41,13 +43,13 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def restore
- TodoService.new.mark_todos_as_pending_by_ids(params[:id], current_user)
+ TodoService.new.restore_todo(current_user.todos.find(params[:id]), current_user)
render json: todos_counts
end
def bulk_restore
- TodoService.new.mark_todos_as_pending_by_ids(params[:ids], current_user)
+ TodoService.new.restore_todos(current_user.todos.for_ids(params[:ids]), current_user)
render json: todos_counts
end
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index a9bd24890ee..c618ee8566a 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -9,6 +9,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:multi_select_board, default_enabled: true)
push_frontend_feature_flag(:sfc_issue_boards, default_enabled: true)
+ push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: false)
end
private
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index 52ee69edaa5..c395b93f4e7 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -27,7 +27,7 @@ class Groups::GroupLinksController < Groups::ApplicationController
end
def destroy
- Groups::GroupLinks::DestroyService.new(nil, nil).execute(@group_link)
+ Groups::GroupLinks::DestroyService.new(group, current_user).execute(@group_link)
respond_to do |format|
format.html do
diff --git a/app/controllers/groups/imports_controller.rb b/app/controllers/groups/imports_controller.rb
new file mode 100644
index 00000000000..b611685f9bc
--- /dev/null
+++ b/app/controllers/groups/imports_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Groups::ImportsController < Groups::ApplicationController
+ include ContinueParams
+
+ def show
+ if @group.import_state.nil? || @group.import_state.finished?
+ if continue_params[:to]
+ redirect_to continue_params[:to], notice: continue_params[:notice]
+ else
+ redirect_to group_path(@group), notice: s_('GroupImport|The group was successfully imported.')
+ end
+ elsif @group.import_state.failed?
+ redirect_to new_group_path(@group), alert: s_('GroupImport|Failed to import group.')
+ else
+ flash.now[:notice] = continue_params[:notice_now]
+ end
+ end
+end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 8cfbd293597..df3fb6b67c2 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -6,17 +6,17 @@ class Groups::MilestonesController < Groups::ApplicationController
before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels, :destroy]
before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy]
before_action do
- push_frontend_feature_flag(:burnup_charts)
+ push_frontend_feature_flag(:burnup_charts, @group)
end
def index
respond_to do |format|
format.html do
- @milestone_states = Milestone.states_count(group_projects_with_access, [group])
- @milestones = Kaminari.paginate_array(milestones).page(params[:page])
+ @milestone_states = Milestone.states_count(group_projects_with_access.without_order, [group])
+ @milestones = milestones.page(params[:page])
end
format.json do
- render json: milestones.map { |m| m.for_display.slice(:id, :title, :name) }
+ render json: milestones.to_json(only: [:id, :title], methods: :name)
end
end
end
@@ -29,7 +29,7 @@ class Groups::MilestonesController < Groups::ApplicationController
@milestone = Milestones::CreateService.new(group, current_user, milestone_params).execute
if @milestone.persisted?
- redirect_to milestone_path
+ redirect_to milestone_path(@milestone)
else
render "new"
end
@@ -39,23 +39,15 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def edit
- render_404 if @milestone.legacy_group_milestone?
end
def update
- # Keep this compatible with legacy group milestones where we have to update
- # all projects milestones states at once.
- milestones, update_params = get_milestones_for_update
- milestones.each do |milestone|
- Milestones::UpdateService.new(milestone.resource_parent, current_user, update_params).execute(milestone)
- end
+ Milestones::UpdateService.new(@milestone.parent, current_user, milestone_params).execute(@milestone)
- redirect_to milestone_path
+ redirect_to milestone_path(@milestone)
end
def destroy
- return render_404 if @milestone.legacy_group_milestone?
-
Milestones::DestroyService.new(group, current_user).execute(@milestone)
respond_to do |format|
@@ -66,14 +58,6 @@ 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
@@ -82,27 +66,21 @@ 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)
+ def milestones
+ MilestonesFinder.new(search_params).execute
end
- def milestone_path
- if @milestone.legacy_group_milestone?
- group_milestone_path(group, @milestone.safe_title, title: @milestone.title)
- else
- group_milestone_path(group, @milestone.iid)
- end
+ def milestone
+ @milestone = group.milestones.find_by_iid(params[:id])
+
+ render_404 unless @milestone
end
- def milestones
- milestones = MilestonesFinder.new(search_params).execute
+ def search_params
+ groups = request.format.json? ? group_ids(include_ancestors: true) : group_ids
@sort = params[:sort] || 'due_date_asc'
- MilestoneArray.sort(milestones + legacy_milestones, @sort)
- end
-
- def legacy_milestones
- GroupMilestone.build_collection(group, group_projects_with_access, params)
+ params.permit(:state, :search_title).merge(sort: @sort, group_ids: groups, project_ids: group_projects_with_access)
end
def group_projects_with_access
@@ -116,23 +94,6 @@ class Groups::MilestonesController < Groups::ApplicationController
group.self_and_descendants.public_or_visible_to_user(current_user).select(:id)
end
end
-
- def milestone
- @milestone =
- if params[:title]
- GroupMilestone.build(group, group_projects_with_access, params[:title])
- else
- group.milestones.find_by_iid(params[:id])
- end
-
- render_404 unless @milestone
- end
-
- def search_params
- groups = request.format.json? ? group_ids(include_ancestors: true) : group_ids
-
- params.permit(:state, :search_title).merge(group_ids: groups)
- end
end
Groups::MilestonesController.prepend_if_ee('EE::Groups::MilestonesController')
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index d5f2239b16a..fba374dbb44 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -57,6 +57,8 @@ class GroupsController < Groups::ApplicationController
@group = Groups::CreateService.new(current_user, group_params).execute
if @group.persisted?
+ track_experiment_event(:onboarding_issues, 'created_namespace')
+
notice = if @group.chat_team.present?
"Group '#{@group.name}' and its Mattermost team were successfully created."
else
@@ -72,7 +74,11 @@ class GroupsController < Groups::ApplicationController
def show
respond_to do |format|
format.html do
- render_show_html
+ if @group.import_state&.in_progress?
+ redirect_to group_import_path(@group)
+ else
+ render_show_html
+ end
end
format.atom do
@@ -264,11 +270,12 @@ class GroupsController < Groups::ApplicationController
def export_rate_limit
prefixed_action = "group_#{params[:action]}".to_sym
- if Gitlab::ApplicationRateLimiter.throttled?(prefixed_action, scope: [current_user, prefixed_action, @group])
+ scope = params[:action] == :download_export ? @group : nil
+
+ if Gitlab::ApplicationRateLimiter.throttled?(prefixed_action, scope: [current_user, scope].compact)
Gitlab::ApplicationRateLimiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user)
- flash[:alert] = _('This endpoint has been requested too many times. Try again later.')
- redirect_to edit_group_path(@group)
+ render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
end
end
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 8a838db04f9..2bf7bdd1ae0 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -6,9 +6,11 @@ class IdeController < ApplicationController
include ClientsidePreviewCSP
include StaticObjectExternalStorageCSP
+ before_action do
+ push_frontend_feature_flag(:build_service_proxy)
+ end
+
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
end
end
-
-IdeController.prepend_if_ee('EE::IdeController')
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 04919a4b9d0..afdea4f7c9d 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -1,10 +1,86 @@
# frozen_string_literal: true
class Import::BaseController < ApplicationController
+ include ActionView::Helpers::SanitizeHelper
+
before_action :import_rate_limit, only: [:create]
+ def status
+ respond_to do |format|
+ format.json do
+ render json: { imported_projects: serialized_imported_projects,
+ provider_repos: serialized_provider_repos,
+ incompatible_repos: serialized_incompatible_repos,
+ namespaces: serialized_namespaces }
+ end
+ format.html
+ end
+ end
+
+ def realtime_changes
+ Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
+ render json: already_added_projects.to_json(only: [:id], methods: [:import_status])
+ end
+
+ protected
+
+ def importable_repos
+ raise NotImplementedError
+ end
+
+ def incompatible_repos
+ []
+ end
+
+ def provider_name
+ raise NotImplementedError
+ end
+
+ def provider_url
+ raise NotImplementedError
+ end
+
private
+ def filter_attribute
+ :name
+ end
+
+ def sanitized_filter_param
+ @filter ||= sanitize(params[:filter])
+ end
+
+ def filtered(collection)
+ return collection unless sanitized_filter_param
+
+ collection.select { |item| item[filter_attribute].include?(sanitized_filter_param) }
+ end
+
+ def serialized_provider_repos
+ Import::ProviderRepoSerializer.new(current_user: current_user).represent(importable_repos, provider: provider_name, provider_url: provider_url)
+ end
+
+ def serialized_incompatible_repos
+ Import::ProviderRepoSerializer.new(current_user: current_user).represent(incompatible_repos, provider: provider_name, provider_url: provider_url)
+ end
+
+ def serialized_imported_projects
+ ProjectSerializer.new.represent(already_added_projects, serializer: :import, provider_url: provider_url)
+ end
+
+ def already_added_projects
+ @already_added_projects ||= filtered(find_already_added_projects(provider_name))
+ end
+
+ def serialized_namespaces
+ NamespaceSerializer.new.represent(namespaces)
+ end
+
+ def namespaces
+ current_user.manageable_groups_with_routes
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def find_already_added_projects(import_type)
current_user.created_projects.where(import_type: import_type).with_import_state
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index c37e799de62..4886aeb5e3f 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Import::BitbucketController < Import::BaseController
+ extend ::Gitlab::Utils::Override
+
include ActionView::Helpers::SanitizeHelper
before_action :verify_bitbucket_import_enabled
@@ -10,7 +12,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: users_import_bitbucket_callback_url)
+ response = oauth_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
@@ -22,9 +24,10 @@ class Import::BitbucketController < Import::BaseController
# rubocop: disable CodeReuse/ActiveRecord
def status
+ return super if Feature.enabled?(:new_import_ui)
+
bitbucket_client = Bitbucket::Client.new(credentials)
repos = bitbucket_client.repos(filter: sanitized_filter_param)
-
@repos, @incompatible_repos = repos.partition { |repo| repo.valid? }
@already_added_projects = find_already_added_projects('bitbucket')
@@ -38,6 +41,10 @@ class Import::BitbucketController < Import::BaseController
render json: find_jobs('bitbucket')
end
+ def realtime_changes
+ super
+ end
+
def create
bitbucket_client = Bitbucket::Client.new(credentials)
@@ -59,7 +66,7 @@ class Import::BitbucketController < Import::BaseController
project = Gitlab::BitbucketImport::ProjectCreator.new(repo, project_name, target_namespace, current_user, credentials).execute
if project.persisted?
- render json: ProjectSerializer.new.represent(project)
+ render json: ProjectSerializer.new.represent(project, serializer: :import)
else
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
@@ -68,16 +75,50 @@ class Import::BitbucketController < Import::BaseController
end
end
+ protected
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ override :importable_repos
+ def importable_repos
+ already_added_projects_names = already_added_projects.map(&:import_source)
+
+ bitbucket_repos.reject { |repo| already_added_projects_names.include?(repo.full_name) || !repo.valid? }
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ override :incompatible_repos
+ def incompatible_repos
+ bitbucket_repos.reject { |repo| repo.valid? }
+ end
+
+ override :provider_name
+ def provider_name
+ :bitbucket
+ end
+
+ override :provider_url
+ def provider_url
+ provider.url
+ end
+
private
- def client
- @client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options)
+ def oauth_client
+ @oauth_client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options)
end
def provider
Gitlab::Auth::OAuth::Provider.config_for('bitbucket')
end
+ def client
+ @client ||= Bitbucket::Client.new(credentials)
+ end
+
+ def bitbucket_repos
+ @bitbucket_repos ||= client.repos(filter: sanitized_filter_param).to_a
+ end
+
def options
OmniAuth::Strategies::Bitbucket.default_options[:client_options].deep_symbolize_keys
end
@@ -91,7 +132,7 @@ class Import::BitbucketController < Import::BaseController
end
def go_to_bitbucket_for_permissions
- redirect_to client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url)
+ redirect_to oauth_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 5fb7b5dccc5..9aa8110257d 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -1,12 +1,16 @@
# frozen_string_literal: true
class Import::BitbucketServerController < Import::BaseController
+ extend ::Gitlab::Utils::Override
+
include ActionView::Helpers::SanitizeHelper
before_action :verify_bitbucket_server_import_enabled
before_action :bitbucket_auth, except: [:new, :configure]
before_action :validate_import_params, only: [:create]
+ rescue_from BitbucketServer::Connection::ConnectionError, with: :bitbucket_connection_error
+
# As a basic sanity check to prevent URL injection, restrict project
# repository input and repository slugs to allowed characters. For Bitbucket:
#
@@ -24,7 +28,7 @@ class Import::BitbucketServerController < Import::BaseController
end
def create
- repo = bitbucket_client.repo(@project_key, @repo_slug)
+ repo = client.repo(@project_key, @repo_slug)
unless repo
return render json: { errors: _("Project %{project_repo} could not be found") % { project_repo: "#{@project_key}/#{@repo_slug}" } }, status: :unprocessable_entity
@@ -38,15 +42,13 @@ class Import::BitbucketServerController < Import::BaseController
project = Gitlab::BitbucketServerImport::ProjectCreator.new(@project_key, @repo_slug, repo, project_name, target_namespace, current_user, credentials).execute
if project.persisted?
- render json: ProjectSerializer.new.represent(project)
+ render json: ProjectSerializer.new.represent(project, serializer: :import)
else
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
end
- rescue BitbucketServer::Connection::ConnectionError => error
- render json: { errors: _("Unable to connect to server: %{error}") % { error: error } }, status: :unprocessable_entity
end
def configure
@@ -59,7 +61,9 @@ class Import::BitbucketServerController < Import::BaseController
# rubocop: disable CodeReuse/ActiveRecord
def status
- @collection = bitbucket_client.repos(page_offset: page_offset, limit: limit_per_page, filter: sanitized_filter_param)
+ return super if Feature.enabled?(:new_import_ui)
+
+ @collection = client.repos(page_offset: page_offset, limit: limit_per_page, filter: sanitized_filter_param)
@repos, @incompatible_repos = @collection.partition { |repo| repo.valid? }
# Use the import URL to filter beyond what BaseService#find_already_added_projects
@@ -67,10 +71,6 @@ 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 => error
- flash[:alert] = _("Unable to connect to server: %{error}") % { error: error }
- clear_session_data
- redirect_to new_import_bitbucket_server_path
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -78,6 +78,38 @@ class Import::BitbucketServerController < Import::BaseController
render json: find_jobs('bitbucket_server')
end
+ def realtime_changes
+ super
+ end
+
+ protected
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ override :importable_repos
+ def importable_repos
+ # Use the import URL to filter beyond what BaseService#find_already_added_projects
+ already_added_projects = filter_added_projects('bitbucket_server', bitbucket_repos.map(&:browse_url))
+ already_added_projects_names = already_added_projects.map(&:import_source)
+
+ bitbucket_repos.reject { |repo| already_added_projects_names.include?(repo.browse_url) || !repo.valid? }
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ override :incompatible_repos
+ def incompatible_repos
+ bitbucket_repos.reject { |repo| repo.valid? }
+ end
+
+ override :provider_name
+ def provider_name
+ :bitbucket_server
+ end
+
+ override :provider_url
+ def provider_url
+ session[bitbucket_server_url_key]
+ end
+
private
# rubocop: disable CodeReuse/ActiveRecord
@@ -86,8 +118,12 @@ class Import::BitbucketServerController < Import::BaseController
end
# rubocop: enable CodeReuse/ActiveRecord
- def bitbucket_client
- @bitbucket_client ||= BitbucketServer::Client.new(credentials)
+ def client
+ @client ||= BitbucketServer::Client.new(credentials)
+ end
+
+ def bitbucket_repos
+ @bitbucket_repos ||= client.repos(page_offset: page_offset, limit: limit_per_page, filter: sanitized_filter_param).to_a
end
def validate_import_params
@@ -153,4 +189,23 @@ class Import::BitbucketServerController < Import::BaseController
def sanitized_filter_param
sanitize(params[:filter])
end
+
+ def bitbucket_connection_error(error)
+ flash[:alert] = _("Unable to connect to server: %{error}") % { error: error }
+ clear_session_data
+
+ respond_to do |format|
+ format.json do
+ render json: {
+ error: {
+ message: _("Unable to connect to server: %{error}") % { error: error },
+ redirect: new_import_bitbucket_server_path
+ }
+ }, status: :unprocessable_entity
+ end
+ format.html do
+ redirect_to new_import_bitbucket_server_path
+ end
+ end
+ end
end
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 4fb6efde7ff..91779a5d6cc 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Import::FogbugzController < Import::BaseController
+ extend ::Gitlab::Utils::Override
+
before_action :verify_fogbugz_import_enabled
before_action :user_map, only: [:new_user_map, :create_user_map]
before_action :verify_blocked_uri, only: :callback
@@ -48,6 +50,8 @@ class Import::FogbugzController < Import::BaseController
return redirect_to new_import_fogbugz_path
end
+ return super if Feature.enabled?(:new_import_ui)
+
@repos = client.repos
@already_added_projects = find_already_added_projects('fogbugz')
@@ -57,6 +61,10 @@ class Import::FogbugzController < Import::BaseController
end
# rubocop: enable CodeReuse/ActiveRecord
+ def realtime_changes
+ super
+ end
+
def jobs
render json: find_jobs('fogbugz')
end
@@ -69,12 +77,35 @@ class Import::FogbugzController < Import::BaseController
project = Gitlab::FogbugzImport::ProjectCreator.new(repo, fb_session, current_user.namespace, current_user, umap).execute
if project.persisted?
- render json: ProjectSerializer.new.represent(project)
+ render json: ProjectSerializer.new.represent(project, serializer: :import)
else
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
end
+ protected
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ override :importable_repos
+ def importable_repos
+ repos = client.repos
+
+ already_added_projects_names = already_added_projects.map(&:import_source)
+
+ repos.reject { |repo| already_added_projects_names.include? repo.name }
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ override :provider_name
+ def provider_name
+ :fogbugz
+ end
+
+ override :provider_url
+ def provider_url
+ session[:fogbugz_uri]
+ end
+
private
def client
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 4e8ceae75bd..097edcd6075 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -76,7 +76,7 @@ class Import::GithubController < Import::BaseController
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)
+ Import::ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url)
end
def serialized_namespaces
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index 5ec8e9e6fc5..a95a67e208c 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Import::GitlabController < Import::BaseController
+ extend ::Gitlab::Utils::Override
+
MAX_PROJECT_PAGES = 15
PER_PAGE_PROJECTS = 100
@@ -16,6 +18,8 @@ class Import::GitlabController < Import::BaseController
# rubocop: disable CodeReuse/ActiveRecord
def status
+ return super if Feature.enabled?(:new_import_ui)
+
@repos = client.projects(starting_page: 1, page_limit: MAX_PROJECT_PAGES, per_page: PER_PAGE_PROJECTS)
@already_added_projects = find_already_added_projects('gitlab')
@@ -37,7 +41,7 @@ class Import::GitlabController < Import::BaseController
project = Gitlab::GitlabImport::ProjectCreator.new(repo, target_namespace, current_user, access_params).execute
if project.persisted?
- render json: ProjectSerializer.new.represent(project)
+ render json: ProjectSerializer.new.represent(project, serializer: :import)
else
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
@@ -46,6 +50,29 @@ class Import::GitlabController < Import::BaseController
end
end
+ protected
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ override :importable_repos
+ def importable_repos
+ repos = client.projects(starting_page: 1, page_limit: MAX_PROJECT_PAGES, per_page: PER_PAGE_PROJECTS)
+
+ already_added_projects_names = already_added_projects.map(&:import_source)
+
+ repos.reject { |repo| already_added_projects_names.include? repo["path_with_namespace"] }
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ override :provider_name
+ def provider_name
+ :gitlab
+ end
+
+ override :provider_url
+ def provider_url
+ 'https://gitlab.com'
+ end
+
private
def client
diff --git a/app/controllers/import/gitlab_groups_controller.rb b/app/controllers/import/gitlab_groups_controller.rb
new file mode 100644
index 00000000000..330af68385e
--- /dev/null
+++ b/app/controllers/import/gitlab_groups_controller.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class Import::GitlabGroupsController < ApplicationController
+ include WorkhorseImportExportUpload
+
+ before_action :ensure_group_import_enabled
+ before_action :import_rate_limit, only: %i[create]
+
+ def create
+ unless file_is_valid?(group_params[:file])
+ return redirect_back_or_default(options: { alert: s_('GroupImport|Unable to process group import file') })
+ end
+
+ group_data = group_params.except(:file).merge(
+ visibility_level: closest_allowed_visibility_level,
+ import_export_upload: ImportExportUpload.new(import_file: group_params[:file])
+ )
+
+ group = ::Groups::CreateService.new(current_user, group_data).execute
+
+ if group.persisted?
+ if Groups::ImportExport::ImportService.new(group: group, user: current_user).async_execute
+ redirect_to(
+ group_path(group),
+ notice: s_("GroupImport|Group '%{group_name}' is being imported.") % { group_name: group.name }
+ )
+ else
+ redirect_to group_path(group), alert: _("Group import could not be scheduled")
+ end
+ else
+ redirect_back_or_default(
+ options: { alert: s_("GroupImport|Group could not be imported: %{errors}") % { errors: group.errors.full_messages.to_sentence } }
+ )
+ end
+ end
+
+ private
+
+ def group_params
+ params.permit(:path, :name, :parent_id, :file)
+ end
+
+ def closest_allowed_visibility_level
+ if group_params[:parent_id].present?
+ parent_group = Group.find(group_params[:parent_id])
+
+ Gitlab::VisibilityLevel.closest_allowed_level(parent_group.visibility_level)
+ else
+ Gitlab::VisibilityLevel::PRIVATE
+ end
+ end
+
+ def ensure_group_import_enabled
+ render_404 unless Feature.enabled?(:group_import_export, @group, default_enabled: true)
+ end
+
+ def import_rate_limit
+ if Gitlab::ApplicationRateLimiter.throttled?(:group_import, scope: current_user)
+ Gitlab::ApplicationRateLimiter.log_request(request, :group_import_request_limit, current_user)
+
+ flash[:alert] = _('This endpoint has been requested too many times. Try again later.')
+ redirect_to new_group_path
+ end
+ end
+end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 6a3715a4675..39d053347f0 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -1,14 +1,11 @@
# frozen_string_literal: true
class Import::GitlabProjectsController < Import::BaseController
- include WorkhorseRequest
+ include WorkhorseImportExportUpload
before_action :whitelist_query_limiting, only: [:create]
before_action :verify_gitlab_project_import_enabled
- skip_before_action :verify_authenticity_token, only: [:authorize]
- before_action :verify_workhorse_api!, only: [:authorize]
-
def new
@namespace = Namespace.find(project_params[:namespace_id])
return render_404 unless current_user.can?(:create_projects, @namespace)
@@ -17,7 +14,7 @@ class Import::GitlabProjectsController < Import::BaseController
end
def create
- unless file_is_valid?
+ unless file_is_valid?(project_params[:file])
return redirect_back_or_default(options: { alert: _("You need to upload a GitLab project export archive (ending in .gz).") })
end
@@ -33,28 +30,8 @@ class Import::GitlabProjectsController < Import::BaseController
end
end
- def authorize
- set_workhorse_internal_api_content_type
-
- authorized = ImportExportUploader.workhorse_authorize(
- has_length: false,
- maximum_size: Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i)
-
- render json: authorized
- rescue SocketError
- render json: _("Error uploading file"), status: :internal_server_error
- end
-
private
- def file_is_valid?
- return false unless project_params[:file].is_a?(::UploadedFile)
-
- filename = project_params[:file].original_filename
-
- ImportExportUploader::EXTENSION_WHITELIST.include?(File.extname(filename).delete('.'))
- end
-
def verify_gitlab_project_import_enabled
render_404 unless gitlab_project_import_enabled?
end
diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb
index 0c0a91e136f..054dc8e6a35 100644
--- a/app/controllers/projects/alert_management_controller.rb
+++ b/app/controllers/projects/alert_management_controller.rb
@@ -2,10 +2,6 @@
class Projects::AlertManagementController < Projects::ApplicationController
before_action :authorize_read_alert_management_alert!
- before_action do
- push_frontend_feature_flag(:alert_list_status_filtering_enabled)
- push_frontend_feature_flag(:create_issue_from_alert_enabled)
- end
def index
end
diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb
index 358e7629958..fef8235628d 100644
--- a/app/controllers/projects/alerting/notifications_controller.rb
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -29,12 +29,22 @@ module Projects
end
def notify_service
- Projects::Alerting::NotifyService
- .new(project, current_user, notification_payload)
+ notify_service_class.new(project, current_user, notification_payload)
+ end
+
+ def notify_service_class
+ # We are tracking the consolidation of these services in
+ # https://gitlab.com/groups/gitlab-org/-/epics/3360
+ # to get rid of this workaround.
+ if Projects::Prometheus::Alerts::NotifyService.processable?(notification_payload)
+ Projects::Prometheus::Alerts::NotifyService
+ else
+ Projects::Alerting::NotifyService
+ end
end
def notification_payload
- params.permit![:notification]
+ @notification_payload ||= params.permit![:notification]
end
end
end
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index 66b51b17790..59a7dff680c 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -8,14 +8,21 @@ class Projects::BadgesController < Projects::ApplicationController
def pipeline
pipeline_status = Gitlab::Badge::Pipeline::Status
- .new(project, params[:ref])
+ .new(project, params[:ref], opts: {
+ key_text: params[:key_text],
+ key_width: params[:key_width]
+ })
render_badge pipeline_status
end
def coverage
coverage_report = Gitlab::Badge::Coverage::Report
- .new(project, params[:ref], params[:job])
+ .new(project, params[:ref], opts: {
+ job: params[:job],
+ key_text: params[:key_text],
+ key_width: params[:key_width]
+ })
render_badge coverage_report
end
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index b62ce940e9c..374b4921dbc 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -20,6 +20,7 @@ class Projects::BlameController < Projects::ApplicationController
environment_params[:find_latest] = true
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
- @blame_groups = Gitlab::Blame.new(@blob, @commit).groups
+ @blame = Gitlab::Blame.new(@blob, @commit)
+ @blame = Gitlab::View::Presenter::Factory.new(@blame, project: @project, path: @path).fabricate!
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 584320a66de..abc1d58cbf1 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -71,6 +71,7 @@ class Projects::BlobController < Projects::ApplicationController
def update
@path = params[:file_path] if params[:file_path].present?
+
create_commit(Files::UpdateService, success_path: -> { after_edit_path },
failure_view: :edit,
failure_path: project_blob_path(@project, @id))
@@ -93,7 +94,6 @@ class Projects::BlobController < Projects::ApplicationController
def destroy
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))
end
@@ -115,6 +115,8 @@ class Projects::BlobController < Projects::ApplicationController
private
+ attr_reader :branch_name
+
def blob
@blob ||= @repository.blob_at(@commit.id, @path)
@@ -254,3 +256,5 @@ class Projects::BlobController < Projects::ApplicationController
params.permit(:full, :since, :to, :bottom, :unfold, :offset, :indent)
end
end
+
+Projects::BlobController.prepend_if_ee('EE::Projects::BlobController')
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index cc595740696..7cfb4a508da 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -25,8 +25,9 @@ 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))
+ @branch_pipeline_statuses = branch_pipeline_statuses
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/48097
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/22851
Gitlab::GitalyClient.allow_n_plus_1_calls do
render
end
@@ -194,4 +195,15 @@ class Projects::BranchesController < Projects::ApplicationController
confidential_issue_project
end
+
+ def branch_pipeline_statuses
+ latest_commits = @branches.map do |branch|
+ [branch.name, repository.commit(branch.dereferenced_target).sha]
+ end.to_h
+
+ latest_pipelines = project.ci_pipelines.latest_pipeline_per_commit(latest_commits.values)
+ latest_commits.transform_values do |commit_sha|
+ latest_pipelines[commit_sha]&.detailed_status(current_user)
+ end.compact
+ end
end
diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
index dfda5fca310..b36c5f1aea6 100644
--- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
+++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
@@ -7,12 +7,13 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
REPORT_WINDOW = 90.days
before_action :validate_feature_flag!
- before_action :authorize_download_code! # Share the same authorization rules as the graphs controller
+ before_action :authorize_read_build_report_results!
before_action :validate_param_type!
def index
respond_to do |format|
- format.csv { send_data(render_csv(results), type: 'text/csv; charset=utf-8') }
+ format.csv { send_data(render_csv(report_results), type: 'text/csv; charset=utf-8') }
+ format.json { render json: render_json(report_results) }
end
end
@@ -37,7 +38,11 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
).render
end
- def results
+ def render_json(collection)
+ Ci::DailyBuildGroupReportResultSerializer.new.represent(collection, param_type: param_type)
+ end
+
+ def report_results
Ci::DailyBuildGroupReportResultsFinder.new(finder_params).execute
end
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 028390c7e2a..06231607f73 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -10,7 +10,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve]
def resolve
- Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
+ Discussions::ResolveService.new(project, current_user, one_or_more_discussions: discussion).execute
render_discussion
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 5f4d88c57e9..4d774123ef1 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -10,7 +10,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
push_frontend_feature_flag(:prometheus_computed_alerts)
end
- before_action :authorize_read_environment!
+ before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect]
before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_stop_environment!, only: [:stop]
before_action :authorize_update_environment!, only: [:edit, :update, :cancel_auto_stop]
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 34246f27241..a8b90f8685f 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -6,7 +6,7 @@ class Projects::GraphsController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :assign_ref_vars
- before_action :authorize_download_code!
+ before_action :authorize_read_repository_graphs!
def show
respond_to do |format|
@@ -54,7 +54,8 @@ class Projects::GraphsController < Projects::ApplicationController
end
def get_daily_coverage_options
- return unless Feature.enabled?(:ci_download_daily_code_coverage, default_enabled: true)
+ return unless Feature.enabled?(:ci_download_daily_code_coverage, @project, default_enabled: true)
+ return unless can?(current_user, :read_build_report_results, project)
date_today = Date.current
report_window = Projects::Ci::DailyBuildGroupReportResultsController::REPORT_WINDOW
@@ -70,6 +71,11 @@ class Projects::GraphsController < Projects::ApplicationController
namespace_id: @project.namespace,
project_id: @project,
format: :csv
+ ),
+ graph_api_path: namespace_project_ci_daily_build_group_report_results_path(
+ namespace_id: @project.namespace,
+ project_id: @project,
+ format: :json
)
}
end
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index d06e24ef39c..a30c455a7e4 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -5,10 +5,6 @@ class Projects::GroupLinksController < Projects::ApplicationController
before_action :authorize_admin_project!
before_action :authorize_admin_project_member!, only: [:update]
- def index
- redirect_to namespace_project_settings_members_path
- end
-
def create
group = Group.find(params[:link_group_id]) if params[:link_group_id].present?
@@ -26,8 +22,7 @@ class Projects::GroupLinksController < Projects::ApplicationController
def update
@group_link = @project.project_group_links.find(params[:id])
-
- @group_link.update(group_link_params)
+ Projects::GroupLinks::UpdateService.new(@group_link).execute(group_link_params)
end
def destroy
diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb
index 711e23dc3ce..976ac7df976 100644
--- a/app/controllers/projects/import/jira_controller.rb
+++ b/app/controllers/projects/import/jira_controller.rb
@@ -4,59 +4,29 @@ module Projects
module Import
class JiraController < Projects::ApplicationController
before_action :authenticate_user!
- before_action :check_issues_available!
before_action :authorize_read_project!
- before_action :jira_import_enabled?
- before_action :jira_integration_configured?
- before_action :authorize_admin_project!, only: [:import]
+ before_action :validate_jira_import_settings!
def show
- jira_service = @project.jira_service
-
- if jira_service.present? && !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project)
- jira_client = jira_service.client
- jira_projects = jira_client.Project.all
-
- if jira_projects.present?
- @jira_projects = jira_projects.map { |p| ["#{p.name} (#{p.key})", p.key] }
- else
- flash[:alert] = 'No projects have been returned from Jira. Please check your Jira configuration.'
- end
- end
-
- unless Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
- flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial?
- end
- end
-
- def import
- jira_project_key = jira_import_params[:jira_project_key]
-
- if jira_project_key.present?
- response = ::JiraImport::StartImportService.new(current_user, @project, jira_project_key).execute
- flash[:notice] = response.message if response.message.present?
- else
- flash[:alert] = 'No Jira project key has been provided.'
- end
-
- redirect_to project_import_jira_path(@project)
end
private
- def jira_import_enabled?
- return if @project.jira_issues_import_feature_flag_enabled?
+ def validate_jira_import_settings!
+ Gitlab::JiraImport.validate_project_settings!(@project, user: current_user, configuration_check: false)
+ true
+ rescue Projects::ImportService::Error => e
+ flash[:notice] = e.message
redirect_to project_issues_path(@project)
- end
- def jira_integration_configured?
- return if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
- return if @project.jira_service
+ false
+ end
- flash[:notice] = _("Configure the Jira integration first on your project's %{strong_start} Settings > Integrations > Jira%{strong_end} page." %
- { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe })
- redirect_to project_issues_path(@project)
+ def jira_service
+ strong_memoize(:jira_service) do
+ @project.jira_service
+ end
end
def jira_import_params
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 3e9d956f7b1..693329848de 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -46,7 +46,6 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
- push_frontend_feature_flag(:save_issuable_health_status, project.group, default_enabled: true)
end
before_action only: :show do
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index e0457925b34..e1f6cbe3dca 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -14,6 +14,8 @@ class Projects::JobsController < Projects::ApplicationController
before_action only: [:show] do
push_frontend_feature_flag(:job_log_json, project, default_enabled: true)
end
+ before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
+ before_action :verify_proxy_request!, only: :proxy_websocket_authorize
layout 'project'
@@ -151,6 +153,10 @@ class Projects::JobsController < Projects::ApplicationController
render json: Gitlab::Workhorse.channel_websocket(@build.terminal_specification)
end
+ def proxy_websocket_authorize
+ render json: proxy_websocket_service(build_service_specification)
+ end
+
private
def authorize_update_build!
@@ -165,10 +171,19 @@ class Projects::JobsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :create_build_terminal, build)
end
+ def authorize_create_proxy_build!
+ return access_denied! unless can?(current_user, :create_build_service_proxy, build)
+ end
+
def verify_api_request!
Gitlab::Workhorse.verify_api_request!(request.headers)
end
+ def verify_proxy_request!
+ verify_api_request!
+ set_workhorse_internal_api_content_type
+ end
+
def raw_send_params
{ type: 'text/plain; charset=utf-8', disposition: 'inline' }
end
@@ -202,6 +217,27 @@ class Projects::JobsController < Projects::ApplicationController
'attachment'
end
-end
-Projects::JobsController.prepend_if_ee('EE::Projects::JobsController')
+ def build_service_specification
+ build.service_specification(service: params['service'],
+ port: params['port'],
+ path: params['path'],
+ subprotocols: proxy_subprotocol)
+ end
+
+ def proxy_subprotocol
+ # This will allow to reuse the same subprotocol set
+ # in the original websocket connection
+ request.headers['HTTP_SEC_WEBSOCKET_PROTOCOL'].presence || ::Ci::BuildRunnerSession::TERMINAL_SUBPROTOCOL
+ end
+
+ # This method provides the information to Workhorse
+ # about the service we want to proxy to.
+ # For security reasons, in case this operation is started by JS,
+ # it's important to use only sourced GitLab JS code
+ def proxy_websocket_service(service)
+ service[:url] = ::Gitlab::UrlHelpers.as_wss(service[:url])
+
+ ::Gitlab::Workhorse.channel_websocket(service)
+ end
+end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 2331674f42c..1bf143c9a91 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -162,8 +162,13 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
def renderable_notes
define_diff_comment_vars unless @notes
- @notes
+ draft_notes =
+ if current_user
+ merge_request.draft_notes.authored_by(current_user)
+ else
+ []
+ end
+
+ @notes.concat(draft_notes)
end
end
-
-Projects::MergeRequests::DiffsController.prepend_if_ee('EE::Projects::MergeRequests::DiffsController')
diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb
new file mode 100644
index 00000000000..f4846b1aa81
--- /dev/null
+++ b/app/controllers/projects/merge_requests/drafts_controller.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+class Projects::MergeRequests::DraftsController < Projects::MergeRequests::ApplicationController
+ include Gitlab::Utils::StrongMemoize
+
+ respond_to :json
+
+ before_action :authorize_create_note!, only: [:create, :publish]
+ before_action :authorize_admin_draft!, only: [:update, :destroy]
+ before_action :authorize_admin_draft!, if: -> { action_name == 'publish' && params[:id].present? }
+
+ def index
+ drafts = prepare_notes_for_rendering(draft_notes)
+ render json: DraftNoteSerializer.new(current_user: current_user).represent(drafts)
+ end
+
+ def create
+ create_params = draft_note_params.merge(in_reply_to_discussion_id: params[:in_reply_to_discussion_id])
+ create_service = DraftNotes::CreateService.new(merge_request, current_user, create_params)
+
+ draft_note = create_service.execute
+
+ prepare_notes_for_rendering(draft_note)
+
+ render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note)
+ end
+
+ def update
+ draft_note.update!(draft_note_params)
+
+ prepare_notes_for_rendering(draft_note)
+
+ render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note)
+ end
+
+ def destroy
+ DraftNotes::DestroyService.new(merge_request, current_user).execute(draft_note)
+
+ head :ok
+ end
+
+ def publish
+ result = DraftNotes::PublishService.new(merge_request, current_user).execute(draft_note(allow_nil: true))
+
+ if result[:status] == :success
+ head :ok
+ else
+ render json: { message: result[:message] }, status: result[:status]
+ end
+ end
+
+ def discard
+ DraftNotes::DestroyService.new(merge_request, current_user).execute
+
+ head :ok
+ end
+
+ private
+
+ def draft_note(allow_nil: false)
+ strong_memoize(:draft_note) do
+ draft_notes.find(params[:id])
+ end
+ rescue ActiveRecord::RecordNotFound => ex
+ # draft_note is allowed to be nil in #publish
+ raise ex unless allow_nil
+ end
+
+ def draft_notes
+ return unless current_user
+
+ strong_memoize(:draft_notes) do
+ merge_request.draft_notes.authored_by(current_user)
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def merge_request
+ @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def draft_note_params
+ params.require(:draft_note).permit(
+ :commit_id,
+ :note,
+ :position,
+ :resolve_discussion
+ ).tap do |h|
+ # Old FE version will still be sending `draft_note[commit_id]` as 'undefined'.
+ # That can result to having a note linked to a commit with 'undefined' ID
+ # which is non-existent.
+ h[:commit_id] = nil if h[:commit_id] == 'undefined'
+ end
+ end
+
+ def prepare_notes_for_rendering(notes)
+ return [] unless notes
+
+ notes = Array.wrap(notes)
+
+ # Preload author and access-level information
+ DraftNote.preload_author(notes)
+ user_ids = notes.map(&:author_id)
+ project.team.max_member_access_for_user_ids(user_ids)
+
+ notes.map(&method(:render_draft_note))
+ end
+
+ def render_draft_note(note)
+ params = { target_id: merge_request.id, target_type: 'MergeRequest', text: note.note }
+ result = PreviewMarkdownService.new(@project, current_user, params).execute
+ markdown_params = { markdown_engine: result[:markdown_engine], issuable_state_filter_enabled: true }
+
+ note.rendered_note = view_context.markdown(result[:text], markdown_params)
+ note.users_referenced = result[:users]
+ note.commands_changes = view_context.markdown(result[:commands])
+
+ note
+ end
+
+ def authorize_admin_draft!
+ access_denied! unless can?(current_user, :admin_note, draft_note)
+ end
+
+ def authorize_create_note!
+ access_denied! unless can?(current_user, :create_note, merge_request)
+ end
+end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 5613b5b9589..55556ea7d31 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -33,6 +33,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
push_frontend_feature_flag(:merge_ref_head_comments, @project)
push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true)
+ push_frontend_feature_flag(:multiline_comments, @project)
+ push_frontend_feature_flag(:file_identifier_hash)
+ push_frontend_feature_flag(:batch_suggestions, @project)
end
before_action do
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 56f1f1a1019..16d63cc184f 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -7,7 +7,7 @@ class Projects::MilestonesController < Projects::ApplicationController
before_action :check_issuables_available!
before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote]
before_action do
- push_frontend_feature_flag(:burnup_charts)
+ push_frontend_feature_flag(:burnup_charts, @project)
end
# Allow read any milestone
@@ -34,7 +34,7 @@ class Projects::MilestonesController < Projects::ApplicationController
@milestones = @milestones.page(params[:page])
end
format.json do
- render json: @milestones.to_json(methods: :name)
+ render json: @milestones.to_json(only: [:id, :title], methods: :name)
end
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 678d0862f48..0b6c0db211e 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -12,8 +12,9 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
push_frontend_feature_flag(:junit_pipeline_view, project)
- push_frontend_feature_flag(:filter_pipelines_search, default_enabled: true)
- push_frontend_feature_flag(:dag_pipeline_tab)
+ push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true)
+ push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: false)
+ push_frontend_feature_flag(:pipelines_security_report_summary, project)
end
before_action :ensure_pipeline, only: [:show]
@@ -95,7 +96,14 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def dag
- render_show
+ respond_to do |format|
+ format.html { render_show }
+ format.json do
+ render json: Ci::DagPipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@pipeline)
+ end
+ end
end
def failures
@@ -269,7 +277,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def index_params
- params.permit(:scope, :username, :ref)
+ params.permit(:scope, :username, :ref, :status)
end
end
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index fcbeb5c840c..a2581e72257 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -53,7 +53,7 @@ class Projects::RefsController < Projects::ApplicationController
format.json do
logs, next_offset = tree_summary.fetch_logs
- response.headers["More-Logs-Offset"] = next_offset if next_offset
+ response.headers["More-Logs-Offset"] = next_offset.to_s if next_offset
render json: logs
end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 228b139d794..d3285b64dab 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -10,6 +10,7 @@ class Projects::ReleasesController < Projects::ApplicationController
push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true)
push_frontend_feature_flag(:release_show_page, project, default_enabled: true)
push_frontend_feature_flag(:release_asset_link_editing, project, default_enabled: true)
+ push_frontend_feature_flag(:release_asset_link_type, project, default_enabled: true)
end
before_action :authorize_update_release!, only: %i[edit update]
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 92c6ce324f7..710ad546e64 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -2,6 +2,7 @@
class Projects::ServicesController < Projects::ApplicationController
include ServiceParams
+ include InternalRedirect
# Authorize
before_action :authorize_admin_project!
@@ -10,6 +11,9 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :web_hook_logs, only: [:edit, :update]
before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update]
before_action :redirect_deprecated_prometheus_service, only: [:update]
+ before_action only: :edit do
+ push_frontend_feature_flag(:integration_form_refactor)
+ end
respond_to :html
@@ -26,8 +30,8 @@ class Projects::ServicesController < Projects::ApplicationController
respond_to do |format|
format.html do
if saved
- redirect_to project_settings_integrations_path(@project),
- notice: success_message
+ target_url = safe_redirect_path(params[:redirect_to]).presence || project_settings_integrations_path(@project)
+ redirect_to target_url, notice: success_message
else
render 'edit'
end
@@ -56,11 +60,10 @@ class Projects::ServicesController < Projects::ApplicationController
return { error: true, message: _('Validations failed.'), service_response: @service.errors.full_messages.join(','), test_failed: false }
end
- data = @service.test_data(project, current_user)
- outcome = @service.test(data)
+ result = Integrations::Test::ProjectService.new(@service, current_user, params[:event]).execute
- unless outcome[:success]
- return { error: true, message: _('Test failed.'), service_response: outcome[:result].to_s, test_failed: true }
+ unless result[:success]
+ return { error: true, message: _('Test failed.'), service_response: result[:message].to_s, test_failed: true }
end
{}
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index a9d1dc0759d..c2292511e0f 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -104,7 +104,7 @@ module Projects
project_params = {
incident_management_setting_attributes: ::Gitlab::Tracking::IncidentManagement.tracking_keys.keys,
- metrics_setting_attributes: [:external_dashboard_url],
+ metrics_setting_attributes: [:external_dashboard_url, :dashboard_timezone],
error_tracking_setting_attributes: [
:enabled,
diff --git a/app/controllers/projects/snippets/application_controller.rb b/app/controllers/projects/snippets/application_controller.rb
new file mode 100644
index 00000000000..3f488b07e96
--- /dev/null
+++ b/app/controllers/projects/snippets/application_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Projects::Snippets::ApplicationController < Projects::ApplicationController
+ include FindSnippet
+ include SnippetAuthorizations
+
+ private
+
+ # This overrides the default snippet create authorization
+ # because ProjectSnippets are checked against the project rather
+ # than the user
+ def authorize_create_snippet!
+ return render_404 unless can?(current_user, :create_snippet, project)
+ end
+
+ def snippet_klass
+ ProjectSnippet
+ end
+end
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 9233f063f55..5ee6abef804 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -1,34 +1,19 @@
# frozen_string_literal: true
-class Projects::SnippetsController < Projects::ApplicationController
- include RendersNotes
+class Projects::SnippetsController < Projects::Snippets::ApplicationController
+ include SnippetsActions
include ToggleAwardEmoji
include SpammableActions
- include SnippetsActions
- include RendersBlob
- include PaginatedCollection
- include Gitlab::NoteableMetadata
-
- skip_before_action :verify_authenticity_token,
- if: -> { action_name == 'show' && js_request? }
before_action :check_snippets_available!
+
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam]
- # Allow create snippet
before_action :authorize_create_snippet!, only: [:new, :create]
-
- # Allow read any snippet
before_action :authorize_read_snippet!, except: [:new, :create, :index]
-
- # Allow modify snippet
before_action :authorize_update_snippet!, only: [:edit, :update]
-
- # Allow destroy snippet
before_action :authorize_admin_snippet!, only: [:destroy]
- respond_to :html
-
def index
@snippet_counts = Snippets::CountService
.new(current_user, project: @project)
@@ -56,61 +41,8 @@ class Projects::SnippetsController < Projects::ApplicationController
handle_repository_error(:new)
end
- def update
- update_params = snippet_params.merge(spammable_params)
-
- service_response = Snippets::UpdateService.new(project, current_user, update_params).execute(@snippet)
- @snippet = service_response.payload[:snippet]
-
- handle_repository_error(:edit)
- end
-
- def show
- conditionally_expand_blob(blob)
-
- respond_to do |format|
- format.html do
- @note = @project.notes.new(noteable: @snippet)
- @noteable = @snippet
-
- @discussions = @snippet.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
- render 'show'
- end
-
- format.json do
- render_blob_json(blob)
- end
-
- format.js do
- if @snippet.embeddable?
- render 'shared/snippets/show'
- else
- head :not_found
- end
- end
- end
- end
-
- def destroy
- service_response = Snippets::DestroyService.new(current_user, @snippet).execute
-
- if service_response.success?
- redirect_to project_snippets_path(project), status: :found
- elsif service_response.http_status == 403
- access_denied!
- else
- redirect_to project_snippet_path(project, @snippet),
- status: :found,
- alert: service_response.message
- end
- end
-
protected
- def snippet
- @snippet ||= @project.snippets.inc_relations_for_view.find(params[:id])
- end
alias_method :awardable, :snippet
alias_method :spammable, :snippet
@@ -118,18 +50,6 @@ class Projects::SnippetsController < Projects::ApplicationController
project_snippet_path(@project, @snippet)
end
- def authorize_read_snippet!
- return render_404 unless can?(current_user, :read_snippet, @snippet)
- end
-
- def authorize_update_snippet!
- return render_404 unless can?(current_user, :update_snippet, @snippet)
- end
-
- def authorize_admin_snippet!
- return render_404 unless can?(current_user, :admin_snippet, @snippet)
- end
-
def snippet_params
params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
end
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index c89bfd110c4..df20daa8f7e 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -41,16 +41,20 @@ class Projects::TagsController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def create
+ # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245
+ evidence_pipeline = find_evidence_pipeline
+
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
+ # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245
if params[:release_description].present?
release_params = {
tag: params[:tag_name],
name: params[:tag_name],
- description: params[:release_description]
+ description: params[:release_description],
+ evidence_pipeline: evidence_pipeline
}
Releases::CreateService
@@ -93,4 +97,14 @@ class Projects::TagsController < Projects::ApplicationController
end
end
end
+
+ private
+
+ # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245
+ def find_evidence_pipeline
+ evidence_pipeline_sha = @project.repository.commit(params[:ref])&.sha
+ return unless evidence_pipeline_sha
+
+ @project.ci_pipelines.for_sha(evidence_pipeline_sha).last
+ end
end
diff --git a/app/controllers/projects/web_ide_terminals_controller.rb b/app/controllers/projects/web_ide_terminals_controller.rb
new file mode 100644
index 00000000000..08ea5c4bca8
--- /dev/null
+++ b/app/controllers/projects/web_ide_terminals_controller.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+class Projects::WebIdeTerminalsController < Projects::ApplicationController
+ before_action :authenticate_user!
+
+ before_action :build, except: [:check_config, :create]
+ before_action :authorize_create_web_ide_terminal!
+ before_action :authorize_read_web_ide_terminal!, except: [:check_config, :create]
+ before_action :authorize_update_web_ide_terminal!, only: [:cancel, :retry]
+
+ def check_config
+ return respond_422 unless branch_sha
+
+ result = ::Ci::WebIdeConfigService.new(project, current_user, sha: branch_sha).execute
+
+ if result[:status] == :success
+ head :ok
+ else
+ respond_422
+ end
+ end
+
+ def show
+ render_terminal(build)
+ end
+
+ def create
+ result = ::Ci::CreateWebIdeTerminalService.new(project,
+ current_user,
+ ref: params[:branch])
+ .execute
+
+ if result[:status] == :error
+ render status: :bad_request, json: result[:message]
+ else
+ pipeline = result[:pipeline]
+ current_build = pipeline.builds.last
+
+ if current_build
+ Gitlab::UsageDataCounters::WebIdeCounter.increment_terminals_count
+
+ render_terminal(current_build)
+ else
+ render status: :bad_request, json: pipeline.errors.full_messages
+ end
+ end
+ end
+
+ def cancel
+ return respond_422 unless build.cancelable?
+
+ build.cancel
+
+ head :ok
+ end
+
+ def retry
+ return respond_422 unless build.retryable?
+
+ new_build = Ci::Build.retry(build, current_user)
+
+ render_terminal(new_build)
+ end
+
+ private
+
+ def authorize_create_web_ide_terminal!
+ return access_denied! unless can?(current_user, :create_web_ide_terminal, project)
+ end
+
+ def authorize_read_web_ide_terminal!
+ authorize_build_ability!(:read_web_ide_terminal)
+ end
+
+ def authorize_update_web_ide_terminal!
+ authorize_build_ability!(:update_web_ide_terminal)
+ end
+
+ def authorize_build_ability!(ability)
+ return access_denied! unless can?(current_user, ability, build)
+ end
+
+ def build
+ @build ||= project.builds.find(params[:id])
+ end
+
+ def branch_sha
+ return unless params[:branch].present?
+
+ project.commit(params[:branch])&.id
+ end
+
+ def render_terminal(current_build)
+ render json: WebIdeTerminalSerializer
+ .new(project: project, current_user: current_user)
+ .represent(current_build)
+ end
+end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 508b1f5bd0a..85e643aa212 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -1,206 +1,11 @@
# frozen_string_literal: true
class Projects::WikisController < Projects::ApplicationController
+ include WikiActions
include PreviewMarkdown
- include SendsBlob
- include Gitlab::Utils::StrongMemoize
- before_action :authorize_read_wiki!
- before_action :authorize_create_wiki!, only: [:edit, :create]
- before_action :authorize_admin_wiki!, only: :destroy
- before_action :load_project_wiki
- before_action :load_page, only: [:show, :edit, :update, :history, :destroy]
- before_action only: [:show, :edit, :update] do
- @valid_encoding = valid_encoding?
- end
- before_action only: [:edit, :update], unless: :valid_encoding? do
- redirect_to(project_wiki_path(@project, @page))
- end
-
- def new
- redirect_to project_wiki_path(@project, SecureRandom.uuid, random_title: true)
- end
-
- def pages
- @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
-
- # `#show` handles a number of scenarios:
- #
- # - If `id` matches a WikiPage, then show the wiki page.
- # - If `id` is a file in the wiki repository, then send the file.
- # - If we know the user wants to create a new page with the given `id`,
- # then display a create form.
- # - Otherwise show the empty wiki page and invite the user to create a page.
- def show
- if @page
- set_encoding_error unless valid_encoding?
-
- # Assign vars expected by MarkupHelper
- @ref = params[:version_id]
- @path = @page.path
-
- render 'show'
- elsif file_blob
- send_blob(@project_wiki.repository, file_blob, allow_caching: @project.public?)
- elsif show_create_form?
- # Assign a title to the WikiPage unless `id` is a randomly generated slug from #new
- title = params[:id] unless params[:random_title].present?
-
- @page = build_page(title: title)
-
- render 'edit'
- else
- render 'empty'
- end
- end
-
- def edit
- end
-
- def update
- return render('empty') unless can?(current_user, :create_wiki, @project)
-
- @page = WikiPages::UpdateService.new(container: @project, current_user: current_user, params: wiki_params).execute(@page)
-
- if @page.valid?
- redirect_to(
- project_wiki_path(@project, @page),
- notice: _('Wiki was successfully updated.')
- )
- else
- render 'edit'
- end
- rescue WikiPage::PageChangedError, WikiPage::PageRenameError, Gitlab::Git::Wiki::OperationError => e
- @error = e
- render 'edit'
- end
-
- def create
- @page = WikiPages::CreateService.new(container: @project, current_user: current_user, params: wiki_params).execute
-
- if @page.persisted?
- redirect_to(
- project_wiki_path(@project, @page),
- notice: _('Wiki was successfully updated.')
- )
- else
- render action: "edit"
- end
- rescue Gitlab::Git::Wiki::OperationError => e
- @page = build_page(wiki_params)
- @error = e
-
- render 'edit'
- end
-
- def history
- if @page
- @page_versions = Kaminari.paginate_array(@page.versions(page: params[:page].to_i),
- total_count: @page.count_versions)
- .page(params[:page])
- else
- redirect_to(
- project_wiki_path(@project, :home),
- notice: _("Page not found")
- )
- end
- end
-
- def destroy
- WikiPages::DestroyService.new(container: @project, current_user: current_user).execute(@page)
-
- redirect_to project_wiki_path(@project, :home),
- status: :found,
- notice: _("Page was successfully deleted")
- rescue Gitlab::Git::Wiki::OperationError => e
- @error = e
- render 'edit'
- end
+ alias_method :container, :project
def git_access
end
-
- private
-
- def show_create_form?
- can?(current_user, :create_wiki, @project) &&
- @page.nil? &&
- # Always show the create form when the wiki has had at least one page created.
- # Otherwise, we only show the form when the user has navigated from
- # the 'empty wiki' page
- (@project_wiki.exists? || params[:view] == 'create')
- end
-
- def load_project_wiki
- @project_wiki = load_wiki
-
- # Call #wiki to make sure the Wiki Repo is initialized
- @project_wiki.wiki
-
- @sidebar_page = @project_wiki.find_sidebar(params[:version_id])
-
- unless @sidebar_page # Fallback to default sidebar
- @sidebar_wiki_entries, @sidebar_limited = @project_wiki.sidebar_entries
- end
- rescue ProjectWiki::CouldNotCreateWikiError
- flash[:notice] = _("Could not create Wiki Repository at this time. Please try again later.")
- redirect_to project_path(@project)
- false
- end
-
- def load_wiki
- ProjectWiki.new(@project, current_user)
- end
-
- def wiki_params
- params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha)
- end
-
- def build_page(args = {})
- WikiPage.new(@project_wiki).tap do |page|
- page.update_attributes(args) # rubocop:disable Rails/ActiveRecordAliases
- end
- end
-
- def load_page
- @page ||= find_page
- end
-
- def find_page
- @project_wiki.find_page(*page_params)
- end
-
- def page_params
- keys = [:id]
- keys << :version_id if params[:action] == 'show'
-
- params.values_at(*keys)
- end
-
- def valid_encoding?
- page_encoding == Encoding::UTF_8
- end
-
- def page_encoding
- strong_memoize(:page_encoding) { @page&.content&.encoding }
- 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.")
- end
-
- def file_blob
- strong_memoize(:file_blob) do
- commit = @project_wiki.repository.commit(@project_wiki.default_branch)
-
- next unless commit
-
- @project_wiki.repository.blob_at(commit.id, params[:id])
- end
- end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 2f86b945b06..f0ddd62e996 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -34,6 +34,12 @@ class ProjectsController < Projects::ApplicationController
# Project Export Rate Limit
before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export]
+ # Experiments
+ before_action only: [:new, :create] do
+ frontend_experimentation_tracking_data(:new_create_project_ui, 'click_tab')
+ push_frontend_feature_flag(:new_create_project_ui) if experiment_enabled?(:new_create_project_ui)
+ end
+
layout :determine_layout
def index
@@ -310,12 +316,11 @@ class ProjectsController < Projects::ApplicationController
render 'projects/empty' if @project.empty_repo?
else
if can?(current_user, :read_wiki, @project)
- @project_wiki = @project.wiki
- @wiki_home = @project_wiki.find_page('home', params[:version_id])
+ @wiki = @project.wiki
+ @wiki_home = @wiki.find_page('home', params[:version_id])
elsif @project.feature_available?(:issues, current_user)
@issues = issuables_collection.page(params[:page])
- @collection_type = 'Issue'
- @issuable_meta_data = issuable_meta_data(@issues, @collection_type, current_user)
+ @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @issues).data
end
render :show
@@ -357,6 +362,7 @@ class ProjectsController < Projects::ApplicationController
def project_params_attributes
[
+ :allow_merge_on_skipped_pipeline,
:avatar,
:build_allow_git_fetch,
:build_coverage_regex,
@@ -483,11 +489,12 @@ class ProjectsController < Projects::ApplicationController
def export_rate_limit
prefixed_action = "project_#{params[:action]}".to_sym
- if rate_limiter.throttled?(prefixed_action, scope: [current_user, prefixed_action, @project])
+ project_scope = params[:action] == :download_export ? @project : nil
+
+ if rate_limiter.throttled?(prefixed_action, scope: [current_user, project_scope].compact)
rate_limiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user)
- flash[:alert] = _('This endpoint has been requested too many times. Try again later.')
- redirect_to edit_project_path(@project)
+ render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
end
end
diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb
new file mode 100644
index 00000000000..515d6b3f9aa
--- /dev/null
+++ b/app/controllers/registrations/experience_levels_controller.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Registrations
+ class ExperienceLevelsController < ApplicationController
+ # This will need to be changed to simply 'devise' as part of
+ # https://gitlab.com/gitlab-org/growth/engineering/issues/64
+ layout 'devise_experimental_separate_sign_up_flow'
+
+ before_action :check_experiment_enabled
+ before_action :ensure_namespace_path_param
+
+ def update
+ current_user.experience_level = params[:experience_level]
+
+ if current_user.save
+ hide_advanced_issues
+ flash[:message] = I18n.t('devise.registrations.signed_up')
+ redirect_to group_path(params[:namespace_path])
+ else
+ render :show
+ end
+ end
+
+ private
+
+ def check_experiment_enabled
+ access_denied! unless experiment_enabled?(:onboarding_issues)
+ end
+
+ def ensure_namespace_path_param
+ redirect_to root_path unless params[:namespace_path].present?
+ end
+
+ def hide_advanced_issues
+ return unless current_user.user_preference.novice?
+
+ settings = cookies[:onboarding_issues_settings]
+ return unless settings
+
+ modified_settings = Gitlab::Json.parse(settings).merge(hideAdvanced: true)
+ cookies[:onboarding_issues_settings] = modified_settings.to_json
+ end
+ end
+end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index ffbccbb01f2..6ab2924a8b5 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -63,6 +63,10 @@ class RegistrationsController < Devise::RegistrationsController
if result[:status] == :success
track_experiment_event(:signup_flow, 'end') # We want this event to be tracked when the user is _in_ the experimental group
+
+ track_experiment_event(:onboarding_issues, 'signed_up') if ::Gitlab.com? && !helpers.in_subscription_flow? && !helpers.in_invitation_flow?
+ return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && !helpers.in_subscription_flow? && !helpers.in_invitation_flow?
+
set_flash_message! :notice, :signed_up
redirect_to path_for_signed_in_user(current_user)
else
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index e3dbe6fcbdf..6a27d63625e 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -9,7 +9,7 @@ module Repositories
rescue_from Gitlab::GitAccess::ForbiddenError, with: :render_403_with_exception
rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404_with_exception
- rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422_with_exception
+ rescue_from Gitlab::GitAccessProject::CreationError, with: :render_422_with_exception
rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503_with_exception
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 04d2b3068da..217f08dd648 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -5,6 +5,10 @@ class SearchController < ApplicationController
include SearchHelper
include RendersCommits
+ SCOPE_PRELOAD_METHOD = {
+ projects: :with_web_entity_associations
+ }.freeze
+
around_action :allow_gitaly_ref_name_caching
skip_before_action :authenticate_user!
@@ -28,12 +32,12 @@ class SearchController < ApplicationController
@scope = search_service.scope
@show_snippets = search_service.show_snippets?
@search_results = search_service.search_results
- @search_objects = search_service.search_objects
+ @search_objects = search_service.search_objects(preload_method)
render_commits if @scope == 'commits'
eager_load_user_status if @scope == 'users'
- increment_navbar_searches_counter
+ increment_search_counters
check_single_commit_result
end
@@ -47,22 +51,11 @@ class SearchController < ApplicationController
render json: { count: count }
end
- # rubocop: disable CodeReuse/ActiveRecord
- def autocomplete
- term = params[:term]
-
- if params[:project_id].present?
- @project = Project.find_by(id: params[:project_id])
- @project = nil unless can?(current_user, :read_project, @project)
- end
-
- @ref = params[:project_ref] if params[:project_ref].present?
+ private
- render json: search_autocomplete_opts(term).to_json
+ def preload_method
+ SCOPE_PRELOAD_METHOD[@scope.to_sym]
end
- # rubocop: enable CodeReuse/ActiveRecord
-
- private
def search_term_valid?
unless search_service.valid_query_length?
@@ -98,9 +91,11 @@ class SearchController < ApplicationController
end
end
- def increment_navbar_searches_counter
+ def increment_search_counters
+ Gitlab::UsageDataCounters::SearchCounter.count(:all_searches)
+
return if params[:nav_source] != 'navbar'
- Gitlab::UsageDataCounters::SearchCounter.increment_navbar_searches_count
+ Gitlab::UsageDataCounters::SearchCounter.count(:navbar_searches)
end
end
diff --git a/app/controllers/snippets/application_controller.rb b/app/controllers/snippets/application_controller.rb
new file mode 100644
index 00000000000..a533e46a75d
--- /dev/null
+++ b/app/controllers/snippets/application_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class Snippets::ApplicationController < ApplicationController
+ include FindSnippet
+ include SnippetAuthorizations
+
+ private
+
+ def authorize_read_snippet!
+ return if can?(current_user, :read_snippet, snippet)
+
+ if current_user
+ render_404
+ else
+ authenticate_user!
+ end
+ end
+
+ def snippet_klass
+ PersonalSnippet
+ end
+end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 425e0458b41..87d87390e57 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -1,19 +1,12 @@
# frozen_string_literal: true
-class SnippetsController < ApplicationController
- include RendersNotes
- include ToggleAwardEmoji
- include SpammableActions
+class SnippetsController < Snippets::ApplicationController
include SnippetsActions
- include RendersBlob
include PreviewMarkdown
- include PaginatedCollection
- include Gitlab::NoteableMetadata
-
- skip_before_action :verify_authenticity_token,
- if: -> { action_name == 'show' && js_request? }
+ include ToggleAwardEmoji
+ include SpammableActions
- before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
+ before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam]
before_action :authorize_create_snippet!, only: [:new, :create]
before_action :authorize_read_snippet!, only: [:show, :raw]
@@ -23,7 +16,6 @@ class SnippetsController < ApplicationController
skip_before_action :authenticate_user!, only: [:index, :show, :raw]
layout 'snippets'
- respond_to :html
def index
if params[:username].present?
@@ -60,62 +52,8 @@ class SnippetsController < ApplicationController
end
end
- def update
- service_response = Snippets::UpdateService.new(nil, current_user, snippet_params).execute(@snippet)
- @snippet = service_response.payload[:snippet]
-
- handle_repository_error(:edit)
- end
-
- def show
- conditionally_expand_blob(blob)
-
- respond_to do |format|
- format.html do
- @note = Note.new(noteable: @snippet)
- @noteable = @snippet
-
- @discussions = @snippet.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
- render 'show'
- end
-
- format.json do
- render_blob_json(blob)
- end
-
- format.js do
- if @snippet.embeddable?
- render 'shared/snippets/show'
- else
- head :not_found
- end
- end
- end
- end
-
- def destroy
- service_response = Snippets::DestroyService.new(current_user, @snippet).execute
-
- if service_response.success?
- redirect_to dashboard_snippets_path, status: :found
- elsif service_response.http_status == 403
- access_denied!
- else
- redirect_to snippet_path(@snippet),
- status: :found,
- alert: service_response.message
- end
- end
-
protected
- # rubocop: disable CodeReuse/ActiveRecord
- def snippet
- @snippet ||= PersonalSnippet.inc_relations_for_view.find_by(id: params[:id])
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
alias_method :awardable, :snippet
alias_method :spammable, :snippet
@@ -123,28 +61,6 @@ class SnippetsController < ApplicationController
snippet_path(@snippet)
end
- def authorize_read_snippet!
- return if can?(current_user, :read_snippet, @snippet)
-
- if current_user
- render_404
- else
- authenticate_user!
- end
- end
-
- def authorize_update_snippet!
- return render_404 unless can?(current_user, :update_snippet, @snippet)
- end
-
- def authorize_admin_snippet!
- return render_404 unless can?(current_user, :admin_snippet, @snippet)
- end
-
- def authorize_create_snippet!
- return render_404 unless can?(current_user, :create_snippet)
- end
-
def snippet_params
params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description).merge(spammable_params)
end
diff --git a/app/finders/admin/runners_finder.rb b/app/finders/admin/runners_finder.rb
deleted file mode 100644
index b2799565f57..00000000000
--- a/app/finders/admin/runners_finder.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# frozen_string_literal: true
-
-class Admin::RunnersFinder < UnionFinder
- NUMBER_OF_RUNNERS_PER_PAGE = 30
-
- def initialize(params:)
- @params = params
- end
-
- def execute
- search!
- filter_by_status!
- filter_by_runner_type!
- filter_by_tag_list!
- sort!
- paginate!
-
- @runners.with_tags
- end
-
- def sort_key
- if @params[:sort] == 'contacted_asc'
- 'contacted_asc'
- else
- 'created_date'
- end
- end
-
- private
-
- def search!
- @runners =
- if @params[:search].present?
- Ci::Runner.search(@params[:search])
- else
- Ci::Runner.all
- end
- end
-
- def filter_by_status!
- filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES)
- end
-
- def filter_by_runner_type!
- 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
-
- def paginate!
- @runners = @runners.page(@params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
- end
-
- def filter_by!(scope_name, available_scopes)
- scope = @params[scope_name]
-
- if scope.present? && available_scopes.include?(scope)
- @runners = @runners.public_send(scope) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
-end
diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb
index 3c3c24c1479..774f08d1ff2 100644
--- a/app/finders/ci/daily_build_group_report_results_finder.rb
+++ b/app/finders/ci/daily_build_group_report_results_finder.rb
@@ -14,21 +14,25 @@ module Ci
end
def execute
- return none unless can?(current_user, :download_code, project)
+ return none unless can?(current_user, :read_build_report_results, project)
Ci::DailyBuildGroupReportResult.recent_results(
- {
- project_id: project,
- ref_path: ref_path,
- date: start_date..end_date
- },
- limit: @limit
+ query_params,
+ limit: limit
)
end
private
- attr_reader :current_user, :project, :ref_path, :start_date, :end_date
+ attr_reader :current_user, :project, :ref_path, :start_date, :end_date, :limit
+
+ def query_params
+ {
+ project_id: project,
+ ref_path: ref_path,
+ date: start_date..end_date
+ }
+ end
def none
Ci::DailyBuildGroupReportResult.none
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
new file mode 100644
index 00000000000..1b76211c524
--- /dev/null
+++ b/app/finders/ci/runners_finder.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Ci
+ class RunnersFinder < UnionFinder
+ include Gitlab::Allowable
+
+ NUMBER_OF_RUNNERS_PER_PAGE = 30
+
+ def initialize(current_user:, group: nil, params:)
+ @params = params
+ @group = group
+ @current_user = current_user
+ end
+
+ def execute
+ search!
+ filter_by_status!
+ filter_by_runner_type!
+ filter_by_tag_list!
+ sort!
+ paginate!
+
+ @runners.with_tags
+
+ rescue Gitlab::Access::AccessDeniedError
+ Ci::Runner.none
+ end
+
+ def sort_key
+ if @params[:sort] == 'contacted_asc'
+ 'contacted_asc'
+ else
+ 'created_date'
+ end
+ end
+
+ private
+
+ def search!
+ @group ? group_runners : all_runners
+
+ @runners = @runners.search(@params[:search]) if @params[:search].present?
+ end
+
+ def all_runners
+ raise Gitlab::Access::AccessDeniedError unless @current_user&.admin?
+
+ @runners = Ci::Runner.all
+ end
+
+ def group_runners
+ raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :admin_group, @group)
+
+ # Getting all runners from the group itself and all its descendants
+ descendant_projects = Project.for_group_and_its_subgroups(@group)
+
+ @runners = Ci::Runner.belonging_to_group_or_project(@group.self_and_descendants, descendant_projects)
+ end
+
+ def filter_by_status!
+ filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES)
+ end
+
+ def filter_by_runner_type!
+ 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
+
+ def paginate!
+ @runners = @runners.page(@params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
+ end
+
+ def filter_by!(scope_name, available_scopes)
+ scope = @params[scope_name]
+
+ if scope.present? && available_scopes.include?(scope)
+ @runners = @runners.public_send(scope) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+end
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 9c56451fd44..52612f1f8aa 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -72,9 +72,10 @@ class EventsFinder
# rubocop: disable CodeReuse/ActiveRecord
def by_action(events)
- return events unless Event::ACTIONS[params[:action]]
+ safe_action = Event.actions[params[:action]]
+ return events unless safe_action
- events.where(action: Event::ACTIONS[params[:action]])
+ events.where(action: safe_action)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 7014f2ec205..5cdc22fd873 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -451,7 +451,7 @@ class IssuableFinder
if params.filter_by_no_label?
items.without_label
elsif params.filter_by_any_label?
- items.any_label
+ items.any_label(params[:sort])
else
items.with_label(params.label_names, params[:sort])
end
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index adf9f1ca9d8..5b48d0817e3 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -105,7 +105,7 @@ class IssuableFinder
end
def project?
- params[:project_id].present?
+ project_id.present?
end
def group
@@ -132,15 +132,19 @@ class IssuableFinder
def project
strong_memoize(:project) do
- next nil unless params[:project_id].present?
+ next nil unless project?
- project = Project.find(params[:project_id])
+ project = project_id.is_a?(Project) ? project_id : Project.find(project_id)
project = nil unless Ability.allowed?(current_user, :"read_#{klass.to_ability_name}", project)
project
end
end
+ def project_id
+ params[:project_id]
+ end
+
def projects
strong_memoize(:projects) do
next [project] if project?
diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb
index 027cdc4fc78..e726772fba4 100644
--- a/app/finders/labels_finder.rb
+++ b/app/finders/labels_finder.rb
@@ -46,7 +46,6 @@ class LabelsFinder < UnionFinder
end
else
if group?
- group = Group.find(params[:group_id])
label_ids << Label.where(group_id: group_ids_for(group))
end
@@ -123,7 +122,11 @@ class LabelsFinder < UnionFinder
end
def group?
- params[:group_id].present?
+ params[:group].present? || params[:group_id].present?
+ end
+
+ def group
+ strong_memoize(:group) { params[:group].presence || Group.find(params[:group_id]) }
end
def project?
@@ -169,7 +172,7 @@ class LabelsFinder < UnionFinder
ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute # rubocop: disable CodeReuse/Finder
end
- @projects = @projects.in_namespace(params[:group_id]) if group?
+ @projects = @projects.in_namespace(group.id) if group?
@projects = @projects.where(id: params[:project_ids]) if projects?
@projects = @projects.reorder(nil)
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index cfe648d9f79..8f0cdf3b255 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -58,10 +58,8 @@ class MilestonesFinder
Milestone.filter_by_state(items, params[:state])
end
- # rubocop: disable CodeReuse/ActiveRecord
def order(items)
- order_statement = Gitlab::Database.nulls_last_order('due_date', 'ASC')
- items.reorder(order_statement).order('title ASC')
+ sort_by = params[:sort].presence || 'due_date_asc'
+ items.sort_by_attribute(sort_by)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index e798db561bf..8e57014f66e 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -148,7 +148,7 @@ class NotesFinder
# Searches for notes matching the given query.
#
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
#
def search(notes)
query = @params[:search]
diff --git a/app/finders/resource_label_event_finder.rb b/app/finders/resource_label_event_finder.rb
deleted file mode 100644
index 9aafd6e91b9..00000000000
--- a/app/finders/resource_label_event_finder.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-class ResourceLabelEventFinder
- include FinderMethods
-
- MAX_PER_PAGE = 100
-
- attr_reader :params, :current_user, :eventable
-
- def initialize(current_user, eventable, params = {})
- @current_user = current_user
- @eventable = eventable
- @params = params
- end
-
- def execute
- events = eventable.resource_label_events.inc_relations
- events = events.page(page).per(per_page)
- events = visible_to_user(events)
-
- Kaminari.paginate_array(events)
- end
-
- private
-
- def visible_to_user(events)
- ResourceLabelEvent.preload_label_subjects(events)
-
- events.select do |event|
- Ability.allowed?(current_user, :read_label, event)
- end
- end
-
- def per_page
- [params[:per_page], MAX_PER_PAGE].compact.min
- end
-
- def page
- params[:page] || 1
- end
-end
diff --git a/app/finders/resource_milestone_event_finder.rb b/app/finders/resource_milestone_event_finder.rb
new file mode 100644
index 00000000000..7af34f0a4bc
--- /dev/null
+++ b/app/finders/resource_milestone_event_finder.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class ResourceMilestoneEventFinder
+ include FinderMethods
+
+ MAX_PER_PAGE = 100
+
+ attr_reader :params, :current_user, :eventable
+
+ def initialize(current_user, eventable, params = {})
+ @current_user = current_user
+ @eventable = eventable
+ @params = params
+ end
+
+ def execute
+ Kaminari.paginate_array(visible_events)
+ end
+
+ private
+
+ def visible_events
+ @visible_events ||= visible_to_user(events)
+ end
+
+ def events
+ @events ||= eventable.resource_milestone_events.include_relations.page(page).per(per_page)
+ end
+
+ def visible_to_user(events)
+ events.select { |event| visible_for_user?(event) }
+ end
+
+ def visible_for_user?(event)
+ milestone = event_milestones[event.milestone_id]
+ return if milestone.blank?
+
+ parent = milestone.parent
+ parent_availabilities[key_for_parent(parent)]
+ end
+
+ def parent_availabilities
+ @parent_availabilities ||= relevant_parents.to_h do |parent|
+ [key_for_parent(parent), Ability.allowed?(current_user, :read_milestone, parent)]
+ end
+ end
+
+ def key_for_parent(parent)
+ "#{parent.class.name}_#{parent.id}"
+ end
+
+ def event_milestones
+ @milestones ||= events.map(&:milestone).uniq.to_h do |milestone|
+ [milestone.id, milestone]
+ end
+ end
+
+ def relevant_parents
+ @relevant_parents ||= event_milestones.map { |_id, milestone| milestone.parent }
+ end
+
+ def per_page
+ [params[:per_page], MAX_PER_PAGE].compact.min
+ end
+
+ def page
+ params[:page] || 1
+ end
+end
diff --git a/app/finders/uploader_finder.rb b/app/finders/uploader_finder.rb
new file mode 100644
index 00000000000..0d1de0d56fd
--- /dev/null
+++ b/app/finders/uploader_finder.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class UploaderFinder
+ # Instantiates a a new FileUploader
+ # FileUploader can be opened via .open agnostic of storage type
+ # Arguments correspond to Upload.secret, Upload.model_type and Upload.file_path
+ # Returns a FileUploader with uploaded file retrieved into the object state
+ def initialize(project, secret, file_path)
+ @project = project
+ @secret = secret
+ @file_path = file_path
+ end
+
+ def execute
+ prevent_path_traversal_attack!
+ retrieve_file_state!
+
+ uploader
+ rescue ::Gitlab::Utils::PathTraversalAttackError
+ nil # no-op if for incorrect files
+ end
+
+ def prevent_path_traversal_attack!
+ Gitlab::Utils.check_path_traversal!(@file_path)
+ end
+
+ def retrieve_file_state!
+ uploader.retrieve_from_store!(@file_path)
+ end
+
+ def uploader
+ @uploader ||= FileUploader.new(@project, secret: @secret)
+ end
+end
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index ebb686c2aa7..f87e0c67604 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -15,6 +15,8 @@
# blocked: boolean
# external: boolean
# without_projects: boolean
+# sort: string
+# id: integer
#
class UsersFinder
include CreatedAtFilter
@@ -30,6 +32,7 @@ class UsersFinder
def execute
users = User.all.order_id_desc
users = by_username(users)
+ users = by_id(users)
users = by_search(users)
users = by_blocked(users)
users = by_active(users)
@@ -40,7 +43,7 @@ class UsersFinder
users = by_without_projects(users)
users = by_custom_attributes(users)
- users
+ order(users)
end
private
@@ -51,6 +54,12 @@ class UsersFinder
users.by_username(params[:username])
end
+ def by_id(users)
+ return users unless params[:id]
+
+ users.id_in(params[:id])
+ end
+
def by_search(users)
return users unless params[:search].present?
@@ -102,6 +111,14 @@ class UsersFinder
users.without_projects
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def order(users)
+ return users unless params[:sort]
+
+ users.order_by(params[:sort])
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
UsersFinder.prepend_if_ee('EE::UsersFinder')
diff --git a/app/graphql/mutations/alert_management/alerts/set_assignees.rb b/app/graphql/mutations/alert_management/alerts/set_assignees.rb
new file mode 100644
index 00000000000..1e0c9fdeeaf
--- /dev/null
+++ b/app/graphql/mutations/alert_management/alerts/set_assignees.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module Alerts
+ class SetAssignees < Base
+ graphql_name 'AlertSetAssignees'
+
+ argument :assignee_usernames,
+ [GraphQL::STRING_TYPE],
+ required: true,
+ description: 'The usernames to assign to the alert. Replaces existing assignees by default.'
+
+ argument :operation_mode,
+ Types::MutationOperationModeEnum,
+ required: false,
+ description: 'The operation to perform. Defaults to REPLACE.'
+
+ def resolve(args)
+ alert = authorized_find!(project_path: args[:project_path], iid: args[:iid])
+ result = set_assignees(alert, args[:assignee_usernames], args[:operation_mode])
+
+ prepare_response(result)
+ end
+
+ private
+
+ def set_assignees(alert, assignee_usernames, operation_mode)
+ operation_mode ||= Types::MutationOperationModeEnum.enum[:replace]
+
+ original_assignees = alert.assignees
+ target_users = find_target_users(assignee_usernames)
+
+ assignees = case Types::MutationOperationModeEnum.enum.key(operation_mode).to_sym
+ when :replace then target_users.uniq
+ when :append then (original_assignees + target_users).uniq
+ when :remove then (original_assignees - target_users)
+ end
+
+ ::AlertManagement::Alerts::UpdateService.new(alert, current_user, assignees: assignees).execute
+ end
+
+ def find_target_users(assignee_usernames)
+ UsersFinder.new(current_user, username: assignee_usernames).execute
+ end
+
+ def prepare_response(result)
+ {
+ alert: result.payload[:alert],
+ errors: result.error? ? [result.message] : []
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb
index ca2057d4845..7fcca63db51 100644
--- a/app/graphql/mutations/alert_management/base.rb
+++ b/app/graphql/mutations/alert_management/base.rb
@@ -3,7 +3,7 @@
module Mutations
module AlertManagement
class Base < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
argument :project_path, GraphQL::ID_TYPE,
required: true,
@@ -32,7 +32,7 @@ module Mutations
return unless project
- resolver = Resolvers::AlertManagementAlertResolver.single.new(object: project, context: context, field: nil)
+ resolver = Resolvers::AlertManagement::AlertResolver.single.new(object: project, context: context, field: nil)
resolver.resolve(iid: iid)
end
end
diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb
index e73a591378a..d820124d26f 100644
--- a/app/graphql/mutations/alert_management/update_alert_status.rb
+++ b/app/graphql/mutations/alert_management/update_alert_status.rb
@@ -27,7 +27,7 @@ module Mutations
def prepare_response(result)
{
alert: result.payload[:alert],
- errors: result.error? ? [result.message] : []
+ errors: result.errors
}
end
end
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index 30510cfab50..33f3f33a440 100644
--- a/app/graphql/mutations/base_mutation.rb
+++ b/app/graphql/mutations/base_mutation.rb
@@ -9,7 +9,7 @@ module Mutations
field :errors, [GraphQL::STRING_TYPE],
null: false,
- description: "Errors encountered during execution of the mutation."
+ description: 'Errors encountered during execution of the mutation.'
def current_user
context[:current_user]
diff --git a/app/graphql/mutations/branches/create.rb b/app/graphql/mutations/branches/create.rb
index 127d5447d0a..214fead2e80 100644
--- a/app/graphql/mutations/branches/create.rb
+++ b/app/graphql/mutations/branches/create.rb
@@ -3,7 +3,7 @@
module Mutations
module Branches
class Create < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
graphql_name 'CreateBranch'
diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb
new file mode 100644
index 00000000000..9ed1bb819c8
--- /dev/null
+++ b/app/graphql/mutations/commits/create.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Commits
+ class Create < BaseMutation
+ include ResolvesProject
+
+ graphql_name 'CommitCreate'
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Project full path the branch is associated with'
+
+ argument :branch, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Name of the branch'
+
+ argument :message,
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: copy_field_description(Types::CommitType, :message)
+
+ argument :actions,
+ [Types::CommitActionType],
+ required: true,
+ description: 'Array of action hashes to commit as a batch'
+
+ field :commit,
+ Types::CommitType,
+ null: true,
+ description: 'The commit after mutation'
+
+ authorize :push_code
+
+ def resolve(project_path:, branch:, message:, actions:)
+ project = authorized_find!(full_path: project_path)
+
+ attributes = {
+ commit_message: message,
+ branch_name: branch,
+ start_branch: branch,
+ actions: actions.map { |action| action.to_h }
+ }
+
+ result = ::Files::MultiService.new(project, current_user, attributes).execute
+
+ {
+ commit: (project.repository.commit(result[:result]) if result[:status] == :success),
+ errors: Array.wrap(result[:message])
+ }
+ end
+
+ private
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
index d63cc27a450..13a56f2e709 100644
--- a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
+++ b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
@@ -3,12 +3,22 @@
module Mutations
module ResolvesIssuable
extend ActiveSupport::Concern
- include Mutations::ResolvesProject
+
+ included do
+ include ResolvesProject
+ end
def resolve_issuable(type:, parent_path:, iid:)
parent = resolve_issuable_parent(type, parent_path)
+ key = type == :merge_request ? :iids : :iid
+ args = { key => iid.to_s }
+
+ resolver = issuable_resolver(type, parent, context)
+ ready, early_return = resolver.ready?(**args)
+
+ return early_return unless ready
- issuable_resolver(type, parent, context).resolve(iid: iid.to_s)
+ resolver.resolve(**args)
end
private
@@ -22,7 +32,7 @@ module Mutations
def resolve_issuable_parent(type, parent_path)
return unless type == :issue || type == :merge_request
- resolve_project(full_path: parent_path)
+ resolve_project(full_path: parent_path) if parent_path.present?
end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/resolves_project.rb b/app/graphql/mutations/concerns/mutations/resolves_project.rb
deleted file mode 100644
index e223e3edd94..00000000000
--- a/app/graphql/mutations/concerns/mutations/resolves_project.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module ResolvesProject
- extend ActiveSupport::Concern
-
- def resolve_project(full_path:)
- project_resolver.resolve(full_path: full_path)
- end
-
- def project_resolver
- Resolvers::ProjectResolver.new(object: nil, context: context, field: nil)
- end
- end
-end
diff --git a/app/graphql/mutations/container_expiration_policies/update.rb b/app/graphql/mutations/container_expiration_policies/update.rb
new file mode 100644
index 00000000000..c210571c6ca
--- /dev/null
+++ b/app/graphql/mutations/container_expiration_policies/update.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ContainerExpirationPolicies
+ class Update < Mutations::BaseMutation
+ include ResolvesProject
+
+ graphql_name 'UpdateContainerExpirationPolicy'
+
+ authorize :destroy_container_image
+
+ argument :project_path,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project path where the container expiration policy is located'
+
+ argument :enabled,
+ GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: copy_field_description(Types::ContainerExpirationPolicyType, :enabled)
+
+ argument :cadence,
+ Types::ContainerExpirationPolicyCadenceEnum,
+ required: false,
+ description: copy_field_description(Types::ContainerExpirationPolicyType, :cadence)
+
+ argument :older_than,
+ Types::ContainerExpirationPolicyOlderThanEnum,
+ required: false,
+ description: copy_field_description(Types::ContainerExpirationPolicyType, :older_than)
+
+ argument :keep_n,
+ Types::ContainerExpirationPolicyKeepEnum,
+ required: false,
+ description: copy_field_description(Types::ContainerExpirationPolicyType, :keep_n)
+
+ field :container_expiration_policy,
+ Types::ContainerExpirationPolicyType,
+ null: true,
+ description: 'The container expiration policy after mutation'
+
+ def resolve(project_path:, **args)
+ project = authorized_find!(full_path: project_path)
+
+ result = ::ContainerExpirationPolicies::UpdateService
+ .new(container: project, current_user: current_user, params: args)
+ .execute
+
+ {
+ container_expiration_policy: result.payload[:container_expiration_policy],
+ errors: result.errors
+ }
+ end
+
+ private
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/discussions/toggle_resolve.rb b/app/graphql/mutations/discussions/toggle_resolve.rb
new file mode 100644
index 00000000000..41fd22c6b55
--- /dev/null
+++ b/app/graphql/mutations/discussions/toggle_resolve.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Discussions
+ class ToggleResolve < BaseMutation
+ graphql_name 'DiscussionToggleResolve'
+
+ description 'Toggles the resolved state of a discussion'
+
+ argument :id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the discussion'
+
+ argument :resolve,
+ GraphQL::BOOLEAN_TYPE,
+ required: true,
+ description: 'Will resolve the discussion when true, and unresolve the discussion when false'
+
+ field :discussion,
+ Types::Notes::DiscussionType,
+ null: true,
+ description: 'The discussion after mutation'
+
+ def resolve(id:, resolve:)
+ discussion = authorized_find_discussion!(id: id)
+ errors = []
+
+ begin
+ if resolve
+ resolve!(discussion)
+ else
+ unresolve!(discussion)
+ end
+ rescue ActiveRecord::RecordNotSaved
+ errors << "Discussion failed to be #{'un' unless resolve}resolved"
+ end
+
+ {
+ discussion: discussion,
+ errors: errors
+ }
+ end
+
+ private
+
+ # `Discussion` permissions are checked through `Discussion#can_resolve?`,
+ # so we use this method of checking permissions rather than by defining
+ # an `authorize` permission and calling `authorized_find!`.
+ def authorized_find_discussion!(id:)
+ find_object(id: id).tap do |discussion|
+ raise_resource_not_available_error! unless discussion&.can_resolve?(current_user)
+ end
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::Discussion)
+ end
+
+ def resolve!(discussion)
+ ::Discussions::ResolveService.new(
+ discussion.project,
+ current_user,
+ one_or_more_discussions: discussion
+ ).execute
+ end
+
+ def unresolve!(discussion)
+ discussion.unresolve!
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb
index 0fff5518665..75befddc261 100644
--- a/app/graphql/mutations/issues/set_confidential.rb
+++ b/app/graphql/mutations/issues/set_confidential.rb
@@ -19,7 +19,7 @@ module Mutations
{
issue: issue,
- errors: issue.errors.full_messages
+ errors: errors_on_object(issue)
}
end
end
diff --git a/app/graphql/mutations/issues/set_due_date.rb b/app/graphql/mutations/issues/set_due_date.rb
index 1855c6f053b..effd863c541 100644
--- a/app/graphql/mutations/issues/set_due_date.rb
+++ b/app/graphql/mutations/issues/set_due_date.rb
@@ -19,7 +19,7 @@ module Mutations
{
issue: issue,
- errors: issue.errors.full_messages
+ errors: errors_on_object(issue)
}
end
end
diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb
index 3710144fff5..7f6d9b0f988 100644
--- a/app/graphql/mutations/issues/update.rb
+++ b/app/graphql/mutations/issues/update.rb
@@ -33,7 +33,7 @@ module Mutations
{
issue: issue,
- errors: issue.errors.full_messages
+ errors: errors_on_object(issue)
}
end
end
diff --git a/app/graphql/mutations/jira_import/import_users.rb b/app/graphql/mutations/jira_import/import_users.rb
new file mode 100644
index 00000000000..c7225e1a99c
--- /dev/null
+++ b/app/graphql/mutations/jira_import/import_users.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Mutations
+ module JiraImport
+ class ImportUsers < BaseMutation
+ include ResolvesProject
+
+ graphql_name 'JiraImportUsers'
+
+ field :jira_users,
+ [Types::JiraUserType],
+ null: true,
+ description: 'Users returned from Jira, matched by email and name if possible.'
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project to import the Jira users into'
+ argument :start_at, GraphQL::INT_TYPE,
+ required: false,
+ description: 'The index of the record the import should started at, default 0 (50 records returned)'
+
+ def resolve(project_path:, start_at:)
+ project = authorized_find!(full_path: project_path)
+
+ service_response = ::JiraImport::UsersImporter.new(context[:current_user], project, start_at).execute
+
+ {
+ jira_users: service_response.payload,
+ errors: service_response.errors
+ }
+ end
+
+ private
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+
+ def authorized_resource?(project)
+ Ability.allowed?(context[:current_user], :admin_project, project)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb
index 6b80c9f8ca4..3df26d33711 100644
--- a/app/graphql/mutations/jira_import/start.rb
+++ b/app/graphql/mutations/jira_import/start.rb
@@ -3,7 +3,7 @@
module Mutations
module JiraImport
class Start < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
graphql_name 'JiraImportStart'
@@ -23,29 +23,21 @@ module Mutations
description: 'Project name of the importer Jira project'
def resolve(project_path:, jira_project_key:)
- project = find_project!(project_path: project_path)
-
- raise_resource_not_available_error! unless project
+ project = authorized_find!(full_path: project_path)
service_response = ::JiraImport::StartImportService
.new(context[:current_user], project, jira_project_key)
.execute
jira_import = service_response.success? ? service_response.payload[:import_data] : nil
- errors = service_response.error? ? [service_response.message] : []
+
{
jira_import: jira_import,
- errors: errors
+ errors: service_response.errors
}
end
private
- def find_project!(project_path:)
- return unless project_path.present?
-
- authorized_find!(full_path: project_path)
- end
-
def find_object(full_path:)
resolve_project(full_path: full_path)
end
diff --git a/app/graphql/mutations/merge_requests/create.rb b/app/graphql/mutations/merge_requests/create.rb
new file mode 100644
index 00000000000..e210987f259
--- /dev/null
+++ b/app/graphql/mutations/merge_requests/create.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Mutations
+ module MergeRequests
+ class Create < BaseMutation
+ include ResolvesProject
+
+ graphql_name 'MergeRequestCreate'
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Project full path the merge request is associated with'
+
+ argument :title, GraphQL::STRING_TYPE,
+ required: true,
+ description: copy_field_description(Types::MergeRequestType, :title)
+
+ argument :source_branch, GraphQL::STRING_TYPE,
+ required: true,
+ description: copy_field_description(Types::MergeRequestType, :source_branch)
+
+ argument :target_branch, GraphQL::STRING_TYPE,
+ required: true,
+ description: copy_field_description(Types::MergeRequestType, :target_branch)
+
+ argument :description, GraphQL::STRING_TYPE,
+ required: false,
+ description: copy_field_description(Types::MergeRequestType, :description)
+
+ field :merge_request,
+ Types::MergeRequestType,
+ null: true,
+ description: 'The merge request after mutation'
+
+ authorize :create_merge_request_from
+
+ def resolve(project_path:, title:, source_branch:, target_branch:, description: nil)
+ project = authorized_find!(full_path: project_path)
+
+ attributes = {
+ title: title,
+ source_branch: source_branch,
+ target_branch: target_branch,
+ author_id: current_user.id,
+ description: description
+ }
+
+ merge_request = ::MergeRequests::CreateService.new(project, current_user, attributes).execute
+
+ {
+ merge_request: merge_request.valid? ? merge_request : nil,
+ errors: errors_on_object(merge_request)
+ }
+ end
+
+ private
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/merge_requests/set_assignees.rb b/app/graphql/mutations/merge_requests/set_assignees.rb
index 8f0025f0a58..de244b62d0f 100644
--- a/app/graphql/mutations/merge_requests/set_assignees.rb
+++ b/app/graphql/mutations/merge_requests/set_assignees.rb
@@ -40,7 +40,7 @@ module Mutations
{
merge_request: merge_request,
- errors: merge_request.errors.full_messages
+ errors: errors_on_object(merge_request)
}
end
end
diff --git a/app/graphql/mutations/merge_requests/set_labels.rb b/app/graphql/mutations/merge_requests/set_labels.rb
index 71f7a353bc9..c1e45808593 100644
--- a/app/graphql/mutations/merge_requests/set_labels.rb
+++ b/app/graphql/mutations/merge_requests/set_labels.rb
@@ -24,8 +24,9 @@ module Mutations
project = merge_request.project
label_ids = label_ids
+ .map { |gid| GlobalID.parse(gid) }
.select(&method(:label_descendant?))
- .map { |gid| GlobalID.parse(gid).model_id } # MergeRequests::UpdateService expects integers
+ .map(&:model_id) # MergeRequests::UpdateService expects integers
attribute_name = case operation_mode
when Types::MutationOperationModeEnum.enum[:append]
@@ -41,12 +42,12 @@ module Mutations
{
merge_request: merge_request,
- errors: merge_request.errors.full_messages
+ errors: errors_on_object(merge_request)
}
end
def label_descendant?(gid)
- GlobalID.parse(gid)&.model_class&.ancestors&.include?(Label)
+ gid&.model_class&.ancestors&.include?(Label)
end
end
end
diff --git a/app/graphql/mutations/merge_requests/set_locked.rb b/app/graphql/mutations/merge_requests/set_locked.rb
index 09aaa0b39aa..c49d5186a03 100644
--- a/app/graphql/mutations/merge_requests/set_locked.rb
+++ b/app/graphql/mutations/merge_requests/set_locked.rb
@@ -21,7 +21,7 @@ module Mutations
{
merge_request: merge_request,
- errors: merge_request.errors.full_messages
+ errors: errors_on_object(merge_request)
}
end
end
diff --git a/app/graphql/mutations/merge_requests/set_milestone.rb b/app/graphql/mutations/merge_requests/set_milestone.rb
index 707d6677952..b3412dd9ed2 100644
--- a/app/graphql/mutations/merge_requests/set_milestone.rb
+++ b/app/graphql/mutations/merge_requests/set_milestone.rb
@@ -22,7 +22,7 @@ module Mutations
{
merge_request: merge_request,
- errors: merge_request.errors.full_messages
+ errors: errors_on_object(merge_request)
}
end
end
diff --git a/app/graphql/mutations/merge_requests/set_subscription.rb b/app/graphql/mutations/merge_requests/set_subscription.rb
index 86750152775..1535481ab37 100644
--- a/app/graphql/mutations/merge_requests/set_subscription.rb
+++ b/app/graphql/mutations/merge_requests/set_subscription.rb
@@ -18,7 +18,7 @@ module Mutations
{
merge_request: merge_request,
- errors: merge_request.errors.full_messages
+ errors: errors_on_object(merge_request)
}
end
end
diff --git a/app/graphql/mutations/merge_requests/set_wip.rb b/app/graphql/mutations/merge_requests/set_wip.rb
index a2aa0c84ee4..5d2077c12f2 100644
--- a/app/graphql/mutations/merge_requests/set_wip.rb
+++ b/app/graphql/mutations/merge_requests/set_wip.rb
@@ -21,7 +21,7 @@ module Mutations
{
merge_request: merge_request,
- errors: merge_request.errors.full_messages
+ errors: errors_on_object(merge_request)
}
end
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/base.rb b/app/graphql/mutations/metrics/dashboard/annotations/base.rb
new file mode 100644
index 00000000000..3126267da64
--- /dev/null
+++ b/app/graphql/mutations/metrics/dashboard/annotations/base.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Metrics
+ module Dashboard
+ module Annotations
+ class Base < BaseMutation
+ private
+
+ # This method is defined here in order to be used by `authorized_find!` in the subclasses.
+ def find_object(id:)
+ GitlabSchema.object_from_id(id)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
new file mode 100644
index 00000000000..fb828ba0e2f
--- /dev/null
+++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Metrics
+ module Dashboard
+ module Annotations
+ class Delete < Base
+ graphql_name 'DeleteAnnotation'
+
+ authorize :delete_metrics_dashboard_annotation
+
+ argument :id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the annotation to delete'
+
+ def resolve(id:)
+ annotation = authorized_find!(id: id)
+
+ result = ::Metrics::Dashboard::Annotations::DeleteService.new(context[:current_user], annotation).execute
+
+ errors = Array.wrap(result[:message])
+
+ {
+ errors: errors
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 6fc223fbee7..e1022358c09 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -3,7 +3,7 @@
module Mutations
module Snippets
class Create < BaseMutation
- include Mutations::ResolvesProject
+ include ResolvesProject
graphql_name 'CreateSnippet'
@@ -60,7 +60,7 @@ module Mutations
snippet = service_response.payload[:snippet]
{
- snippet: snippet.valid? ? snippet : nil,
+ snippet: service_response.success? ? snippet : nil,
errors: errors_on_object(snippet)
}
end
diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb
index 5694985717c..d30d1bcbcf0 100644
--- a/app/graphql/mutations/todos/mark_all_done.rb
+++ b/app/graphql/mutations/todos/mark_all_done.rb
@@ -28,7 +28,9 @@ module Mutations
def mark_all_todos_done
return [] unless current_user
- TodoService.new.mark_all_todos_as_done_by_user(current_user)
+ todos = TodosFinder.new(current_user).execute
+
+ TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_all_done)
end
end
end
diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb
index d738e387c43..748e02d8782 100644
--- a/app/graphql/mutations/todos/mark_done.rb
+++ b/app/graphql/mutations/todos/mark_done.rb
@@ -30,7 +30,7 @@ module Mutations
private
def mark_done(todo)
- TodoService.new.mark_todo_as_done(todo, current_user)
+ TodoService.new.resolve_todo(todo, current_user, resolved_by_action: :api_done)
end
end
end
diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb
index c4597bd84a2..a0a1772db0a 100644
--- a/app/graphql/mutations/todos/restore.rb
+++ b/app/graphql/mutations/todos/restore.rb
@@ -18,7 +18,7 @@ module Mutations
def resolve(id:)
todo = authorized_find!(id: id)
- restore(todo.id) if todo.done?
+ restore(todo)
{
todo: todo.reset,
@@ -28,8 +28,8 @@ module Mutations
private
- def restore(id)
- TodoService.new.mark_todos_as_pending_by_ids([id], current_user)
+ def restore(todo)
+ TodoService.new.restore_todo(todo, current_user)
end
end
end
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
index 8a6265207cd..e95651b232f 100644
--- a/app/graphql/mutations/todos/restore_many.rb
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -68,7 +68,7 @@ module Mutations
end
def restore(todos)
- TodoService.new.mark_todos_as_pending(todos, current_user)
+ TodoService.new.restore_todos(todos, current_user)
end
end
end
diff --git a/app/graphql/resolvers/alert_management/alert_resolver.rb b/app/graphql/resolvers/alert_management/alert_resolver.rb
new file mode 100644
index 00000000000..71a7615685a
--- /dev/null
+++ b/app/graphql/resolvers/alert_management/alert_resolver.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module AlertManagement
+ class AlertResolver < BaseResolver
+ include LooksAhead
+
+ argument :iid, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'IID of the alert. For example, "1"'
+
+ argument :statuses, [Types::AlertManagement::StatusEnum],
+ as: :status,
+ required: false,
+ description: 'Alerts with the specified statues. For example, [TRIGGERED]'
+
+ argument :sort, Types::AlertManagement::AlertSortEnum,
+ description: 'Sort alerts by this criteria',
+ required: false
+
+ argument :search, GraphQL::STRING_TYPE,
+ description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
+ required: false
+
+ type Types::AlertManagement::AlertType, null: true
+
+ def resolve_with_lookahead(**args)
+ parent = object.respond_to?(:sync) ? object.sync : object
+ return ::AlertManagement::Alert.none if parent.nil?
+
+ apply_lookahead(::AlertManagement::AlertsFinder.new(context[:current_user], parent, args).execute)
+ end
+
+ def preloads
+ {
+ assignees: [:assignees],
+ notes: [:ordered_notes, { ordered_notes: [:system_note_metadata, :project, :noteable] }]
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
index 7f4346632ca..a45de21002f 100644
--- a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
+++ b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
@@ -5,6 +5,10 @@ module Resolvers
class AlertStatusCountsResolver < BaseResolver
type Types::AlertManagement::AlertStatusCountsType, null: true
+ argument :search, GraphQL::STRING_TYPE,
+ description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
+ required: false
+
def resolve(**args)
::Gitlab::AlertManagement::AlertStatusCounts.new(context[:current_user], object, args)
end
diff --git a/app/graphql/resolvers/alert_management_alert_resolver.rb b/app/graphql/resolvers/alert_management_alert_resolver.rb
deleted file mode 100644
index 51ebbb96476..00000000000
--- a/app/graphql/resolvers/alert_management_alert_resolver.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-module Resolvers
- class AlertManagementAlertResolver < BaseResolver
- argument :iid, GraphQL::STRING_TYPE,
- required: false,
- description: 'IID of the alert. For example, "1"'
-
- argument :statuses, [Types::AlertManagement::StatusEnum],
- as: :status,
- required: false,
- description: 'Alerts with the specified statues. For example, [TRIGGERED]'
-
- argument :sort, Types::AlertManagement::AlertSortEnum,
- description: 'Sort alerts by this criteria',
- required: false
-
- argument :search, GraphQL::STRING_TYPE,
- description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
- required: false
-
- type Types::AlertManagement::AlertType, null: true
-
- def resolve(**args)
- parent = object.respond_to?(:sync) ? object.sync : object
- return ::AlertManagement::Alert.none if parent.nil?
-
- ::AlertManagement::AlertsFinder.new(context[:current_user], parent, args).execute
- end
- end
-end
diff --git a/app/graphql/resolvers/assigned_merge_requests_resolver.rb b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
new file mode 100644
index 00000000000..fa08b142a7e
--- /dev/null
+++ b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class AssignedMergeRequestsResolver < UserMergeRequestsResolver
+ def user_role
+ :assignee
+ end
+ end
+end
diff --git a/app/graphql/resolvers/authored_merge_requests_resolver.rb b/app/graphql/resolvers/authored_merge_requests_resolver.rb
new file mode 100644
index 00000000000..e19bc9e8715
--- /dev/null
+++ b/app/graphql/resolvers/authored_merge_requests_resolver.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class AuthoredMergeRequestsResolver < UserMergeRequestsResolver
+ def user_role
+ :author
+ end
+ end
+end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index cf0642930ad..7daff68c069 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -3,27 +3,33 @@
module Resolvers
class BaseResolver < GraphQL::Schema::Resolver
extend ::Gitlab::Utils::Override
+ include ::Gitlab::Utils::StrongMemoize
def self.single
@single ||= Class.new(self) do
+ def ready?(**args)
+ ready, early_return = super
+ [ready, select_result(early_return)]
+ end
+
def resolve(**args)
- super.first
+ select_result(super)
end
def single?
true
end
+
+ def select_result(results)
+ results&.first
+ end
end
end
def self.last
- @last ||= Class.new(self) do
- def resolve(**args)
- super.last
- end
-
- def single?
- true
+ @last ||= Class.new(self.single) do
+ def select_result(results)
+ results&.last
end
end
end
@@ -59,6 +65,17 @@ module Resolvers
end
end
+ def synchronized_object
+ strong_memoize(:synchronized_object) do
+ case object
+ when BatchLoader::GraphQL
+ object.sync
+ else
+ object
+ end
+ end
+ end
+
def single?
false
end
diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb
new file mode 100644
index 00000000000..becc6debd33
--- /dev/null
+++ b/app/graphql/resolvers/concerns/looks_ahead.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module LooksAhead
+ extend ActiveSupport::Concern
+
+ FEATURE_FLAG = :graphql_lookahead_support
+
+ included do
+ attr_accessor :lookahead
+ end
+
+ def resolve(**args)
+ self.lookahead = args.delete(:lookahead)
+
+ resolve_with_lookahead(**args)
+ end
+
+ def apply_lookahead(query)
+ return query unless Feature.enabled?(FEATURE_FLAG)
+
+ selection = node_selection
+
+ includes = preloads.each.flat_map do |name, requirements|
+ selection&.selects?(name) ? requirements : []
+ end
+ preloads = (unconditional_includes + includes).uniq
+
+ return query if preloads.empty?
+
+ query.preload(*preloads) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ private
+
+ def unconditional_includes
+ []
+ end
+
+ def preloads
+ {}
+ end
+
+ def node_selection
+ return unless lookahead
+
+ if lookahead.selects?(:nodes)
+ lookahead.selection(:nodes)
+ elsif lookahead.selects?(:edges)
+ lookahead.selection(:edges).selection(:nodes)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
new file mode 100644
index 00000000000..a2140728a27
--- /dev/null
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+# Mixin for resolving merge requests. All arguments must be in forms
+# that `MergeRequestsFinder` can handle, so you may need to use aliasing.
+module ResolvesMergeRequests
+ extend ActiveSupport::Concern
+ include LooksAhead
+
+ included do
+ type Types::MergeRequestType, null: true
+ end
+
+ def resolve_with_lookahead(**args)
+ args[:iids] = Array.wrap(args[:iids]) if args[:iids]
+ args.compact!
+
+ if project && args.keys == [:iids]
+ batch_load_merge_requests(args[:iids])
+ else
+ args[:project_id] ||= project
+
+ apply_lookahead(MergeRequestsFinder.new(current_user, args).execute)
+ end.then(&(single? ? :first : :itself))
+ end
+
+ def ready?(**args)
+ return early_return if no_results_possible?(args)
+
+ super
+ end
+
+ def early_return
+ [false, single? ? nil : MergeRequest.none]
+ end
+
+ private
+
+ def batch_load_merge_requests(iids)
+ iids.map { |iid| batch_load(iid) }.select(&:itself) # .compact doesn't work on BatchLoader
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def batch_load(iid)
+ BatchLoader::GraphQL.for(iid.to_s).batch(key: project) do |iids, loader, args|
+ query = args[:key].merge_requests.where(iid: iids)
+
+ apply_lookahead(query).each do |mr|
+ loader.call(mr.iid.to_s, mr)
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def unconditional_includes
+ [:target_project]
+ end
+
+ def preloads
+ {
+ assignees: [:assignees],
+ labels: [:labels],
+ author: [:author],
+ milestone: [:milestone],
+ head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }]
+ }
+ end
+end
diff --git a/app/graphql/resolvers/concerns/resolves_project.rb b/app/graphql/resolvers/concerns/resolves_project.rb
new file mode 100644
index 00000000000..3c5ce3dab01
--- /dev/null
+++ b/app/graphql/resolvers/concerns/resolves_project.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module ResolvesProject
+ def resolve_project(full_path: nil, project_id: nil)
+ unless full_path.present? ^ project_id.present?
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'Incompatible arguments: projectId, projectPath.'
+ end
+
+ if full_path.present?
+ ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(Project, full_path).find
+ else
+ ::GitlabSchema.object_from_id(project_id, expected_type: Project)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb
index 46d3360baae..cbb0bf998a6 100644
--- a/app/graphql/resolvers/full_path_resolver.rb
+++ b/app/graphql/resolvers/full_path_resolver.rb
@@ -11,12 +11,7 @@ module Resolvers
end
def model_by_full_path(model, full_path)
- BatchLoader::GraphQL.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 |model_instance|
- loader.call(model_instance.full_path, model_instance)
- end
- end
+ ::Gitlab::Graphql::Loaders::FullPathModelLoader.new(model, full_path).find
end
end
end
diff --git a/app/graphql/resolvers/merge_request_resolver.rb b/app/graphql/resolvers/merge_request_resolver.rb
new file mode 100644
index 00000000000..a47a128ea32
--- /dev/null
+++ b/app/graphql/resolvers/merge_request_resolver.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class MergeRequestResolver < BaseResolver.single
+ include ResolvesMergeRequests
+
+ alias_method :project, :synchronized_object
+
+ argument :iid, GraphQL::STRING_TYPE,
+ required: true,
+ as: :iids,
+ description: 'IID of the merge request, for example `1`'
+
+ def no_results_possible?(args)
+ project.nil?
+ end
+ end
+end
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index 25121dce005..3aa52341eec 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -2,47 +2,43 @@
module Resolvers
class MergeRequestsResolver < BaseResolver
- argument :iid, GraphQL::STRING_TYPE,
- required: false,
- description: 'IID of the merge request, for example `1`'
+ include ResolvesMergeRequests
+
+ alias_method :project, :synchronized_object
argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'Array of IIDs of merge requests, for example `[1, 2]`'
- type Types::MergeRequestType, null: true
+ argument :source_branches, [GraphQL::STRING_TYPE],
+ required: false,
+ as: :source_branch,
+ description: 'Array of source branch names. All resolved merge requests will have one of these branches as their source.'
- alias_method :project, :object
+ argument :target_branches, [GraphQL::STRING_TYPE],
+ required: false,
+ as: :target_branch,
+ description: 'Array of target branch names. All resolved merge requests will have one of these branches as their target.'
- def resolve(**args)
- project = object.respond_to?(:sync) ? object.sync : object
- return MergeRequest.none if project.nil?
+ argument :state, ::Types::MergeRequestStateEnum,
+ required: false,
+ description: 'A merge request state. If provided, all resolved merge requests will have this state.'
- args[:iids] ||= [args[:iid]].compact
+ argument :labels, [GraphQL::STRING_TYPE],
+ required: false,
+ as: :label_name,
+ description: 'Array of label names. All resolved merge requests will have all of these labels.'
- if args[:iids].any?
- batch_load_merge_requests(args[:iids])
- else
- args[:project_id] = project.id
-
- MergeRequestsFinder.new(context[:current_user], args).execute
- end
+ def self.single
+ ::Resolvers::MergeRequestResolver
end
- def batch_load_merge_requests(iids)
- iids.map { |iid| batch_load(iid) }.select(&:itself) # .compact doesn't work on BatchLoader
+ def no_results_possible?(args)
+ project.nil? || some_argument_is_empty?(args)
end
- # rubocop: disable CodeReuse/ActiveRecord
- def batch_load(iid)
- BatchLoader::GraphQL.for(iid.to_s).batch(key: project) do |iids, loader, args|
- arg_key = args[:key].respond_to?(:sync) ? args[:key].sync : args[:key]
-
- arg_key.merge_requests.where(iid: iids).each do |mr|
- loader.call(mr.iid.to_s, mr)
- end
- end
+ def some_argument_is_empty?(args)
+ args.values.any? { |v| v.is_a?(Array) && v.empty? }
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/graphql/resolvers/project_members_resolver.rb b/app/graphql/resolvers/project_members_resolver.rb
new file mode 100644
index 00000000000..3846531762e
--- /dev/null
+++ b/app/graphql/resolvers/project_members_resolver.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ProjectMembersResolver < BaseResolver
+ argument :search, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Search query'
+
+ type Types::ProjectMemberType, null: true
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ return Member.none unless project.present?
+
+ MembersFinder
+ .new(project, context[:current_user], params: args)
+ .execute
+ end
+ end
+end
diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb
new file mode 100644
index 00000000000..5bafe3dd140
--- /dev/null
+++ b/app/graphql/resolvers/project_pipeline_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ProjectPipelineResolver < BaseResolver
+ alias_method :project, :object
+
+ argument :iid, GraphQL::ID_TYPE,
+ required: true,
+ description: 'IID of the Pipeline, e.g., "1"'
+
+ def resolve(iid:)
+ BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args|
+ args[:key].ci_pipelines.for_iid(iids).each { |pl| loader.call(pl.iid.to_s, pl) }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/jira_imports_resolver.rb b/app/graphql/resolvers/projects/jira_imports_resolver.rb
index 25361c068d9..aa9b7139f38 100644
--- a/app/graphql/resolvers/projects/jira_imports_resolver.rb
+++ b/app/graphql/resolvers/projects/jira_imports_resolver.rb
@@ -14,8 +14,6 @@ module Resolvers
end
def authorized_resource?(project)
- return false unless project.jira_issues_import_feature_flag_enabled?
-
context[:current_user].present? && Ability.allowed?(context[:current_user], :read_project, project)
end
end
diff --git a/app/graphql/resolvers/projects/jira_projects_resolver.rb b/app/graphql/resolvers/projects/jira_projects_resolver.rb
new file mode 100644
index 00000000000..a8c3768df41
--- /dev/null
+++ b/app/graphql/resolvers/projects/jira_projects_resolver.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class JiraProjectsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ argument :name,
+ GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Project name or key'
+
+ def resolve(name: nil, **args)
+ authorize!(project)
+
+ response, start_cursor, end_cursor = jira_projects(name: name, **compute_pagination_params(args))
+ end_cursor = nil if !!response.payload[:is_last]
+
+ if response.success?
+ Gitlab::Graphql::ExternallyPaginatedArray.new(start_cursor, end_cursor, *response.payload[:projects])
+ else
+ raise Gitlab::Graphql::Errors::BaseError, response.message
+ end
+ end
+
+ def authorized_resource?(project)
+ Ability.allowed?(context[:current_user], :admin_project, project)
+ end
+
+ private
+
+ alias_method :jira_service, :object
+
+ def project
+ jira_service&.project
+ end
+
+ def compute_pagination_params(params)
+ after_cursor = Base64.decode64(params[:after].to_s)
+ before_cursor = Base64.decode64(params[:before].to_s)
+
+ # differentiate between 0 cursor and nil or invalid cursor that decodes into zero.
+ after_index = after_cursor.to_i == 0 && after_cursor != "0" ? nil : after_cursor.to_i
+ before_index = before_cursor.to_i == 0 && before_cursor != "0" ? nil : before_cursor.to_i
+
+ if after_index.present? && before_index.present?
+ if after_index >= before_index
+ { start_at: 0, limit: 0 }
+ else
+ { start_at: after_index + 1, limit: before_index - after_index - 1 }
+ end
+ elsif after_index.present?
+ { start_at: after_index + 1, limit: nil }
+ elsif before_index.present?
+ { start_at: 0, limit: before_index - 1 }
+ else
+ { start_at: 0, limit: nil }
+ end
+ end
+
+ def jira_projects(name:, start_at:, limit:)
+ args = { query: name, start_at: start_at, limit: limit }.compact
+
+ response = Jira::Requests::Projects.new(project.jira_service, args).execute
+
+ return [response, nil, nil] if response.error?
+
+ projects = response.payload[:projects]
+ start_cursor = start_at == 0 ? nil : Base64.encode64((start_at - 1).to_s)
+ end_cursor = Base64.encode64((start_at + projects.size - 1).to_s)
+
+ [response, start_cursor, end_cursor]
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/user_merge_requests_resolver.rb b/app/graphql/resolvers/user_merge_requests_resolver.rb
new file mode 100644
index 00000000000..b0d6e159f73
--- /dev/null
+++ b/app/graphql/resolvers/user_merge_requests_resolver.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class UserMergeRequestsResolver < MergeRequestsResolver
+ include ResolvesProject
+
+ argument :project_path, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The full-path of the project the authored merge requests should be in. Incompatible with projectId.'
+
+ argument :project_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The global ID of the project the authored merge requests should be in. Incompatible with projectPath.'
+
+ attr_reader :project
+ alias_method :user, :synchronized_object
+
+ def ready?(project_id: nil, project_path: nil, **args)
+ return early_return unless can_read_profile?
+
+ if project_id || project_path
+ load_project(project_path, project_id)
+ return early_return unless can_read_project?
+ elsif args[:iids].present?
+ raise ::Gitlab::Graphql::Errors::ArgumentError,
+ 'iids requires projectPath or projectId'
+ end
+
+ super(**args)
+ end
+
+ def resolve(**args)
+ prepare_args(args)
+ key = :"#{user_role}_id"
+ super(key => user.id, **args)
+ end
+
+ def user_role
+ raise NotImplementedError
+ end
+
+ private
+
+ def can_read_profile?
+ Ability.allowed?(current_user, :read_user_profile, user)
+ end
+
+ def can_read_project?
+ Ability.allowed?(current_user, :read_merge_request, project)
+ end
+
+ def load_project(project_path, project_id)
+ @project = resolve_project(full_path: project_path, project_id: project_id)
+ @project = @project.sync if @project.respond_to?(:sync)
+ end
+
+ def no_results_possible?(args)
+ some_argument_is_empty?(args)
+ end
+
+ # These arguments are handled in load_project, and should not be passed to
+ # the finder directly.
+ def prepare_args(args)
+ args.delete(:project_id)
+ args.delete(:project_path)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/user_resolver.rb b/app/graphql/resolvers/user_resolver.rb
new file mode 100644
index 00000000000..a34cecba491
--- /dev/null
+++ b/app/graphql/resolvers/user_resolver.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class UserResolver < BaseResolver
+ description 'Retrieve a single user'
+
+ type Types::UserType, null: true
+
+ argument :id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'ID of the User'
+
+ argument :username, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Username of the User'
+
+ def ready?(id: nil, username: nil)
+ unless id.present? ^ username.present?
+ raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a single username or id'
+ end
+
+ super
+ end
+
+ def resolve(id: nil, username: nil)
+ if id
+ GitlabSchema.object_from_id(id, expected_type: User)
+ else
+ batch_load(username)
+ end
+ end
+
+ private
+
+ def batch_load(username)
+ BatchLoader::GraphQL.for(username).batch do |usernames, loader|
+ User.by_username(usernames).each do |user|
+ loader.call(user.username, user)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb
new file mode 100644
index 00000000000..110a283b42e
--- /dev/null
+++ b/app/graphql/resolvers/users_resolver.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class UsersResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ description 'Find Users'
+
+ argument :ids, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'List of user Global IDs'
+
+ argument :usernames, [GraphQL::STRING_TYPE], required: false,
+ description: 'List of usernames'
+
+ argument :sort, Types::SortEnum,
+ description: 'Sort users by this criteria',
+ required: false,
+ default_value: 'created_desc'
+
+ def resolve(ids: nil, usernames: nil, sort: nil)
+ authorize!
+
+ ::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort)).execute
+ end
+
+ def ready?(**args)
+ args = { ids: nil, usernames: nil }.merge!(args)
+
+ return super if args.values.compact.blank?
+
+ if args.values.all?
+ raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a list of usernames or ids'
+ end
+
+ super
+ end
+
+ def authorize!
+ Ability.allowed?(context[:current_user], :read_users_list) || raise_resource_not_available_error!
+ end
+
+ private
+
+ def finder_params(ids, usernames, sort)
+ params = {}
+ params[:sort] = sort if sort
+ params[:username] = usernames if usernames
+ params[:id] = parse_gids(ids) if ids
+ params
+ end
+
+ def parse_gids(gids)
+ gids.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::User).model_id }
+ end
+ end
+end
diff --git a/app/graphql/types/access_level_enum.rb b/app/graphql/types/access_level_enum.rb
new file mode 100644
index 00000000000..6754d3d28ce
--- /dev/null
+++ b/app/graphql/types/access_level_enum.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ class AccessLevelEnum < BaseEnum
+ graphql_name 'AccessLevelEnum'
+ description 'Access level to a resource'
+
+ value 'NO_ACCESS', value: Gitlab::Access::NO_ACCESS
+ value 'GUEST', value: Gitlab::Access::GUEST
+ value 'REPORTER', value: Gitlab::Access::REPORTER
+ value 'DEVELOPER', value: Gitlab::Access::DEVELOPER
+ value 'MAINTAINER', value: Gitlab::Access::MAINTAINER
+ value 'OWNER', value: Gitlab::Access::OWNER
+ end
+end
diff --git a/app/graphql/types/access_level_type.rb b/app/graphql/types/access_level_type.rb
new file mode 100644
index 00000000000..c7f915f5038
--- /dev/null
+++ b/app/graphql/types/access_level_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+# rubocop:disable Graphql/AuthorizeTypes
+
+module Types
+ class AccessLevelType < Types::BaseObject
+ graphql_name 'AccessLevel'
+ description 'Represents the access level of a relationship between a User and object that it is related to'
+
+ field :integer_value, GraphQL::INT_TYPE, null: true,
+ description: 'Integer representation of access level',
+ method: :to_i
+
+ field :string_value, Types::AccessLevelEnum, null: true,
+ description: 'String representation of access level',
+ method: :to_i
+ end
+end
diff --git a/app/graphql/types/alert_management/alert_sort_enum.rb b/app/graphql/types/alert_management/alert_sort_enum.rb
index e6d38af8170..3faac9ce53c 100644
--- a/app/graphql/types/alert_management/alert_sort_enum.rb
+++ b/app/graphql/types/alert_management/alert_sort_enum.rb
@@ -6,16 +6,16 @@ module Types
graphql_name 'AlertManagementAlertSort'
description 'Values for sorting alerts'
- value 'START_TIME_ASC', 'Start time by ascending order', value: :start_time_asc
- value 'START_TIME_DESC', 'Start time by descending order', value: :start_time_desc
- value 'END_TIME_ASC', 'End time by ascending order', value: :end_time_asc
- value 'END_TIME_DESC', 'End time by descending order', value: :end_time_desc
+ value 'STARTED_AT_ASC', 'Start time by ascending order', value: :started_at_asc
+ value 'STARTED_AT_DESC', 'Start time by descending order', value: :started_at_desc
+ value 'ENDED_AT_ASC', 'End time by ascending order', value: :ended_at_asc
+ value 'ENDED_AT_DESC', 'End time by descending order', value: :ended_at_desc
value 'CREATED_TIME_ASC', 'Created time by ascending order', value: :created_at_asc
value 'CREATED_TIME_DESC', 'Created time by descending order', value: :created_at_desc
value 'UPDATED_TIME_ASC', 'Created time by ascending order', value: :updated_at_asc
value 'UPDATED_TIME_DESC', 'Created time by descending order', value: :updated_at_desc
- value 'EVENTS_COUNT_ASC', 'Events count by ascending order', value: :events_count_asc
- value 'EVENTS_COUNT_DESC', 'Events count by descending order', value: :events_count_desc
+ value 'EVENT_COUNT_ASC', 'Events count by ascending order', value: :event_count_asc
+ value 'EVENT_COUNT_DESC', 'Events count by descending order', value: :event_count_desc
value 'SEVERITY_ASC', 'Severity by ascending order', value: :severity_asc
value 'SEVERITY_DESC', 'Severity by descending order', value: :severity_desc
value 'STATUS_ASC', 'Status by ascending order', value: :status_asc
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index a766fb3236d..8215ccb152c 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -6,6 +6,8 @@ module Types
graphql_name 'AlertManagementAlert'
description "Describes an alert from the project's Alert Management"
+ implements(Types::Notes::NoteableType)
+
authorize :read_alert_management_alert
field :iid,
@@ -83,6 +85,15 @@ module Types
Types::TimeType,
null: true,
description: 'Timestamp the alert was last updated'
+
+ field :assignees,
+ Types::UserType.connection_type,
+ null: true,
+ description: 'Assignees of the alert'
+
+ def notes
+ object.ordered_notes
+ end
end
end
end
diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb
index dad16898ba6..70e665f8fc3 100644
--- a/app/graphql/types/base_object.rb
+++ b/app/graphql/types/base_object.rb
@@ -12,5 +12,9 @@ module Types
def id
GitlabSchema.id_from_object(object)
end
+
+ def current_user
+ context[:current_user]
+ end
end
end
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
index c0be782ed1e..f5dc9e08427 100644
--- a/app/graphql/types/board_type.rb
+++ b/app/graphql/types/board_type.rb
@@ -15,7 +15,7 @@ module Types
field :lists,
Types::BoardListType.connection_type,
null: true,
- description: 'Lists of the project board',
+ description: 'Lists of the board',
resolver: Resolvers::BoardListsResolver,
extras: [:lookahead]
end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index d77b2a2ba32..32050766e5b 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -42,3 +42,5 @@ module Types
end
end
end
+
+Types::Ci::PipelineType.prepend_if_ee('::EE::Types::Ci::PipelineType')
diff --git a/app/graphql/types/commit_action_mode_enum.rb b/app/graphql/types/commit_action_mode_enum.rb
new file mode 100644
index 00000000000..77658a85b51
--- /dev/null
+++ b/app/graphql/types/commit_action_mode_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class CommitActionModeEnum < BaseEnum
+ graphql_name 'CommitActionMode'
+ description 'Mode of a commit action'
+
+ value 'CREATE', description: 'Create command', value: :create
+ value 'DELETE', description: 'Delete command', value: :delete
+ value 'MOVE', description: 'Move command', value: :move
+ value 'UPDATE', description: 'Update command', value: :update
+ value 'CHMOD', description: 'Chmod command', value: :chmod
+ end
+end
diff --git a/app/graphql/types/commit_action_type.rb b/app/graphql/types/commit_action_type.rb
new file mode 100644
index 00000000000..7674abb11eb
--- /dev/null
+++ b/app/graphql/types/commit_action_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class CommitActionType < BaseInputObject
+ argument :action, type: Types::CommitActionModeEnum, required: true,
+ description: 'The action to perform, create, delete, move, update, chmod'
+ argument :file_path, type: GraphQL::STRING_TYPE, required: true,
+ description: 'Full path to the file'
+ argument :content, type: GraphQL::STRING_TYPE, required: false,
+ description: 'Content of the file'
+ argument :previous_path, type: GraphQL::STRING_TYPE, required: false,
+ description: 'Original full path to the file being moved'
+ argument :last_commit_id, type: GraphQL::STRING_TYPE, required: false,
+ description: 'Last known file commit ID'
+ argument :execute_filemode, type: GraphQL::BOOLEAN_TYPE, required: false,
+ description: 'Enables/disables the execute flag on the file'
+ argument :encoding, type: Types::CommitEncodingEnum, required: false,
+ description: 'Encoding of the file. Default is text'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/commit_encoding_enum.rb b/app/graphql/types/commit_encoding_enum.rb
new file mode 100644
index 00000000000..0ea89b82db7
--- /dev/null
+++ b/app/graphql/types/commit_encoding_enum.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ class CommitEncodingEnum < BaseEnum
+ graphql_name 'CommitEncoding'
+
+ value 'TEXT', description: 'Text encoding', value: :text
+ value 'BASE64', description: 'Base64 encoding', value: :base64
+ end
+end
diff --git a/app/graphql/types/container_expiration_policy_cadence_enum.rb b/app/graphql/types/container_expiration_policy_cadence_enum.rb
new file mode 100644
index 00000000000..bb8bdf2197b
--- /dev/null
+++ b/app/graphql/types/container_expiration_policy_cadence_enum.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerExpirationPolicyCadenceEnum < BaseEnum
+ OPTIONS_MAPPING = {
+ '1d': 'EVERY_DAY',
+ '7d': 'EVERY_WEEK',
+ '14d': 'EVERY_TWO_WEEKS',
+ '1month': 'EVERY_MONTH',
+ '3month': 'EVERY_THREE_MONTHS'
+ }.freeze
+
+ ::ContainerExpirationPolicy.cadence_options.each do |option, description|
+ value OPTIONS_MAPPING[option], description, value: option.to_s
+ end
+ end
+end
diff --git a/app/graphql/types/container_expiration_policy_keep_enum.rb b/app/graphql/types/container_expiration_policy_keep_enum.rb
new file mode 100644
index 00000000000..7632df61092
--- /dev/null
+++ b/app/graphql/types/container_expiration_policy_keep_enum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerExpirationPolicyKeepEnum < BaseEnum
+ OPTIONS_MAPPING = {
+ 1 => 'ONE_TAG',
+ 5 => 'FIVE_TAGS',
+ 10 => 'TEN_TAGS',
+ 25 => 'TWENTY_FIVE_TAGS',
+ 50 => 'FIFTY_TAGS',
+ 100 => 'ONE_HUNDRED_TAGS'
+ }.freeze
+
+ ::ContainerExpirationPolicy.keep_n_options.each do |option, description|
+ value OPTIONS_MAPPING[option], description, value: option
+ end
+ end
+end
diff --git a/app/graphql/types/container_expiration_policy_older_than_enum.rb b/app/graphql/types/container_expiration_policy_older_than_enum.rb
new file mode 100644
index 00000000000..da70534b0d7
--- /dev/null
+++ b/app/graphql/types/container_expiration_policy_older_than_enum.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerExpirationPolicyOlderThanEnum < BaseEnum
+ OPTIONS_MAPPING = {
+ '7d': 'SEVEN_DAYS',
+ '14d': 'FOURTEEN_DAYS',
+ '30d': 'THIRTY_DAYS',
+ '90d': 'NINETY_DAYS'
+ }.freeze
+
+ ::ContainerExpirationPolicy.older_than_options.each do |option, description|
+ value OPTIONS_MAPPING[option], description, value: option.to_s
+ end
+ end
+end
diff --git a/app/graphql/types/container_expiration_policy_type.rb b/app/graphql/types/container_expiration_policy_type.rb
new file mode 100644
index 00000000000..da53dbcbd39
--- /dev/null
+++ b/app/graphql/types/container_expiration_policy_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerExpirationPolicyType < BaseObject
+ graphql_name 'ContainerExpirationPolicy'
+
+ description 'A tag expiration policy designed to keep only the images that matter most'
+
+ authorize :destroy_container_image
+
+ field :created_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was created'
+ field :updated_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was updated'
+ field :enabled, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates whether this container expiration policy is enabled'
+ field :older_than, Types::ContainerExpirationPolicyOlderThanEnum, null: true, description: 'Tags older that this will expire'
+ field :cadence, Types::ContainerExpirationPolicyCadenceEnum, null: false, description: 'This container expiration policy schedule'
+ field :keep_n, Types::ContainerExpirationPolicyKeepEnum, null: true, description: 'Number of tags to retain'
+ field :name_regex, GraphQL::STRING_TYPE, null: true, description: 'Tags with names matching this regex pattern will expire'
+ field :name_regex_keep, GraphQL::STRING_TYPE, null: true, description: 'Tags with names matching this regex pattern will be preserved'
+ field :next_run_at, Types::TimeType, null: true, description: 'Next time that this container expiration policy will get executed'
+ end
+end
diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb
new file mode 100644
index 00000000000..a2fc9953c67
--- /dev/null
+++ b/app/graphql/types/evidence_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ class EvidenceType < BaseObject
+ graphql_name 'ReleaseEvidence'
+ description 'Evidence for a release'
+
+ authorize :download_code
+
+ present_using Releases::EvidencePresenter
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the evidence'
+ field :sha, GraphQL::STRING_TYPE, null: true,
+ description: 'SHA1 ID of the evidence hash'
+ field :filepath, GraphQL::STRING_TYPE, null: true,
+ description: 'URL from where the evidence can be downloaded'
+ field :collected_at, Types::TimeType, null: true,
+ description: 'Timestamp when the evidence was collected'
+ end
+end
diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb
new file mode 100644
index 00000000000..ffffa3247db
--- /dev/null
+++ b/app/graphql/types/group_member_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ class GroupMemberType < BaseObject
+ expose_permissions Types::PermissionTypes::Group
+ authorize :read_group
+
+ implements MemberInterface
+
+ graphql_name 'GroupMember'
+ description 'Represents a Group Member'
+
+ field :group, Types::GroupType, null: true,
+ description: 'Group that a User is a member of',
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.source_id).find }
+ end
+end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 20b4c66ba95..fd7d9a9ba3d 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -65,6 +65,45 @@ module Types
null: true,
description: 'A single board of the group',
resolver: Resolvers::BoardsResolver.single
+
+ field :label,
+ Types::LabelType,
+ null: true,
+ description: 'A label available on this group' do
+ argument :title, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Title of the label'
+ end
+
+ def label(title:)
+ BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
+ LabelsFinder
+ .new(current_user, group: args[:key], title: titles)
+ .execute
+ .each { |label| loader.call(label.title, label) }
+ end
+ end
+
+ field :labels,
+ Types::LabelType.connection_type,
+ null: true,
+ description: 'Labels available on this group' do
+ argument :search_term, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'A search term to find labels with'
+ end
+
+ def labels(search_term: nil)
+ LabelsFinder
+ .new(current_user, group: group, search: search_term)
+ .execute
+ end
+
+ private
+
+ def group
+ object.respond_to?(:sync) ? object.sync : object
+ end
end
end
diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb
index 4a124566ffb..cf58a53b40d 100644
--- a/app/graphql/types/jira_import_type.rb
+++ b/app/graphql/types/jira_import_type.rb
@@ -15,6 +15,12 @@ module Types
description: 'User that started the Jira import'
field :jira_project_key, GraphQL::STRING_TYPE, null: false,
description: 'Project key for the imported Jira project'
+ field :imported_issues_count, GraphQL::INT_TYPE, null: false,
+ description: 'Count of issues that were successfully imported'
+ field :failed_to_import_count, GraphQL::INT_TYPE, null: false,
+ description: 'Count of issues that failed to import'
+ field :total_issue_count, GraphQL::INT_TYPE, null: false,
+ description: 'Total count of issues that were attempted to import'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/jira_user_type.rb b/app/graphql/types/jira_user_type.rb
new file mode 100644
index 00000000000..8aa21ce669b
--- /dev/null
+++ b/app/graphql/types/jira_user_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ # Authorization is at project level for owners or admins on mutation level
+ class JiraUserType < BaseObject
+ graphql_name 'JiraUser'
+
+ field :jira_account_id, GraphQL::STRING_TYPE, null: false,
+ description: 'Account id of the Jira user'
+ field :jira_display_name, GraphQL::STRING_TYPE, null: false,
+ description: 'Display name of the Jira user'
+ field :jira_email, GraphQL::STRING_TYPE, null: true,
+ description: 'Email of the Jira user, returned only for users with public emails'
+ field :gitlab_id, GraphQL::INT_TYPE, null: true,
+ description: 'Id of the matched GitLab user'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/member_interface.rb b/app/graphql/types/member_interface.rb
new file mode 100644
index 00000000000..976836221bc
--- /dev/null
+++ b/app/graphql/types/member_interface.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module MemberInterface
+ include BaseInterface
+
+ field :access_level, Types::AccessLevelType, null: true,
+ description: 'GitLab::Access level'
+
+ field :created_by, Types::UserType, null: true,
+ description: 'User that authorized membership'
+
+ field :created_at, Types::TimeType, null: true,
+ description: 'Date and time the membership was created'
+
+ field :updated_at, Types::TimeType, null: true,
+ description: 'Date and time the membership was last updated'
+
+ field :expires_at, Types::TimeType, null: true,
+ description: 'Date and time the membership expires'
+ end
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index cd4c6b4d46a..cb4ff7ea0c5 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -28,6 +28,8 @@ module Types
description: 'Timestamp of when the merge request was created'
field :updated_at, Types::TimeType, null: false,
description: 'Timestamp of when the merge request was last updated'
+ field :merged_at, Types::TimeType, null: true, complexity: 5,
+ description: 'Timestamp of when the merge request was merged, null if not merged'
field :source_project, Types::ProjectType, null: true,
description: 'Source project of the merge request'
field :target_project, Types::ProjectType, null: false,
@@ -81,8 +83,14 @@ module Types
description: 'Default merge commit message of the merge request'
field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false,
description: 'Indicates if a merge is currently occurring'
- field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false,
+ field :source_branch_exists, GraphQL::BOOLEAN_TYPE,
+ null: false, calls_gitaly: true,
+ method: :source_branch_exists?,
description: 'Indicates if the source branch of the merge request exists'
+ field :target_branch_exists, GraphQL::BOOLEAN_TYPE,
+ null: false, calls_gitaly: true,
+ method: :target_branch_exists?,
+ description: 'Indicates if the target branch of the merge request exists'
field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged'
field :web_url, GraphQL::STRING_TYPE, null: true,
@@ -103,6 +111,8 @@ module Types
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
field :assignees, Types::UserType.connection_type, null: true, complexity: 5,
description: 'Assignees of the merge request'
+ field :author, Types::UserType, null: true,
+ description: 'User who created this merge request'
field :participants, Types::UserType.connection_type, null: true, complexity: 5,
description: 'Participants in the merge request'
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5,
diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb
index d684533ff94..bbcce2d9596 100644
--- a/app/graphql/types/metrics/dashboard_type.rb
+++ b/app/graphql/types/metrics/dashboard_type.rb
@@ -10,6 +10,9 @@ module Types
field :path, GraphQL::STRING_TYPE, null: true,
description: 'Path to a file with the dashboard definition'
+ field :schema_validation_warnings, [GraphQL::STRING_TYPE], null: true,
+ description: 'Dashboard schema validation warnings'
+
field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true,
description: 'Annotations added to the dashboard',
resolver: Resolvers::Metrics::Dashboards::AnnotationResolver
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index 900f8c6f01d..99bd6e819d6 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -35,5 +35,17 @@ module Types
field :updated_at, Types::TimeType, null: false,
description: 'Timestamp of last milestone update'
+
+ field :project_milestone, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates if milestone is at project level',
+ method: :project_milestone?
+
+ field :group_milestone, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates if milestone is at group level',
+ method: :group_milestone?
+
+ field :subgroup_milestone, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates if milestone is at subgroup level',
+ method: :subgroup_milestone?
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index aeff84b83b8..8874c56dfdb 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -9,13 +9,17 @@ module Types
mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs
mount_mutation Mutations::AlertManagement::CreateAlertIssue
mount_mutation Mutations::AlertManagement::UpdateAlertStatus
+ mount_mutation Mutations::AlertManagement::Alerts::SetAssignees
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle
mount_mutation Mutations::Branches::Create, calls_gitaly: true
+ mount_mutation Mutations::Commits::Create, calls_gitaly: true
+ mount_mutation Mutations::Discussions::ToggleResolve
mount_mutation Mutations::Issues::SetConfidential
mount_mutation Mutations::Issues::SetDueDate
mount_mutation Mutations::Issues::Update
+ mount_mutation Mutations::MergeRequests::Create
mount_mutation Mutations::MergeRequests::SetLabels
mount_mutation Mutations::MergeRequests::SetLocked
mount_mutation Mutations::MergeRequests::SetMilestone
@@ -23,6 +27,7 @@ module Types
mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true
mount_mutation Mutations::MergeRequests::SetAssignees
mount_mutation Mutations::Metrics::Dashboard::Annotations::Create
+ mount_mutation Mutations::Metrics::Dashboard::Annotations::Delete
mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true
mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true
mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true
@@ -44,8 +49,10 @@ module Types
mount_mutation Mutations::Snippets::Create
mount_mutation Mutations::Snippets::MarkAsSpam
mount_mutation Mutations::JiraImport::Start
+ mount_mutation Mutations::JiraImport::ImportUsers
mount_mutation Mutations::DesignManagement::Upload, calls_gitaly: true
mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true
+ mount_mutation Mutations::ContainerExpirationPolicies::Update
end
end
diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb
index 74a233e9d26..a51d253097d 100644
--- a/app/graphql/types/notes/discussion_type.rb
+++ b/app/graphql/types/notes/discussion_type.rb
@@ -7,6 +7,8 @@ module Types
authorize :read_note
+ implements(Types::ResolvableInterface)
+
field :id, GraphQL::ID_TYPE, null: false,
description: "ID of this discussion"
field :reply_id, GraphQL::ID_TYPE, null: false,
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index d48cc868434..8755b4ccad5 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -9,6 +9,8 @@ module Types
expose_permissions Types::PermissionTypes::Note
+ implements(Types::ResolvableInterface)
+
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the note'
@@ -22,11 +24,6 @@ module Types
description: 'User who wrote this note',
resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.author_id).find }
- field :resolved_by, Types::UserType,
- null: true,
- description: 'User that resolved the discussion',
- resolve: -> (note, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.resolved_by_id).find }
-
field :system, GraphQL::BOOLEAN_TYPE,
null: false,
description: 'Indicates whether this note was created by the system or by a user'
@@ -44,11 +41,6 @@ module Types
description: "Timestamp of the note's last activity"
field :discussion, Types::Notes::DiscussionType, null: true,
description: 'The discussion this note is a part of'
- field :resolvable, GraphQL::BOOLEAN_TYPE, null: false,
- description: 'Indicates if this note can be resolved. That is, if it is a resolvable discussion or simply a standalone note',
- method: :resolvable?
- field :resolved_at, Types::TimeType, null: true,
- description: "Timestamp of the note's resolution"
field :position, Types::Notes::DiffPositionType, null: true,
description: 'The position of this note on a diff'
field :confidential, GraphQL::BOOLEAN_TYPE, null: true,
diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb
index 187c9109f8c..3a16d54f9cd 100644
--- a/app/graphql/types/notes/noteable_type.rb
+++ b/app/graphql/types/notes/noteable_type.rb
@@ -19,6 +19,8 @@ module Types
Types::SnippetType
when ::DesignManagement::Design
Types::DesignManagement::DesignType
+ when ::AlertManagement::Alert
+ Types::AlertManagement::AlertType
else
raise "Unknown GraphQL type for #{object}"
end
diff --git a/app/graphql/types/permission_types/ci/pipeline.rb b/app/graphql/types/permission_types/ci/pipeline.rb
index 73e44a33eba..cfd68380005 100644
--- a/app/graphql/types/permission_types/ci/pipeline.rb
+++ b/app/graphql/types/permission_types/ci/pipeline.rb
@@ -6,7 +6,8 @@ module Types
class Pipeline < BasePermissionType
graphql_name 'PipelinePermissions'
- abilities :update_pipeline, :admin_pipeline, :destroy_pipeline
+ abilities :admin_pipeline, :destroy_pipeline
+ ability_field :update_pipeline, calls_gitaly: true
end
end
end
diff --git a/app/graphql/types/permission_types/merge_request.rb b/app/graphql/types/permission_types/merge_request.rb
index d877fc177d2..28b7ebd2af6 100644
--- a/app/graphql/types/permission_types/merge_request.rb
+++ b/app/graphql/types/permission_types/merge_request.rb
@@ -3,6 +3,11 @@
module Types
module PermissionTypes
class MergeRequest < BasePermissionType
+ PERMISSION_FIELDS = %i[push_to_source_branch
+ remove_source_branch
+ cherry_pick_on_current_merge_request
+ revert_on_current_merge_request].freeze
+
present_using MergeRequestPresenter
description 'Check permissions for the current user on a merge request'
graphql_name 'MergeRequestPermissions'
@@ -10,10 +15,9 @@ module Types
abilities :read_merge_request, :admin_merge_request,
:update_merge_request, :create_note
- permission_field :push_to_source_branch, method: :can_push_to_source_branch?, calls_gitaly: true
- permission_field :remove_source_branch, method: :can_remove_source_branch?, calls_gitaly: true
- permission_field :cherry_pick_on_current_merge_request, method: :can_cherry_pick_on_current_merge_request?
- permission_field :revert_on_current_merge_request, method: :can_revert_on_current_merge_request?
+ PERMISSION_FIELDS.each do |field_name|
+ permission_field field_name, method: :"can_#{field_name}?", calls_gitaly: true
+ end
end
end
end
diff --git a/app/graphql/types/project_member_type.rb b/app/graphql/types/project_member_type.rb
new file mode 100644
index 00000000000..e9ccb51886b
--- /dev/null
+++ b/app/graphql/types/project_member_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ class ProjectMemberType < BaseObject
+ graphql_name 'ProjectMember'
+ description 'Represents a Project Member'
+
+ expose_permissions Types::PermissionTypes::Project
+
+ implements MemberInterface
+
+ authorize :read_project
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the member'
+
+ field :user, Types::UserType, null: false,
+ description: 'User that is associated with the member object',
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.user_id).find }
+
+ field :project, Types::ProjectType, null: true,
+ description: 'Project that User is a member of',
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.source_id).find }
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 4e438ed2576..bbfb7fc4f20 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -95,6 +95,8 @@ module Types
description: 'Status of Jira import background job of the project'
field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if merge requests of the project can only be merged with successful jobs'
+ field :allow_merge_on_skipped_pipeline, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs'
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if users can request member access to the project'
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true,
@@ -125,6 +127,7 @@ module Types
Types::MergeRequestType.connection_type,
null: true,
description: 'Merge requests of the project',
+ extras: [:lookahead],
resolver: Resolvers::MergeRequestsResolver
field :merge_request,
@@ -139,6 +142,11 @@ module Types
description: 'Issues of the project',
resolver: Resolvers::IssuesResolver
+ field :project_members,
+ Types::ProjectMemberType.connection_type,
+ description: 'Members of the project',
+ resolver: Resolvers::ProjectMembersResolver
+
field :environments,
Types::EnvironmentType.connection_type,
null: true,
@@ -157,6 +165,12 @@ module Types
description: 'Build pipelines of the project',
resolver: Resolvers::ProjectPipelinesResolver
+ field :pipeline,
+ Types::Ci::PipelineType,
+ null: true,
+ description: 'Build pipeline of the project',
+ resolver: Resolvers::ProjectPipelineResolver
+
field :sentry_detailed_error,
Types::ErrorTracking::SentryDetailedErrorType,
null: true,
@@ -210,13 +224,14 @@ module Types
Types::AlertManagement::AlertType.connection_type,
null: true,
description: 'Alert Management alerts of the project',
- resolver: Resolvers::AlertManagementAlertResolver
+ extras: [:lookahead],
+ resolver: Resolvers::AlertManagement::AlertResolver
field :alert_management_alert,
Types::AlertManagement::AlertType,
null: true,
description: 'A single Alert Management alert of the project',
- resolver: Resolvers::AlertManagementAlertResolver.single
+ resolver: Resolvers::AlertManagement::AlertResolver.single
field :alert_management_alert_status_counts,
Types::AlertManagement::AlertStatusCountsType,
@@ -237,6 +252,50 @@ module Types
description: 'A single release of the project',
resolver: Resolvers::ReleasesResolver.single,
feature_flag: :graphql_release_data
+
+ field :container_expiration_policy,
+ Types::ContainerExpirationPolicyType,
+ null: true,
+ description: 'The container expiration policy of the project'
+
+ field :label,
+ Types::LabelType,
+ null: true,
+ description: 'A label available on this project' do
+ argument :title, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Title of the label'
+ end
+
+ def label(title:)
+ BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
+ LabelsFinder
+ .new(current_user, project: args[:key], title: titles)
+ .execute
+ .each { |label| loader.call(label.title, label) }
+ end
+ end
+
+ field :labels,
+ Types::LabelType.connection_type,
+ null: true,
+ description: 'Labels available on this project' do
+ argument :search_term, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'A search term to find labels with'
+ end
+
+ def labels(search_term: nil)
+ LabelsFinder
+ .new(current_user, project: project, search: search_term)
+ .execute
+ end
+
+ private
+
+ def project
+ @project ||= object.respond_to?(:sync) ? object.sync : object
+ end
end
end
diff --git a/app/graphql/types/projects/service_type.rb b/app/graphql/types/projects/service_type.rb
index 55dd828d4b8..4ae7cb77904 100644
--- a/app/graphql/types/projects/service_type.rb
+++ b/app/graphql/types/projects/service_type.rb
@@ -6,7 +6,7 @@ module Types
include Types::BaseInterface
graphql_name 'Service'
- # TODO: Add all the fields that we want to expose for the project services intergrations
+ # TODO: Add all the fields that we want to expose for the project services integrations
# https://gitlab.com/gitlab-org/gitlab/-/issues/213088
field :type, GraphQL::STRING_TYPE, null: true,
description: 'Class name of the service'
diff --git a/app/graphql/types/projects/services/jira_project_type.rb b/app/graphql/types/projects/services/jira_project_type.rb
new file mode 100644
index 00000000000..ccf9107f398
--- /dev/null
+++ b/app/graphql/types/projects/services/jira_project_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module Projects
+ module Services
+ # rubocop:disable Graphql/AuthorizeTypes
+ class JiraProjectType < BaseObject
+ graphql_name 'JiraProject'
+
+ field :key, GraphQL::STRING_TYPE, null: false,
+ description: 'Key of the Jira project'
+ field :project_id, GraphQL::INT_TYPE, null: false,
+ description: 'ID of the Jira project',
+ method: :id
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the Jira project'
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb
index 4fd9e61f5a4..e81963f752d 100644
--- a/app/graphql/types/projects/services/jira_service_type.rb
+++ b/app/graphql/types/projects/services/jira_service_type.rb
@@ -9,9 +9,14 @@ module Types
implements(Types::Projects::ServiceType)
authorize :admin_project
- # This is a placeholder for now for the actuall implementation of the JiraServiceType
- # Here we will want to expose a field with jira_projects fetched through Jira Rest API
- # MR implementing it https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28190
+
+ field :projects,
+ Types::Projects::Services::JiraProjectType.connection_type,
+ null: true,
+ connection: false,
+ extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension],
+ description: 'List of Jira projects fetched through Jira REST API',
+ resolver: Resolvers::Projects::JiraProjectsResolver
end
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 70cdcb62bc6..362e4004b73 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -47,10 +47,24 @@ module Types
null: false,
description: 'Fields related to design management'
+ field :user, Types::UserType,
+ null: true,
+ description: 'Find a user',
+ resolver: Resolvers::UserResolver
+
+ field :users, Types::UserType.connection_type,
+ null: true,
+ description: 'Find users',
+ resolver: Resolvers::UsersResolver
+
field :echo, GraphQL::STRING_TYPE, null: false,
description: 'Text to echo back',
resolver: Resolvers::EchoResolver
+ field :user, Types::UserType, null: true,
+ description: 'Find a user on this instance',
+ resolver: Resolvers::UserResolver
+
def design_management
DesignManagementObject.new(nil)
end
diff --git a/app/graphql/types/release_assets_type.rb b/app/graphql/types/release_assets_type.rb
new file mode 100644
index 00000000000..58ad05b5365
--- /dev/null
+++ b/app/graphql/types/release_assets_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ class ReleaseAssetsType < BaseObject
+ graphql_name 'ReleaseAssets'
+
+ authorize :read_release
+
+ alias_method :release, :object
+
+ present_using ReleasePresenter
+
+ field :assets_count, GraphQL::INT_TYPE, null: true,
+ description: 'Number of assets of the release'
+ field :links, Types::ReleaseLinkType.connection_type, null: true,
+ description: 'Asset links of the release'
+ field :sources, Types::ReleaseSourceType.connection_type, null: true,
+ description: 'Sources of the release'
+ end
+end
diff --git a/app/graphql/types/release_link_type.rb b/app/graphql/types/release_link_type.rb
new file mode 100644
index 00000000000..070f14a90df
--- /dev/null
+++ b/app/graphql/types/release_link_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ class ReleaseLinkType < BaseObject
+ graphql_name 'ReleaseLink'
+
+ authorize :read_release
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the link'
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the link'
+ field :url, GraphQL::STRING_TYPE, null: true,
+ description: 'URL of the link'
+ field :link_type, Types::ReleaseLinkTypeEnum, null: true,
+ description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`'
+ field :external, GraphQL::BOOLEAN_TYPE, null: true, method: :external?,
+ description: 'Indicates the link points to an external resource'
+ end
+end
diff --git a/app/graphql/types/release_link_type_enum.rb b/app/graphql/types/release_link_type_enum.rb
new file mode 100644
index 00000000000..b364855833f
--- /dev/null
+++ b/app/graphql/types/release_link_type_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class ReleaseLinkTypeEnum < BaseEnum
+ graphql_name 'ReleaseLinkType'
+ description 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`'
+
+ ::Releases::Link.link_types.keys.each do |link_type|
+ value link_type.upcase, value: link_type, description: "#{link_type.titleize} link type"
+ end
+ end
+end
diff --git a/app/graphql/types/release_source_type.rb b/app/graphql/types/release_source_type.rb
new file mode 100644
index 00000000000..0ec1ad85a39
--- /dev/null
+++ b/app/graphql/types/release_source_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class ReleaseSourceType < BaseObject
+ graphql_name 'ReleaseSource'
+
+ authorize :read_release_sources
+
+ field :format, GraphQL::STRING_TYPE, null: true,
+ description: 'Format of the source'
+ field :url, GraphQL::STRING_TYPE, null: true,
+ description: 'Download URL of the source'
+ end
+end
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index 632351be5d3..3d8e5a93c68 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -23,8 +23,12 @@ module Types
description: 'Timestamp of when the release was created'
field :released_at, Types::TimeType, null: true,
description: 'Timestamp of when the release was released'
+ field :assets, Types::ReleaseAssetsType, null: true, method: :itself,
+ description: 'Assets of the release'
field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Milestones associated to the release'
+ field :evidences, Types::EvidenceType.connection_type, null: true,
+ description: 'Evidence for the release'
field :author, Types::UserType, null: true,
description: 'User that created the release'
diff --git a/app/graphql/types/resolvable_interface.rb b/app/graphql/types/resolvable_interface.rb
new file mode 100644
index 00000000000..a39092c70ca
--- /dev/null
+++ b/app/graphql/types/resolvable_interface.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Types
+ # This Interface contains fields that are shared between objects that include either
+ # the `ResolvableNote` or `ResolvableDiscussion` modules.
+ module ResolvableInterface
+ include Types::BaseInterface
+
+ field :resolved_by, Types::UserType,
+ null: true,
+ description: 'User who resolved the object'
+
+ def resolved_by
+ return unless object.resolved_by_id
+
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.resolved_by_id).find
+ end
+
+ field :resolved, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates if the object is resolved',
+ method: :resolved?
+ field :resolvable, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates if the object can be resolved',
+ method: :resolvable?
+ field :resolved_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the object was resolved'
+ end
+end
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index b23c4f71ffa..73ca3425ded 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -27,9 +27,12 @@ module Types
authorize: :read_project,
resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, snippet.project_id).find }
+ # Author can be nil in some scenarios. For example,
+ # when the admin setting restricted visibility
+ # level is set to public
field :author, Types::UserType,
description: 'The owner of the snippet',
- null: false,
+ null: true,
resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, snippet.author_id).find }
field :file_name, GraphQL::STRING_TYPE,
@@ -65,6 +68,11 @@ module Types
calls_gitaly: true,
null: false
+ field :blobs, type: [Types::Snippets::BlobType],
+ description: 'Snippet blobs',
+ calls_gitaly: true,
+ null: false
+
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository',
calls_gitaly: true,
diff --git a/app/graphql/types/snippets/file_input_action_enum.rb b/app/graphql/types/snippets/file_input_action_enum.rb
new file mode 100644
index 00000000000..7785853f3a8
--- /dev/null
+++ b/app/graphql/types/snippets/file_input_action_enum.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Snippets
+ class FileInputActionEnum < BaseEnum
+ graphql_name 'SnippetFileInputActionEnum'
+ description 'Type of a snippet file input action'
+
+ value 'create', value: :create
+ value 'update', value: :update
+ value 'delete', value: :delete
+ value 'move', value: :move
+ end
+ end
+end
diff --git a/app/graphql/types/snippets/file_input_type.rb b/app/graphql/types/snippets/file_input_type.rb
new file mode 100644
index 00000000000..85a02c8f493
--- /dev/null
+++ b/app/graphql/types/snippets/file_input_type.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Types
+ module Snippets
+ class FileInputType < BaseInputObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'SnippetFileInputType'
+ description 'Represents an action to perform over a snippet file'
+
+ argument :action, Types::Snippets::FileInputActionEnum,
+ description: 'Type of input action',
+ required: true
+
+ argument :previous_path, GraphQL::STRING_TYPE,
+ description: 'Previous path of the snippet file',
+ required: false
+
+ argument :file_path, GraphQL::STRING_TYPE,
+ description: 'Path of the snippet file',
+ required: true
+
+ argument :content, GraphQL::STRING_TYPE,
+ description: 'Snippet file content',
+ required: false
+ end
+ end
+end
diff --git a/app/graphql/types/user_state_enum.rb b/app/graphql/types/user_state_enum.rb
new file mode 100644
index 00000000000..d34936b4c48
--- /dev/null
+++ b/app/graphql/types/user_state_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class UserStateEnum < BaseEnum
+ graphql_name 'UserState'
+ description 'Possible states of a user'
+
+ value 'active', 'The user is active and is able to use the system', value: 'active'
+ value 'blocked', 'The user has been blocked and is prevented from using the system', value: 'blocked'
+ value 'deactivated', 'The user is no longer active and is unable to use the system', value: 'deactivated'
+ end
+end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index 29a3f5d452f..ab3c84ea539 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -12,12 +12,12 @@ module Types
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the user'
- field :name, GraphQL::STRING_TYPE, null: false,
- description: 'Human-readable name of the user'
- field :state, GraphQL::STRING_TYPE, null: false,
- description: 'State of the issue'
field :username, GraphQL::STRING_TYPE, null: false,
description: 'Username of the user. Unique within this instance of GitLab'
+ field :name, GraphQL::STRING_TYPE, null: false,
+ description: 'Human-readable name of the user'
+ field :state, Types::UserStateEnum, null: false,
+ description: 'State of the user'
field :avatar_url, GraphQL::STRING_TYPE, null: true,
description: "URL of the user's avatar"
field :web_url, GraphQL::STRING_TYPE, null: false,
@@ -25,6 +25,20 @@ module Types
field :todos, Types::TodoType.connection_type, null: false,
resolver: Resolvers::TodoResolver,
description: 'Todos of the user'
+ field :group_memberships, Types::GroupMemberType.connection_type, null: true,
+ description: 'Group memberships of the user',
+ method: :group_members
+ field :project_memberships, Types::ProjectMemberType.connection_type, null: true,
+ description: 'Project memberships of the user',
+ method: :project_members
+
+ # Merge request field: MRs can be either authored or assigned:
+ field :authored_merge_requests, Types::MergeRequestType.connection_type, null: true,
+ resolver: Resolvers::AuthoredMergeRequestsResolver,
+ description: 'Merge Requests authored by the user'
+ field :assigned_merge_requests, Types::MergeRequestType.connection_type, null: true,
+ resolver: Resolvers::AssignedMergeRequestsResolver,
+ description: 'Merge Requests assigned to the user'
field :snippets,
Types::SnippetType.connection_type,
diff --git a/app/helpers/active_sessions_helper.rb b/app/helpers/active_sessions_helper.rb
index 84aa1160f12..8fb23f99cb3 100644
--- a/app/helpers/active_sessions_helper.rb
+++ b/app/helpers/active_sessions_helper.rb
@@ -20,6 +20,6 @@ module ActiveSessionsHelper
'monitor-o'
end
- sprite_icon(icon_name, size: 16, css_class: 'prepend-top-2')
+ sprite_icon(icon_name, size: 16, css_class: 'gl-mt-2')
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 2df33073a89..bdfdf5a69b3 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -103,7 +103,7 @@ module ApplicationHelper
page: body_data_page,
page_type_id: controller.params[:id],
find_file: find_file_path,
- group: "#{@group&.path}"
+ group: @group&.path
}.merge(project_data)
end
@@ -113,6 +113,7 @@ module ApplicationHelper
{
project_id: @project.id,
project: @project.path,
+ group: @project.group&.path,
namespace_id: @project.namespace&.id
}
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index b9f0e3582df..e709d15a946 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -26,6 +26,17 @@ module ApplicationSettingsHelper
end
end
+ def storage_weights
+ ApplicationSetting.repository_storages_weighted_attributes.map do |attribute|
+ storage = attribute.to_s.delete_prefix('repository_storages_weighted_')
+ {
+ name: attribute,
+ label: storage,
+ value: @application_setting.repository_storages_weighted[storage] || 0
+ }
+ end
+ end
+
def all_protocols_enabled?
Gitlab::CurrentSettings.enabled_git_access_protocol.blank?
end
@@ -228,6 +239,7 @@ module ApplicationSettingsHelper
:import_sources,
:max_artifacts_size,
:max_attachment_size,
+ :max_import_size,
:max_pages_size,
:metrics_method_call_threshold,
:minimum_password_length,
@@ -261,6 +273,8 @@ module ApplicationSettingsHelper
:sourcegraph_enabled,
:sourcegraph_url,
:sourcegraph_public_only,
+ :spam_check_endpoint_enabled,
+ :spam_check_endpoint_url,
:terminal_max_session_time,
:terms,
:throttle_authenticated_api_enabled,
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
index 0f0d5350df6..0f14680607e 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -2,7 +2,7 @@
module AutoDevopsHelper
def show_auto_devops_callout?(project)
- Feature.get(:auto_devops_banner_disabled).off? &&
+ Feature.disabled?(:auto_devops_banner_disabled) &&
show_callout?('auto_devops_settings_dismissed') &&
can?(current_user, :admin_pipeline, project) &&
project.has_auto_devops_implicitly_disabled? &&
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 39aaf242231..1204f882707 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -17,15 +17,23 @@ module ClustersHelper
end
end
+ def js_clusters_list_data(path = nil)
+ {
+ endpoint: path,
+ img_tags: {
+ aws: { path: image_path('illustrations/logos/amazon_eks.svg'), text: s_('ClusterIntegration|Amazon EKS') },
+ default: { path: image_path('illustrations/logos/kubernetes.svg'), text: _('Kubernetes Cluster') },
+ gcp: { path: image_path('illustrations/logos/google_gke.svg'), text: s_('ClusterIntegration|Google GKE') }
+ }
+ }
+ end
+
+ # This method is depreciated and will be removed when associated HAML files are moved to JavaScript
def provider_icon(provider = nil)
- case provider
- when 'aws'
- image_tag 'illustrations/logos/amazon_eks.svg', alt: s_('ClusterIntegration|Amazon EKS'), class: 'gl-h-full'
- when 'gcp'
- image_tag 'illustrations/logos/google_gke.svg', alt: s_('ClusterIntegration|Google GKE'), class: 'gl-h-full'
- else
- image_tag 'illustrations/logos/kubernetes.svg', alt: _('Kubernetes Cluster'), class: 'gl-h-full'
- end
+ img_data = js_clusters_list_data.dig(:img_tags, provider&.to_sym) ||
+ js_clusters_list_data.dig(:img_tags, :default)
+
+ image_tag img_data[:path], alt: img_data[:text], class: 'gl-h-full'
end
def render_gcp_signup_offer
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index e7b561af3da..41a255434af 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -60,7 +60,8 @@ module EnvironmentsHelper
'custom-metrics-path' => project_prometheus_metrics_path(project),
'validate-query-path' => validate_query_project_prometheus_metrics_path(project),
'custom-metrics-available' => "#{custom_metrics_available?(project)}",
- 'prometheus-alerts-available' => "#{can?(current_user, :read_prometheus_alerts, project)}"
+ 'prometheus-alerts-available' => "#{can?(current_user, :read_prometheus_alerts, project)}",
+ 'dashboard-timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase
}
end
@@ -68,10 +69,11 @@ module EnvironmentsHelper
return {} unless environment
{
- 'current-environment-name' => environment.name,
- 'has-metrics' => "#{environment.has_metrics?}",
- 'prometheus-status' => "#{environment.prometheus_status}",
- 'environment-state' => "#{environment.state}"
+ 'metrics-dashboard-base-path' => environment_metrics_path(environment),
+ 'current-environment-name' => environment.name,
+ 'has-metrics' => "#{environment.has_metrics?}",
+ 'prometheus-status' => "#{environment.prometheus_status}",
+ 'environment-state' => "#{environment.state}"
}
end
@@ -94,7 +96,8 @@ module EnvironmentsHelper
'empty-loading-svg-path' => image_path('illustrations/monitoring/loading.svg'),
'empty-no-data-svg-path' => image_path('illustrations/monitoring/no_data.svg'),
'empty-no-data-small-svg-path' => image_path('illustrations/chart-empty-state-small.svg'),
- 'empty-unable-to-connect-svg-path' => image_path('illustrations/monitoring/unable_to_connect.svg')
+ 'empty-unable-to-connect-svg-path' => image_path('illustrations/monitoring/unable_to_connect.svg'),
+ 'custom-dashboard-base-path' => Metrics::Dashboard::CustomDashboardService::DASHBOARD_ROOT
}
end
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index e93aeba6dfd..c1f343edd10 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -188,7 +188,7 @@ module EventsHelper
end
def event_wiki_page_target_url(event)
- project_wiki_url(event.project, event.target.canonical_slug)
+ project_wiki_url(event.project, event.target&.canonical_slug || Wiki::HOMEPAGE)
end
def event_note_title_html(event)
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 4474534045b..8a9380f4771 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -162,8 +162,8 @@ module GitlabRoutingHelper
# against the arguments. We can speed this up 10x by generating the strings directly.
# /*namespace_id/:project_id/-/jobs/:job_id/artifacts/download(.:format)
- def fast_download_project_job_artifacts_path(project, job)
- expose_fast_artifacts_path(project, job, :download)
+ def fast_download_project_job_artifacts_path(project, job, params = {})
+ expose_fast_artifacts_path(project, job, :download, params)
end
# /*namespace_id/:project_id/-/jobs/:job_id/artifacts/keep(.:format)
@@ -176,8 +176,13 @@ module GitlabRoutingHelper
expose_fast_artifacts_path(project, job, :browse)
end
- def expose_fast_artifacts_path(project, job, action)
+ def expose_fast_artifacts_path(project, job, action, params = {})
path = "#{project.full_path}/-/jobs/#{job.id}/artifacts/#{action}"
+
+ unless params.empty?
+ path += "?#{params.to_query}"
+ end
+
Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, path)
end
@@ -240,6 +245,14 @@ module GitlabRoutingHelper
end
end
+ def gitlab_dashboard_snippets_path(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ project_snippets_path(snippet.project, *args)
+ else
+ dashboard_snippets_path
+ end
+ end
+
def gitlab_raw_snippet_path(snippet, *args)
if snippet.is_a?(ProjectSnippet)
raw_project_snippet_path(snippet.project, snippet, *args)
@@ -298,6 +311,16 @@ module GitlabRoutingHelper
toggle_award_emoji_snippet_url(snippet, *new_args)
end
+ # Wikis
+
+ def wiki_path(wiki, **options)
+ Gitlab::UrlBuilder.wiki_url(wiki, only_path: true, **options)
+ end
+
+ def wiki_page_path(wiki, page, **options)
+ Gitlab::UrlBuilder.wiki_page_url(wiki, page, only_path: true, **options)
+ end
+
private
def snippet_query_params(snippet, *args)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 1ce99652463..a848c814742 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -205,9 +205,9 @@ module IssuablesHelper
author_output
end
- 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_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip gl-ml-2', 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 if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block gl-ml-3")
output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none")
output.join.html_safe
@@ -276,6 +276,7 @@ module IssuablesHelper
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,
+ issuableStatus: issuable.state,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
lockVersion: issuable.lock_version,
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 39edfeea81e..244b97c7196 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -9,40 +9,6 @@ module IssuesHelper
classes.join(' ')
end
- def url_for_issue(issue_iid, project = @project, options = {})
- return '' if project.nil?
-
- url =
- if options[:internal]
- url_for_internal_issue(issue_iid, project, options)
- else
- url_for_tracker_issue(issue_iid, project, options)
- end
-
- # Ensure we return a valid URL to prevent possible XSS.
- URI.parse(url).to_s
- rescue URI::InvalidURIError
- ''
- end
-
- def url_for_tracker_issue(issue_iid, project, options)
- if options[:only_path]
- project.issues_tracker.issue_path(issue_iid)
- else
- project.issues_tracker.issue_url(issue_iid)
- end
- end
-
- def url_for_internal_issue(issue_iid, project = @project, options = {})
- helpers = Gitlab::Routing.url_helpers
-
- if options[:only_path]
- helpers.namespace_project_issue_path(namespace_id: project.namespace, project_id: project, id: issue_iid)
- else
- helpers.namespace_project_issue_url(namespace_id: project.namespace, project_id: project, id: issue_iid)
- end
- end
-
def status_box_class(item)
if item.try(:expired?)
'status-box-expired'
@@ -168,11 +134,6 @@ module IssuesHelper
def show_moved_service_desk_issue_warning?(issue)
false
end
-
- # Required for Banzai::Filter::IssueReferenceFilter
- module_function :url_for_issue
- module_function :url_for_internal_issue
- module_function :url_for_tracker_issue
end
IssuesHelper.prepend_if_ee('EE::IssuesHelper')
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 4f66356c27e..7ab2b33de8c 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -129,8 +129,8 @@ module MarkupHelper
context.merge!(
pipeline: :wiki,
project: @project,
- project_wiki: @project_wiki,
- repository: @project_wiki.repository,
+ wiki: @wiki,
+ repository: @wiki.repository,
page_slug: wiki_page.slug,
issuable_state_filter_enabled: true
)
@@ -300,7 +300,7 @@ module MarkupHelper
# RepositoryLinkFilter and UploadLinkFilter
commit: @commit,
- project_wiki: @project_wiki,
+ wiki: @wiki,
ref: @ref,
requested_path: @path
)
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 228dc2cc27f..b9f8d81bc4e 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -56,6 +56,45 @@ module NamespacesHelper
namespaces_options(selected, options)
end
+ def namespace_storage_alert(namespace)
+ return {} if current_user.nil?
+
+ payload = Namespaces::CheckStorageSizeService.new(namespace, current_user).execute.payload
+
+ return {} if payload.empty?
+
+ alert_level = payload[:alert_level]
+ root_namespace = payload[:root_namespace]
+
+ return {} if cookies["hide_storage_limit_alert_#{root_namespace.id}_#{alert_level}"] == 'true'
+
+ payload
+ end
+
+ def namespace_storage_alert_style(alert_level)
+ if alert_level == :error || alert_level == :alert
+ 'danger'
+ else
+ alert_level.to_s
+ end
+ end
+
+ def namespace_storage_alert_icon(alert_level)
+ if alert_level == :error || alert_level == :alert
+ 'error'
+ elsif alert_level == :info
+ 'information-o'
+ else
+ alert_level.to_s
+ end
+ end
+
+ def namespace_storage_usage_link(namespace)
+ # The usage quota page is only available in EE. This will be changed in
+ # the future, see https://gitlab.com/gitlab-org/gitlab/-/issues/220042.
+ nil
+ end
+
private
# Many importers create a temporary Group, so use the real
@@ -89,4 +128,4 @@ module NamespacesHelper
end
end
-NamespacesHelper.include_if_ee('EE::NamespacesHelper')
+NamespacesHelper.prepend_if_ee('EE::NamespacesHelper')
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index acf9f8c5b5b..782f1d3e759 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -3,6 +3,13 @@
module NotesHelper
MAX_PRERENDERED_NOTES = 10
+ def note_target_title(note)
+ # The design title is already present in `Event#note_target_reference`.
+ return if note.nil? || note.for_design?
+
+ note.title
+ end
+
def note_target_fields(note)
if note.noteable
hidden_field_tag(:target_type, note.noteable.class.name.underscore) +
@@ -54,8 +61,8 @@ module NotesHelper
class: 'add-diff-note js-add-diff-note-button',
type: 'submit', name: 'button',
data: diff_view_line_data(line_code, position, line_type),
- title: 'Add a comment to this line' do
- icon('comment-o')
+ title: _('Add a comment to this line') do
+ sprite_icon('comment', size: 12)
end
end
@@ -162,7 +169,7 @@ module NotesHelper
end
def notes_data(issuable)
- {
+ data = {
discussionsPath: discussions_path(issuable),
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
@@ -174,6 +181,16 @@ module NotesHelper
prerenderedNotesCount: issuable.capped_notes_count(MAX_PRERENDERED_NOTES),
lastFetchedAt: Time.now.to_i
}
+
+ if issuable.is_a?(MergeRequest)
+ data.merge!(
+ draftsPath: project_merge_request_drafts_path(@project, issuable),
+ draftsPublishPath: publish_project_merge_request_drafts_path(@project, issuable),
+ draftsDiscardPath: discard_project_merge_request_drafts_path(@project, issuable)
+ )
+ end
+
+ data
end
def discussion_resolved_intro(discussion)
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 8fd277564df..68dfd008921 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -122,6 +122,6 @@ module NotificationsHelper
end
def notification_event_disabled?(event)
- event == :fixed_pipeline && Feature.disabled?(:ci_pipeline_fixed_notifications)
+ event == :fixed_pipeline && !Gitlab::Ci::Features.pipeline_fixed_notifications?
end
end
diff --git a/app/helpers/numbers_helper.rb b/app/helpers/numbers_helper.rb
index 3c0b11c4d32..38d3f90dd55 100644
--- a/app/helpers/numbers_helper.rb
+++ b/app/helpers/numbers_helper.rb
@@ -1,15 +1,14 @@
# frozen_string_literal: true
module NumbersHelper
- # rubocop: disable CodeReuse/ActiveRecord
def limited_counter_with_delimiter(resource, **options)
limit = options.fetch(:limit, 1000).to_i
- count = resource.limit(limit + 1).count(:all)
+ count = resource.page.total_count_with_limit(:all, limit: limit)
+
if count > limit
number_with_delimiter(count - 1, options) + '+'
else
number_with_delimiter(count, options)
end
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 46e2c9ce56e..a44760e85ca 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -104,6 +104,16 @@ module PageLayoutHelper
end
end
+ # This helper ensures there is always a default `Gitlab::SearchContext` available
+ # to all controller that use the application layout.
+ def search_context
+ strong_memoize(:search_context) do
+ next super if defined?(super)
+
+ Gitlab::SearchContext::Builder.new(controller.view_context).build!
+ end
+ end
+
def fluid_layout
current_user && current_user.layout == "fluid"
end
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
index af86ef715c2..bc585899591 100644
--- a/app/helpers/projects/alert_management_helper.rb
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -15,7 +15,7 @@ module Projects::AlertManagementHelper
{
'alert-id' => alert_id,
'project-path' => project.full_path,
- 'new-issue-path' => new_project_issue_path(project)
+ 'project-issues-path' => project_issues_path(project)
}
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index d743ea6aeea..bda9a69d71f 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -284,8 +284,8 @@ module ProjectsHelper
"xcode://clone?repo=#{CGI.escape(default_url_to_repo(project))}"
end
- def link_to_bfg
- link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer'
+ def link_to_filter_repo
+ link_to 'git filter-repo', 'https://github.com/newren/git-filter-repo', target: '_blank', rel: 'noopener noreferrer'
end
def explore_projects_tab?
@@ -367,6 +367,10 @@ module ProjectsHelper
@project.metrics_setting_external_dashboard_url
end
+ def metrics_dashboard_timezone
+ @project.metrics_setting_dashboard_timezone
+ end
+
def grafana_integration_url
@project.grafana_integration&.grafana_url
end
@@ -410,7 +414,7 @@ module ProjectsHelper
nav_tabs << :pipelines
end
- if can?(current_user, :read_environment, project) || can?(current_user, :read_cluster, project)
+ if can_view_operations_tab?(current_user, project)
nav_tabs << :operations
end
@@ -438,22 +442,29 @@ module ProjectsHelper
def tab_ability_map
{
- environments: :read_environment,
- milestones: :read_milestone,
- snippets: :read_snippet,
- settings: :admin_project,
- builds: :read_build,
- clusters: :read_cluster,
- serverless: :read_cluster,
- error_tracking: :read_sentry_issue,
- alert_management: :read_alert_management_alert,
- labels: :read_label,
- issues: :read_issue,
- project_members: :read_project_member,
- wiki: :read_wiki
+ environments: :read_environment,
+ metrics_dashboards: :metrics_dashboard,
+ milestones: :read_milestone,
+ snippets: :read_snippet,
+ settings: :admin_project,
+ builds: :read_build,
+ clusters: :read_cluster,
+ serverless: :read_cluster,
+ error_tracking: :read_sentry_issue,
+ alert_management: :read_alert_management_alert,
+ labels: :read_label,
+ issues: :read_issue,
+ project_members: :read_project_member,
+ wiki: :read_wiki
}
end
+ def can_view_operations_tab?(current_user, project)
+ [:read_environment, :read_cluster, :metrics_dashboard].any? do |ability|
+ can?(current_user, ability, project)
+ end
+ end
+
def search_tab_ability_map
@search_tab_ability_map ||= tab_ability_map.merge(
blobs: :download_code,
@@ -533,11 +544,6 @@ module ProjectsHelper
end
end
- def project_wiki_path_with_version(proj, page, version, is_newest)
- url_params = is_newest ? {} : { version_id: version }
- project_wiki_path(proj, page, url_params)
- end
-
def project_status_css_class(status)
case status
when "started"
@@ -670,7 +676,6 @@ module ProjectsHelper
def sidebar_settings_paths
%w[
projects#edit
- project_members#index
integrations#show
services#edit
hooks#index
@@ -729,7 +734,7 @@ module ProjectsHelper
end
def native_code_navigation_enabled?(project)
- Feature.enabled?(:code_navigation, project)
+ Feature.enabled?(:code_navigation, project, default_enabled: true)
end
def show_visibility_confirm_modal?(project)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 5ad65c59a2e..4e3b6aad8cc 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -3,28 +3,6 @@
module SearchHelper
SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets].freeze
- def search_autocomplete_opts(term)
- return unless current_user
-
- resources_results = [
- groups_autocomplete(term),
- projects_autocomplete(term)
- ].flatten
-
- search_pattern = Regexp.new(Regexp.escape(term), "i")
-
- generic_results = project_autocomplete + default_autocomplete + help_autocomplete
- generic_results.concat(default_autocomplete_admin) if current_user.admin?
- generic_results.select! { |result| result[:label] =~ search_pattern }
-
- [
- resources_results,
- generic_results
- ].flatten.uniq do |item|
- item[:label]
- end
- end
-
def search_entries_info(collection, scope, term)
return if collection.to_a.empty?
@@ -95,91 +73,6 @@ module SearchHelper
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 }
- ]
- end
-
- # Autocomplete results for settings pages, for admins
- def default_autocomplete_admin
- [
- { 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") }
- ]
- end
-
- # Autocomplete results for the current project, if it's defined
- def project_autocomplete
- if @project && @project.repository.root_ref
- 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) }
- ]
- else
- []
- end
- end
-
- # Autocomplete results for the current user's groups
- # rubocop: disable CodeReuse/ActiveRecord
- def groups_autocomplete(term, limit = 5)
- current_user.authorized_groups.order_id_desc.search(term).limit(limit).map do |group|
- {
- category: "Groups",
- id: group.id,
- label: "#{search_result_sanitize(group.full_name)}",
- url: group_path(group),
- avatar_url: group.avatar_url || ''
- }
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # Autocomplete results for the current user's projects
- # rubocop: disable CodeReuse/ActiveRecord
- def projects_autocomplete(term, limit = 5)
- current_user.authorized_projects.order_id_desc.search_by_title(term)
- .sorted_by_stars_desc.non_archived.limit(limit).map do |p|
- {
- category: "Projects",
- id: p.id,
- value: "#{search_result_sanitize(p.name)}",
- label: "#{search_result_sanitize(p.full_name)}",
- url: project_path(p),
- avatar_url: p.avatar_url || ''
- }
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def search_result_sanitize(str)
Sanitize.clean(str)
end
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index b13cc93436f..fe839b92ba6 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -98,6 +98,28 @@ module ServicesHelper
end
end
+ def integration_form_refactor?
+ Feature.enabled?(:integration_form_refactor, @project)
+ end
+
+ def trigger_events_for_service
+ return [] unless integration_form_refactor?
+
+ ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json
+ end
+
+ def fields_for_service
+ return [] unless integration_form_refactor?
+
+ ServiceFieldSerializer.new(service: @service).represent(@service.global_fields).to_json
+ end
+
+ def show_service_trigger_events?
+ return false if @service.is_a?(JiraService) || integration_form_refactor?
+
+ @service.configurable_events.present?
+ end
+
extend self
end
diff --git a/app/helpers/subscribable_banner_helper.rb b/app/helpers/subscribable_banner_helper.rb
new file mode 100644
index 00000000000..c9d4370f8ad
--- /dev/null
+++ b/app/helpers/subscribable_banner_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module SubscribableBannerHelper
+ # Overridden in EE
+ def display_subscription_banner!
+ end
+end
+
+SubscribableBannerHelper.prepend_if_ee('EE::SubscribableBannerHelper')
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/timeboxes_helper.rb
index df1ee54c5ac..0bffdba7349 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module MilestonesHelper
+module TimeboxesHelper
include EntityDateHelper
include Gitlab::Utils::StrongMemoize
@@ -209,30 +209,27 @@ module MilestonesHelper
end
end
- def milestone_date_range(milestone)
- if milestone.start_date && milestone.due_date
- "#{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}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') }
+ def timebox_date_range(timebox)
+ if timebox.start_date && timebox.due_date
+ "#{timebox.start_date.to_s(:medium)}–#{timebox.due_date.to_s(:medium)}"
+ elsif timebox.due_date
+ if timebox.due_date.past?
+ _("expired on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) }
else
- _("expires on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') }
+ _("expires on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) }
end
- elsif milestone.start_date
- if milestone.start_date.past?
- _("started on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') }
+ elsif timebox.start_date
+ if timebox.start_date.past?
+ _("started on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) }
else
- _("starts on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') }
+ _("starts on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) }
end
end
end
+ alias_method :milestone_date_range, :timebox_date_range
def milestone_tab_path(milestone, tab)
- if milestone.global_milestone?
- url_for(action: tab, title: milestone.title, format: :json)
- else
- url_for(action: tab, format: :json)
- end
+ url_for(action: tab, format: :json)
end
def update_milestone_path(milestone, params = {})
@@ -246,11 +243,7 @@ module MilestonesHelper
def group_milestone_route(milestone, params = {})
params = nil if params.empty?
- if milestone.legacy_group_milestone?
- group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params)
- else
- group_milestone_path(milestone.group, milestone.iid, milestone: params)
- end
+ group_milestone_path(milestone.group, milestone.iid, milestone: params)
end
def group_or_project_milestone_path(milestone)
@@ -306,4 +299,4 @@ module MilestonesHelper
end
end
-MilestonesHelper.prepend_if_ee('EE::MilestonesHelper')
+TimeboxesHelper.prepend_if_ee('EE::TimeboxesHelper')
diff --git a/app/helpers/milestones_routing_helper.rb b/app/helpers/timeboxes_routing_helper.rb
index a49b561533a..6fb5a1a3185 100644
--- a/app/helpers/milestones_routing_helper.rb
+++ b/app/helpers/timeboxes_routing_helper.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module MilestonesRoutingHelper
+module TimeboxesRoutingHelper
def milestone_path(milestone, *args)
if milestone.group_milestone?
group_milestone_path(milestone.group, milestone, *args)
@@ -17,3 +17,5 @@ module MilestonesRoutingHelper
end
end
end
+
+TimeboxesRoutingHelper.prepend_if_ee('EE::TimeboxesRoutingHelper')
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 41f39c7e798..2b4f2f11d1e 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -37,11 +37,12 @@ module TodosHelper
end
def todo_target_title(todo)
- if todo.target
- "\"#{todo.target.title}\""
- else
- ""
- end
+ # Design To Dos' filenames are displayed in `#todo_target_link` (see `Design#to_reference`),
+ # so to avoid displaying duplicate filenames in the To Do list for designs,
+ # we return an empty string here.
+ return "" if todo.target.blank? || todo.for_design?
+
+ "\"#{todo.target.title}\""
end
def todo_parent_path(todo)
@@ -54,6 +55,7 @@ module TodosHelper
def todo_target_type_name(todo)
return _('design') if todo.for_design?
+ return _('alert') if todo.for_alert?
todo.target_type.titleize.downcase
end
@@ -67,6 +69,8 @@ module TodosHelper
project_commit_path(todo.project, todo.target, path_options)
elsif todo.for_design?
todos_design_path(todo, path_options)
+ elsif todo.for_alert?
+ details_project_alert_management_path(todo.project, todo.target)
else
path = [todo.resource_parent, todo.target]
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index d62839cf037..304b58d232a 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -167,6 +167,17 @@ module VisibilityLevelHelper
[requested_level, max_allowed_visibility_level(form_model)].min
end
+ def available_visibility_levels(form_model)
+ Gitlab::VisibilityLevel.values.reject do |level|
+ disallowed_visibility_level?(form_model, level) ||
+ restricted_visibility_levels.include?(level)
+ end
+ end
+
+ def snippets_selected_visibility_level(visibility_levels, selected)
+ visibility_levels.find { |level| level == selected } || visibility_levels.min
+ end
+
def multiple_visibility_levels_restricted?
restricted_visibility_levels.many? # rubocop: disable CodeReuse/ActiveRecord
end
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index dd8fde2a697..3c983606b73 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -22,7 +22,7 @@ module WikiHelper
page_slug_split
.map do |dir_or_page|
current_slug = "#{current_slug}#{dir_or_page}/"
- add_to_breadcrumb_dropdown link_to(WikiPage.unhyphenize(dir_or_page).capitalize, project_wiki_path(@project, current_slug)), location: :after
+ add_to_breadcrumb_dropdown link_to(WikiPage.unhyphenize(dir_or_page).capitalize, wiki_page_path(@wiki, current_slug)), location: :after
end
end
@@ -32,7 +32,7 @@ module WikiHelper
content_tag(:div, class: 'alert alert-danger') do
case error
when WikiPage::PageChangedError
- page_link = link_to s_("WikiPageConflictMessage|the page"), project_wiki_path(@project, @page), target: "_blank"
+ page_link = link_to s_("WikiPageConflictMessage|the page"), wiki_page_path(@wiki, @page), target: "_blank"
concat(
(s_("WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs.") % { page_link: page_link }).html_safe
)
@@ -45,26 +45,63 @@ module WikiHelper
end
def wiki_attachment_upload_url
- expose_url(api_v4_projects_wikis_attachments_path(id: @project.id))
+ expose_url(api_v4_projects_wikis_attachments_path(id: @wiki.container.id))
end
- def wiki_sort_controls(project, sort, direction)
- sort ||= ProjectWiki::TITLE_ORDER
+ def wiki_sort_controls(wiki, sort, direction)
+ sort ||= Wiki::TITLE_ORDER
link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort rspec-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),
+ link_to(wiki_path(wiki, action: :pages, 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
+ if key == Wiki::CREATED_AT_ORDER
s_("Wiki|Created date")
else
s_("Wiki|Title")
end
end
+
+ def wiki_empty_state_messages(wiki)
+ case wiki.container
+ when Project
+ {
+ writable: {
+ title: s_('WikiEmpty|The wiki lets you write documentation for your project'),
+ body: s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on.")
+ },
+ issuable: {
+ title: s_('WikiEmpty|This project has no wiki pages'),
+ body: s_('WikiEmptyIssueMessage|You must be a project member in order to add wiki pages. If you have suggestions for how to improve the wiki for this project, consider opening an issue in the %{issues_link}.')
+ },
+ readonly: {
+ title: s_('WikiEmpty|This project has no wiki pages'),
+ body: s_('WikiEmpty|You must be a project member in order to add wiki pages.')
+ }
+ }
+ when Group
+ {
+ writable: {
+ title: s_('WikiEmpty|The wiki lets you write documentation for your group'),
+ body: s_("WikiEmpty|A wiki is where you can store all the details about your group. This can include why you've created it, its principles, how to use it, and so on.")
+ },
+ issuable: {
+ title: s_('WikiEmpty|This group has no wiki pages'),
+ body: s_('WikiEmptyIssueMessage|You must be a group member in order to add wiki pages. If you have suggestions for how to improve the wiki for this group, consider opening an issue in the %{issues_link}.')
+ },
+ readonly: {
+ title: s_('WikiEmpty|This group has no wiki pages'),
+ body: s_('WikiEmpty|You must be a group member in order to add wiki pages.')
+ }
+ }
+ else
+ raise NotImplementedError, "Unknown wiki container type #{wiki.container.class.name}"
+ end
+ end
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index d4d93ab9795..bcf60bea0e0 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -126,3 +126,5 @@ module Emails
end
end
end
+
+Emails::Issues.prepend_if_ee('EE::Emails::Issues')
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 4b19149a833..c327a0bab43 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -45,13 +45,20 @@ module Emails
end
end
- def unknown_sign_in_email(user, ip)
+ def unknown_sign_in_email(user, ip, time)
@user = user
@ip = ip
+ @time = time
@target_url = edit_profile_password_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email, subject: subject(_("Unknown sign-in from new location")))
+ mail(
+ to: @user.notification_email,
+ subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host })
+ ) do |format|
+ format.html { render layout: 'mailer' }
+ format.text { render layout: 'mailer' }
+ end
end
end
end
diff --git a/app/mailers/emails/reviews.rb b/app/mailers/emails/reviews.rb
new file mode 100644
index 00000000000..ddb9e161a80
--- /dev/null
+++ b/app/mailers/emails/reviews.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Emails
+ module Reviews
+ def new_review_email(recipient_id, review_id)
+ setup_review_email(review_id, recipient_id)
+
+ mail_answer_thread(@merge_request, review_thread_options(recipient_id))
+ end
+
+ private
+
+ def review_thread_options(recipient_id)
+ {
+ from: sender(@author.id),
+ to: User.find(recipient_id).notification_email_for(@merge_request.target_project.group),
+ subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})")
+ }
+ end
+
+ def setup_review_email(review_id, recipient_id)
+ review = Review.find_by_id(review_id)
+
+ @notes = review.notes
+ @author = review.author
+ @author_name = review.author_name
+ @project = review.project
+ @merge_request = review.merge_request
+ @target_url = project_merge_request_url(@project, @merge_request)
+ @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key)
+ end
+ end
+end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index d9483bab543..2cf72d40635 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -18,8 +18,9 @@ class Notify < ApplicationMailer
include Emails::RemoteMirrors
include Emails::Releases
include Emails::Groups
+ include Emails::Reviews
- helper MilestonesHelper
+ helper TimeboxesHelper
helper MergeRequestsHelper
helper DiffHelper
helper BlobHelper
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index c931b5a848f..cb7c6a36c27 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -162,7 +162,7 @@ class NotifyPreview < ActionMailer::Preview
end
def unknown_sign_in_email
- Notify.unknown_sign_in_email(user, '127.0.0.1').message
+ Notify.unknown_sign_in_email(user, '127.0.0.1', Time.current).message
end
private
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 065bd5507be..a23190cc8b3 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -36,7 +36,7 @@ class ActiveSession
timestamp = Time.current
active_user_session = new(
- ip_address: request.ip,
+ ip_address: request.remote_ip,
browser: client.name,
os: client.os_name,
device_name: client.device_name,
diff --git a/app/models/alert_management.rb b/app/models/alert_management.rb
new file mode 100644
index 00000000000..0346b1f155f
--- /dev/null
+++ b/app/models/alert_management.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ def self.table_name_prefix
+ 'alert_management_'
+ end
+end
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index acaf474ecc2..af60ddd6f9a 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
+require_dependency 'alert_management'
+
module AlertManagement
class Alert < ApplicationRecord
+ include IidRoutes
include AtomicInternalId
include ShaAttribute
include Sortable
+ include Noteable
include Gitlab::SQL::Pattern
STATUSES = {
@@ -23,9 +27,15 @@ module AlertManagement
belongs_to :project
belongs_to :issue, optional: true
- has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) }
- self.table_name = 'alert_management_alerts'
+ has_many :alert_assignees, inverse_of: :alert
+ has_many :assignees, through: :alert_assignees
+
+ has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :ordered_notes, -> { fresh }, as: :noteable, class_name: 'Note'
+ has_many :user_mentions, class_name: 'AlertManagement::AlertUserMention', foreign_key: :alert_management_alert_id
+
+ has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) }
sha_attribute :fingerprint
@@ -102,7 +112,7 @@ module AlertManagement
scope :order_start_time, -> (sort_order) { order(started_at: sort_order) }
scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) }
- scope :order_events_count, -> (sort_order) { order(events: sort_order) }
+ scope :order_event_count, -> (sort_order) { order(events: sort_order) }
scope :order_severity, -> (sort_order) { order(severity: sort_order) }
scope :order_status, -> (sort_order) { order(status: sort_order) }
@@ -110,12 +120,12 @@ module AlertManagement
def self.sort_by_attribute(method)
case method.to_s
- when 'start_time_asc' then order_start_time(:asc)
- when 'start_time_desc' then order_start_time(:desc)
- when 'end_time_asc' then order_end_time(:asc)
- when 'end_time_desc' then order_end_time(:desc)
- when 'events_count_asc' then order_events_count(:asc)
- when 'events_count_desc' then order_events_count(:desc)
+ when 'started_at_asc' then order_start_time(:asc)
+ when 'started_at_desc' then order_start_time(:desc)
+ when 'ended_at_asc' then order_end_time(:asc)
+ when 'ended_at_desc' then order_end_time(:desc)
+ when 'event_count_asc' then order_event_count(:asc)
+ when 'event_count_desc' then order_event_count(:desc)
when 'severity_asc' then order_severity(:asc)
when 'severity_desc' then order_severity(:desc)
when 'status_asc' then order_status(:asc)
@@ -135,8 +145,28 @@ module AlertManagement
monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus]
end
+ def register_new_event!
+ increment!(:events)
+ end
+
+ # required for todos (typically contains an identifier like issue iid)
+ # no-op; we could use iid, but we don't have a reference prefix
+ def to_reference(_from = nil, full: false)
+ ''
+ end
+
+ def execute_services
+ return unless project.has_active_services?(:alert_hooks)
+
+ project.execute_services(hook_data, :alert_hooks)
+ end
+
private
+ def hook_data
+ Gitlab::DataBuilder::Alert.build(self)
+ end
+
def hosts_length
return unless hosts
diff --git a/app/models/alert_management/alert_assignee.rb b/app/models/alert_management/alert_assignee.rb
new file mode 100644
index 00000000000..c74b2699182
--- /dev/null
+++ b/app/models/alert_management/alert_assignee.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class AlertAssignee < ApplicationRecord
+ belongs_to :alert, inverse_of: :alert_assignees
+ belongs_to :assignee, class_name: 'User', foreign_key: :user_id
+
+ validates :alert, presence: true
+ validates :assignee, presence: true, uniqueness: { scope: :alert_id }
+ end
+end
diff --git a/app/models/alert_management/alert_user_mention.rb b/app/models/alert_management/alert_user_mention.rb
new file mode 100644
index 00000000000..d36aa80ee05
--- /dev/null
+++ b/app/models/alert_management/alert_user_mention.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class AlertUserMention < UserMention
+ belongs_to :alert_management_alert, class_name: '::AlertManagement::Alert'
+ belongs_to :note
+ end
+end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 0979d03f6e6..c7e4d64d3d5 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -5,6 +5,10 @@ class ApplicationRecord < ActiveRecord::Base
alias_method :reset, :reload
+ def self.without_order
+ reorder(nil)
+ end
+
def self.id_in(ids)
where(id: ids)
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index b29d6731b08..425a0e05c7d 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -19,6 +19,12 @@ class ApplicationSetting < ApplicationRecord
belongs_to :instance_administrators_group, class_name: "Group"
+ def self.repository_storages_weighted_attributes
+ @repository_storages_weighted_atributes ||= Gitlab.config.repositories.storages.keys.map { |k| "repository_storages_weighted_#{k}".to_sym }.freeze
+ end
+
+ store_accessor :repository_storages_weighted, *Gitlab.config.repositories.storages.keys, prefix: true
+
# Include here so it can override methods from
# `add_authentication_token_field`
# We don't prepend for now because otherwise we'll need to
@@ -39,6 +45,7 @@ class ApplicationSetting < ApplicationRecord
cache_markdown_field :after_sign_up_text
default_value_for :id, 1
+ default_value_for :repository_storages_weighted, {}
chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds
@@ -136,6 +143,10 @@ class ApplicationSetting < ApplicationRecord
presence: true,
numericality: { only_integer: true, greater_than: 0 }
+ validates :max_import_size,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
validates :max_pages_size,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0,
@@ -152,6 +163,7 @@ class ApplicationSetting < ApplicationRecord
validates :repository_storages, presence: true
validate :check_repository_storages
+ validate :check_repository_storages_weighted
validates :auto_devops_domain,
allow_blank: true,
@@ -271,6 +283,10 @@ class ApplicationSetting < ApplicationRecord
validates :allowed_key_types, presence: true
+ repository_storages_weighted_attributes.each do |attribute|
+ validates attribute, allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
+ end
+
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
unless Gitlab::VisibilityLevel.options.value?(level)
@@ -301,6 +317,13 @@ class ApplicationSetting < ApplicationRecord
numericality: { greater_than: 0, less_than_or_equal_to: 10 },
if: :external_authorization_service_enabled
+ validates :spam_check_endpoint_url,
+ addressable_url: true, allow_blank: true
+
+ validates :spam_check_endpoint_url,
+ presence: true,
+ if: :spam_check_endpoint_enabled
+
validates :external_auth_client_key,
presence: true,
if: -> (setting) { setting.external_auth_client_cert.present? }
@@ -427,6 +450,12 @@ class ApplicationSetting < ApplicationRecord
recaptcha_enabled || login_recaptcha_protection_enabled
end
+ repository_storages_weighted_attributes.each do |attribute|
+ define_method :"#{attribute}=" do |value|
+ super(value.to_i)
+ end
+ end
+
private
def parsed_grafana_url
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 221e4d5e0c6..d24136cc04a 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -86,6 +86,7 @@ module ApplicationSettingImplementation
local_markdown_version: 0,
max_artifacts_size: Settings.artifacts['max_size'],
max_attachment_size: Settings.gitlab['max_attachment_size'],
+ max_import_size: 50,
mirror_available: true,
outbound_local_requests_whitelist: [],
password_authentication_enabled_for_git: true,
@@ -104,6 +105,7 @@ module ApplicationSettingImplementation
login_recaptcha_protection_enabled: false,
repository_checks_enabled: true,
repository_storages: ['default'],
+ repository_storages_weighted: { default: 100 },
require_two_factor_authentication: false,
restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'],
session_expire_delay: Settings.gitlab['session_expire_delay'],
@@ -115,6 +117,8 @@ module ApplicationSettingImplementation
sourcegraph_enabled: false,
sourcegraph_url: nil,
sourcegraph_public_only: true,
+ spam_check_endpoint_enabled: false,
+ spam_check_endpoint_url: nil,
minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH,
namespace_storage_size_limit: 0,
terminal_max_session_time: 0,
@@ -151,7 +155,7 @@ module ApplicationSettingImplementation
snowplow_app_id: nil,
snowplow_iglu_registry_url: nil,
custom_http_clone_url_root: nil,
- productivity_analytics_start_date: Time.now,
+ productivity_analytics_start_date: Time.current,
snippet_size_limit: 50.megabytes
}
end
@@ -260,6 +264,10 @@ module ApplicationSettingImplementation
Array(read_attribute(:repository_storages))
end
+ def repository_storages_weighted
+ read_attribute(:repository_storages_weighted)
+ end
+
def commit_email_hostname
super.presence || self.class.default_commit_email_hostname
end
@@ -289,10 +297,21 @@ module ApplicationSettingImplementation
performance_bar_allowed_group_id.present?
end
- # Choose one of the available repository storage options. Currently all have
- # equal weighting.
+ def normalized_repository_storage_weights
+ strong_memoize(:normalized_repository_storage_weights) do
+ weights_total = repository_storages_weighted.values.reduce(:+)
+
+ repository_storages_weighted.transform_values do |w|
+ next w if weights_total == 0
+
+ w.to_f / weights_total
+ end
+ end
+ end
+
+ # Choose one of the available repository storage options based on a normalized weighted probability.
def pick_repository_storage
- repository_storages.sample
+ normalized_repository_storage_weights.max_by { |_, weight| rand**(1.0 / weight) }.first
end
def runners_registration_token
@@ -420,6 +439,12 @@ module ApplicationSettingImplementation
invalid.empty?
end
+ def check_repository_storages_weighted
+ invalid = repository_storages_weighted.keys - Gitlab.config.repositories.storages.keys
+ errors.add(:repository_storages_weighted, "can't include: %{invalid_storages}" % { invalid_storages: invalid.join(", ") }) unless
+ invalid.empty?
+ end
+
def terms_exist
return unless enforce_terms?
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 7ff0076c3e3..3bbd2e43a51 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -2,6 +2,9 @@
class AuditEvent < ApplicationRecord
include CreatedAtFilterable
+ include IgnorableColumns
+
+ ignore_column :updated_at, remove_with: '13.3', remove_after: '2020-08-22'
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
diff --git a/app/models/badge.rb b/app/models/badge.rb
index 3400d6d407d..4339d419b48 100644
--- a/app/models/badge.rb
+++ b/app/models/badge.rb
@@ -18,7 +18,7 @@ class Badge < ApplicationRecord
# This regex will build the new PLACEHOLDER_REGEX with the new information
PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze
- default_scope { order_created_at_asc }
+ default_scope { order_created_at_asc } # rubocop:disable Cop/DefaultScope
scope :order_created_at_asc, -> { reorder(created_at: :asc) }
diff --git a/app/models/blob.rb b/app/models/blob.rb
index c8df6c7732a..874bf58530e 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -50,6 +50,7 @@ class Blob < SimpleDelegator
BlobViewer::License,
BlobViewer::Contributing,
BlobViewer::Changelog,
+ BlobViewer::MetricsDashboardYml,
BlobViewer::CargoToml,
BlobViewer::Cartfile,
@@ -57,6 +58,7 @@ class Blob < SimpleDelegator
BlobViewer::Gemfile,
BlobViewer::Gemspec,
BlobViewer::GodepsJson,
+ BlobViewer::GoMod,
BlobViewer::PackageJson,
BlobViewer::Podfile,
BlobViewer::Podspec,
diff --git a/app/models/blob_viewer/go_mod.rb b/app/models/blob_viewer/go_mod.rb
new file mode 100644
index 00000000000..ae57e2c0526
--- /dev/null
+++ b/app/models/blob_viewer/go_mod.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module BlobViewer
+ class GoMod < DependencyManager
+ include ServerSide
+ include Gitlab::Utils::StrongMemoize
+
+ MODULE_REGEX = /
+ \A (?# beginning of file)
+ module\s+ (?# module directive)
+ (?<name>.*?) (?# module name)
+ \s*(?:\/\/.*)? (?# comment)
+ (?:\n|\z) (?# newline or end of file)
+ /x.freeze
+
+ self.file_types = %i(go_mod go_sum)
+
+ def manager_name
+ 'Go Modules'
+ end
+
+ def manager_url
+ 'https://golang.org/ref/mod'
+ end
+
+ def package_type
+ 'go'
+ end
+
+ def package_name
+ strong_memoize(:package_name) do
+ next if blob.name != 'go.mod'
+ next unless match = MODULE_REGEX.match(blob.data)
+
+ match[:name]
+ end
+ end
+
+ def package_url
+ Gitlab::Golang.package_url(package_name)
+ end
+ end
+end
diff --git a/app/models/blob_viewer/metrics_dashboard_yml.rb b/app/models/blob_viewer/metrics_dashboard_yml.rb
new file mode 100644
index 00000000000..c05fb5d88d6
--- /dev/null
+++ b/app/models/blob_viewer/metrics_dashboard_yml.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module BlobViewer
+ class MetricsDashboardYml < Base
+ include ServerSide
+ include Gitlab::Utils::StrongMemoize
+ include Auxiliary
+
+ self.partial_name = 'metrics_dashboard_yml'
+ self.loading_partial_name = 'metrics_dashboard_yml_loading'
+ self.file_types = %i(metrics_dashboard)
+ self.binary = false
+
+ def valid?
+ errors.blank?
+ end
+
+ def errors
+ strong_memoize(:errors) do
+ prepare!
+ parse_blob_data
+ end
+ end
+
+ private
+
+ def parse_blob_data
+ yaml = ::Gitlab::Config::Loader::Yaml.new(blob.data).load_raw!
+
+ ::PerformanceMonitoring::PrometheusDashboard.from_json(yaml)
+ nil
+ rescue Gitlab::Config::Loader::FormatError => error
+ wrap_yml_syntax_error(error)
+ rescue ActiveModel::ValidationError => invalid
+ invalid.model.errors
+ end
+
+ def wrap_yml_syntax_error(error)
+ ::PerformanceMonitoring::PrometheusDashboard.new.errors.tap do |errors|
+ errors.add(:'YAML syntax', error.message)
+ end
+ end
+ end
+end
diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb
index 2f1cd830791..979f0e1ab92 100644
--- a/app/models/board_group_recent_visit.rb
+++ b/app/models/board_group_recent_visit.rb
@@ -14,7 +14,7 @@ class BoardGroupRecentVisit < ApplicationRecord
def self.visited!(user, board)
visit = find_or_create_by(user: user, group: board.group, board: board)
- visit.touch if visit.updated_at < Time.now
+ visit.touch if visit.updated_at < Time.current
rescue ActiveRecord::RecordNotUnique
retry
end
diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb
index 236d88e909c..509c8f97b83 100644
--- a/app/models/board_project_recent_visit.rb
+++ b/app/models/board_project_recent_visit.rb
@@ -14,7 +14,7 @@ class BoardProjectRecentVisit < ApplicationRecord
def self.visited!(user, board)
visit = find_or_create_by(user: user, project: board.project, board: board)
- visit.touch if visit.updated_at < Time.now
+ visit.touch if visit.updated_at < Time.current
rescue ActiveRecord::RecordNotUnique
retry
end
diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb
index 28aab279545..6e39d7e2204 100644
--- a/app/models/chat_team.rb
+++ b/app/models/chat_team.rb
@@ -12,6 +12,6 @@ class ChatTeam < ApplicationRecord
# Either the group is not found, or the user doesn't have the proper
# access on the mattermost instance. In the first case, we're done either way
# in the latter case, we can't recover by retrying, so we just log what happened
- Rails.logger.error("Mattermost team deletion failed: #{e}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error("Mattermost team deletion failed: #{e}")
end
end
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 1e92a47ab49..58c26e8c806 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -16,6 +16,9 @@ module Ci
has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline",
foreign_key: :source_job_id
+ has_one :sourced_pipeline, class_name: "::Ci::Sources::Pipeline", foreign_key: :source_job_id
+ has_one :downstream_pipeline, through: :sourced_pipeline, source: :pipeline
+
validates :ref, presence: true
# rubocop:disable Cop/ActiveRecordSerialize
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 7f64ea7dd97..b5e68b55f72 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -26,7 +26,8 @@ module Ci
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
refspecs: -> (build) { build.merge_request_ref? },
- artifacts_exclude: -> (build) { build.supports_artifacts_exclude? }
+ artifacts_exclude: -> (build) { build.supports_artifacts_exclude? },
+ release_steps: -> (build) { build.release_steps? }
}.freeze
DEFAULT_RETRIES = {
@@ -39,6 +40,7 @@ module Ci
has_one :resource, class_name: 'Ci::Resource', inverse_of: :build
has_many :trace_sections, class_name: 'Ci::BuildTraceSection'
has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id
+ has_many :report_results, class_name: 'Ci::BuildReportResult', inverse_of: :build
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent
has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id
@@ -55,6 +57,7 @@ module Ci
delegate :url, to: :runner_session, prefix: true, allow_nil: true
delegate :terminal_specification, to: :runner_session, allow_nil: true
+ delegate :service_specification, to: :runner_session, allow_nil: true
delegate :gitlab_deploy_token, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
@@ -137,8 +140,8 @@ module Ci
.includes(:metadata, :job_artifacts_metadata)
end
- scope :with_artifacts_not_expired, ->() { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
- scope :with_expired_artifacts, ->() { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.now) }
+ scope :with_artifacts_not_expired, ->() { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) }
+ scope :with_expired_artifacts, ->() { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.current) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }
scope :scheduled_actions, ->() { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) }
@@ -259,7 +262,7 @@ module Ci
end
before_transition any => :waiting_for_resource do |build|
- build.waiting_for_resource_at = Time.now
+ build.waiting_for_resource_at = Time.current
end
before_transition on: :enqueue_waiting_for_resource do |build|
@@ -352,7 +355,7 @@ module Ci
begin
Ci::Build.retry(build, build.user)
rescue Gitlab::Access::AccessDeniedError => ex
- Rails.logger.error "Unable to auto-retry job #{build.id}: #{ex}" # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error "Unable to auto-retry job #{build.id}: #{ex}"
end
end
end
@@ -576,7 +579,7 @@ module Ci
def environment_changed_page_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless environment_status
+ break variables unless environment_status && Feature.enabled?(:modifed_path_ci_variables, project)
variables.append(key: 'CI_MERGE_REQUEST_CHANGED_PAGE_PATHS', value: environment_status.changed_paths.join(','))
variables.append(key: 'CI_MERGE_REQUEST_CHANGED_PAGE_URLS', value: environment_status.changed_urls.join(','))
@@ -686,6 +689,10 @@ module Ci
job_artifacts.any?
end
+ def has_test_reports?
+ job_artifacts.test_reports.exists?
+ end
+
def has_old_trace?
old_trace.present?
end
@@ -713,7 +720,7 @@ module Ci
end
def needs_touch?
- Time.now - updated_at > 15.minutes.to_i
+ Time.current - updated_at > 15.minutes.to_i
end
def valid_token?(token)
@@ -756,13 +763,13 @@ module Ci
# and use that for `ExpireBuildInstanceArtifactsWorker`?
def erase_erasable_artifacts!
- job_artifacts.erasable.destroy_all # rubocop: disable DestroyAll
+ job_artifacts.erasable.destroy_all # rubocop: disable Cop/DestroyAll
end
def erase(opts = {})
return false unless erasable?
- job_artifacts.destroy_all # rubocop: disable DestroyAll
+ job_artifacts.destroy_all # rubocop: disable Cop/DestroyAll
erase_trace!
update_erased!(opts[:erased_by])
end
@@ -776,11 +783,11 @@ module Ci
end
def artifacts_expired?
- artifacts_expire_at && artifacts_expire_at < Time.now
+ artifacts_expire_at && artifacts_expire_at < Time.current
end
def artifacts_expire_in
- artifacts_expire_at - Time.now if artifacts_expire_at
+ artifacts_expire_at - Time.current if artifacts_expire_at
end
def artifacts_expire_in=(value)
@@ -809,6 +816,7 @@ module Ci
def steps
[Gitlab::Ci::Build::Step.from_commands(self),
+ Gitlab::Ci::Build::Step.from_release(self),
Gitlab::Ci::Build::Step.from_after_script(self)].compact
end
@@ -872,6 +880,16 @@ module Ci
options&.dig(:artifacts, :reports)&.any?
end
+ def supports_artifacts_exclude?
+ options&.dig(:artifacts, :exclude)&.any? &&
+ Gitlab::Ci::Features.artifacts_exclude_enabled?
+ end
+
+ def release_steps?
+ options.dig(:release)&.any? &&
+ Gitlab::Ci::Features.release_generation_enabled?
+ end
+
def hide_secrets(trace)
return unless trace
@@ -945,11 +963,6 @@ module Ci
failure_reason: :data_integrity_failure)
end
- def supports_artifacts_exclude?
- options&.dig(:artifacts, :exclude)&.any? &&
- Gitlab::Ci::Features.artifacts_exclude_enabled?
- end
-
def degradation_threshold
var = yaml_variables.find { |v| v[:key] == DEGRADATION_THRESHOLD_VARIABLE_NAME }
var[:value]&.to_i if var
@@ -993,7 +1006,7 @@ module Ci
end
def update_erased!(user = nil)
- self.update(erased_by: user, erased_at: Time.now, artifacts_expire_at: nil)
+ self.update(erased_by: user, erased_at: Time.current, artifacts_expire_at: nil)
end
def unscoped_project
@@ -1026,7 +1039,7 @@ module Ci
end
def has_expiring_artifacts?
- artifacts_expire_at.present? && artifacts_expire_at > Time.now
+ artifacts_expire_at.present? && artifacts_expire_at > Time.current
end
def job_jwt_variables
diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb
index d3ff870e36a..2fcd1708cf4 100644
--- a/app/models/ci/build_dependencies.rb
+++ b/app/models/ci/build_dependencies.rb
@@ -45,7 +45,7 @@ module Ci
end
def valid_local?
- return true if Feature.enabled?('ci_disable_validates_dependencies')
+ return true if Feature.enabled?(:ci_disable_validates_dependencies)
local.all?(&:valid_dependency?)
end
diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb
new file mode 100644
index 00000000000..530233ad5c0
--- /dev/null
+++ b/app/models/ci/build_report_result.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Ci
+ class BuildReportResult < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ self.primary_key = :build_id
+
+ belongs_to :build, class_name: "Ci::Build", inverse_of: :report_results
+ belongs_to :project, class_name: "Project", inverse_of: :build_report_results
+
+ validates :build, :project, presence: true
+ validates :data, json_schema: { filename: "build_report_result_data" }
+
+ store_accessor :data, :tests
+
+ def tests_name
+ tests.dig("name")
+ end
+
+ def tests_duration
+ tests.dig("duration")
+ end
+
+ def tests_success
+ tests.dig("success").to_i
+ end
+
+ def tests_failed
+ tests.dig("failed").to_i
+ end
+
+ def tests_errored
+ tests.dig("errored").to_i
+ end
+
+ def tests_skipped
+ tests.dig("skipped").to_i
+ end
+
+ def tests_total
+ [tests_success, tests_failed, tests_errored, tests_skipped].sum
+ end
+ end
+end
diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb
index b46bbe69c7c..bc7f17f046c 100644
--- a/app/models/ci/build_runner_session.rb
+++ b/app/models/ci/build_runner_session.rb
@@ -7,6 +7,8 @@ module Ci
extend Gitlab::Ci::Model
TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'
+ DEFAULT_SERVICE_NAME = 'build'.freeze
+ DEFAULT_PORT_NAME = 'default_port'.freeze
self.table_name = 'ci_builds_runner_session'
@@ -23,6 +25,17 @@ module Ci
channel_specification(wss_url, TERMINAL_SUBPROTOCOL)
end
+ def service_specification(service: nil, path: nil, port: nil, subprotocols: nil)
+ return {} unless url.present?
+
+ port = port.presence || DEFAULT_PORT_NAME
+ service = service.presence || DEFAULT_SERVICE_NAME
+ url = "#{self.url}/proxy/#{service}/#{port}/#{path}"
+ subprotocols = subprotocols.presence || ::Ci::BuildRunnerSession::TERMINAL_SUBPROTOCOL
+
+ channel_specification(url, subprotocols)
+ end
+
private
def channel_specification(url, subprotocol)
@@ -37,5 +50,3 @@ module Ci
end
end
end
-
-Ci::BuildRunnerSession.prepend_if_ee('EE::Ci::BuildRunnerSession')
diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb
index 3506b27e974..d6617b8c2eb 100644
--- a/app/models/ci/daily_build_group_report_result.rb
+++ b/app/models/ci/daily_build_group_report_result.rb
@@ -9,6 +9,8 @@ module Ci
belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
belongs_to :project
+ validates :data, json_schema: { filename: "daily_build_group_report_result_data" }
+
def self.upsert_reports(data)
upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any?
end
diff --git a/app/models/ci/freeze_period.rb b/app/models/ci/freeze_period.rb
index bf03b92259a..d215372bb45 100644
--- a/app/models/ci/freeze_period.rb
+++ b/app/models/ci/freeze_period.rb
@@ -5,7 +5,7 @@ module Ci
include StripAttribute
self.table_name = 'ci_freeze_periods'
- default_scope { order(created_at: :asc) }
+ default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope
belongs_to :project, inverse_of: :freeze_periods
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
index 4b2081f2977..779c6c0396f 100644
--- a/app/models/ci/group.rb
+++ b/app/models/ci/group.rb
@@ -24,7 +24,7 @@ module Ci
def status
strong_memoize(:status) do
- if Feature.enabled?(:ci_composite_status, project, default_enabled: false)
+ if ::Gitlab::Ci::Features.composite_status?(project)
Gitlab::Ci::Status::Composite
.new(@jobs)
.status
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
index c674f76d229..8245729a884 100644
--- a/app/models/ci/instance_variable.rb
+++ b/app/models/ci/instance_variable.rb
@@ -3,8 +3,13 @@
module Ci
class InstanceVariable < ApplicationRecord
extend Gitlab::Ci::Model
+ extend Gitlab::ProcessMemoryCache::Helper
include Ci::NewHasVariable
include Ci::Maskable
+ include Limitable
+
+ self.limit_name = 'ci_instance_level_variables'
+ self.limit_scope = Limitable::GLOBAL_SCOPE
alias_attribute :secret_value, :value
@@ -12,8 +17,14 @@ module Ci
message: "(%{value}) has already been taken"
}
+ validates :encrypted_value, length: {
+ maximum: 1024,
+ too_long: 'The encrypted value of the provided variable exceeds %{count} bytes. Variables over 700 characters risk exceeding the limit.'
+ }
+
scope :unprotected, -> { where(protected: false) }
- after_commit { self.class.touch_redis_cache_timestamp }
+
+ after_commit { self.class.invalidate_memory_cache(:ci_instance_variable_data) }
class << self
def all_cached
@@ -24,10 +35,6 @@ module Ci
cached_data[:unprotected]
end
- def touch_redis_cache_timestamp(time = Time.current.to_f)
- shared_backend.write(:ci_instance_variable_changed_at, time)
- end
-
private
def cached_data
@@ -37,39 +44,13 @@ module Ci
{ all: all_records, unprotected: all_records.reject(&:protected?) }
end
end
+ end
- def fetch_memory_cache(key, &payload)
- cache = process_backend.read(key)
-
- if cache && !stale_cache?(cache)
- cache[:data]
- else
- store_cache(key, &payload)
- end
- end
-
- def stale_cache?(cache_info)
- shared_timestamp = shared_backend.read(:ci_instance_variable_changed_at)
- return true unless shared_timestamp
-
- shared_timestamp.to_f > cache_info[:cached_at].to_f
- end
-
- def store_cache(key)
- data = yield
- time = Time.current.to_f
-
- process_backend.write(key, data: data, cached_at: time)
- touch_redis_cache_timestamp(time)
- data
- end
-
- def shared_backend
- Rails.cache
- end
+ private
- def process_backend
- Gitlab::ProcessMemoryCache.cache_backend
+ def validate_plan_limit_not_exceeded
+ if Gitlab::Ci::Features.instance_level_variables_limit_enabled?
+ super
end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index d931428dccd..8aba9356949 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -5,6 +5,7 @@ module Ci
include AfterCommitQueue
include ObjectStorage::BackgroundMove
include UpdateProjectStatistics
+ include UsageStatistics
include Sortable
extend Gitlab::Ci::Model
@@ -26,6 +27,7 @@ module Ci
accessibility: 'gl-accessibility.json',
codequality: 'gl-code-quality-report.json',
sast: 'gl-sast-report.json',
+ secret_detection: 'gl-secret-detection-report.json',
dependency_scanning: 'gl-dependency-scanning-report.json',
container_scanning: 'gl-container-scanning-report.json',
dast: 'gl-dast-report.json',
@@ -37,7 +39,8 @@ module Ci
dotenv: '.env',
cobertura: 'cobertura-coverage.xml',
terraform: 'tfplan.json',
- cluster_applications: 'gl-cluster-applications.json'
+ cluster_applications: 'gl-cluster-applications.json',
+ requirements: 'requirements.json'
}.freeze
INTERNAL_TYPES = {
@@ -62,13 +65,15 @@ module Ci
accessibility: :raw,
codequality: :raw,
sast: :raw,
+ secret_detection: :raw,
dependency_scanning: :raw,
container_scanning: :raw,
dast: :raw,
license_management: :raw,
license_scanning: :raw,
performance: :raw,
- terraform: :raw
+ terraform: :raw,
+ requirements: :raw
}.freeze
DOWNLOADABLE_TYPES = %w[
@@ -87,6 +92,8 @@ module Ci
metrics
performance
sast
+ secret_detection
+ requirements
].freeze
TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
@@ -109,6 +116,7 @@ module Ci
after_save :update_file_store, if: :saved_change_to_file?
+ scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) }
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
@@ -147,7 +155,8 @@ module Ci
where(file_type: types)
end
- scope :expired, -> (limit) { where('expire_at < ?', Time.now).limit(limit) }
+ scope :expired, -> (limit) { where('expire_at < ?', Time.current).limit(limit) }
+ scope :downloadable, -> { where(file_type: DOWNLOADABLE_TYPES) }
scope :locked, -> { where(locked: true) }
scope :unlocked, -> { where(locked: [false, nil]) }
@@ -176,7 +185,9 @@ module Ci
cobertura: 17,
terraform: 18, # Transformed json
accessibility: 19,
- cluster_applications: 20
+ cluster_applications: 20,
+ secret_detection: 21, ## EE-specific
+ requirements: 22 ## EE-specific
}
enum file_format: {
@@ -242,8 +253,16 @@ module Ci
super || self.file_location.nil?
end
+ def expired?
+ expire_at.present? && expire_at < Time.current
+ end
+
+ def expiring?
+ expire_at.present? && expire_at > Time.current
+ end
+
def expire_in
- expire_at - Time.now if expire_at
+ expire_at - Time.current if expire_at
end
def expire_in=(value)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 5db1635f64d..497e1a4d74a 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -31,6 +31,7 @@ module Ci
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
belongs_to :merge_request, class_name: 'MergeRequest'
belongs_to :external_pull_request
+ belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines
has_internal_id :iid, scope: :project, presence: false, track_if: -> { !importing? }, ensure_if: -> { !importing? }, init: ->(s) do
s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count
@@ -40,11 +41,15 @@ module Ci
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :job_artifacts, through: :builds
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
has_many :deployments, through: :builds
has_many :environments, -> { distinct }, through: :deployments
+ has_many :latest_builds, -> { latest }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build'
+ has_many :downloadable_artifacts, -> { not_expired.downloadable }, through: :latest_builds, source: :job_artifacts
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
@@ -56,7 +61,6 @@ module Ci
has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus'
has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :scheduled_actions, -> { latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
- has_many :artifacts, -> { latest.with_artifacts_not_expired.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
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'
@@ -64,13 +68,6 @@ module Ci
has_one :source_pipeline, class_name: 'Ci::Sources::Pipeline', inverse_of: :pipeline
- has_one :ref_status, ->(pipeline) {
- # We use .read_attribute to save 1 extra unneeded query to load the :project.
- unscope(:where)
- .where(project_id: pipeline.read_attribute(:project_id), ref: pipeline.ref, tag: pipeline.tag)
- # Sadly :inverse_of is not supported (yet) by Rails for composite PKs.
- }, class_name: 'Ci::Ref', inverse_of: :pipelines
-
has_one :chat_data, class_name: 'Ci::PipelineChatData'
has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline
@@ -163,11 +160,11 @@ module Ci
# Create a separate worker for each new operation
before_transition [:created, :waiting_for_resource, :preparing, :pending] => :running do |pipeline|
- pipeline.started_at = Time.now
+ pipeline.started_at = Time.current
end
before_transition any => [:success, :failed, :canceled] do |pipeline|
- pipeline.finished_at = Time.now
+ pipeline.finished_at = Time.current
pipeline.update_duration
end
@@ -235,12 +232,10 @@ module Ci
end
after_transition any => [:success, :failed] do |pipeline|
+ ref_status = pipeline.ci_ref&.update_status_by!(pipeline)
+
pipeline.run_after_commit do
- if Feature.enabled?(:ci_pipeline_fixed_notifications)
- PipelineUpdateCiRefStatusWorker.perform_async(pipeline.id)
- else
- PipelineNotificationWorker.perform_async(pipeline.id)
- end
+ PipelineNotificationWorker.perform_async(pipeline.id, ref_status: ref_status)
end
end
@@ -260,6 +255,7 @@ module Ci
scope :for_sha_or_source_sha, -> (sha) { for_sha(sha).or(for_source_sha(sha)) }
scope :for_ref, -> (ref) { where(ref: ref) }
scope :for_id, -> (id) { where(id: id) }
+ scope :for_iid, -> (iid) { where(iid: iid) }
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
scope :with_reports, -> (reports_scope) do
@@ -397,11 +393,11 @@ module Ci
end
def ordered_stages
- if Feature.enabled?(:ci_atomic_processing, project, default_enabled: false)
+ if ::Gitlab::Ci::Features.atomic_processing?(project)
# The `Ci::Stage` contains all up-to date data
# as atomic processing updates all data in-bulk
stages
- elsif Feature.enabled?(:ci_pipeline_persisted_stages, default_enabled: true) && complete?
+ elsif complete?
# The `Ci::Stage` contains up-to date data only for `completed` pipelines
# this is due to asynchronous processing of pipeline, and stages possibly
# not updated inline with processing of pipeline
@@ -445,7 +441,7 @@ module Ci
end
def legacy_stages
- if Feature.enabled?(:ci_composite_status, project, default_enabled: false)
+ if ::Gitlab::Ci::Features.composite_status?(project)
legacy_stages_using_composite_status
else
legacy_stages_using_sql
@@ -798,13 +794,17 @@ module Ci
@latest_builds_with_artifacts ||= builds.latest.with_artifacts_not_expired.to_a
end
+ def latest_report_builds(reports_scope = ::Ci::JobArtifact.with_reports)
+ builds.latest.with_reports(reports_scope)
+ end
+
def has_reports?(reports_scope)
- complete? && builds.latest.with_reports(reports_scope).exists?
+ complete? && latest_report_builds(reports_scope).exists?
end
def test_reports
Gitlab::Ci::Reports::TestReports.new.tap do |test_reports|
- builds.latest.with_reports(Ci::JobArtifact.test_reports).preload(:project).find_each do |build|
+ latest_report_builds(Ci::JobArtifact.test_reports).preload(:project).find_each do |build|
build.collect_test_reports!(test_reports)
end
end
@@ -826,7 +826,7 @@ module Ci
def coverage_reports
Gitlab::Ci::Reports::CoverageReports.new.tap do |coverage_reports|
- builds.latest.with_reports(Ci::JobArtifact.coverage_reports).each do |build|
+ latest_report_builds(Ci::JobArtifact.coverage_reports).each do |build|
build.collect_coverage_reports!(coverage_reports)
end
end
@@ -834,7 +834,7 @@ module Ci
def terraform_reports
::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports|
- builds.latest.with_reports(::Ci::JobArtifact.terraform_reports).each do |build|
+ latest_report_builds(::Ci::JobArtifact.terraform_reports).each do |build|
build.collect_terraform_reports!(terraform_reports)
end
end
@@ -969,6 +969,12 @@ module Ci
processables.populate_scheduling_type!
end
+ def ensure_ci_ref!
+ return unless Gitlab::Ci::Features.pipeline_fixed_notifications?
+
+ self.ci_ref = Ci::Ref.ensure_for(self)
+ end
+
private
def pipeline_data
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 7e203cb67c4..2ccd8445aa8 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -27,9 +27,11 @@ module Ci
# https://gitlab.com/gitlab-org/gitlab/issues/195991
pipeline: 7,
chat: 8,
+ webide: 9,
merge_request_event: 10,
external_pull_request_event: 11,
- parent_pipeline: 12
+ parent_pipeline: 12,
+ ondemand_scan: 13
}
end
@@ -40,6 +42,7 @@ module Ci
unknown_source: nil,
repository_source: 1,
auto_devops_source: 2,
+ webide_source: 3,
remote_source: 4,
external_project_source: 5,
bridge_source: 6
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index cc00500662d..ac5785d9c91 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -4,12 +4,8 @@ module Ci
class Processable < ::CommitStatus
include Gitlab::Utils::StrongMemoize
- has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
-
accepts_nested_attributes_for :needs
- enum scheduling_type: { stage: 0, dag: 1 }, _prefix: true
-
scope :preload_needs, -> { preload(:needs) }
scope :with_needs, -> (names = nil) do
diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb
index a0782bc0444..be6062b6e6e 100644
--- a/app/models/ci/ref.rb
+++ b/app/models/ci/ref.rb
@@ -3,21 +3,62 @@
module Ci
class Ref < ApplicationRecord
extend Gitlab::Ci::Model
+ include Gitlab::OptimisticLocking
- STATUSES = %w[success failed fixed].freeze
-
- belongs_to :project
- belongs_to :last_updated_by_pipeline, foreign_key: :last_updated_by_pipeline_id, class_name: 'Ci::Pipeline'
- # ActiveRecord doesn't support composite FKs for this reason we have to do the 'unscope(:where)'
- # hack.
- has_many :pipelines, ->(ref) {
- # We use .read_attribute to save 1 extra unneeded query to load the :project.
- unscope(:where)
- .where(ref: ref.ref, project_id: ref.read_attribute(:project_id), tag: ref.tag)
- # Sadly :inverse_of is not supported (yet) by Rails for composite PKs.
- }, inverse_of: :ref_status
-
- validates :status, inclusion: { in: STATUSES }
- validates :last_updated_by_pipeline, presence: true
+ FAILING_STATUSES = %w[failed broken still_failing].freeze
+
+ belongs_to :project, inverse_of: :ci_refs
+ has_many :pipelines, class_name: 'Ci::Pipeline', foreign_key: :ci_ref_id, inverse_of: :ci_ref
+
+ state_machine :status, initial: :unknown do
+ event :succeed do
+ transition unknown: :success
+ transition fixed: :success
+ transition %i[failed broken still_failing] => :fixed
+ end
+
+ event :do_fail do
+ transition unknown: :failed
+ transition %i[failed broken] => :still_failing
+ transition %i[success fixed] => :broken
+ end
+
+ state :unknown, value: 0
+ state :success, value: 1
+ state :failed, value: 2
+ state :fixed, value: 3
+ state :broken, value: 4
+ state :still_failing, value: 5
+ end
+
+ class << self
+ def ensure_for(pipeline)
+ safe_find_or_create_by(project_id: pipeline.project_id,
+ ref_path: pipeline.source_ref_path)
+ end
+
+ def failing_state?(status_name)
+ FAILING_STATUSES.include?(status_name)
+ end
+ end
+
+ def last_finished_pipeline_id
+ Ci::Pipeline.where(ci_ref_id: self.id).finished.order(id: :desc).select(:id).take&.id
+ end
+
+ def update_status_by!(pipeline)
+ return unless Gitlab::Ci::Features.pipeline_fixed_notifications?
+
+ retry_lock(self) do
+ next unless last_finished_pipeline_id == pipeline.id
+
+ case pipeline.status
+ when 'success' then self.succeed
+ when 'failed' then self.do_fail
+ end
+
+ self.status_name
+ end
+ end
end
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index d4e9217ff9f..8fc273556f0 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -23,10 +23,17 @@ module Ci
project_type: 3
}
- ONLINE_CONTACT_TIMEOUT = 1.hour
+ # This `ONLINE_CONTACT_TIMEOUT` needs to be larger than
+ # `RUNNER_QUEUE_EXPIRY_TIME+UPDATE_CONTACT_COLUMN_EVERY`
+ #
+ ONLINE_CONTACT_TIMEOUT = 2.hours
+
+ # The `RUNNER_QUEUE_EXPIRY_TIME` indicates the longest interval that
+ # Runner request needs to be refreshed by Rails instead of being handled
+ # by Workhorse
RUNNER_QUEUE_EXPIRY_TIME = 1.hour
- # This needs to be less than `ONLINE_CONTACT_TIMEOUT`
+ # The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner DB entry can be updated
UPDATE_CONTACT_COLUMN_EVERY = (40.minutes..55.minutes).freeze
AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze
@@ -81,6 +88,17 @@ module Ci
joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: groups })
}
+ scope :belonging_to_group_or_project, -> (group_id, project_id) {
+ groups = ::Group.where(id: group_id)
+
+ group_runners = joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: groups })
+ project_runners = joins(:runner_projects).where(ci_runner_projects: { project_id: project_id })
+
+ union_sql = ::Gitlab::SQL::Union.new([group_runners, project_runners]).to_sql
+
+ from("(#{union_sql}) #{table_name}")
+ }
+
scope :belonging_to_parent_group_of_project, -> (project_id) {
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
hierarchy_groups = Gitlab::ObjectHierarchy.new(project_groups).base_and_ancestors
@@ -145,14 +163,14 @@ module Ci
# Searches for runners matching the given query.
#
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
#
# This method performs a *partial* match on tokens, thus a query for "a"
# will match any runner where the token contains the letter "a". As a result
# you should *not* use this method for non-admin purposes as otherwise users
# might be able to query a list of all runners.
#
- # query - The search query as a String
+ # query - The search query as a String.
#
# Returns an ActiveRecord::Relation.
def self.search(query)
@@ -271,9 +289,9 @@ module Ci
ensure_runner_queue_value == value if value.present?
end
- def update_cached_info(values)
+ def heartbeat(values)
values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {}
- values[:contacted_at] = Time.now
+ values[:contacted_at] = Time.current
cache_attributes(values)
@@ -309,7 +327,7 @@ module Ci
real_contacted_at = read_attribute(:contacted_at)
real_contacted_at.nil? ||
- (Time.now - real_contacted_at) >= contacted_at_max_age
+ (Time.current - real_contacted_at) >= contacted_at_max_age
end
def tag_constraints
diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb
index 1efa44c39c5..53c90fa56d5 100644
--- a/app/models/clusters/applications/cert_manager.rb
+++ b/app/models/clusters/applications/cert_manager.rb
@@ -38,7 +38,8 @@ module Clusters
chart: chart,
files: files.merge(cluster_issuer_file),
preinstall: pre_install_script,
- postinstall: post_install_script
+ postinstall: post_install_script,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
@@ -47,7 +48,8 @@ module Clusters
name: 'certmanager',
rbac: cluster.platform_kubernetes_rbac?,
files: files,
- postdelete: post_delete_script
+ postdelete: post_delete_script,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/applications/crossplane.rb b/app/models/clusters/applications/crossplane.rb
index 420e56c1742..2e5a8210b3c 100644
--- a/app/models/clusters/applications/crossplane.rb
+++ b/app/models/clusters/applications/crossplane.rb
@@ -35,7 +35,8 @@ module Clusters
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
- files: files
+ files: files,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
index 0d029aabc3b..58ac0c1f188 100644
--- a/app/models/clusters/applications/elastic_stack.rb
+++ b/app/models/clusters/applications/elastic_stack.rb
@@ -34,7 +34,8 @@ module Clusters
repository: repository,
files: files,
preinstall: migrate_to_3_script,
- postinstall: post_install_script
+ postinstall: post_install_script,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
@@ -43,7 +44,8 @@ module Clusters
name: 'elastic-stack',
rbac: cluster.platform_kubernetes_rbac?,
files: files,
- postdelete: post_delete_script
+ postdelete: post_delete_script,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
@@ -51,7 +53,7 @@ module Clusters
super.merge('wait-for-elasticsearch.sh': File.read("#{Rails.root}/vendor/elastic_stack/wait-for-elasticsearch.sh"))
end
- def elasticsearch_client
+ def elasticsearch_client(timeout: nil)
strong_memoize(:elasticsearch_client) do
next unless kube_client
@@ -63,6 +65,7 @@ module Clusters
# ensure TLS certs are properly verified
faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl]
faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store]
+ faraday.options.timeout = timeout unless timeout.nil?
end
rescue Kubeclient::HttpError => error
@@ -118,7 +121,8 @@ module Clusters
Gitlab::Kubernetes::Helm::DeleteCommand.new(
name: 'elastic-stack',
rbac: cluster.platform_kubernetes_rbac?,
- files: files
+ files: files,
+ local_tiller_enabled: cluster.local_tiller_enabled?
).delete_command,
Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack", "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE)
]
diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb
index 3fd6e870edc..1bcd39618f6 100644
--- a/app/models/clusters/applications/fluentd.rb
+++ b/app/models/clusters/applications/fluentd.rb
@@ -32,7 +32,8 @@ module Clusters
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
- files: files
+ files: files,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index 4a1bcac4bb7..226a9c26db0 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -52,7 +52,8 @@ module Clusters
Gitlab::Kubernetes::Helm::InitCommand.new(
name: name,
files: files,
- rbac: cluster.platform_kubernetes_rbac?
+ rbac: cluster.platform_kubernetes_rbac?,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
@@ -60,7 +61,8 @@ module Clusters
Gitlab::Kubernetes::Helm::ResetCommand.new(
name: name,
files: files,
- rbac: cluster.platform_kubernetes_rbac?
+ rbac: cluster.platform_kubernetes_rbac?,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index dd354198910..a44450ec7a9 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -63,7 +63,8 @@ module Clusters
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
- files: files
+ files: files,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 056ea355de6..b737f0f962f 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -45,7 +45,8 @@ module Clusters
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
files: files,
- repository: repository
+ repository: repository,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 3047da12dd9..b55fc3c45fc 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -77,7 +77,8 @@ module Clusters
chart: chart,
files: files,
repository: REPOSITORY,
- postinstall: install_knative_metrics
+ postinstall: install_knative_metrics,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
@@ -99,7 +100,8 @@ module Clusters
rbac: cluster.platform_kubernetes_rbac?,
files: files,
predelete: delete_knative_services_and_metrics,
- postdelete: delete_knative_istio_leftovers
+ postdelete: delete_knative_istio_leftovers,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 3183318690c..24bb1df6d22 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -37,7 +37,7 @@ module Clusters
end
after_transition any => :updating do |application|
- application.update(last_update_started_at: Time.now)
+ application.update(last_update_started_at: Time.current)
end
end
@@ -66,7 +66,8 @@ module Clusters
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
files: files,
- postinstall: install_knative_metrics
+ postinstall: install_knative_metrics,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
@@ -76,7 +77,8 @@ module Clusters
version: version,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
- files: files_with_replaced_values(values)
+ files: files_with_replaced_values(values),
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
@@ -85,7 +87,8 @@ module Clusters
name: name,
rbac: cluster.platform_kubernetes_rbac?,
files: files,
- predelete: delete_knative_istio_metrics
+ predelete: delete_knative_istio_metrics,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index a861126908f..6d3b6c4ed8f 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.16.1'
+ VERSION = '0.17.1'
self.table_name = 'clusters_applications_runners'
@@ -36,7 +36,8 @@ module Clusters
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
files: files,
- repository: repository
+ repository: repository,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 83f558af1a1..bde7a2104ba 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -36,6 +36,8 @@ module Clusters
has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project'
has_many :deployment_clusters
has_many :deployments, inverse_of: :cluster
+ has_many :successful_deployments, -> { success }, class_name: 'Deployment'
+ has_many :environments, -> { distinct }, through: :deployments
has_many :cluster_groups, class_name: 'Clusters::Group'
has_many :groups, through: :cluster_groups, class_name: '::Group'
@@ -125,12 +127,23 @@ module Clusters
scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) }
scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) }
+ scope :with_enabled_modsecurity, -> { joins(:application_ingress).merge(::Clusters::Applications::Ingress.modsecurity_enabled) }
+ scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) }
+ scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct }
+ scope :preload_elasticstack, -> { preload(:application_elastic_stack) }
+ scope :preload_environments, -> { preload(:environments) }
+
scope :managed, -> { where(managed: true) }
scope :with_persisted_applications, -> { eager_load(*APPLICATIONS_ASSOCIATIONS) }
scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
scope :with_management_project, -> { where.not(management_project: nil) }
scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) }
+ scope :with_application_prometheus, -> { includes(:application_prometheus).joins(:application_prometheus) }
+ scope :with_project_alert_service_data, -> (project_ids) do
+ conditions = { projects: { alerts_service: [:data] } }
+ includes(conditions).joins(conditions).where(projects: { id: project_ids })
+ end
def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc)
return [] if clusterable.is_a?(Instance)
@@ -321,6 +334,10 @@ module Clusters
end
end
+ def local_tiller_enabled?
+ Feature.enabled?(:managed_apps_local_tiller, clusterable, default_enabled: false)
+ end
+
private
def unique_management_project_environment_scope
@@ -368,7 +385,10 @@ module Clusters
def retrieve_nodes
result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.get_nodes }
- cluster_nodes = result[:response].to_a
+
+ return unless result[:response]
+
+ cluster_nodes = result[:response]
result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.metrics_client.get_nodes }
nodes_metrics = result[:response].to_a
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
index 297d00aa281..c1f63758906 100644
--- a/app/models/clusters/concerns/application_core.rb
+++ b/app/models/clusters/concerns/application_core.rb
@@ -15,7 +15,7 @@ module Clusters
def set_initial_status
return unless not_installable?
- self.status = status_states[:installable] if cluster&.application_helm_available? || ::Gitlab::Kubernetes::Helm.local_tiller_enabled?
+ self.status = status_states[:installable] if cluster&.application_helm_available? || cluster&.local_tiller_enabled?
end
def can_uninstall?
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
index 77c606553d2..ade27e69642 100644
--- a/app/models/clusters/concerns/application_data.rb
+++ b/app/models/clusters/concerns/application_data.rb
@@ -7,7 +7,8 @@ module Clusters
Gitlab::Kubernetes::Helm::DeleteCommand.new(
name: name,
rbac: cluster.platform_kubernetes_rbac?,
- files: files
+ files: files,
+ local_tiller_enabled: cluster.local_tiller_enabled?
)
end
@@ -32,7 +33,7 @@ module Clusters
private
def use_tiller_ssl?
- return false if ::Gitlab::Kubernetes::Helm.local_tiller_enabled?
+ return false if cluster.local_tiller_enabled?
cluster.application_helm.has_ssl?
end
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index 0b915126f8a..86d74ed7b1c 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -97,13 +97,21 @@ module Clusters
application.status_reason = status_reason if status_reason
end
- before_transition any => [:installed, :updated] do |application, _|
- # When installing any application we are also performing an update
- # of tiller (see Gitlab::Kubernetes::Helm::ClientCommand) so
- # therefore we need to reflect that in the database.
-
- unless ::Gitlab::Kubernetes::Helm.local_tiller_enabled?
- application.cluster.application_helm.update!(version: Gitlab::Kubernetes::Helm::HELM_VERSION)
+ before_transition any => [:installed, :updated] do |application, transition|
+ unless application.cluster.local_tiller_enabled? || application.is_a?(Clusters::Applications::Helm)
+ if transition.event == :make_externally_installed
+ # If an application is externally installed
+ # We assume the helm application is externally installed too
+ helm = application.cluster.application_helm || application.cluster.build_application_helm
+
+ helm.make_externally_installed!
+ else
+ # When installing any application we are also performing an update
+ # of tiller (see Gitlab::Kubernetes::Helm::ClientCommand) so
+ # therefore we need to reflect that in the database.
+
+ application.cluster.application_helm.update!(version: Gitlab::Kubernetes::Helm::HELM_VERSION)
+ end
end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 7e99f128dad..475f82f23ca 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -14,6 +14,10 @@ class CommitStatus < ApplicationRecord
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
+ has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
+
+ enum scheduling_type: { stage: 0, dag: 1 }, _prefix: true
+
delegate :commit, to: :pipeline
delegate :sha, :short_sha, :before_sha, to: :pipeline
@@ -90,7 +94,12 @@ class CommitStatus < ApplicationRecord
end
before_save if: :status_changed?, unless: :importing? do
- if Feature.disabled?(:ci_atomic_processing, project)
+ # we mark `processed` as always changed:
+ # another process might change its value and our object
+ # will not be refreshed to pick the change
+ self.processed_will_change!
+
+ if !::Gitlab::Ci::Features.atomic_processing?(project)
self.processed = nil
elsif latest?
self.processed = false # force refresh of all dependent ones
@@ -132,15 +141,15 @@ class CommitStatus < ApplicationRecord
end
before_transition [:created, :waiting_for_resource, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status|
- commit_status.queued_at = Time.now
+ commit_status.queued_at = Time.current
end
before_transition [:created, :preparing, :pending] => :running do |commit_status|
- commit_status.started_at = Time.now
+ commit_status.started_at = Time.current
end
before_transition any => [:success, :failed, :canceled] do |commit_status|
- commit_status.finished_at = Time.now
+ commit_status.finished_at = Time.current
end
before_transition any => :failed do |commit_status, transition|
@@ -185,8 +194,10 @@ class CommitStatus < ApplicationRecord
end
def self.update_as_processed!
- # Marks items as processed, and increases `lock_version` (Optimisitc Locking)
- update_all('processed=TRUE, lock_version=COALESCE(lock_version,0)+1')
+ # Marks items as processed
+ # we do not increase `lock_version`, as we are the one
+ # holding given lock_version (Optimisitc Locking)
+ update_all(processed: true)
end
def self.locking_enabled?
@@ -276,7 +287,7 @@ class CommitStatus < ApplicationRecord
end
def schedule_stage_and_pipeline_update
- if Feature.enabled?(:ci_atomic_processing, project)
+ if ::Gitlab::Ci::Features.atomic_processing?(project)
# Atomic Processing requires only single Worker
PipelineProcessWorker.perform_async(pipeline_id, [id])
else
diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb
index d459af23a2f..de176ffde5c 100644
--- a/app/models/concerns/cacheable_attributes.rb
+++ b/app/models/concerns/cacheable_attributes.rb
@@ -55,7 +55,7 @@ module CacheableAttributes
current_without_cache.tap { |current_record| current_record&.cache! }
rescue => e
if Rails.env.production?
- Rails.logger.warn("Cached record for #{name} couldn't be loaded, falling back to uncached record: #{e}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.warn("Cached record for #{name} couldn't be loaded, falling back to uncached record: #{e}")
else
raise e
end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index ccd90ea5900..7ea5382a4fa 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -18,7 +18,7 @@ module Ci
variables.concat(deployment_variables(environment: environment))
variables.concat(yaml_variables)
variables.concat(user_variables)
- variables.concat(dependency_variables) if Feature.enabled?(:ci_dependency_variables, project)
+ variables.concat(dependency_variables)
variables.concat(secret_instance_variables)
variables.concat(secret_group_variables)
variables.concat(secret_project_variables(environment: environment))
diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb
index 6314b46a7e3..af5f4e30d06 100644
--- a/app/models/concerns/each_batch.rb
+++ b/app/models/concerns/each_batch.rb
@@ -17,7 +17,7 @@ module EachBatch
# Example:
#
# User.each_batch do |relation|
- # relation.update_all(updated_at: Time.now)
+ # relation.update_all(updated_at: Time.current)
# end
#
# The supplied block is also passed an optional batch index:
diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb
new file mode 100644
index 00000000000..60aa46ce04c
--- /dev/null
+++ b/app/models/concerns/featurable.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+# == Featurable concern
+#
+# This concern adds features (tools) functionality to Project and Group
+# To enable features you need to call `set_available_features`
+#
+# Example:
+#
+# class ProjectFeature
+# include Featurable
+# set_available_features %i(wiki merge_request)
+
+module Featurable
+ extend ActiveSupport::Concern
+
+ # Can be enabled only for members, everyone or disabled
+ # Access control is made only for non private containers.
+ #
+ # Permission levels:
+ #
+ # Disabled: not enabled for anyone
+ # Private: enabled only for team members
+ # Enabled: enabled for everyone able to access the project
+ # Public: enabled for everyone (only allowed for pages)
+ DISABLED = 0
+ PRIVATE = 10
+ ENABLED = 20
+ PUBLIC = 30
+
+ STRING_OPTIONS = HashWithIndifferentAccess.new({
+ 'disabled' => DISABLED,
+ 'private' => PRIVATE,
+ 'enabled' => ENABLED,
+ 'public' => PUBLIC
+ }).freeze
+
+ class_methods do
+ def set_available_features(available_features = [])
+ @available_features = available_features
+
+ class_eval do
+ available_features.each do |feature|
+ define_method("#{feature}_enabled?") do
+ public_send("#{feature}_access_level") > DISABLED # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+ end
+
+ def available_features
+ @available_features
+ end
+
+ def access_level_attribute(feature)
+ feature = ensure_feature!(feature)
+
+ "#{feature}_access_level".to_sym
+ end
+
+ def quoted_access_level_column(feature)
+ attribute = connection.quote_column_name(access_level_attribute(feature))
+ table = connection.quote_table_name(table_name)
+
+ "#{table}.#{attribute}"
+ end
+
+ def access_level_from_str(level)
+ STRING_OPTIONS.fetch(level)
+ end
+
+ def str_from_access_level(level)
+ STRING_OPTIONS.key(level)
+ end
+
+ def ensure_feature!(feature)
+ feature = feature.model_name.plural if feature.respond_to?(:model_name)
+ feature = feature.to_sym
+ raise ArgumentError, "invalid feature: #{feature}" unless available_features.include?(feature)
+
+ feature
+ end
+ end
+
+ def access_level(feature)
+ public_send(self.class.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def feature_available?(feature, user)
+ # 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, feature)
+ end
+
+ def string_access_level(feature)
+ self.class.str_from_access_level(access_level(feature))
+ end
+end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index b80f8c2bbb2..c885dea862f 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -66,7 +66,7 @@ module HasStatus
# 1. By plucking all related objects,
# 2. Or executes expensive SQL query
def slow_composite_status(project:)
- if Feature.enabled?(:ci_composite_status, project, default_enabled: false)
+ if ::Gitlab::Ci::Features.composite_status?(project)
Gitlab::Ci::Status::Composite
.new(all, with_allow_failure: columns_hash.key?('allow_failure'))
.status
@@ -160,7 +160,7 @@ module HasStatus
if started_at && finished_at
finished_at - started_at
elsif started_at
- Time.now - started_at
+ Time.current - started_at
end
end
end
diff --git a/app/models/concerns/import_state/sidekiq_job_tracker.rb b/app/models/concerns/import_state/sidekiq_job_tracker.rb
index 55f171d158d..b7d0ed0f51b 100644
--- a/app/models/concerns/import_state/sidekiq_job_tracker.rb
+++ b/app/models/concerns/import_state/sidekiq_job_tracker.rb
@@ -5,14 +5,17 @@ module ImportState
extend ActiveSupport::Concern
included do
+ scope :with_jid, -> { where.not(jid: nil) }
+ scope :without_jid, -> { where(jid: nil) }
+
# Refreshes the expiration time of the associated import job ID.
#
# This method can be used by asynchronous importers to refresh the status,
- # preventing the StuckImportJobsWorker from marking the import as failed.
+ # preventing the Gitlab::Import::StuckProjectImportJobsWorker from marking the import as failed.
def refresh_jid_expiration
return unless jid
- Gitlab::SidekiqStatus.set(jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION)
+ Gitlab::SidekiqStatus.set(jid, Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION)
end
def self.jid_by(project_id:, status:)
diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb
new file mode 100644
index 00000000000..644a0ba1b5e
--- /dev/null
+++ b/app/models/concerns/integration.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Integration
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def with_custom_integration_for(integration, page = nil, per = nil)
+ custom_integration_project_ids = Service
+ .where(type: integration.type)
+ .where(inherit_from_id: nil)
+ .distinct # Required until https://gitlab.com/gitlab-org/gitlab/-/issues/207385
+ .page(page)
+ .per(per)
+ .pluck(:project_id)
+
+ Project.where(id: custom_integration_project_ids)
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index a1b14dca4ac..220af8ab7c7 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -39,15 +39,6 @@ module Issuable
locked: 4
}.with_indifferent_access.freeze
- # This object is used to gather issuable meta data for displaying
- # upvotes, downvotes, notes and closing merge requests count for issues and merge requests
- # lists avoiding n+1 queries and improving performance.
- IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :mrs_count) do
- def merge_requests_count(user = nil)
- mrs_count
- end
- end
-
included do
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description, issuable_state_filter_enabled: true
@@ -139,7 +130,6 @@ module Issuable
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) }
- scope :any_label, -> { joins(:label_links).distinct }
scope :join_project, -> { joins(:project) }
scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
scope :references_project, -> { references(:project) }
@@ -185,6 +175,10 @@ module Issuable
assignees.count > 1
end
+ def supports_weight?
+ false
+ end
+
private
def description_max_length_for_new_records_is_valid
@@ -201,7 +195,7 @@ module Issuable
class_methods do
# Searches for records with a matching title.
#
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
#
# query - The search query as a String
#
@@ -225,7 +219,7 @@ module Issuable
# Searches for records with a matching title or description.
#
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
#
# query - The search query as a String
# matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
@@ -316,6 +310,14 @@ module Issuable
end
end
+ def any_label(sort = nil)
+ if sort
+ joins(:label_links).group(*grouping_columns(sort))
+ else
+ joins(:label_links).distinct
+ end
+ end
+
# Includes table keys in group by clause when sorting
# preventing errors in postgres
#
@@ -401,6 +403,10 @@ module Issuable
participants(user).include?(user)
end
+ def can_assign_epic?(user)
+ false
+ end
+
def to_hook_data(user, old_associations: {})
changes = previous_changes
diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb
index f320f54bb82..3cb0bd85936 100644
--- a/app/models/concerns/limitable.rb
+++ b/app/models/concerns/limitable.rb
@@ -2,6 +2,7 @@
module Limitable
extend ActiveSupport::Concern
+ GLOBAL_SCOPE = :limitable_global_scope
included do
class_attribute :limit_scope
@@ -14,14 +15,34 @@ module Limitable
private
def validate_plan_limit_not_exceeded
+ if GLOBAL_SCOPE == limit_scope
+ validate_global_plan_limit_not_exceeded
+ else
+ validate_scoped_plan_limit_not_exceeded
+ end
+ end
+
+ def validate_scoped_plan_limit_not_exceeded
scope_relation = self.public_send(limit_scope) # rubocop:disable GitlabSecurity/PublicSend
return unless scope_relation
relation = self.class.where(limit_scope => scope_relation)
+ limits = scope_relation.actual_limits
- if scope_relation.actual_limits.exceeded?(limit_name, relation)
- errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") %
- { name: limit_name.humanize(capitalize: false), count: scope_relation.actual_limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend
- end
+ check_plan_limit_not_exceeded(limits, relation)
+ end
+
+ def validate_global_plan_limit_not_exceeded
+ relation = self.class.all
+ limits = Plan.default.actual_limits
+
+ check_plan_limit_not_exceeded(limits, relation)
+ end
+
+ def check_plan_limit_not_exceeded(limits, relation)
+ return unless limits.exceeded?(limit_name, relation)
+
+ errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") %
+ { name: limit_name.humanize(capitalize: false), count: limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index d157404f7bc..7b4485376d4 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -259,8 +259,8 @@ module Mentionable
# for the test period.
# During the test period the flag should be enabled at the group level.
def store_mentioned_users_to_db_enabled?
- return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group) if self.respond_to?(:project)
- return Feature.enabled?(:store_mentioned_users_to_db, self.group) if self.respond_to?(:group)
+ return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group, default_enabled: true) if self.respond_to?(:project)
+ return Feature.enabled?(:store_mentioned_users_to_db, self.group, default_enabled: true) if self.respond_to?(:group)
end
end
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index fa5a79cc12b..5f24564dc56 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -97,26 +97,6 @@ module Milestoneish
due_date && due_date.past?
end
- def group_milestone?
- false
- end
-
- def project_milestone?
- false
- end
-
- def legacy_group_milestone?
- false
- end
-
- def dashboard_milestone?
- false
- end
-
- def global_milestone?
- false
- end
-
def total_time_spent
@total_time_spent ||= issues.joins(:timelogs).sum(:time_spent) + merge_requests.joins(:timelogs).sum(:time_spent)
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 933a0b167e2..183b902dd37 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -24,7 +24,7 @@ module Noteable
# 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
+ @system_note_timestamp || Time.current # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
attr_writer :system_note_timestamp
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index 761a151a474..adb6a59e11c 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -44,7 +44,7 @@ module PrometheusAdapter
{
success: true,
data: data,
- last_update: Time.now.utc
+ last_update: Time.current.utc
}
rescue Gitlab::PrometheusClient::Error => err
{ success: false, result: err.message }
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index 1653ecdb305..1d89a4497d9 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -50,7 +50,7 @@ module RelativePositioning
# This method takes two integer values (positions) and
# calculates the position between them. The range is huge as
# the maximum integer value is 2147483647. We are incrementing position by IDEAL_DISTANCE * 2 every time
- # when we have enough space. If distance is less then IDEAL_DISTANCE we are calculating an average number
+ # when we have enough space. If distance is less than IDEAL_DISTANCE, we are calculating an average number.
def position_between(pos_before, pos_after)
pos_before ||= MIN_POSITION
pos_after ||= MAX_POSITION
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index 5d78eea7fca..5174ae05d15 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -23,7 +23,10 @@ module ResolvableDiscussion
:last_note
)
- delegate :potentially_resolvable?, to: :first_note
+ delegate :potentially_resolvable?,
+ :noteable_id,
+ :noteable_type,
+ to: :first_note
delegate :resolved_at,
:resolved_by,
@@ -79,7 +82,7 @@ module ResolvableDiscussion
return false unless current_user
return false unless resolvable?
- current_user == self.noteable.author ||
+ current_user == self.noteable.try(:author) ||
current_user.can?(:resolve_note, self.project)
end
diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb
index 2d2d5fb7168..4e8a1bb643e 100644
--- a/app/models/concerns/resolvable_note.rb
+++ b/app/models/concerns/resolvable_note.rb
@@ -23,7 +23,7 @@ module ResolvableNote
class_methods do
# This method must be kept in sync with `#resolve!`
def resolve!(current_user)
- unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id)
+ unresolved.update_all(resolved_at: Time.current, resolved_by_id: current_user.id)
end
# This method must be kept in sync with `#unresolve!`
@@ -57,7 +57,7 @@ module ResolvableNote
return false unless resolvable?
return false if resolved?
- self.resolved_at = Time.now
+ self.resolved_at = Time.current
self.resolved_by = current_user
self.resolved_by_push = resolved_by_push
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index da4f2a79895..250889fdf8b 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -67,7 +67,7 @@ module Storage
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_before_last_save} to #{full_path}" # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.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
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
index d29e6a01c56..8927e42dd97 100644
--- a/app/models/concerns/timebox.rb
+++ b/app/models/concerns/timebox.rb
@@ -7,7 +7,9 @@ module Timebox
include CacheMarkdownField
include Gitlab::SQL::Pattern
include IidRoutes
+ include Referable
include StripAttribute
+ include FromUnion
TimeboxStruct = Struct.new(:title, :name, :id) do
# Ensure these models match the interface required for exporting
@@ -64,7 +66,11 @@ module Timebox
groups = groups.compact if groups.is_a? Array
groups = [] if groups.nil?
- where(project_id: projects).or(where(group_id: groups))
+ if Feature.enabled?(:optimized_timebox_queries, default_enabled: true)
+ from_union([where(project_id: projects), where(group_id: groups)], remove_duplicates: false)
+ else
+ where(project_id: projects).or(where(group_id: groups))
+ end
end
scope :within_timeframe, -> (start_date, end_date) do
@@ -122,6 +128,35 @@ module Timebox
end
end
+ ##
+ # Returns the String necessary to reference a Timebox in Markdown. Group
+ # timeboxes only support name references, and do not support cross-project
+ # references.
+ #
+ # format - Symbol format to use (default: :iid, optional: :name)
+ #
+ # Examples:
+ #
+ # Milestone.first.to_reference # => "%1"
+ # Iteration.first.to_reference(format: :name) # => "*iteration:\"goal\""
+ # Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-foss%1"
+ # Iteration.first.to_reference(same_namespace_project) # => "gitlab-foss*iteration:1"
+ #
+ def to_reference(from = nil, format: :name, full: false)
+ format_reference = timebox_format_reference(format)
+ reference = "#{self.class.reference_prefix}#{format_reference}"
+
+ if project
+ "#{project.to_reference_base(from, full: full)}#{reference}"
+ else
+ reference
+ end
+ end
+
+ def reference_link_text(from = nil)
+ self.class.reference_prefix + self.title
+ end
+
def title=(value)
write_attribute(:title, sanitize_title(value)) if value.present?
end
@@ -162,6 +197,20 @@ module Timebox
private
+ def timebox_format_reference(format = :iid)
+ raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
+
+ if group_timebox? && format == :iid
+ raise ArgumentError, _('Cannot refer to a group %{timebox_type} by an internal id!') % { timebox_type: timebox_name }
+ end
+
+ if format == :name && !name.include?('"')
+ %("#{name}")
+ else
+ iid
+ end
+ end
+
# Timebox titles must be unique across project and group timeboxes
def uniqueness_of_title
if project
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index 4099039dd96..a1f83884f02 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -4,6 +4,10 @@ module TokenAuthenticatable
extend ActiveSupport::Concern
class_methods do
+ def encrypted_token_authenticatable_fields
+ @encrypted_token_authenticatable_fields ||= []
+ end
+
private
def add_authentication_token_field(token_field, options = {})
@@ -12,6 +16,7 @@ module TokenAuthenticatable
end
token_authenticatable_fields.push(token_field)
+ encrypted_token_authenticatable_fields.push(token_field) if options[:encrypted]
attr_accessor :cleartext_tokens
diff --git a/app/models/concerns/update_highest_role.rb b/app/models/concerns/update_highest_role.rb
index 7efc436c6c8..6432cc794a5 100644
--- a/app/models/concerns/update_highest_role.rb
+++ b/app/models/concerns/update_highest_role.rb
@@ -29,9 +29,7 @@ module UpdateHighestRole
UpdateHighestRoleWorker.perform_in(HIGHEST_ROLE_JOB_DELAY, update_highest_role_attribute)
else
# use same logging as ExclusiveLeaseGuard
- # rubocop:disable Gitlab/RailsLogger
- Rails.logger.error('Cannot obtain an exclusive lease. There must be another instance already in execution.')
- # rubocop:enable Gitlab/RailsLogger
+ Gitlab::AppLogger.error('Cannot obtain an exclusive lease. There must be another instance already in execution.')
end
end
end
diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb
index 76bfbabf3b3..b1dd720d908 100644
--- a/app/models/container_expiration_policy.rb
+++ b/app/models/container_expiration_policy.rb
@@ -13,6 +13,8 @@ class ContainerExpirationPolicy < ApplicationRecord
validates :cadence, presence: true, inclusion: { in: ->(_) { self.cadence_options.stringify_keys } }
validates :older_than, inclusion: { in: ->(_) { self.older_than_options.stringify_keys } }, allow_nil: true
validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true
+ validates :name_regex, untrusted_regexp: true, if: :enabled?
+ validates :name_regex_keep, untrusted_regexp: true, if: :enabled?
scope :active, -> { where(enabled: true) }
scope :preloaded, -> { preload(project: [:route]) }
@@ -50,4 +52,8 @@ class ContainerExpirationPolicy < ApplicationRecord
def set_next_run_at
self.next_run_at = Time.zone.now + ChronicDuration.parse(cadence).seconds
end
+
+ def disable!
+ update_attribute(:enabled, false)
+ end
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 455c672cea3..b0f7edac2f3 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -16,7 +16,13 @@ class ContainerRepository < ApplicationRecord
scope :ordered, -> { order(:name) }
scope :with_api_entity_associations, -> { preload(project: [:route, { namespace: :route }]) }
scope :for_group_and_its_subgroups, ->(group) do
- where(project_id: Project.for_group_and_its_subgroups(group).with_container_registry.select(:id))
+ project_scope = Project
+ .for_group_and_its_subgroups(group)
+ .with_container_registry
+ .select(:id)
+
+ ContainerRepository
+ .joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id")
end
scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
@@ -67,6 +73,12 @@ class ContainerRepository < ApplicationRecord
end
end
+ def tags_count
+ return 0 unless manifest && manifest['tags']
+
+ manifest['tags'].size
+ end
+
def blob(config)
ContainerRegistry::Blob.new(self, config)
end
diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb
deleted file mode 100644
index 48c09f4cd6b..00000000000
--- a/app/models/dashboard_group_milestone.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-# Dashboard Group Milestones are milestones that allow us to pull more info out for the UI that the Milestone object doesn't allow for
-class DashboardGroupMilestone < GlobalMilestone
- extend ::Gitlab::Utils::Override
-
- attr_reader :group_name
-
- def initialize(milestone)
- super
-
- @group_name = milestone.group.full_name
- end
-
- def self.build_collection(groups, params)
- milestones = Milestone.of_groups(groups.select(:id))
- .reorder_by_due_date_asc
- .order_by_name_asc
- milestones = milestones.search_title(params[:search_title]) if params[:search_title].present?
- Milestone.filter_by_state(milestones, params[:state]).map { |m| new(m) }
- end
-
- def dashboard_milestone?
- true
- end
-
- def merge_requests_enabled?
- true
- end
-end
diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb
deleted file mode 100644
index fd59b94b737..00000000000
--- a/app/models/dashboard_milestone.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-class DashboardMilestone < GlobalMilestone
- attr_reader :project_name
-
- def initialize(milestone)
- super
-
- @project_name = milestone.project.full_name
- end
-
- def project_milestone?
- true
- end
-
- def merge_requests_enabled?
- project.merge_requests_enabled?
- end
-end
diff --git a/app/models/data_list.rb b/app/models/data_list.rb
new file mode 100644
index 00000000000..12011cb17f7
--- /dev/null
+++ b/app/models/data_list.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class DataList
+ def initialize(batch, data_fields_hash, klass)
+ @batch = batch
+ @data_fields_hash = data_fields_hash
+ @klass = klass
+ end
+
+ def to_array
+ [klass, columns, values]
+ end
+
+ private
+
+ attr_reader :batch, :data_fields_hash, :klass
+
+ def columns
+ data_fields_hash.keys << 'service_id'
+ end
+
+ def values
+ batch.map { |row| data_fields_hash.values << row['id'] }
+ end
+end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index ba65acff7f3..aa3e3a8f66d 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -64,7 +64,7 @@ class Deployment < ApplicationRecord
end
before_transition any => [:success, :failed, :canceled] do |deployment|
- deployment.finished_at = Time.now
+ deployment.finished_at = Time.current
end
after_transition any => :success do |deployment|
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
index e9b69eab7a7..0dca6333fa1 100644
--- a/app/models/design_management/design.rb
+++ b/app/models/design_management/design.rb
@@ -20,9 +20,11 @@ module DesignManagement
has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: 'DesignUserMention', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
validates :project, :filename, presence: true
validates :issue, presence: true, unless: :importing?
- validates :filename, uniqueness: { scope: :issue_id }
+ validates :filename, uniqueness: { scope: :issue_id }, length: { maximum: 255 }
validate :validate_file_is_image
alias_attribute :title, :filename
@@ -126,68 +128,23 @@ module DesignManagement
# #12["filename with [] in it.jpg"]
def to_reference(from = nil, full: false)
infix = full ? '/designs' : ''
- totally_simple = %r{ \A #{self.class.simple_file_name} \z }x
- safe_name = if totally_simple.match?(filename)
- filename
- elsif filename =~ /[<>]/
- %Q{base64:#{Base64.strict_encode64(filename)}}
- else
- escaped = filename.gsub(%r{[\\"]}) { |x| "\\#{x}" }
- %Q{"#{escaped}"}
- end
+ safe_name = Sanitize.fragment(filename)
"#{issue.to_reference(from, full: full)}#{infix}[#{safe_name}]"
end
def self.reference_pattern
- @reference_pattern ||= begin
- # Filenames can be escaped with double quotes to name filenames
- # that include square brackets, or other special characters
- %r{
- #{Issue.reference_pattern}
- (\/designs)?
- \[
- (?<design> #{simple_file_name} | #{quoted_file_name} | #{base_64_encoded_name})
- \]
- }x
- end
- end
-
- def self.simple_file_name
- %r{
- (?<simple_file_name>
- ( \w | [_:,'-] | \. | \s )+
- \.
- \w+
- )
- }x
- end
-
- def self.base_64_encoded_name
- %r{
- base64:
- (?<base_64_encoded_name>
- [A-Za-z0-9+\n]+
- =?
- )
- }x
- end
-
- def self.quoted_file_name
- %r{
- "
- (?<escaped_filename>
- (\\ \\ | \\ " | [^"\\])+
- )
- "
- }x
+ # no-op: We only support link_reference_pattern parsing
end
def self.link_reference_pattern
@link_reference_pattern ||= begin
- exts = SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT
path_segment = %r{issues/#{Gitlab::Regex.issue}/designs}
- filename_pattern = %r{(?<simple_file_name>[a-z0-9_=-]+\.(#{exts.join('|')}))}i
+ ext = Regexp.new(Regexp.union(SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT).source, Regexp::IGNORECASE)
+ valid_char = %r{[^/\s]} # any char that is not a forward slash or whitespace
+ filename_pattern = %r{
+ (?<url_filename> #{valid_char}+ \. #{ext})
+ }x
super(path_segment, filename_pattern)
end
@@ -234,6 +191,11 @@ module DesignManagement
alias_method :after_note_created, :after_note_changed
alias_method :after_note_destroyed, :after_note_changed
+ # Part of the interface of objects we can create events about
+ def resource_parent
+ project
+ end
+
private
def head_version
diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb
index 6be98fe3d44..55c9084caf2 100644
--- a/app/models/design_management/version.rb
+++ b/app/models/design_management/version.rb
@@ -88,7 +88,7 @@ module DesignManagement
rows = design_actions.map { |action| action.row_attrs(version) }
- Gitlab::Database.bulk_insert(::DesignManagement::Action.table_name, rows)
+ Gitlab::Database.bulk_insert(::DesignManagement::Action.table_name, rows) # rubocop:disable Gitlab/BulkInsert
version.designs.reset
version.validate!
design_actions.each(&:performed)
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index ff39dbb59f3..4b2e62bf761 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -136,7 +136,7 @@ class DiffNote < Note
# As an extra benefit, the returned `diff_file` already
# has `highlighted_diff_lines` data set from Redis on
# `Diff::FileCollection::MergeRequestDiff`.
- file = noteable.diffs(original_position.diff_options).diff_files.first
+ file = original_position.find_diff_file_from(noteable)
# if line is not found in persisted diffs, fallback and retrieve file from repository using gitaly
# This is required because of https://gitlab.com/gitlab-org/gitlab/issues/42676
file = nil if file&.line_for_position(original_position).nil? && importing?
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index c07078c03dd..e928bb0959a 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -20,6 +20,7 @@ class Discussion
:noteable_ability_name,
:to_ability_name,
:editable?,
+ :resolved_by_id,
:system_note_with_references_visible_for?,
:resource_parent,
diff --git a/app/models/draft_note.rb b/app/models/draft_note.rb
new file mode 100644
index 00000000000..febede9beba
--- /dev/null
+++ b/app/models/draft_note.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+class DraftNote < ApplicationRecord
+ include DiffPositionableNote
+ include Gitlab::Utils::StrongMemoize
+ include Sortable
+ include ShaAttribute
+
+ PUBLISH_ATTRS = %i(noteable_id noteable_type type note).freeze
+ DIFF_ATTRS = %i(position original_position change_position commit_id).freeze
+
+ sha_attribute :commit_id
+
+ # Attribute used to store quick actions changes and users referenced.
+ attr_accessor :commands_changes
+ attr_accessor :users_referenced
+
+ # Text with quick actions filtered out
+ attr_accessor :rendered_note
+
+ attr_accessor :review
+
+ belongs_to :author, class_name: 'User'
+ belongs_to :merge_request
+
+ validates :merge_request_id, presence: true
+ validates :author_id, presence: true, uniqueness: { scope: [:merge_request_id, :discussion_id] }, if: :discussion_id?
+ validates :discussion_id, allow_nil: true, format: { with: /\A\h{40}\z/ }
+
+ scope :authored_by, ->(u) { where(author_id: u.id) }
+
+ delegate :file_path, :file_hash, :file_identifier_hash, to: :diff_file, allow_nil: true
+
+ def self.positions
+ where.not(position: nil)
+ .select(:position)
+ .map(&:position)
+ end
+
+ def project
+ merge_request.target_project
+ end
+
+ # noteable_id and noteable_type methods
+ # are used to generate discussion_id on Discussion.discussion_id
+ def noteable_id
+ merge_request_id
+ end
+
+ def noteable
+ merge_request
+ end
+
+ def noteable_type
+ "MergeRequest"
+ end
+
+ def for_commit?
+ commit_id.present?
+ end
+
+ def importing?
+ false
+ end
+
+ def resolvable?
+ false
+ end
+
+ def emoji_awardable?
+ false
+ end
+
+ def on_diff?
+ position&.complete?
+ end
+
+ def type
+ return 'DiffNote' if on_diff?
+ return 'DiscussionNote' if discussion_id.present?
+
+ 'Note'
+ end
+
+ def references
+ {
+ users: users_referenced,
+ commands: commands_changes
+ }
+ end
+
+ def line_code
+ @line_code ||= diff_file&.line_code_for_position(original_position)
+ end
+
+ def publish_params
+ attrs = PUBLISH_ATTRS.dup
+ attrs.concat(DIFF_ATTRS) if on_diff?
+ params = slice(*attrs)
+ params[:in_reply_to_discussion_id] = discussion_id if discussion_id.present?
+ params[:review_id] = review.id if review.present?
+
+ params
+ end
+
+ def self.preload_author(draft_notes)
+ ActiveRecord::Associations::Preloader.new.preload(draft_notes, { author: :status })
+ end
+
+ def diff_file
+ strong_memoize(:diff_file) do
+ file = original_position&.diff_file(project.repository)
+
+ file&.unfold_diff_lines(original_position)
+
+ file
+ end
+ end
+
+ def commit
+ @commit ||= project.commit(commit_id) if commit_id.present?
+ end
+end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 21044771bbb..8dae2d760f5 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -339,7 +339,7 @@ class Environment < ApplicationRecord
end
def auto_stop_in
- auto_stop_at - Time.now if auto_stop_at
+ auto_stop_at - Time.current if auto_stop_at
end
def auto_stop_in=(value)
diff --git a/app/models/event.rb b/app/models/event.rb
index 12b85697690..9c0fcbb354b 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -7,36 +7,31 @@ class Event < ApplicationRecord
include DeleteWithLimit
include CreatedAtFilterable
include Gitlab::Utils::StrongMemoize
+ include UsageStatistics
- default_scope { reorder(nil) }
-
- CREATED = 1
- UPDATED = 2
- CLOSED = 3
- REOPENED = 4
- PUSHED = 5
- COMMENTED = 6
- MERGED = 7
- JOINED = 8 # User joined project
- LEFT = 9 # User left project
- DESTROYED = 10
- EXPIRED = 11 # User left project due to expiry
+ default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope
ACTIONS = HashWithIndifferentAccess.new(
- created: CREATED,
- updated: UPDATED,
- closed: CLOSED,
- reopened: REOPENED,
- pushed: PUSHED,
- commented: COMMENTED,
- merged: MERGED,
- joined: JOINED,
- left: LEFT,
- destroyed: DESTROYED,
- expired: EXPIRED
+ created: 1,
+ updated: 2,
+ closed: 3,
+ reopened: 4,
+ pushed: 5,
+ commented: 6,
+ merged: 7,
+ joined: 8, # User joined project
+ left: 9, # User left project
+ destroyed: 10,
+ expired: 11, # User left project due to expiry
+ approved: 12,
+ archived: 13 # Recoverable deletion
).freeze
- WIKI_ACTIONS = [CREATED, UPDATED, DESTROYED].freeze
+ private_constant :ACTIONS
+
+ WIKI_ACTIONS = [:created, :updated, :destroyed].freeze
+
+ DESIGN_ACTIONS = [:created, :updated, :destroyed, :archived].freeze
TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue,
@@ -46,16 +41,20 @@ class Event < ApplicationRecord
project: Project,
snippet: Snippet,
user: User,
- wiki: WikiPage::Meta
+ wiki: WikiPage::Meta,
+ design: DesignManagement::Design
).freeze
RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour
REPOSITORY_UPDATED_AT_INTERVAL = 5.minutes
+ enum action: ACTIONS, _suffix: true
+
delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true
delegate :title, to: :issue, prefix: true, allow_nil: true
delegate :title, to: :merge_request, prefix: true, allow_nil: true
delegate :title, to: :note, prefix: true, allow_nil: true
+ delegate :title, to: :design, prefix: true, allow_nil: true
belongs_to :author, class_name: "User"
belongs_to :project
@@ -77,16 +76,16 @@ class Event < ApplicationRecord
# Callbacks
after_create :reset_project_activity
after_create :set_last_repository_updated_at, if: :push_action?
- after_create :track_user_interacted_projects
+ after_create ->(event) { UserInteractedProject.track(event) }
# Scopes
scope :recent, -> { reorder(id: :desc) }
- scope :code_push, -> { where(action: PUSHED) }
- scope :merged, -> { where(action: MERGED) }
scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') }
+ scope :for_design, -> { where(target_type: 'DesignManagement::Design') }
# Needed to implement feature flag: can be removed when feature flag is removed
scope :not_wiki_page, -> { where('target_type IS NULL or target_type <> ?', 'WikiPage::Meta') }
+ scope :not_design, -> { where('target_type IS NULL or target_type <> ?', 'DesignManagement::Design') }
scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association
@@ -105,6 +104,13 @@ class Event < ApplicationRecord
# should ensure the ID points to a valid user.
validates :author_id, presence: true
+ validates :action_enum_value,
+ if: :design?,
+ inclusion: {
+ in: actions.values_at(*DESIGN_ACTIONS),
+ message: ->(event, _data) { "#{event.action} is not a valid design action" }
+ }
+
self.inheritance_column = 'action'
class << self
@@ -113,7 +119,7 @@ class Event < ApplicationRecord
end
def find_sti_class(action)
- if action.to_i == PUSHED
+ if actions.fetch(action, action) == actions[:pushed] # action can be integer or symbol
PushEvent
else
Event
@@ -123,19 +129,15 @@ class Event < ApplicationRecord
# Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions
where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)",
- Event::PUSHED,
- %w(MergeRequest Issue), [Event::CREATED, Event::CLOSED, Event::MERGED],
- "Note", Event::COMMENTED)
+ actions[:pushed],
+ %w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]],
+ "Note", actions[:commented])
end
def limit_recent(limit = 20, offset = nil)
recent.limit(limit).offset(offset)
end
- def actions
- ACTIONS.keys
- end
-
def target_types
TARGET_TYPES.keys
end
@@ -148,7 +150,9 @@ class Event < ApplicationRecord
def visible_to_user?(user = nil)
return false unless capability.present?
- Ability.allowed?(user, capability, permission_object)
+ capability.all? do |rule|
+ Ability.allowed?(user, rule, permission_object)
+ end
end
def resource_parent
@@ -159,46 +163,10 @@ class Event < ApplicationRecord
target.try(:title)
end
- def created_action?
- action == CREATED
- end
-
def push_action?
false
end
- def merged_action?
- action == MERGED
- end
-
- def closed_action?
- action == CLOSED
- end
-
- def reopened_action?
- action == REOPENED
- end
-
- def joined_action?
- action == JOINED
- end
-
- def left_action?
- action == LEFT
- end
-
- def expired_action?
- action == EXPIRED
- end
-
- def destroyed_action?
- action == DESTROYED
- end
-
- def commented_action?
- action == COMMENTED
- end
-
def membership_changed?
joined_action? || left_action? || expired_action?
end
@@ -208,11 +176,11 @@ class Event < ApplicationRecord
end
def created_wiki_page?
- wiki_page? && action == CREATED
+ wiki_page? && created_action?
end
def updated_wiki_page?
- wiki_page? && action == UPDATED
+ wiki_page? && updated_action?
end
def created_target?
@@ -239,6 +207,10 @@ class Event < ApplicationRecord
target_type == 'WikiPage::Meta'
end
+ def design?
+ target_type == 'DesignManagement::Design'
+ end
+
def milestone
target if milestone?
end
@@ -247,6 +219,10 @@ class Event < ApplicationRecord
target if issue?
end
+ def design
+ target if design?
+ end
+
def merge_request
target if merge_request?
end
@@ -266,6 +242,8 @@ class Event < ApplicationRecord
def action_name
if push_action?
push_action_name
+ elsif design?
+ design_action_names[action.to_sym]
elsif closed_action?
"closed"
elsif merged_action?
@@ -386,34 +364,30 @@ class Event < ApplicationRecord
protected
- # rubocop:disable Metrics/CyclomaticComplexity
- # rubocop:disable Metrics/PerceivedComplexity
- #
- # TODO Refactor this method so we no longer need to disable the above cops
- # https://gitlab.com/gitlab-org/gitlab/-/issues/216879.
def capability
@capability ||= begin
- if push_action? || commit_note?
- :download_code
- elsif membership_changed? || created_project_action?
- :read_project
- elsif issue? || issue_note?
- :read_issue
- elsif merge_request? || merge_request_note?
- :read_merge_request
- elsif personal_snippet_note? || project_snippet_note?
- :read_snippet
- elsif milestone?
- :read_milestone
- elsif wiki_page?
- :read_wiki
- elsif design_note?
- :read_design
- end
- end
- end
- # rubocop:enable Metrics/CyclomaticComplexity
- # rubocop:enable Metrics/PerceivedComplexity
+ capabilities.flat_map do |ability, syms|
+ if syms.any? { |sym| send(sym) } # rubocop: disable GitlabSecurity/PublicSend
+ [ability]
+ else
+ []
+ end
+ end
+ end
+ end
+
+ def capabilities
+ {
+ download_code: %i[push_action? commit_note?],
+ read_project: %i[membership_changed? created_project_action?],
+ read_issue: %i[issue? issue_note?],
+ read_merge_request: %i[merge_request? merge_request_note?],
+ read_snippet: %i[personal_snippet_note? project_snippet_note?],
+ read_milestone: %i[milestone?],
+ read_wiki: %i[wiki_page?],
+ read_design: %i[design_note? design?]
+ }
+ end
private
@@ -455,11 +429,17 @@ class Event < ApplicationRecord
.update_all(last_repository_updated_at: created_at)
end
- def track_user_interacted_projects
- # Note the call to .available? is due to earlier migrations
- # that would otherwise conflict with the call to .track
- # (because the table does not exist yet).
- UserInteractedProject.track(self) if UserInteractedProject.available?
+ def design_action_names
+ {
+ created: _('uploaded'),
+ updated: _('revised'),
+ destroyed: _('deleted'),
+ archived: _('archived')
+ }
+ end
+
+ def action_enum_value
+ self.class.actions[action]
end
end
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
deleted file mode 100644
index 43de7454cb7..00000000000
--- a/app/models/global_milestone.rb
+++ /dev/null
@@ -1,108 +0,0 @@
-# frozen_string_literal: true
-# Global Milestones are milestones that can be shared across multiple projects
-class GlobalMilestone
- include Milestoneish
-
- STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
-
- 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,
- :timebox_id, :milestoneish_id, :resource_parent, :releases, to: :milestone
-
- def to_hash
- {
- name: title,
- title: title,
- group_name: group&.full_name,
- project_name: project&.full_name
- }
- end
-
- def for_display
- @milestone
- end
-
- def self.build_collection(projects, params)
- 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?
-
- Milestone.filter_by_state(items, params[:state]).map { |m| new(m) }
- end
-
- # necessary for legacy milestones
- def self.build(projects, title)
- milestones = Milestone.of_projects(projects).where(title: title)
- return if milestones.blank?
-
- new(milestones.first)
- end
-
- 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
-
- def self.group_milestones_states_count(group)
- return STATE_COUNT_HASH unless group
-
- counts_by_state = Milestone.of_groups(group).count_by_state
-
- {
- opened: counts_by_state['active'] || 0,
- closed: counts_by_state['closed'] || 0,
- all: counts_by_state.values.sum
- }
- end
-
- def self.legacy_group_milestone_states_count(projects)
- return STATE_COUNT_HASH unless projects
-
- # 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
-
- {
- opened: relation['active'] || 0,
- closed: relation['closed'] || 0,
- all: relation.values.sum
- }
- end
-
- def initialize(milestone)
- @milestone = milestone
- end
-
- def active?
- state == 'active'
- end
-
- def closed?
- state == 'closed'
- end
-
- def issues
- @issues ||= Issue.of_milestones(milestone).includes(:project, :assignees, :labels)
- end
-
- def merge_requests
- @merge_requests ||= MergeRequest.of_milestones(milestone).includes(:target_project, :assignees, :labels)
- end
-
- def labels
- @labels ||= GlobalLabel.build_collection(milestone.labels).sort_by!(&:title)
- end
-
- def global_milestone?
- true
- end
-end
-
-GlobalMilestone.include_if_ee('::EE::GlobalMilestone')
diff --git a/app/models/group.rb b/app/models/group.rb
index 04cb6b8b4da..dd7624ab420 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -325,15 +325,17 @@ class Group < Namespace
def members_with_parents
# Avoids an unnecessary SELECT when the group has no parents
source_ids =
- if parent_id
+ if has_parent?
self_and_ancestors.reorder(nil).select(:id)
else
id
end
- GroupMember
- .active_without_invites_and_requests
- .where(source_id: source_ids)
+ group_hierarchy_members = GroupMember.active_without_invites_and_requests
+ .where(source_id: source_ids)
+
+ GroupMember.from_union([group_hierarchy_members,
+ members_from_self_and_ancestor_group_shares])
end
def members_from_self_and_ancestors_with_effective_access_level
@@ -398,7 +400,7 @@ class Group < Namespace
.first
&.access_level
- max_member_access || max_member_access_for_user_from_shared_groups(user) || GroupMember::NO_ACCESS
+ max_member_access || GroupMember::NO_ACCESS
end
def mattermost_team_params
@@ -494,6 +496,11 @@ class Group < Namespace
# TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
end
+ def preload_shared_group_links
+ preloader = ActiveRecord::Associations::Preloader.new
+ preloader.preload(self, shared_with_group_links: [shared_with_group: :route])
+ end
+
private
def update_two_factor_requirement
@@ -524,27 +531,39 @@ class Group < Namespace
errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.")
end
- def max_member_access_for_user_from_shared_groups(user)
+ def members_from_self_and_ancestor_group_shares
group_group_link_table = GroupGroupLink.arel_table
group_member_table = GroupMember.arel_table
- group_group_links_query = GroupGroupLink.where(shared_group_id: self_and_ancestors_ids)
+ source_ids =
+ if has_parent?
+ self_and_ancestors.reorder(nil).select(:id)
+ else
+ id
+ end
+
+ group_group_links_query = GroupGroupLink.where(shared_group_id: source_ids)
cte = Gitlab::SQL::CTE.new(:group_group_links_cte, group_group_links_query)
cte_alias = cte.table.alias(GroupGroupLink.table_name)
- link = GroupGroupLink
- .with(cte.to_arel)
- .select(smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]],
- 'group_access'))
- .from([group_member_table, cte.alias_to(group_group_link_table)])
- .where(group_member_table[:user_id].eq(user.id))
- .where(group_member_table[:requested_at].eq(nil))
- .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id]))
- .where(group_member_table[:source_type].eq('Namespace'))
- .reorder(Arel::Nodes::Descending.new(group_group_link_table[:group_access]))
- .first
-
- link&.group_access
+ # Instead of members.access_level, we need to maximize that access_level at
+ # the respective group_group_links.group_access.
+ member_columns = GroupMember.attribute_names.map do |column_name|
+ if column_name == 'access_level'
+ smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]],
+ 'access_level')
+ else
+ group_member_table[column_name]
+ end
+ end
+
+ GroupMember
+ .with(cte.to_arel)
+ .select(*member_columns)
+ .from([group_member_table, cte.alias_to(group_group_link_table)])
+ .where(group_member_table[:requested_at].eq(nil))
+ .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id]))
+ .where(group_member_table[:source_type].eq('Namespace'))
end
def smallest_value_arel(args, column_alias)
diff --git a/app/models/group_deploy_key.rb b/app/models/group_deploy_key.rb
new file mode 100644
index 00000000000..d1f1aa544cd
--- /dev/null
+++ b/app/models/group_deploy_key.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class GroupDeployKey < Key
+ self.table_name = 'group_deploy_keys'
+
+ validates :user, presence: true
+
+ def type
+ 'DeployKey'
+ end
+end
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index c233f59b1a6..fdc54ba33ab 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -14,6 +14,7 @@ class GroupGroupLink < ApplicationRecord
presence: true
scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
+ scope :public_or_visible_to_user, ->(group, user) { where(shared_group: group, shared_with_group: Group.public_or_visible_to_user(user)) } # rubocop:disable Cop/GroupPublicOrVisibleToUser
def self.access_options
Gitlab::Access.options_with_owner
diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb
index 7773b887249..d22c1ac5550 100644
--- a/app/models/group_import_state.rb
+++ b/app/models/group_import_state.rb
@@ -5,7 +5,8 @@ class GroupImportState < ApplicationRecord
belongs_to :group, inverse_of: :import_state
- validates :group, :status, :jid, presence: true
+ validates :group, :status, presence: true
+ validates :jid, presence: true, if: -> { started? || finished? }
state_machine :status, initial: :created do
state :created, value: 0
@@ -31,4 +32,8 @@ class GroupImportState < ApplicationRecord
state.update_column(:last_error, last_error) if last_error
end
end
+
+ def in_progress?
+ created? || started?
+ end
end
diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb
deleted file mode 100644
index 60e97174e50..00000000000
--- a/app/models/group_milestone.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-# Group Milestones are milestones that can be shared among many projects within the same group
-class GroupMilestone < GlobalMilestone
- attr_reader :group, :milestones
-
- def self.build_collection(group, projects, params)
- 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)
- 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
- { group_id: group.id }
- end
-
- def legacy_group_milestone?
- true
- end
-
- def merge_requests_enabled?
- true
- end
-end
-
-GroupMilestone.include_if_ee('::EE::GroupMilestone')
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index b6882701e23..21cf6bfa414 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -25,8 +25,6 @@ class InternalId < ApplicationRecord
validates :usage, presence: true
- REQUIRED_SCHEMA_VERSION = 20180305095250
-
# Increments #last_value and saves the record
#
# The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
@@ -63,24 +61,16 @@ class InternalId < ApplicationRecord
class << self
def track_greatest(subject, scope, usage, new_value, init)
- return new_value unless available?
-
InternalIdGenerator.new(subject, scope, usage)
.track_greatest(init, new_value)
end
def generate_next(subject, scope, usage, init)
- # Shortcut if `internal_ids` table is not available (yet)
- # This can be the case in other (unrelated) migration specs
- return (init.call(subject) || 0) + 1 unless available?
-
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
@@ -95,20 +85,6 @@ class InternalId < ApplicationRecord
where(filter).delete_all
end
-
- def available?
- 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
- Gitlab::SafeRequestStore[:internal_ids_available_flag] = nil
- super
- end
end
class InternalIdGenerator
diff --git a/app/models/issue.rb b/app/models/issue.rb
index a04ac412940..5c5190f88b1 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -59,6 +59,9 @@ class Issue < ApplicationRecord
has_one :sentry_issue
has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
+ has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
+ has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
+ has_many :prometheus_alerts, through: :prometheus_alert_events
accepts_nested_attributes_for :sentry_issue
@@ -86,12 +89,14 @@ class Issue < ApplicationRecord
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) }
scope :with_label_attributes, ->(label_attributes) { joins(:labels).where(labels: label_attributes) }
+ scope :with_alert_management_alerts, -> { joins(:alert_management_alert) }
+ scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) }
+ scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) }
scope :public_only, -> { where(confidential: false) }
scope :confidential_only, -> { where(confidential: true) }
scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
- scope :with_alert_management_alerts, -> { joins(:alert_management_alert) }
# An issue can be uniquely identified by project_id and iid
# Takes one or more sets of composite IDs, expressed as hash-like records of
@@ -139,6 +144,10 @@ class Issue < ApplicationRecord
issue.closed_at = nil
issue.closed_by = nil
end
+
+ after_transition any => :closed do |issue|
+ issue.resolve_associated_alert_management_alert
+ end
end
# Alias to state machine .with_state_id method
@@ -344,10 +353,26 @@ class Issue < ApplicationRecord
previous_changes['updated_at']&.first || updated_at
end
+ def banzai_render_context(field)
+ super.merge(label_url_method: :project_issues_url)
+ end
+
def design_collection
@design_collection ||= ::DesignManagement::DesignCollection.new(self)
end
+ def resolve_associated_alert_management_alert
+ return unless alert_management_alert
+ return if alert_management_alert.resolve
+
+ Gitlab::AppLogger.warn(
+ message: 'Cannot resolve an associated Alert Management alert',
+ issue_id: id,
+ alert_id: alert_management_alert.id,
+ alert_errors: alert_management_alert.errors.messages
+ )
+ end
+
private
def ensure_metrics
diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb
index d4e51dcfbca..a5e1957c096 100644
--- a/app/models/issue/metrics.rb
+++ b/app/models/issue/metrics.rb
@@ -11,11 +11,11 @@ class Issue::Metrics < ApplicationRecord
def record!
if issue.milestone_id.present? && self.first_associated_with_milestone_at.blank?
- self.first_associated_with_milestone_at = Time.now
+ self.first_associated_with_milestone_at = Time.current
end
if issue_assigned_to_list_label? && self.first_added_to_board_at.blank?
- self.first_added_to_board_at = Time.now
+ self.first_added_to_board_at = Time.current
end
self.save
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index 1acd08f2063..2bda0725471 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class Iteration < ApplicationRecord
- include Timebox
-
self.table_name = 'sprints'
attr_accessor :skip_future_date_validation
@@ -15,9 +13,6 @@ class Iteration < ApplicationRecord
include AtomicInternalId
- has_many :issues, foreign_key: 'sprint_id'
- has_many :merge_requests, foreign_key: 'sprint_id'
-
belongs_to :project
belongs_to :group
@@ -33,6 +28,12 @@ class Iteration < ApplicationRecord
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
+ scope :within_timeframe, -> (start_date, end_date) do
+ where('start_date is not NULL or due_date is not NULL')
+ .where('start_date is NULL or start_date <= ?', end_date)
+ .where('due_date is NULL or due_date >= ?', start_date)
+ end
+
state_machine :state_enum, initial: :upcoming do
event :start do
transition upcoming: :started
@@ -62,6 +63,14 @@ class Iteration < ApplicationRecord
else iterations.upcoming
end
end
+
+ def reference_prefix
+ '*iteration:'
+ end
+
+ def reference_pattern
+ nil
+ end
end
def state
@@ -72,6 +81,10 @@ class Iteration < ApplicationRecord
self.state_enum = STATE_ENUM_MAP[value]
end
+ def resource_parent
+ group || project
+ end
+
private
def start_or_due_dates_changed?
@@ -98,3 +111,5 @@ class Iteration < ApplicationRecord
end
end
end
+
+Iteration.prepend_if_ee('EE::Iteration')
diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb
index 92147794e88..2d952c552a8 100644
--- a/app/models/jira_import_state.rb
+++ b/app/models/jira_import_state.rb
@@ -7,6 +7,7 @@ class JiraImportState < ApplicationRecord
self.table_name = 'jira_imports'
+ ERROR_MESSAGE_SIZE = 1000 # 1000 characters limit
STATUSES = { initial: 0, scheduled: 1, started: 2, failed: 3, finished: 4 }.freeze
belongs_to :project
@@ -14,6 +15,7 @@ class JiraImportState < ApplicationRecord
belongs_to :label
scope :by_jira_project_key, -> (jira_project_key) { where(jira_project_key: jira_project_key) }
+ scope :with_status, ->(statuses) { where(status: statuses) }
validates :project, presence: true
validates :jira_project_key, presence: true
@@ -25,6 +27,8 @@ class JiraImportState < ApplicationRecord
message: _('Cannot have multiple Jira imports running at the same time')
}
+ before_save :ensure_error_message_size
+
alias_method :scheduled_by, :user
state_machine :status, initial: :initial do
@@ -47,7 +51,7 @@ class JiraImportState < ApplicationRecord
after_transition initial: :scheduled do |state, _|
state.run_after_commit do
job_id = Gitlab::JiraImport::Stage::StartImportWorker.perform_async(project.id)
- state.update(jid: job_id, scheduled_at: Time.now) if job_id
+ state.update(jid: job_id, scheduled_at: Time.current) if job_id
end
end
@@ -65,6 +69,13 @@ class JiraImportState < ApplicationRecord
end
end
+ after_transition any => :failed do |state, transition|
+ arguments_hash = transition.args.first
+ error_message = arguments_hash&.dig(:error_message)
+
+ state.update_column(:error_message, error_message) if error_message.present?
+ end
+
# Supress warning:
# both JiraImportState and its :status machine have defined a different default for "status".
# although both have same value but represented in 2 ways: integer(0) and symbol(:initial)
@@ -102,4 +113,18 @@ class JiraImportState < ApplicationRecord
def self.finished_imports_count
finished.sum(:imported_issues_count)
end
+
+ def mark_as_failed(error_message)
+ sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message)
+
+ do_fail(error_message: error_message)
+ rescue ActiveRecord::ActiveRecordError => e
+ Gitlab::AppLogger.error("Error setting import status to failed: #{e.message}. Original error: #{sanitized_message}")
+ end
+
+ private
+
+ def ensure_error_message_size
+ self.error_message = error_message&.truncate(ERROR_MESSAGE_SIZE)
+ end
end
diff --git a/app/models/label.rb b/app/models/label.rb
index 652b5e23490..910cc0d68cd 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -31,7 +31,7 @@ class Label < ApplicationRecord
validates :title, uniqueness: { scope: [:group_id, :project_id] }
validates :title, length: { maximum: 255 }
- default_scope { order(title: :asc) }
+ default_scope { order(title: :asc) } # rubocop:disable Cop/DefaultScope
scope :templates, -> { where(template: true, type: [Label.name, nil]) }
scope :with_title, ->(title) { where(title: title) }
@@ -133,7 +133,7 @@ class Label < ApplicationRecord
# Searches for labels with a matching title or description.
#
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
#
# query - The search query as a String.
#
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 6a86aebae39..3761484b15d 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -44,13 +44,13 @@ class LfsObject < ApplicationRecord
file_store == LfsObjectUploader::Store::LOCAL
end
- # rubocop: disable DestroyAll
+ # rubocop: disable Cop/DestroyAll
def self.destroy_unreferenced
joins("LEFT JOIN lfs_objects_projects ON lfs_objects_projects.lfs_object_id = #{table_name}.id")
.where(lfs_objects_projects: { id: nil })
.destroy_all
end
- # rubocop: enable DestroyAll
+ # rubocop: enable Cop/DestroyAll
def self.calculate_oid(path)
self.hexdigest(path)
diff --git a/app/models/license_template.rb b/app/models/license_template.rb
index 73e403f98b4..bd24259984b 100644
--- a/app/models/license_template.rb
+++ b/app/models/license_template.rb
@@ -39,7 +39,7 @@ class LicenseTemplate
end
# Populate placeholders in the LicenseTemplate content
- def resolve!(project_name: nil, fullname: nil, year: Time.now.year.to_s)
+ def resolve!(project_name: nil, fullname: nil, year: Time.current.year.to_s)
# Ensure the string isn't shared with any other instance of LicenseTemplate
new_content = content.dup
new_content.gsub!(YEAR_TEMPLATE_REGEX, year) if year.present?
diff --git a/app/models/member.rb b/app/models/member.rb
index 791073da095..f2926d32d47 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -320,7 +320,7 @@ class Member < ApplicationRecord
return false unless invite?
self.invite_token = nil
- self.invite_accepted_at = Time.now.utc
+ self.invite_accepted_at = Time.current.utc
self.user = new_user
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 431a2ccf416..9a916cd40ae 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -13,12 +13,19 @@ class GroupMember < Member
# Make sure group member points only to group as it source
default_value_for :source_type, SOURCE_TYPE
validates :source_type, format: { with: /\ANamespace\z/ }
- default_scope { where(source_type: SOURCE_TYPE) }
+ default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) }
- scope :count_users_by_group_id, -> { joins(:user).group(:source_id).count }
scope :of_ldap_type, -> { where(ldap: true) }
+ scope :count_users_by_group_id, -> do
+ if Feature.enabled?(:optimized_count_users_by_group_id)
+ group(:source_id).count
+ else
+ joins(:user).group(:source_id).count
+ end
+ end
+
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index fa2e0cb8198..833b27756ab 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -9,7 +9,7 @@ class ProjectMember < Member
default_value_for :source_type, SOURCE_TYPE
validates :source_type, format: { with: /\AProject\z/ }
validates :access_level, inclusion: { in: Gitlab::Access.values }
- default_scope { where(source_type: SOURCE_TYPE) }
+ default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
scope :in_project, ->(project) { where(source_id: project.id) }
scope :in_namespaces, ->(groups) do
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index b4d0b729454..caf7b554427 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -88,6 +88,9 @@ class MergeRequest < ApplicationRecord
has_many :deployments,
through: :deployment_merge_requests
+ has_many :draft_notes
+ has_many :reviews, inverse_of: :merge_request
+
KNOWN_MERGE_PARAMS = [
:auto_merge_strategy,
:should_remove_source_branch,
@@ -101,7 +104,7 @@ class MergeRequest < ApplicationRecord
after_create :ensure_merge_request_diff
after_update :clear_memoized_shas
after_update :reload_diff_if_branch_changed
- after_save :ensure_metrics, unless: :importing?
+ after_commit :ensure_metrics, on: [:create, :update], unless: :importing?
after_commit :expire_etag_cache, unless: :importing?
# When this attribute is true some MR validation is ignored
@@ -541,13 +544,21 @@ class MergeRequest < ApplicationRecord
merge_request_diffs.where.not(id: merge_request_diff.id)
end
- # Overwritten in EE
- def note_positions_for_paths(paths, _user = nil)
+ def note_positions_for_paths(paths, user = nil)
positions = notes.new_diff_notes.joins(:note_diff_file)
.where('note_diff_files.old_path IN (?) OR note_diff_files.new_path IN (?)', paths, paths)
.positions
- Gitlab::Diff::PositionCollection.new(positions, diff_head_sha)
+ collection = Gitlab::Diff::PositionCollection.new(positions, diff_head_sha)
+
+ return collection unless user
+
+ positions = draft_notes
+ .authored_by(user)
+ .positions
+ .select { |pos| paths.include?(pos.file_path) }
+
+ collection.concat(positions)
end
def preloads_discussion_diff_highlighting?
@@ -866,7 +877,7 @@ class MergeRequest < ApplicationRecord
check_service = MergeRequests::MergeabilityCheckService.new(self)
- if async && Feature.enabled?(:async_merge_request_check_mergeability, project, default_enabled: true)
+ if async
check_service.async_execute
else
check_service.execute(retry_lease: false)
@@ -885,11 +896,11 @@ class MergeRequest < ApplicationRecord
end
def merge_event
- @merge_event ||= target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: Event::MERGED).last
+ @merge_event ||= target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: :merged).last
end
def closed_event
- @closed_event ||= target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: Event::CLOSED).last
+ @closed_event ||= target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: :closed).last
end
def work_in_progress?
@@ -1158,6 +1169,7 @@ class MergeRequest < ApplicationRecord
def mergeable_ci_state?
return true unless project.only_allow_merge_if_pipeline_succeeds?
return false unless actual_head_pipeline
+ return true if project.allow_merge_on_skipped_pipeline? && actual_head_pipeline.skipped?
actual_head_pipeline.success?
end
@@ -1302,8 +1314,6 @@ class MergeRequest < ApplicationRecord
end
def has_accessibility_reports?
- return false unless Feature.enabled?(:accessibility_report_view, project)
-
actual_head_pipeline.present? && actual_head_pipeline.has_reports?(Ci::JobArtifact.accessibility_reports)
end
@@ -1568,6 +1578,10 @@ class MergeRequest < ApplicationRecord
deployments.visible.includes(:environment).order(id: :desc).limit(10)
end
+ def banzai_render_context(field)
+ super.merge(label_url_method: :project_merge_requests_url)
+ end
+
private
def with_rebase_lock
diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb
index eecb10e6dbc..de97fc33f8d 100644
--- a/app/models/merge_request_context_commit.rb
+++ b/app/models/merge_request_context_commit.rb
@@ -20,7 +20,7 @@ class MergeRequestContextCommit < ApplicationRecord
# create MergeRequestContextCommit by given commit sha and it's diff file record
def self.bulk_insert(*args)
- Gitlab::Database.bulk_insert('merge_request_context_commits', *args)
+ Gitlab::Database.bulk_insert('merge_request_context_commits', *args) # rubocop:disable Gitlab/BulkInsert
end
def to_commit
diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb
index 9dce7c53ab6..b89d1983ce3 100644
--- a/app/models/merge_request_context_commit_diff_file.rb
+++ b/app/models/merge_request_context_commit_diff_file.rb
@@ -12,6 +12,6 @@ class MergeRequestContextCommitDiffFile < ApplicationRecord
# create MergeRequestContextCommitDiffFile by given diff file record(s)
def self.bulk_insert(*args)
- Gitlab::Database.bulk_insert('merge_request_context_commit_diff_files', *args)
+ Gitlab::Database.bulk_insert('merge_request_context_commit_diff_files', *args) # rubocop:disable Gitlab/BulkInsert
end
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index f793bd3d76f..66b27aeac91 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -437,7 +437,7 @@ class MergeRequestDiff < ApplicationRecord
transaction do
MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all
- Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert
update!(stored_externally: false)
end
@@ -495,7 +495,7 @@ class MergeRequestDiff < ApplicationRecord
rows = build_external_merge_request_diff_files(rows) if use_external_diff?
# Faster inserts
- Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert
end
def build_external_diff_tempfile(rows)
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index 2819ea7ce1e..9f6933d0879 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -27,6 +27,6 @@ class MergeRequestDiffCommit < ApplicationRecord
)
end
- Gitlab::Database.bulk_insert(self.table_name, rows)
+ Gitlab::Database.bulk_insert(self.table_name, rows) # rubocop:disable Gitlab/BulkInsert
end
end
diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb
index 8166880f0c9..3383dda20c9 100644
--- a/app/models/metrics/dashboard/annotation.rb
+++ b/app/models/metrics/dashboard/annotation.rb
@@ -3,6 +3,8 @@
module Metrics
module Dashboard
class Annotation < ApplicationRecord
+ include DeleteWithLimit
+
self.table_name = 'metrics_dashboard_annotations'
belongs_to :environment, inverse_of: :metrics_dashboard_annotations
@@ -14,14 +16,25 @@ module Metrics
validates :panel_xid, length: { maximum: 255 }
validate :single_ownership
validate :orphaned_annotation
+ validate :ending_at_after_starting_at
scope :after, ->(after) { where('starting_at >= ?', after) }
scope :before, ->(before) { where('starting_at <= ?', before) }
scope :for_dashboard, ->(dashboard_path) { where(dashboard_path: dashboard_path) }
+ scope :ending_before, ->(timestamp) { where('COALESCE(ending_at, starting_at) < ?', timestamp) }
private
+ # If annotation has NULL in ending_at column that indicates, that this annotation IS TIED TO SINGLE POINT
+ # IN TIME designated by starting_at timestamp. It does NOT mean that annotation is ever going starting from
+ # stating_at timestamp
+ def ending_at_after_starting_at
+ return if ending_at.blank? || starting_at.blank? || starting_at <= ending_at
+
+ errors.add(:ending_at, s_("Metrics::Dashboard::Annotation|can't be before starting_at time"))
+ end
+
def single_ownership
return if cluster.nil? ^ environment.nil?
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index b5e4f62792e..58adfd5f70b 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -2,7 +2,6 @@
class Milestone < ApplicationRecord
include Sortable
- include Referable
include Timebox
include Milestoneish
include FromUnion
@@ -29,6 +28,7 @@ class Milestone < ApplicationRecord
scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) }
scope :reorder_by_due_date_asc, -> { reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
+ scope :with_api_entity_associations, -> { preload(project: [:project_feature, :route, namespace: :route]) }
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
@@ -122,35 +122,6 @@ class Milestone < ApplicationRecord
}
end
- ##
- # Returns the String necessary to reference a Milestone in Markdown. Group
- # milestones only support name references, and do not support cross-project
- # references.
- #
- # format - Symbol format to use (default: :iid, optional: :name)
- #
- # Examples:
- #
- # Milestone.first.to_reference # => "%1"
- # Milestone.first.to_reference(format: :name) # => "%\"goal\""
- # Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-foss%1"
- # Milestone.first.to_reference(same_namespace_project) # => "gitlab-foss%1"
- #
- def to_reference(from = nil, format: :name, full: false)
- format_reference = milestone_format_reference(format)
- reference = "#{self.class.reference_prefix}#{format_reference}"
-
- if project
- "#{project.to_reference_base(from, full: full)}#{reference}"
- else
- reference
- end
- end
-
- def reference_link_text(from = nil)
- self.class.reference_prefix + self.title
- end
-
def for_display
self
end
@@ -179,22 +150,12 @@ class Milestone < ApplicationRecord
end
end
- private
-
- def milestone_format_reference(format = :iid)
- 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!')
- end
-
- if format == :name && !name.include?('"')
- %("#{name}")
- else
- iid
- end
+ def subgroup_milestone?
+ group_milestone? && parent.subgroup?
end
+ private
+
def issues_finder_params
{ project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 8116f7a256f..90b4be7a674 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -100,11 +100,11 @@ class Namespace < ApplicationRecord
# Searches for namespaces matching the given query.
#
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
#
- # query - The search query as a String
+ # query - The search query as a String.
#
- # Returns an ActiveRecord::Relation
+ # Returns an ActiveRecord::Relation.
def search(query)
fuzzy_search(query, [:name, :path])
end
@@ -277,7 +277,7 @@ class Namespace < ApplicationRecord
end
def has_parent?
- parent.present?
+ parent_id.present? || parent.present?
end
def root_ancestor
diff --git a/app/models/note.rb b/app/models/note.rb
index d174ba8fe83..6b6a7c50b00 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -72,6 +72,7 @@ class Note < ApplicationRecord
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User'
+ belongs_to :review, inverse_of: :notes
has_many :todos
@@ -273,6 +274,10 @@ class Note < ApplicationRecord
noteable_type == "Snippet"
end
+ def for_alert_mangement_alert?
+ noteable_type == 'AlertManagement::Alert'
+ end
+
def for_personal_snippet?
noteable.is_a?(PersonalSnippet)
end
@@ -350,8 +355,10 @@ class Note < ApplicationRecord
self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
end
- def confidential?
- confidential || noteable.try(:confidential?)
+ def confidential?(include_noteable: false)
+ return true if confidential
+
+ include_noteable && noteable.try(:confidential?)
end
def editable?
@@ -393,7 +400,13 @@ class Note < ApplicationRecord
end
def noteable_ability_name
- for_snippet? ? 'snippet' : noteable_type.demodulize.underscore
+ if for_snippet?
+ 'snippet'
+ elsif for_alert_mangement_alert?
+ 'alert_management_alert'
+ else
+ noteable_type.demodulize.underscore
+ end
end
def can_be_discussion_note?
@@ -520,7 +533,7 @@ class Note < ApplicationRecord
end
def banzai_render_context(field)
- super.merge(noteable: noteable, system_note: system?)
+ super.merge(noteable: noteable, system_note: system?, label_url_method: noteable_label_url_method)
end
def retrieve_upload(_identifier, paths)
@@ -603,6 +616,10 @@ class Note < ApplicationRecord
errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT
end
+
+ def noteable_label_url_method
+ for_merge_request? ? :project_merge_requests_url : :project_issues_url
+ end
end
Note.prepend_if_ee('EE::Note')
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index da5e4012f05..856496f0941 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -49,11 +49,11 @@ class PagesDomain < ApplicationRecord
after_update :update_daemon, if: :saved_change_to_pages_config?
after_destroy :update_daemon
- scope :enabled, -> { where('enabled_until >= ?', Time.now ) }
+ scope :enabled, -> { where('enabled_until >= ?', Time.current ) }
scope :needs_verification, -> do
verified_at = arel_table[:verified_at]
enabled_until = arel_table[:enabled_until]
- threshold = Time.now + VERIFICATION_THRESHOLD
+ threshold = Time.current + VERIFICATION_THRESHOLD
where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold))))
end
@@ -69,7 +69,7 @@ class PagesDomain < ApplicationRecord
from_union([user_provided, certificate_not_valid, certificate_expiring])
end
- scope :for_removal, -> { where("remove_at < ?", Time.now) }
+ scope :for_removal, -> { where("remove_at < ?", Time.current) }
scope :with_logging_info, -> { includes(project: [:namespace, :route]) }
@@ -141,7 +141,7 @@ class PagesDomain < ApplicationRecord
def expired?
return false unless x509
- current = Time.new
+ current = Time.current
current < x509.not_before || x509.not_after < current
end
diff --git a/app/models/pages_domain_acme_order.rb b/app/models/pages_domain_acme_order.rb
index 63d7fbc8206..411456cc237 100644
--- a/app/models/pages_domain_acme_order.rb
+++ b/app/models/pages_domain_acme_order.rb
@@ -3,7 +3,7 @@
class PagesDomainAcmeOrder < ApplicationRecord
belongs_to :pages_domain
- scope :expired, -> { where("expires_at < ?", Time.now) }
+ scope :expired, -> { where("expires_at < ?", Time.current) }
validates :pages_domain, presence: true
validates :expires_at, presence: true
diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb
index 57222c61b36..b04e7e689cd 100644
--- a/app/models/performance_monitoring/prometheus_dashboard.rb
+++ b/app/models/performance_monitoring/prometheus_dashboard.rb
@@ -4,30 +4,38 @@ module PerformanceMonitoring
class PrometheusDashboard
include ActiveModel::Model
- attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating
+ attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating, :links
validates :dashboard, presence: true
validates :panel_groups, presence: true
class << self
def from_json(json_content)
- dashboard = new(
- dashboard: json_content['dashboard'],
- panel_groups: json_content['panel_groups'].map { |group| PrometheusPanelGroup.from_json(group) }
- )
-
- dashboard.tap(&:validate!)
+ build_from_hash(json_content).tap(&:validate!)
end
def find_for(project:, user:, path:, options: {})
- dashboard_response = Gitlab::Metrics::Dashboard::Finder.find(project, user, options.merge(dashboard_path: path))
- return unless dashboard_response[:status] == :success
+ template = { path: path, environment: options[:environment] }
+ rsp = Gitlab::Metrics::Dashboard::Finder.find(project, user, options.merge(dashboard_path: path))
+
+ case rsp[:http_status] || rsp[:status]
+ when :success
+ new(template.merge(rsp[:dashboard] || {})) # when there is empty dashboard file returned rsp is still a success
+ when :unprocessable_entity
+ new(template) # validation error
+ else
+ nil # any other error
+ end
+ end
+
+ private
+
+ def build_from_hash(attributes)
+ return new unless attributes.is_a?(Hash)
new(
- {
- path: path,
- environment: options[:environment]
- }.merge(dashboard_response[:dashboard])
+ dashboard: attributes['dashboard'],
+ panel_groups: attributes['panel_groups']&.map { |group| PrometheusPanelGroup.from_json(group) }
)
end
end
@@ -36,6 +44,15 @@ module PerformanceMonitoring
self.as_json(only: yaml_valid_attributes).to_yaml
end
+ # This method is planned to be refactored as a part of https://gitlab.com/gitlab-org/gitlab/-/issues/219398
+ # implementation. For new existing logic was reused to faster deliver MVC
+ def schema_validation_warnings
+ self.class.from_json(self.as_json)
+ nil
+ rescue ActiveModel::ValidationError => exception
+ exception.model.errors.map { |attr, error| "#{attr}: #{error}" }
+ end
+
private
def yaml_valid_attributes
diff --git a/app/models/performance_monitoring/prometheus_metric.rb b/app/models/performance_monitoring/prometheus_metric.rb
index 7b8bef906fa..d67b1809d93 100644
--- a/app/models/performance_monitoring/prometheus_metric.rb
+++ b/app/models/performance_monitoring/prometheus_metric.rb
@@ -10,16 +10,24 @@ module PerformanceMonitoring
validates :query, presence: true, unless: :query_range
validates :query_range, presence: true, unless: :query
- def self.from_json(json_content)
- metric = PrometheusMetric.new(
- id: json_content['id'],
- unit: json_content['unit'],
- label: json_content['label'],
- query: json_content['query'],
- query_range: json_content['query_range']
- )
+ class << self
+ def from_json(json_content)
+ build_from_hash(json_content).tap(&:validate!)
+ end
- metric.tap(&:validate!)
+ private
+
+ def build_from_hash(attributes)
+ return new unless attributes.is_a?(Hash)
+
+ new(
+ id: attributes['id'],
+ unit: attributes['unit'],
+ label: attributes['label'],
+ query: attributes['query'],
+ query_range: attributes['query_range']
+ )
+ end
end
end
end
diff --git a/app/models/performance_monitoring/prometheus_panel.rb b/app/models/performance_monitoring/prometheus_panel.rb
index 3fe029abda0..a16a68ba832 100644
--- a/app/models/performance_monitoring/prometheus_panel.rb
+++ b/app/models/performance_monitoring/prometheus_panel.rb
@@ -8,17 +8,24 @@ module PerformanceMonitoring
validates :title, presence: true
validates :metrics, presence: true
+ class << self
+ def from_json(json_content)
+ build_from_hash(json_content).tap(&:validate!)
+ end
- def self.from_json(json_content)
- panel = new(
- type: json_content['type'],
- title: json_content['title'],
- y_label: json_content['y_label'],
- weight: json_content['weight'],
- metrics: json_content['metrics'].map { |metric| PrometheusMetric.from_json(metric) }
- )
+ private
- panel.tap(&:validate!)
+ def build_from_hash(attributes)
+ return new unless attributes.is_a?(Hash)
+
+ new(
+ type: attributes['type'],
+ title: attributes['title'],
+ y_label: attributes['y_label'],
+ weight: attributes['weight'],
+ metrics: attributes['metrics']&.map { |metric| PrometheusMetric.from_json(metric) }
+ )
+ end
end
def id(group_title)
diff --git a/app/models/performance_monitoring/prometheus_panel_group.rb b/app/models/performance_monitoring/prometheus_panel_group.rb
index e672545fce3..f88106f259b 100644
--- a/app/models/performance_monitoring/prometheus_panel_group.rb
+++ b/app/models/performance_monitoring/prometheus_panel_group.rb
@@ -8,15 +8,22 @@ module PerformanceMonitoring
validates :group, presence: true
validates :panels, presence: true
+ class << self
+ def from_json(json_content)
+ build_from_hash(json_content).tap(&:validate!)
+ end
- def self.from_json(json_content)
- panel_group = new(
- group: json_content['group'],
- priority: json_content['priority'],
- panels: json_content['panels'].map { |panel| PrometheusPanel.from_json(panel) }
- )
+ private
- panel_group.tap(&:validate!)
+ def build_from_hash(attributes)
+ return new unless attributes.is_a?(Hash)
+
+ new(
+ group: attributes['group'],
+ priority: attributes['priority'],
+ panels: attributes['panels']&.map { |panel| PrometheusPanel.from_json(panel) }
+ )
+ end
end
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index c0dd2eb8584..845e9e83e78 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -33,6 +33,7 @@ class Project < ApplicationRecord
include OptionallySearch
include FromUnion
include IgnorableColumns
+ include Integration
extend Gitlab::Cache::RequestCache
extend Gitlab::ConfigHelper
@@ -95,8 +96,7 @@ class Project < ApplicationRecord
after_create :create_project_feature, unless: :project_feature
after_create :create_ci_cd_settings,
- unless: :ci_cd_settings,
- if: proc { ProjectCiCdSetting.available? }
+ unless: :ci_cd_settings
after_create :create_container_expiration_policy,
unless: :container_expiration_policy
@@ -198,7 +198,7 @@ class Project < ApplicationRecord
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
has_one :grafana_integration, inverse_of: :project
- has_one :project_setting, ->(project) { where_or_create_by(project: project) }, inverse_of: :project
+ has_one :project_setting, inverse_of: :project, autosave: true
has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting'
# Merge Requests for target project should be removed with it
@@ -282,7 +282,7 @@ class Project < ApplicationRecord
class_name: 'Ci::Pipeline',
inverse_of: :project
has_many :stages, class_name: 'Ci::Stage', inverse_of: :project
- has_many :ci_refs, class_name: 'Ci::Ref'
+ has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project
# Ci::Build objects store data on the file system such as artifact files and
# build traces. Currently there's no efficient way of removing this data in
@@ -291,6 +291,7 @@ class Project < ApplicationRecord
has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
+ has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project
has_many :job_artifacts, class_name: 'Ci::JobArtifact'
has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
@@ -328,6 +329,9 @@ class Project < ApplicationRecord
has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove'
+ has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
+ has_many :reviews, inverse_of: :project
+
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :project_setting, update_only: true
@@ -368,9 +372,11 @@ class Project < ApplicationRecord
delegate :root_ancestor, to: :namespace, allow_nil: true
delegate :last_pipeline, to: :commit, allow_nil: true
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
+ delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci
delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
+ delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, to: :project_setting
# Validations
validates :creator, presence: true, on: :create
@@ -442,7 +448,7 @@ class Project < ApplicationRecord
scope :archived, -> { where(archived: true) }
scope :non_archived, -> { where(archived: false) }
scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct }
- scope :with_push, -> { joins(:events).where('events.action = ?', Event::PUSHED) }
+ scope :with_push, -> { joins(:events).merge(Event.pushed_action) }
scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') }
scope :inc_routes, -> { includes(:route, namespace: :route) }
scope :with_statistics, -> { includes(:statistics) }
@@ -507,6 +513,11 @@ class Project < ApplicationRecord
.where(project_pages_metadata: { project_id: nil })
end
+ scope :with_api_entity_associations, -> {
+ preload(:project_feature, :route, :tags,
+ group: :ip_restrictions, namespace: [:route, :owner])
+ }
+
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
chronic_duration_attr :build_timeout_human_readable, :build_timeout,
@@ -521,6 +532,10 @@ class Project < ApplicationRecord
# Used by Projects::CleanupService to hold a map of rewritten object IDs
mount_uploader :bfg_object_map, AttachmentUploader
+ def self.with_web_entity_associations
+ preload(:project_feature, :route, :creator, :group, namespace: [:route, :owner])
+ end
+
def self.eager_load_namespace_and_owner
includes(namespace: :owner)
end
@@ -602,8 +617,7 @@ class Project < ApplicationRecord
# Searches for a list of projects based on the query given in `query`.
#
# On PostgreSQL this method uses "ILIKE" to perform a case-insensitive
- # search. On MySQL a regular "LIKE" is used as it's already
- # case-insensitive.
+ # search.
#
# query - The search query as a String.
def search(query, include_namespace: false)
@@ -713,6 +727,10 @@ class Project < ApplicationRecord
super
end
+ def project_setting
+ super.presence || build_project_setting
+ end
+
def all_pipelines
if builds_enabled?
super
@@ -729,6 +747,10 @@ class Project < ApplicationRecord
end
end
+ def active_webide_pipelines(user:)
+ webide_pipelines.running_or_pending.for_user(user)
+ end
+
def autoclose_referenced_issues
return true if super.nil?
@@ -798,10 +820,6 @@ class Project < ApplicationRecord
Feature.enabled?(:context_commits, default_enabled: true)
end
- def jira_issues_import_feature_flag_enabled?
- Feature.enabled?(:jira_issue_import, self, default_enabled: true)
- end
-
# LFS and hashed repository storage are required for using Design Management.
def design_management_enabled?
lfs_enabled? && hashed_storage?(:repository)
@@ -889,18 +907,6 @@ class Project < ApplicationRecord
latest_jira_import&.status || 'initial'
end
- def validate_jira_import_settings!(user: nil)
- raise Projects::ImportService::Error, _('Jira import feature is disabled.') unless jira_issues_import_feature_flag_enabled?
- raise Projects::ImportService::Error, _('Jira integration not configured.') unless jira_service&.active?
-
- if user
- raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user)
- raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self)
- end
-
- raise Projects::ImportService::Error, _('Unable to connect to the Jira instance. Please check your Jira integration configuration.') unless jira_service.test(nil)[:success]
- end
-
def human_import_status_name
import_state&.human_status_name || 'none'
end
@@ -921,17 +927,15 @@ class Project < ApplicationRecord
job_id
end
- # rubocop:disable Gitlab/RailsLogger
def log_import_activity(job_id, type: :import)
job_type = type.to_s.capitalize
if job_id
- Rails.logger.info("#{job_type} job scheduled for #{full_path} with job ID #{job_id}.")
+ Gitlab::AppLogger.info("#{job_type} job scheduled for #{full_path} with job ID #{job_id}.")
else
- Rails.logger.error("#{job_type} job failed to create for #{full_path}.")
+ Gitlab::AppLogger.error("#{job_type} job failed to create for #{full_path}.")
end
end
- # rubocop:enable Gitlab/RailsLogger
def reset_cache_and_import_attrs
run_after_commit do
@@ -1007,7 +1011,7 @@ class Project < ApplicationRecord
end
def jira_import?
- import_type == 'jira' && latest_jira_import.present? && jira_issues_import_feature_flag_enabled?
+ import_type == 'jira' && latest_jira_import.present?
end
def gitlab_project_import?
@@ -1036,7 +1040,7 @@ class Project < ApplicationRecord
remote_mirrors.stuck.update_all(
update_status: :failed,
last_error: _('The remote mirror took to long to complete.'),
- last_update_at: Time.now
+ last_update_at: Time.current
)
end
@@ -1194,14 +1198,6 @@ class Project < ApplicationRecord
get_issue(issue_id)
end
- def default_issue_tracker
- gitlab_issue_tracker_service || create_gitlab_issue_tracker_service
- end
-
- def issues_tracker
- external_issue_tracker || default_issue_tracker
- end
-
def external_issue_reference_pattern
external_issue_tracker.class.reference_pattern(only_long: issues_enabled?)
end
@@ -1257,7 +1253,7 @@ class Project < ApplicationRecord
available_services_names.map do |service_name|
find_or_initialize_service(service_name)
- end
+ end.sort_by(&:title)
end
def disabled_services
@@ -1267,16 +1263,7 @@ class Project < ApplicationRecord
def find_or_initialize_service(name)
return if disabled_services.include?(name)
- service = find_service(services, name)
- return service if service
-
- template = find_service(services_templates, name)
-
- if template
- Service.build_from_template(id, template)
- else
- public_send("build_#{name}_service") # rubocop:disable GitlabSecurity/PublicSend
- end
+ find_service(services, name) || build_from_instance_or_template(name) || public_send("build_#{name}_service") # rubocop:disable GitlabSecurity/PublicSend
end
# rubocop: disable CodeReuse/ServiceClass
@@ -1781,17 +1768,15 @@ class Project < ApplicationRecord
ensure_pages_metadatum.update!(deployed: false)
end
- # rubocop:disable Gitlab/RailsLogger
def write_repository_config(gl_full_path: full_path)
# We'd need to keep track of project full path otherwise directory tree
# created with hashed storage enabled cannot be usefully imported using
# the import rake task.
repository.raw_repository.write_config(full_path: gl_full_path)
rescue Gitlab::Git::Repository::NoRepository => e
- Rails.logger.error("Error writing to .git/config for project #{full_path} (#{id}): #{e.message}.")
+ Gitlab::AppLogger.error("Error writing to .git/config for project #{full_path} (#{id}): #{e.message}.")
nil
end
- # rubocop:enable Gitlab/RailsLogger
def after_import
repository.expire_content_cache
@@ -1834,17 +1819,15 @@ class Project < ApplicationRecord
@pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self)
end
- # rubocop:disable Gitlab/RailsLogger
def add_export_job(current_user:, after_export_strategy: nil, params: {})
job_id = ProjectExportWorker.perform_async(current_user.id, self.id, after_export_strategy, params)
if job_id
- Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}"
+ Gitlab::AppLogger.info "Export job started for project ID #{self.id} with job ID #{job_id}"
else
- Rails.logger.error "Export job failed to start for project ID #{self.id}"
+ Gitlab::AppLogger.error "Export job failed to start for project ID #{self.id}"
end
end
- # rubocop:enable Gitlab/RailsLogger
def import_export_shared
@import_export_shared ||= Gitlab::ImportExport::Shared.new(self)
@@ -2082,21 +2065,6 @@ class Project < ApplicationRecord
end
end
- def change_repository_storage(new_repository_storage_key)
- return if repository_read_only?
- return if repository_storage == new_repository_storage_key
-
- raise ArgumentError unless ::Gitlab.config.repositories.storages.key?(new_repository_storage_key)
-
- storage_move = repository_storage_moves.create!(
- source_storage_name: repository_storage,
- destination_storage_name: new_repository_storage_key
- )
- storage_move.schedule!
-
- self.repository_read_only = true
- end
-
def pushes_since_gc
Gitlab::Redis::SharedState.with { |redis| redis.get(pushes_since_gc_redis_shared_state_key).to_i }
end
@@ -2438,12 +2406,32 @@ class Project < ApplicationRecord
touch(:last_activity_at, :last_repository_updated_at)
end
+ def metrics_setting
+ super || build_metrics_setting
+ end
+
private
def find_service(services, name)
services.find { |service| service.to_param == name }
end
+ def build_from_instance_or_template(name)
+ instance = find_service(services_instances, name)
+ return Service.build_from_integration(id, instance) if instance
+
+ template = find_service(services_templates, name)
+ return Service.build_from_integration(id, template) if template
+ end
+
+ def services_templates
+ @services_templates ||= Service.templates
+ end
+
+ def services_instances
+ @services_instances ||= Service.instances
+ end
+
def closest_namespace_setting(name)
namespace.closest_setting(name)
end
@@ -2572,10 +2560,6 @@ class Project < ApplicationRecord
end
end
- def services_templates
- @services_templates ||= Service.where(template: true)
- end
-
def ensure_pages_metadatum
pages_metadatum || create_pages_metadatum!
rescue ActiveRecord::RecordNotUnique
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index c295837002a..e5fc481b035 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -3,9 +3,6 @@
class ProjectCiCdSetting < ApplicationRecord
belongs_to :project, inverse_of: :ci_cd_settings
- # The version of the schema that first introduced this model/table.
- MINIMUM_SCHEMA_VERSION = 20180403035759
-
DEFAULT_GIT_DEPTH = 50
before_create :set_default_git_depth
@@ -20,16 +17,6 @@ class ProjectCiCdSetting < ApplicationRecord
default_value_for :forward_deployment_enabled, true
- def self.available?
- @available ||=
- ActiveRecord::Migrator.current_version >= MINIMUM_SCHEMA_VERSION
- end
-
- def self.reset_column_information
- @available = nil
- super
- end
-
def forward_deployment_enabled?
super && ::Feature.enabled?(:forward_deployment_enabled, project, default_enabled: true)
end
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 9201cd24d66..b3ebcbd4b17 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -1,51 +1,16 @@
# frozen_string_literal: true
class ProjectFeature < ApplicationRecord
- # == Project features permissions
- #
- # Grants access level to project tools
- #
- # Tools can be enabled only for users, everyone or disabled
- # Access control is made only for non private projects
- #
- # levels:
- #
- # Disabled: not enabled for anyone
- # Private: enabled only for team members
- # Enabled: enabled for everyone able to access the project
- # Public: enabled for everyone (only allowed for pages)
- #
-
- # Permission levels
- DISABLED = 0
- PRIVATE = 10
- ENABLED = 20
- PUBLIC = 30
+ include Featurable
FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze
+
+ set_available_features(FEATURES)
+
PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER, metrics_dashboard: Gitlab::Access::REPORTER }.freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze
- STRING_OPTIONS = HashWithIndifferentAccess.new({
- 'disabled' => DISABLED,
- 'private' => PRIVATE,
- 'enabled' => ENABLED,
- 'public' => PUBLIC
- }).freeze
class << self
- def access_level_attribute(feature)
- feature = ensure_feature!(feature)
-
- "#{feature}_access_level".to_sym
- end
-
- def quoted_access_level_column(feature)
- attribute = connection.quote_column_name(access_level_attribute(feature))
- table = connection.quote_table_name(table_name)
-
- "#{table}.#{attribute}"
- end
-
def required_minimum_access_level(feature)
feature = ensure_feature!(feature)
@@ -60,24 +25,6 @@ class ProjectFeature < ApplicationRecord
required_minimum_access_level(feature)
end
end
-
- def access_level_from_str(level)
- STRING_OPTIONS.fetch(level)
- end
-
- def str_from_access_level(level)
- STRING_OPTIONS.key(level)
- end
-
- private
-
- def ensure_feature!(feature)
- feature = feature.model_name.plural if feature.respond_to?(:model_name)
- feature = feature.to_sym
- 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
@@ -107,45 +54,6 @@ class ProjectFeature < ApplicationRecord
end
end
- def feature_available?(feature, user)
- # 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, feature)
- end
-
- def access_level(feature)
- public_send(ProjectFeature.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def string_access_level(feature)
- ProjectFeature.str_from_access_level(access_level(feature))
- end
-
- def builds_enabled?
- builds_access_level > DISABLED
- end
-
- def wiki_enabled?
- wiki_access_level > DISABLED
- end
-
- def merge_requests_enabled?
- merge_requests_access_level > DISABLED
- end
-
- def forking_enabled?
- forking_access_level > DISABLED
- end
-
- def issues_enabled?
- issues_access_level > DISABLED
- end
-
- def pages_enabled?
- pages_access_level > DISABLED
- end
-
def public_pages?
return true unless Gitlab.config.pages.access_control
@@ -164,7 +72,7 @@ class ProjectFeature < ApplicationRecord
# which cannot be higher than repository access level
def repository_children_level
validator = lambda do |field|
- level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend
+ level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > repository_access_level
self.errors.add(field, "cannot have higher visibility level than repository access level") if not_allowed
end
@@ -175,8 +83,8 @@ class ProjectFeature < ApplicationRecord
# Validates access level for other than pages cannot be PUBLIC
def allowed_access_levels
validator = lambda do |field|
- level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend
- not_allowed = level > ProjectFeature::ENABLED
+ level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend
+ not_allowed = level > ENABLED
self.errors.add(field, "cannot have public visibility level") if not_allowed
end
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index f1c491d1a05..f065246e8af 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -15,8 +15,6 @@ class ProjectGroupLink < ApplicationRecord
scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
- after_commit :refresh_group_members_authorized_projects
-
alias_method :shared_with_group, :group
def self.access_options
@@ -49,10 +47,6 @@ class ProjectGroupLink < ApplicationRecord
errors.add(:base, _("Project cannot be shared with the group it is in or one of its ancestors."))
end
end
-
- def refresh_group_members_authorized_projects
- group.refresh_members_authorized_projects
- end
end
ProjectGroupLink.prepend_if_ee('EE::ProjectGroupLink')
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index e434ea58729..4bd3ffbea2f 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -84,7 +84,11 @@ class ProjectImportState < ApplicationRecord
update_column(:last_error, sanitized_message)
rescue ActiveRecord::ActiveRecordError => e
- Rails.logger.error("Error setting import status to failed: #{e.message}. Original error: #{sanitized_message}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::Import::Logger.error(
+ message: 'Error setting import status to failed',
+ error: e.message,
+ original_error: sanitized_message
+ )
ensure
@errors = original_errors
end
diff --git a/app/models/project_metrics_setting.rb b/app/models/project_metrics_setting.rb
index a2a7dc571a4..c66d0f52f4c 100644
--- a/app/models/project_metrics_setting.rb
+++ b/app/models/project_metrics_setting.rb
@@ -4,6 +4,13 @@ class ProjectMetricsSetting < ApplicationRecord
belongs_to :project
validates :external_dashboard_url,
+ allow_nil: true,
length: { maximum: 255 },
addressable_url: { enforce_sanitization: true, ascii_only: true }
+
+ enum dashboard_timezone: { local: 0, utc: 1 }
+
+ def dashboard_timezone=(val)
+ super(val&.downcase)
+ end
end
diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb
index e88cc5cfca6..b18d9765a57 100644
--- a/app/models/project_repository_storage_move.rb
+++ b/app/models/project_repository_storage_move.rb
@@ -18,6 +18,7 @@ class ProjectRepositoryStorageMove < ApplicationRecord
on: :create,
presence: true,
inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
+ validate :project_repository_writable, on: :create
state_machine initial: :initial do
event :schedule do
@@ -36,7 +37,9 @@ class ProjectRepositoryStorageMove < ApplicationRecord
transition [:initial, :scheduled, :started] => :failed
end
- after_transition initial: :scheduled do |storage_move, _|
+ after_transition initial: :scheduled do |storage_move|
+ storage_move.project.update_column(:repository_read_only, true)
+
storage_move.run_after_commit do
ProjectUpdateRepositoryStorageWorker.perform_async(
storage_move.project_id,
@@ -46,6 +49,17 @@ class ProjectRepositoryStorageMove < ApplicationRecord
end
end
+ after_transition started: :finished do |storage_move|
+ storage_move.project.update_columns(
+ repository_read_only: false,
+ repository_storage: storage_move.destination_storage_name
+ )
+ end
+
+ after_transition started: :failed do |storage_move|
+ storage_move.project.update_column(:repository_read_only, false)
+ end
+
state :initial, value: 1
state :scheduled, value: 2
state :started, value: 3
@@ -55,4 +69,10 @@ class ProjectRepositoryStorageMove < ApplicationRecord
scope :order_created_at_desc, -> { order(created_at: :desc) }
scope :with_projects, -> { includes(project: :route) }
+
+ private
+
+ def project_repository_writable
+ errors.add(:project, _('is read only')) if project&.repository_read_only?
+ end
end
diff --git a/app/models/project_services/alerts_service.rb b/app/models/project_services/alerts_service.rb
index 16bf37fd189..58c47accfd1 100644
--- a/app/models/project_services/alerts_service.rb
+++ b/app/models/project_services/alerts_service.rb
@@ -41,7 +41,7 @@ class AlertsService < Service
end
def description
- _('Receive alerts on GitLab from any source')
+ _('Authorize external services to send alerts to GitLab')
end
def detailed_description
diff --git a/app/models/project_services/chat_message/alert_message.rb b/app/models/project_services/chat_message/alert_message.rb
new file mode 100644
index 00000000000..c8913775843
--- /dev/null
+++ b/app/models/project_services/chat_message/alert_message.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module ChatMessage
+ class AlertMessage < BaseMessage
+ attr_reader :title
+ attr_reader :alert_url
+ attr_reader :severity
+ attr_reader :events
+ attr_reader :status
+ attr_reader :started_at
+
+ def initialize(params)
+ @project_name = params[:project_name] || params.dig(:project, :path_with_namespace)
+ @project_url = params.dig(:project, :web_url) || params[:project_url]
+ @title = params.dig(:object_attributes, :title)
+ @alert_url = params.dig(:object_attributes, :url)
+ @severity = params.dig(:object_attributes, :severity)
+ @events = params.dig(:object_attributes, :events)
+ @status = params.dig(:object_attributes, :status)
+ @started_at = params.dig(:object_attributes, :started_at)
+ end
+
+ def attachments
+ [{
+ title: title,
+ title_link: alert_url,
+ color: attachment_color,
+ fields: attachment_fields
+ }]
+ end
+
+ def message
+ "Alert firing in #{project_name}"
+ end
+
+ private
+
+ def attachment_color
+ "#C95823"
+ end
+
+ def attachment_fields
+ [
+ {
+ title: "Severity",
+ value: severity.to_s.humanize,
+ short: true
+ },
+ {
+ title: "Events",
+ value: events,
+ short: true
+ },
+ {
+ title: "Status",
+ value: status.to_s.humanize,
+ short: true
+ },
+ {
+ title: "Start time",
+ value: format_time(started_at),
+ short: true
+ }
+ ]
+ end
+
+ # This formats time into the following format
+ # April 23rd, 2020 1:06AM UTC
+ def format_time(time)
+ time = Time.zone.parse(time.to_s)
+ time.strftime("%B #{time.day.ordinalize}, %Y %l:%M%p %Z")
+ end
+ end
+end
diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb
index 0a2d9120adc..c4fcdff8386 100644
--- a/app/models/project_services/chat_message/merge_message.rb
+++ b/app/models/project_services/chat_message/merge_message.rb
@@ -48,7 +48,7 @@ module ChatMessage
end
def merge_request_message
- "#{user_combined_name} #{state_or_action_text} #{merge_request_link} in #{project_link}"
+ "#{user_combined_name} #{state_or_action_text} merge request #{merge_request_link} in #{project_link}"
end
def merge_request_link
diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb
index 1cd3837433f..f4c6938fa78 100644
--- a/app/models/project_services/chat_message/pipeline_message.rb
+++ b/app/models/project_services/chat_message/pipeline_message.rb
@@ -183,7 +183,7 @@ module ChatMessage
if ref_type == 'tag'
"#{project_url}/-/tags/#{ref}"
else
- "#{project_url}/commits/#{ref}"
+ "#{project_url}/-/commits/#{ref}"
end
end
@@ -200,14 +200,14 @@ module ChatMessage
end
def pipeline_failed_jobs_url
- "#{project_url}/pipelines/#{pipeline_id}/failures"
+ "#{project_url}/-/pipelines/#{pipeline_id}/failures"
end
def pipeline_url
if failed_jobs.any?
pipeline_failed_jobs_url
else
- "#{project_url}/pipelines/#{pipeline_id}"
+ "#{project_url}/-/pipelines/#{pipeline_id}"
end
end
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index c92e8ecb31c..ad531412fb7 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -252,8 +252,8 @@ class HipchatService < Service
status = pipeline_attributes[:status]
duration = pipeline_attributes[:duration]
- branch_link = "<a href=\"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"
- pipeline_url = "<a href=\"#{project_url}/pipelines/#{pipeline_id}\">##{pipeline_id}</a>"
+ branch_link = "<a href=\"#{project_url}/-/commits/#{CGI.escape(ref)}\">#{ref}</a>"
+ pipeline_url = "<a href=\"#{project_url}/-/pipelines/#{pipeline_id}\">##{pipeline_id}</a>"
"#{project_link}: Pipeline #{pipeline_url} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)"
end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 53da874ede8..bb4d35cad22 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -6,6 +6,8 @@ class JiraService < IssueTrackerService
include ApplicationHelper
include ActionView::Helpers::AssetUrlHelper
+ PROJECTS_PER_PAGE = 50
+
validates :url, public_url: true, presence: true, if: :activated?
validates :api_url, public_url: true, allow_blank: true
validates :username, presence: true, if: :activated?
@@ -201,6 +203,10 @@ class JiraService < IssueTrackerService
add_comment(data, jira_issue)
end
+ def valid_connection?
+ test(nil)[:success]
+ end
+
def test(_)
result = test_settings
success = result.present?
@@ -209,11 +215,6 @@ class JiraService < IssueTrackerService
{ success: success, result: result }
end
- # Jira does not need test data.
- def test_data(_, _)
- nil
- end
-
override :support_close_issue?
def support_close_issue?
true
@@ -413,17 +414,9 @@ class JiraService < IssueTrackerService
# Handle errors when doing Jira API calls
def jira_request
yield
- rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error
+ rescue => error
@error = error
- log_error(
- "Error sending message",
- client_url: client_url,
- error: {
- exception_class: error.class.name,
- exception_message: error.message,
- exception_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(error.backtrace)
- }
- )
+ log_error("Error sending message", client_url: client_url, error: @error.message)
nil
end
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index a58a264de5e..c11b2f7cc65 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -56,12 +56,6 @@ class PipelinesEmailService < Service
project&.ci_pipelines&.any?
end
- def test_data(project, user)
- data = Gitlab::DataBuilder::Pipeline.build(project.ci_pipelines.last)
- data[:user] = user.hook_attrs
- data
- end
-
def fields
[
{ type: 'textarea',
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 4a28d1ff2b0..44a41969b1c 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -5,6 +5,8 @@ class PrometheusService < MonitoringService
# Access to prometheus is directly through the API
prop_accessor :api_url
+ prop_accessor :google_iap_service_account_json
+ prop_accessor :google_iap_audience_client_id
boolean_accessor :manual_configuration
# We need to allow the self-monitoring project to connect to the internal
@@ -49,7 +51,7 @@ class PrometheusService < MonitoringService
end
def fields
- [
+ result = [
{
type: 'checkbox',
name: 'manual_configuration',
@@ -64,6 +66,28 @@ class PrometheusService < MonitoringService
required: true
}
]
+
+ if Feature.enabled?(:prometheus_service_iap_auth)
+ result += [
+ {
+ type: 'text',
+ name: 'google_iap_audience_client_id',
+ title: 'Google IAP Audience Client ID',
+ placeholder: s_('PrometheusService|Client ID of the IAP secured resource (looks like IAP_CLIENT_ID.apps.googleusercontent.com)'),
+ autocomplete: 'off',
+ required: false
+ },
+ {
+ type: 'textarea',
+ name: 'google_iap_service_account_json',
+ title: 'Google IAP Service Account JSON',
+ placeholder: s_('PrometheusService|Contents of the credentials.json file of your service account, like: { "type": "service_account", "project_id": ... }'),
+ required: false
+ }
+ ]
+ end
+
+ result
end
# Check we can connect to the Prometheus API
@@ -77,7 +101,14 @@ class PrometheusService < MonitoringService
def prometheus_client
return unless should_return_client?
- Gitlab::PrometheusClient.new(api_url, allow_local_requests: allow_local_api_url?)
+ options = { allow_local_requests: allow_local_api_url? }
+
+ if Feature.enabled?(:prometheus_service_iap_auth) && behind_iap?
+ # Adds the Authorization header
+ options[:headers] = iap_client.apply({})
+ end
+
+ Gitlab::PrometheusClient.new(api_url, options)
end
def prometheus_available?
@@ -149,4 +180,12 @@ class PrometheusService < MonitoringService
Prometheus::CreateDefaultAlertsWorker.perform_async(project_id)
end
+
+ def behind_iap?
+ manual_configuration? && google_iap_audience_client_id.present? && google_iap_service_account_json.present?
+ end
+
+ def iap_client
+ @iap_client ||= Google::Auth::Credentials.new(Gitlab::Json.parse(google_iap_service_account_json), target_audience: google_iap_audience_client_id).client
+ end
end
diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb
index 6d567bb1383..79245e84238 100644
--- a/app/models/project_services/slack_service.rb
+++ b/app/models/project_services/slack_service.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class SlackService < ChatNotificationService
+ prop_accessor EVENT_CHANNEL['alert']
+
def title
'Slack notifications'
end
@@ -21,13 +23,25 @@ class SlackService < ChatNotificationService
'https://hooks.slack.com/services/…'
end
+ def supported_events
+ additional = []
+ additional << 'alert'
+
+ super + additional
+ end
+
+ def get_message(object_kind, data)
+ return ChatMessage::AlertMessage.new(data) if object_kind == 'alert'
+
+ super
+ end
+
module Notifier
private
def notify(message, opts)
# See https://gitlab.com/gitlab-org/slack-notifier/#custom-http-client
notifier = Slack::Messenger.new(webhook, opts.merge(http_client: HTTPClient))
-
notifier.ping(
message.pretext,
attachments: message.attachments,
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 7c93faf3928..9022d3e879d 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -4,10 +4,6 @@ class ProjectSetting < ApplicationRecord
belongs_to :project, inverse_of: :project_setting
self.primary_key = :project_id
-
- def self.where_or_create_by(attrs)
- where(primary_key => safe_find_or_create_by(attrs))
- end
end
ProjectSetting.prepend_if_ee('EE::ProjectSetting')
diff --git a/app/models/prometheus_alert_event.rb b/app/models/prometheus_alert_event.rb
index 7e61f6d5e3c..25f58a0b9d5 100644
--- a/app/models/prometheus_alert_event.rb
+++ b/app/models/prometheus_alert_event.rb
@@ -34,10 +34,4 @@ class PrometheusAlertEvent < ApplicationRecord
def self.status_value_for(name)
state_machines[:status].states[name].value
end
-
- def self.payload_key_for(gitlab_alert_id, started_at)
- plain = [gitlab_alert_id, started_at].join('/')
-
- Digest::SHA1.hexdigest(plain)
- end
end
diff --git a/app/models/push_event.rb b/app/models/push_event.rb
index 5cab686f20b..0f626cb618e 100644
--- a/app/models/push_event.rb
+++ b/app/models/push_event.rb
@@ -68,7 +68,7 @@ class PushEvent < Event
end
def self.sti_name
- PUSHED
+ actions[:pushed]
end
def push_action?
@@ -111,7 +111,7 @@ class PushEvent < Event
end
def validate_push_action
- return if action == PUSHED
+ return if pushed_action?
errors.add(:action, "the action #{action.inspect} is not valid")
end
diff --git a/app/models/releases/evidence.rb b/app/models/releases/evidence.rb
index 1aac7e33e41..7c428f5ad03 100644
--- a/app/models/releases/evidence.rb
+++ b/app/models/releases/evidence.rb
@@ -1,44 +1,35 @@
# frozen_string_literal: true
-class Releases::Evidence < ApplicationRecord
- include ShaAttribute
- include Presentable
+module Releases
+ class Evidence < ApplicationRecord
+ include ShaAttribute
+ include Presentable
- belongs_to :release, inverse_of: :evidences
+ belongs_to :release, inverse_of: :evidences
- before_validation :generate_summary_and_sha
+ default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope
- default_scope { order(created_at: :asc) }
+ sha_attribute :summary_sha
+ alias_attribute :collected_at, :created_at
+ alias_attribute :sha, :summary_sha
- sha_attribute :summary_sha
- alias_attribute :collected_at, :created_at
-
- def milestones
- @milestones ||= release.milestones.includes(:issues)
- end
-
- ##
- # Return `summary` without sensitive information.
- #
- # Removing issues from summary in order to prevent leaking confidential ones.
- # See more https://gitlab.com/gitlab-org/gitlab/issues/121930
- def summary
- safe_summary = read_attribute(:summary)
-
- safe_summary.dig('release', 'milestones')&.each do |milestone|
- milestone.delete('issues')
+ def milestones
+ @milestones ||= release.milestones.includes(:issues)
end
- safe_summary
- end
-
- private
+ ##
+ # Return `summary` without sensitive information.
+ #
+ # Removing issues from summary in order to prevent leaking confidential ones.
+ # See more https://gitlab.com/gitlab-org/gitlab/issues/121930
+ def summary
+ safe_summary = read_attribute(:summary)
- def generate_summary_and_sha
- summary = Evidences::EvidenceSerializer.new.represent(self) # rubocop: disable CodeReuse/Serializer
- return unless summary
+ safe_summary.dig('release', 'milestones')&.each do |milestone|
+ milestone.delete('issues')
+ end
- self.summary = summary
- self.summary_sha = Gitlab::CryptoHelper.sha256(summary)
+ safe_summary
+ end
end
end
diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb
index 65be2a22958..dc7e78a85a9 100644
--- a/app/models/releases/link.rb
+++ b/app/models/releases/link.rb
@@ -14,6 +14,13 @@ module Releases
scope :sorted, -> { order(created_at: :desc) }
+ enum link_type: {
+ other: 0,
+ runbook: 1,
+ package: 2,
+ image: 3
+ }
+
def internal?
url.start_with?(release.project.web_url)
end
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index 8e7612e63c8..8b15d481c1b 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -68,13 +68,13 @@ class RemoteMirror < ApplicationRecord
after_transition any => :started do |remote_mirror, _|
Gitlab::Metrics.add_event(:remote_mirrors_running)
- remote_mirror.update(last_update_started_at: Time.now)
+ remote_mirror.update(last_update_started_at: Time.current)
end
after_transition started: :finished do |remote_mirror, _|
Gitlab::Metrics.add_event(:remote_mirrors_finished)
- timestamp = Time.now
+ timestamp = Time.current
remote_mirror.update!(
last_update_at: timestamp,
last_successful_update_at: timestamp,
@@ -86,7 +86,7 @@ class RemoteMirror < ApplicationRecord
after_transition started: :failed do |remote_mirror|
Gitlab::Metrics.add_event(:remote_mirrors_failed)
- remote_mirror.update(last_update_at: Time.now)
+ remote_mirror.update(last_update_at: Time.current)
remote_mirror.run_after_commit do
RemoteMirrorNotificationWorker.perform_async(remote_mirror.id)
@@ -144,9 +144,9 @@ class RemoteMirror < ApplicationRecord
return unless sync?
if recently_scheduled?
- RepositoryUpdateRemoteMirrorWorker.perform_in(backoff_delay, self.id, Time.now)
+ RepositoryUpdateRemoteMirrorWorker.perform_in(backoff_delay, self.id, Time.current)
else
- RepositoryUpdateRemoteMirrorWorker.perform_async(self.id, Time.now)
+ RepositoryUpdateRemoteMirrorWorker.perform_async(self.id, Time.current)
end
end
@@ -261,7 +261,7 @@ class RemoteMirror < ApplicationRecord
def recently_scheduled?
return false unless self.last_update_started_at
- self.last_update_started_at >= Time.now - backoff_delay
+ self.last_update_started_at >= Time.current - backoff_delay
end
def reset_fields
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 2673033ff1f..911cfc7db38 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -950,7 +950,6 @@ class Repository
async_remove_remote(remote_name) if tmp_remote_name
end
- # rubocop:disable Gitlab/RailsLogger
def async_remove_remote(remote_name)
return unless remote_name
return unless project
@@ -958,14 +957,13 @@ class Repository
job_id = RepositoryRemoveRemoteWorker.perform_async(project.id, remote_name)
if job_id
- Rails.logger.info("Remove remote job scheduled for #{project.id} with remote name: #{remote_name} job ID #{job_id}.")
+ Gitlab::AppLogger.info("Remove remote job scheduled for #{project.id} with remote name: #{remote_name} job ID #{job_id}.")
else
- Rails.logger.info("Remove remote job failed to create for #{project.id} with remote name #{remote_name}.")
+ Gitlab::AppLogger.info("Remove remote job failed to create for #{project.id} with remote name #{remote_name}.")
end
job_id
end
- # rubocop:enable Gitlab/RailsLogger
def fetch_source_branch!(source_repository, source_branch, local_ref)
raw_repository.fetch_source_branch!(source_repository.raw_repository, source_branch, local_ref)
@@ -1171,7 +1169,7 @@ class Repository
if target
target.committed_date
else
- Time.now
+ Time.current
end
end
end
diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb
index e468d716239..6b1793a551f 100644
--- a/app/models/repository_language.rb
+++ b/app/models/repository_language.rb
@@ -4,7 +4,7 @@ class RepositoryLanguage < ApplicationRecord
belongs_to :project
belongs_to :programming_language
- default_scope { includes(:programming_language) }
+ default_scope { includes(:programming_language) } # rubocop:disable Cop/DefaultScope
validates :project, presence: true
validates :share, inclusion: { in: 0..100, message: "The share of a language is between 0 and 100" }
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index 845be408d5e..cc96698be09 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -71,6 +71,14 @@ class ResourceLabelEvent < ResourceEvent
end
end
+ def self.visible_to_user?(user, events)
+ ResourceLabelEvent.preload_label_subjects(events)
+
+ events.select do |event|
+ Ability.allowed?(user, :read_label, event)
+ end
+ end
+
private
def label_reference
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index 039f26d8e3f..36068cf508b 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -9,6 +9,8 @@ class ResourceMilestoneEvent < ResourceEvent
validate :exactly_one_issuable
+ scope :include_relations, -> { includes(:user, milestone: [:project, :group]) }
+
enum action: {
add: 1,
remove: 2
@@ -26,4 +28,12 @@ class ResourceMilestoneEvent < ResourceEvent
def milestone_title
milestone&.title
end
+
+ def milestone_parent
+ milestone&.parent
+ end
+
+ def issuable
+ issue || merge_request
+ end
end
diff --git a/app/models/review.rb b/app/models/review.rb
new file mode 100644
index 00000000000..5a30e2963c8
--- /dev/null
+++ b/app/models/review.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Review < ApplicationRecord
+ include Participable
+ include Mentionable
+
+ belongs_to :author, class_name: 'User', foreign_key: :author_id, inverse_of: :reviews
+ belongs_to :merge_request, inverse_of: :reviews
+ belongs_to :project, inverse_of: :reviews
+
+ has_many :notes, -> { order(:id) }, inverse_of: :review
+
+ delegate :name, to: :author, prefix: true
+
+ participant :author
+
+ def all_references(current_user = nil, extractor: nil)
+ ext = super
+
+ notes.each do |note|
+ note.all_references(current_user, extractor: ext)
+ end
+
+ ext
+ end
+
+ def user_mentions
+ merge_request.user_mentions.where.not(note_id: nil)
+ end
+end
diff --git a/app/models/route.rb b/app/models/route.rb
index 63a0461807b..706589e79b8 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -42,7 +42,7 @@ class Route < ApplicationRecord
old_path = route.path
# Callbacks must be run manually
- route.update_columns(attributes.merge(updated_at: Time.now))
+ route.update_columns(attributes.merge(updated_at: Time.current))
# We are not calling route.delete_conflicting_redirects here, in hopes
# of avoiding deadlocks. The parent (self, in this method) already
diff --git a/app/models/self_managed_prometheus_alert_event.rb b/app/models/self_managed_prometheus_alert_event.rb
index d2d4a5c37d4..cf26563e92d 100644
--- a/app/models/self_managed_prometheus_alert_event.rb
+++ b/app/models/self_managed_prometheus_alert_event.rb
@@ -15,10 +15,4 @@ class SelfManagedPrometheusAlertEvent < ApplicationRecord
yield event if block_given?
end
end
-
- def self.payload_key_for(started_at, alert_name, query_expression)
- plain = [started_at, alert_name, query_expression].join('/')
-
- Digest::SHA1.hexdigest(plain)
- end
end
diff --git a/app/models/service.rb b/app/models/service.rb
index fb4d9a77077..2880526c9de 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -22,6 +22,7 @@ class Service < ApplicationRecord
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
default_value_for :active, false
+ default_value_for :alert_events, true
default_value_for :push_events, true
default_value_for :issues_events, true
default_value_for :confidential_issues_events, true
@@ -72,6 +73,7 @@ class Service < ApplicationRecord
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 :alert_hooks, -> { where(alert_events: true, active: true) }
scope :external_issue_trackers, -> { issue_trackers.active.without_defaults }
scope :deployment, -> { where(category: 'deployment') }
@@ -134,8 +136,12 @@ class Service < ApplicationRecord
%w(active)
end
- def test_data(project, user)
- Gitlab::DataBuilder::Push.build_sample(project, user)
+ def to_service_hash
+ as_json(methods: :type, except: %w[id template instance project_id])
+ end
+
+ def to_data_fields_hash
+ data_fields.as_json(only: data_fields.class.column_names).except('id', 'service_id')
end
def event_channel_names
@@ -164,7 +170,7 @@ class Service < ApplicationRecord
end
def configurable_events
- events = self.class.supported_events
+ events = supported_events
# No need to disable individual triggers when there is only one
if events.count == 1
@@ -335,17 +341,19 @@ class Service < ApplicationRecord
services_names.map { |service_name| "#{service_name}_service".camelize }
end
- def self.build_from_template(project_id, template)
- service = template.dup
+ def self.build_from_integration(project_id, integration)
+ service = integration.dup
- if template.supports_data_fields?
- data_fields = template.data_fields.dup
+ if integration.supports_data_fields?
+ data_fields = integration.data_fields.dup
data_fields.service = service
end
service.template = false
+ service.instance = false
+ service.inherit_from_id = integration.id if integration.instance?
service.project_id = project_id
- service.active = false if service.active? && service.invalid?
+ service.active = false if service.invalid?
service
end
@@ -394,6 +402,8 @@ class Service < ApplicationRecord
"Event will be triggered when a commit is created/updated"
when "deployment"
"Event will be triggered when a deployment finishes"
+ when "alert"
+ "Event will be triggered when a new, unique alert is recorded"
end
end
diff --git a/app/models/service_list.rb b/app/models/service_list.rb
new file mode 100644
index 00000000000..fa3760f0c56
--- /dev/null
+++ b/app/models/service_list.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class ServiceList
+ def initialize(batch, service_hash, extra_hash = {})
+ @batch = batch
+ @service_hash = service_hash
+ @extra_hash = extra_hash
+ end
+
+ def to_array
+ [Service, columns, values]
+ end
+
+ private
+
+ attr_reader :batch, :service_hash, :extra_hash
+
+ def columns
+ (service_hash.keys << 'project_id') + extra_hash.keys
+ end
+
+ def values
+ batch.map do |project_id|
+ (service_hash.values << project_id) + extra_hash.values
+ end
+ end
+end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 72ebdf61787..b63ab003711 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -337,7 +337,7 @@ class Snippet < ApplicationRecord
class << self
# Searches for snippets with a matching title, description or file name.
#
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
#
# query - The search query as a String.
#
diff --git a/app/models/snippet_input_action.rb b/app/models/snippet_input_action.rb
new file mode 100644
index 00000000000..7f4ab775ab0
--- /dev/null
+++ b/app/models/snippet_input_action.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class SnippetInputAction
+ include ActiveModel::Validations
+
+ ACTIONS = %i[create update delete move].freeze
+
+ ACTIONS.each do |action_const|
+ define_method "#{action_const}_action?" do
+ action == action_const
+ end
+ end
+
+ attr_reader :action, :previous_path, :file_path, :content
+
+ validates :action, inclusion: { in: ACTIONS, message: "%{value} is not a valid action" }
+ validates :previous_path, presence: true, if: :move_action?
+ validates :file_path, presence: true
+ validates :content, presence: true, if: -> (action) { action.create_action? || action.update_action? }
+ validate :ensure_same_file_path_and_previous_path, if: :update_action?
+ validate :ensure_allowed_action
+
+ def initialize(action: nil, previous_path: nil, file_path: nil, content: nil, allowed_actions: nil)
+ @action = action&.to_sym
+ @previous_path = previous_path
+ @file_path = file_path
+ @content = content
+ @allowed_actions = Array(allowed_actions).map(&:to_sym)
+ end
+
+ def to_commit_action
+ {
+ action: action,
+ previous_path: build_previous_path,
+ file_path: file_path,
+ content: content
+ }
+ end
+
+ private
+
+ def build_previous_path
+ return previous_path unless update_action?
+
+ previous_path.presence || file_path
+ end
+
+ def ensure_same_file_path_and_previous_path
+ return if previous_path.blank? || file_path.blank?
+ return if previous_path == file_path
+
+ errors.add(:file_path, "can't be different from the previous_path attribute")
+ end
+
+ def ensure_allowed_action
+ return if @allowed_actions.empty?
+
+ unless @allowed_actions.include?(action)
+ errors.add(:action, 'is not allowed')
+ end
+ end
+end
diff --git a/app/models/snippet_input_action_collection.rb b/app/models/snippet_input_action_collection.rb
new file mode 100644
index 00000000000..38313e3a980
--- /dev/null
+++ b/app/models/snippet_input_action_collection.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class SnippetInputActionCollection
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :actions
+
+ delegate :empty?, :any?, :[], to: :actions
+
+ def initialize(actions = [], allowed_actions: nil)
+ @actions = actions.map { |action| SnippetInputAction.new(action.merge(allowed_actions: allowed_actions)) }
+ end
+
+ def to_commit_actions
+ strong_memoize(:commit_actions) do
+ actions.map { |action| action.to_commit_action }
+ end
+ end
+
+ def valid?
+ strong_memoize(:valid) do
+ actions.all?(&:valid?)
+ end
+ end
+end
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index 72690ad7d04..7e34988c7a0 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -107,7 +107,7 @@ class SshHostKey
if status.success? && !errors.present?
{ known_hosts: known_hosts }
else
- Rails.logger.debug("Failed to detect SSH host keys for #{id}: #{errors}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.debug("Failed to detect SSH host keys for #{id}: #{errors}")
{ error: 'Failed to detect SSH host keys' }
end
diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb
index 345172cca76..f643d52587e 100644
--- a/app/models/storage/legacy_project.rb
+++ b/app/models/storage/legacy_project.rb
@@ -35,7 +35,7 @@ module Storage
gitlab_shell.mv_repository(repository_storage, "#{old_full_path}.wiki", "#{new_full_path}.wiki")
return true
rescue => e
- Rails.logger.error "Exception renaming #{old_full_path} -> #{new_full_path}: #{e}" # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.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/terraform/state.rb b/app/models/terraform/state.rb
index c4e047ff9d1..6ed074b2190 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -2,6 +2,8 @@
module Terraform
class State < ApplicationRecord
+ include UsageStatistics
+
DEFAULT = '{"version":1}'.freeze
HEX_REGEXP = %r{\A\h+\z}.freeze
UUID_LENGTH = 32
diff --git a/app/models/todo.rb b/app/models/todo.rb
index dc42551f0ab..102f36a991e 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -66,6 +66,8 @@ class Todo < ApplicationRecord
scope :with_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: :route }]) }
scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) }
+ enum resolved_by_action: { system_done: 0, api_all_done: 1, api_done: 2, mark_all_done: 3, mark_done: 4 }, _prefix: :resolved_by
+
state_machine :state, initial: :pending do
event :done do
transition [:pending] => :done
@@ -100,17 +102,17 @@ class Todo < ApplicationRecord
state.nil? ? exists?(target: target) : exists?(target: target, state: state)
end
- # Updates the state of a relation of todos to the new state.
+ # Updates attributes of a relation of todos to the new state.
#
- # new_state - The new state of the todos.
+ # new_attributes - The new attributes of the todos.
#
# Returns an `Array` containing the IDs of the updated todos.
- def update_state(new_state)
- # Only update those that are not really on that state
- base = where.not(state: new_state).except(:order)
+ def batch_update(**new_attributes)
+ # Only update those that have different state
+ base = where.not(state: new_attributes[:state]).except(:order)
ids = base.pluck(:id)
- base.update_all(state: new_state, updated_at: Time.now)
+ base.update_all(new_attributes.merge(updated_at: Time.current))
ids
end
@@ -187,6 +189,10 @@ class Todo < ApplicationRecord
target_type == DesignManagement::Design.name
end
+ def for_alert?
+ target_type == AlertManagement::Alert.name
+ end
+
# override to return commits, which are not active record
def target
if for_commit?
diff --git a/app/models/uploads/base.rb b/app/models/uploads/base.rb
index 442ed733566..7555c72e101 100644
--- a/app/models/uploads/base.rb
+++ b/app/models/uploads/base.rb
@@ -7,7 +7,7 @@ module Uploads
attr_reader :logger
def initialize(logger: nil)
- @logger = Rails.logger # rubocop:disable Gitlab/RailsLogger
+ @logger = Gitlab::AppLogger
end
def delete_keys_async(keys_to_delete)
diff --git a/app/models/user.rb b/app/models/user.rb
index 927ffa4d12b..431a5b3a5b7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -69,7 +69,6 @@ class User < ApplicationRecord
MINIMUM_INACTIVE_DAYS = 180
- ignore_column :bot_type, remove_with: '13.1', remove_after: '2020-05-22'
ignore_column :ghost, remove_with: '13.2', remove_after: '2020-06-22'
# Override Devise::Models::Trackable#update_tracked_fields!
@@ -181,6 +180,8 @@ class User < ApplicationRecord
has_one :user_highest_role
has_one :user_canonical_email
+ has_many :reviews, foreign_key: :author_id, inverse_of: :author
+
#
# Validations
#
@@ -264,18 +265,21 @@ class User < ApplicationRecord
# User's role
enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true
+ delegate :notes_filter_for,
+ :set_notes_filter,
+ :first_day_of_week, :first_day_of_week=,
+ :timezone, :timezone=,
+ :time_display_relative, :time_display_relative=,
+ :time_format_in_24h, :time_format_in_24h=,
+ :show_whitespace_in_diffs, :show_whitespace_in_diffs=,
+ :tab_width, :tab_width=,
+ :sourcegraph_enabled, :sourcegraph_enabled=,
+ :setup_for_company, :setup_for_company=,
+ :render_whitespace_in_code, :render_whitespace_in_code=,
+ :experience_level, :experience_level=,
+ to: :user_preference
+
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
- delegate :show_whitespace_in_diffs, :show_whitespace_in_diffs=, to: :user_preference
- delegate :tab_width, :tab_width=, to: :user_preference
- delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference
- delegate :setup_for_company, :setup_for_company=, to: :user_preference
- delegate :render_whitespace_in_code, :render_whitespace_in_code=, to: :user_preference
delegate :job_title, :job_title=, to: :user_detail, allow_nil: true
accepts_nested_attributes_for :user_preference, update_only: true
@@ -342,6 +346,7 @@ class User < ApplicationRecord
where('EXISTS (?)',
::PersonalAccessToken
.where('personal_access_tokens.user_id = users.id')
+ .without_impersonation
.expiring_and_not_notified(at).select(1))
end
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
@@ -517,7 +522,7 @@ class User < ApplicationRecord
# Searches users matching the given query.
#
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
#
# query - The search query as a String
#
@@ -560,7 +565,7 @@ class User < ApplicationRecord
# searches user by given pattern
# it compares name, email, username fields and user's secondary emails with given pattern
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ # This method uses ILIKE on PostgreSQL.
def search_with_secondary_emails(query)
return none if query.blank?
@@ -689,7 +694,7 @@ class User < ApplicationRecord
@reset_token, enc = Devise.token_generator.generate(self.class, :reset_password_token)
self.reset_password_token = enc
- self.reset_password_sent_at = Time.now.utc
+ self.reset_password_sent_at = Time.current.utc
@reset_token
end
@@ -716,7 +721,7 @@ class User < ApplicationRecord
otp_grace_period_started_at: nil,
otp_backup_codes: nil
)
- self.u2f_registrations.destroy_all # rubocop: disable DestroyAll
+ self.u2f_registrations.destroy_all # rubocop: disable Cop/DestroyAll
end
end
@@ -957,11 +962,11 @@ class User < ApplicationRecord
end
def allow_password_authentication_for_web?
- Gitlab::CurrentSettings.password_authentication_enabled_for_web? && !ldap_user? && !ultraauth_user?
+ Gitlab::CurrentSettings.password_authentication_enabled_for_web? && !ldap_user?
end
def allow_password_authentication_for_git?
- Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !ldap_user? && !ultraauth_user?
+ Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !ldap_user?
end
def can_change_username?
@@ -1049,14 +1054,6 @@ class User < ApplicationRecord
end
end
- def ultraauth_user?
- if identities.loaded?
- identities.find { |identity| Gitlab::Auth::OAuth::Provider.ultraauth_provider?(identity.provider) && !identity.extern_uid.nil? }
- else
- identities.exists?(["provider = ? AND extern_uid IS NOT NULL", "ultraauth"])
- end
- end
-
def ldap_identity
@ldap_identity ||= identities.find_by(["provider LIKE ?", "ldap%"])
end
@@ -1129,7 +1126,7 @@ class User < ApplicationRecord
if !Gitlab.config.ldap.enabled
false
elsif ldap_user?
- !last_credential_check_at || (last_credential_check_at + ldap_sync_time) < Time.now
+ !last_credential_check_at || (last_credential_check_at + ldap_sync_time) < Time.current
else
false
end
@@ -1378,7 +1375,7 @@ class User < ApplicationRecord
def contributed_projects
events = Event.select(:project_id)
.contributions.where(author_id: self)
- .where("created_at > ?", Time.now - 1.year)
+ .where("created_at > ?", Time.current - 1.year)
.distinct
.reorder(nil)
@@ -1642,16 +1639,12 @@ class User < ApplicationRecord
super.presence || build_user_detail
end
- def todos_limited_to(ids)
- todos.where(id: ids)
- end
-
def pending_todo_for(target)
todos.find_by(target: target, state: :pending)
end
def password_expired?
- !!(password_expires_at && password_expires_at < Time.now)
+ !!(password_expires_at && password_expires_at < Time.current)
end
def can_be_deactivated?
@@ -1832,7 +1825,7 @@ class User < ApplicationRecord
def update_highest_role?
return false unless persisted?
- (previous_changes.keys & %w(state user_type ghost)).any?
+ (previous_changes.keys & %w(state user_type)).any?
end
def update_highest_role_attribute
diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb
index f6f72f4b77a..1c615777018 100644
--- a/app/models/user_interacted_project.rb
+++ b/app/models/user_interacted_project.rb
@@ -9,9 +9,6 @@ class UserInteractedProject < ApplicationRecord
CACHE_EXPIRY_TIME = 1.day
- # Schema version required for this model
- REQUIRED_SCHEMA_VERSION = 20180223120443
-
class << self
def track(event)
# For events without a project, we simply don't care.
@@ -38,17 +35,6 @@ class UserInteractedProject < ApplicationRecord
end
end
- # Check if we can safely call .track (table exists)
- def available?
- @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization
- end
-
- # Flushes cached information about schema
- def reset_column_information
- @available_flag = nil
- super
- end
-
private
def cached_exists?(project_id:, user_id:, &block)
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 48a56cded0e..d3b3a46bf74 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -15,6 +15,8 @@ class UserPreference < ApplicationRecord
less_than_or_equal_to: Gitlab::TabWidth::MAX
}
+ enum experience_level: { novice: 0, experienced: 1 }
+
default_value_for :tab_width, value: Gitlab::TabWidth::DEFAULT, allows_nil: false
default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false
default_value_for :time_display_relative, value: true, allows_nil: false
diff --git a/app/models/web_ide_terminal.rb b/app/models/web_ide_terminal.rb
new file mode 100644
index 00000000000..ef70df2405f
--- /dev/null
+++ b/app/models/web_ide_terminal.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+class WebIdeTerminal
+ include ::Gitlab::Routing
+
+ attr_reader :build, :project
+
+ delegate :id, :status, to: :build
+
+ def initialize(build)
+ @build = build
+ @project = build.project
+ end
+
+ def show_path
+ web_ide_terminal_route_generator(:show)
+ end
+
+ def retry_path
+ web_ide_terminal_route_generator(:retry)
+ end
+
+ def cancel_path
+ web_ide_terminal_route_generator(:cancel)
+ end
+
+ def terminal_path
+ terminal_project_job_path(project, build, format: :ws)
+ end
+
+ def proxy_websocket_path
+ proxy_project_job_path(project, build, format: :ws)
+ end
+
+ def services
+ build.services.map(&:alias).compact + Array(build.image&.alias)
+ end
+
+ private
+
+ def web_ide_terminal_route_generator(action, options = {})
+ options.reverse_merge!(action: action,
+ controller: 'projects/web_ide_terminals',
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: build.id,
+ only_path: true)
+
+ url_for(options)
+ end
+end
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index 54bcec32095..4c497cc304c 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -205,7 +205,7 @@ class Wiki
end
def wiki_base_path
- Gitlab.config.gitlab.relative_url_root + web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}, '')
+ web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}, '')
end
private
diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb
index 712ba79bbd2..df2fe25b08b 100644
--- a/app/models/wiki_directory.rb
+++ b/app/models/wiki_directory.rb
@@ -15,6 +15,6 @@ class WikiDirectory
# Relative path to the partial to be used when rendering collections
# of this object.
def to_partial_path
- 'projects/wikis/wiki_directory'
+ '../shared/wikis/wiki_directory'
end
end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 319cdd38d93..9e4e2f68d38 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -261,8 +261,7 @@ class WikiPage
# Relative path to the partial to be used when rendering collections
# of this object.
def to_partial_path
- # TODO: Move into shared/ with https://gitlab.com/gitlab-org/gitlab/-/issues/196054
- 'projects/wikis/wiki_page'
+ '../shared/wikis/wiki_page'
end
def id
@@ -271,7 +270,10 @@ class WikiPage
def title_changed?
if persisted?
- old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(page.url_path))
+ # A page's `title` will be returned from Gollum/Gitaly with any +<>
+ # characters changed to -, whereas the `path` preserves these characters.
+ path_without_extension = Pathname(page.path).sub_ext('').to_s
+ old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(path_without_extension))
new_title, new_dir = wiki.page_title_and_dir(self.class.unhyphenize(title))
new_title != old_title || (title.include?('/') && new_dir != old_dir)
diff --git a/app/models/wiki_page/meta.rb b/app/models/wiki_page/meta.rb
index 474968122b1..215d84dc463 100644
--- a/app/models/wiki_page/meta.rb
+++ b/app/models/wiki_page/meta.rb
@@ -120,7 +120,7 @@ class WikiPage
end
def insert_slugs(strings, is_new, canonical_slug)
- creation = Time.now.utc
+ creation = Time.current.utc
slug_attrs = strings.map do |slug|
{
diff --git a/app/models/wiki_page/slug.rb b/app/models/wiki_page/slug.rb
index 246fa8d6442..c1725d34921 100644
--- a/app/models/wiki_page/slug.rb
+++ b/app/models/wiki_page/slug.rb
@@ -16,11 +16,11 @@ class WikiPage
scope :canonical, -> { where(canonical: true) }
def update_columns(attrs = {})
- super(attrs.reverse_merge(updated_at: Time.now.utc))
+ super(attrs.reverse_merge(updated_at: Time.current.utc))
end
def self.update_all(attrs = {})
- super(attrs.reverse_merge(updated_at: Time.now.utc))
+ super(attrs.reverse_merge(updated_at: Time.current.utc))
end
end
end
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index 12892a69257..0879a740f8a 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -36,6 +36,10 @@ module Ci
@subject.has_terminal?
end
+ condition(:is_web_ide_terminal, scope: :subject) do
+ @subject.pipeline.webide?
+ end
+
rule { protected_ref | archived }.policy do
prevent :update_build
prevent :update_commit_status
@@ -50,6 +54,24 @@ module Ci
end
rule { can?(:update_build) & terminal }.enable :create_build_terminal
+
+ rule { is_web_ide_terminal & can?(:create_web_ide_terminal) & (admin | owner_of_job) }.policy do
+ enable :read_web_ide_terminal
+ enable :update_web_ide_terminal
+ end
+
+ rule { is_web_ide_terminal & ~can?(:update_web_ide_terminal) }.policy do
+ prevent :create_build_terminal
+ end
+
+ rule { can?(:update_web_ide_terminal) & terminal }.policy do
+ enable :create_build_terminal
+ enable :create_build_service_proxy
+ end
+
+ rule { ~can?(:build_service_proxy_enabled) }.policy do
+ prevent :create_build_service_proxy
+ end
end
end
diff --git a/app/policies/container_expiration_policy_policy.rb b/app/policies/container_expiration_policy_policy.rb
new file mode 100644
index 00000000000..709435f47d3
--- /dev/null
+++ b/app/policies/container_expiration_policy_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ContainerExpirationPolicyPolicy < BasePolicy
+ delegate { @subject.project }
+end
diff --git a/app/policies/draft_note_policy.rb b/app/policies/draft_note_policy.rb
new file mode 100644
index 00000000000..be99d12c5f8
--- /dev/null
+++ b/app/policies/draft_note_policy.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class DraftNotePolicy < BasePolicy
+ delegate { @subject.merge_request }
+
+ condition(:is_author) { @user && @subject.author == @user }
+
+ rule { is_author }.policy do
+ enable :read_note
+ enable :admin_note
+ enable :resolve_note
+ end
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 8df4fc5e88c..f87c72007ec 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -147,6 +147,10 @@ class ProjectPolicy < BasePolicy
@user && @user.confirmed?
end
+ condition(:build_service_proxy_enabled) do
+ ::Feature.enabled?(:build_service_proxy, @subject)
+ end
+
features = %w[
merge_requests
issues
@@ -278,7 +282,6 @@ class ProjectPolicy < BasePolicy
rule { can?(:metrics_dashboard) }.policy do
enable :read_prometheus
- enable :read_environment
enable :read_deployment
end
@@ -429,27 +432,11 @@ class ProjectPolicy < BasePolicy
rule { builds_disabled | repository_disabled }.policy do
prevent(*create_read_update_admin_destroy(:build))
prevent(*create_read_update_admin_destroy(:pipeline_schedule))
+ prevent(*create_read_update_admin_destroy(:environment))
prevent(*create_read_update_admin_destroy(:cluster))
prevent(*create_read_update_admin_destroy(:deployment))
end
- # Enabling `read_environment` specifically for the condition of `metrics_dashboard_allowed` is
- # necessary due to the route for metrics dashboard requiring an environment id.
- # This will be addressed in https://gitlab.com/gitlab-org/gitlab/-/issues/213833 when
- # environments and metrics are decoupled and these rules will be removed.
-
- rule { (builds_disabled | repository_disabled) & ~metrics_dashboard_allowed}.policy do
- prevent(*create_read_update_admin_destroy(:environment))
- end
-
- rule { (builds_disabled | repository_disabled) & metrics_dashboard_allowed}.policy do
- prevent :create_environment
- prevent :update_environment
- prevent :admin_environment
- prevent :destroy_environment
- enable :read_environment
- 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 them to access external CI
@@ -577,6 +564,18 @@ class ProjectPolicy < BasePolicy
enable :read_project
end
+ rule { can?(:create_pipeline) & can?(:maintainer_access) }.enable :create_web_ide_terminal
+
+ rule { build_service_proxy_enabled }.enable :build_service_proxy_enabled
+
+ rule { can?(:download_code) }.policy do
+ enable :read_repository_graphs
+ end
+
+ rule { can?(:read_build) & can?(:read_pipeline) }.policy do
+ enable :read_build_report_results
+ end
+
private
def team_member?
diff --git a/app/policies/releases/link_policy.rb b/app/policies/releases/link_policy.rb
new file mode 100644
index 00000000000..4a662fafb2f
--- /dev/null
+++ b/app/policies/releases/link_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Releases
+ class LinkPolicy < BasePolicy
+ delegate { @subject.release.project }
+ end
+end
diff --git a/app/policies/releases/source_policy.rb b/app/policies/releases/source_policy.rb
new file mode 100644
index 00000000000..8b86b925589
--- /dev/null
+++ b/app/policies/releases/source_policy.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Releases
+ class SourcePolicy < BasePolicy
+ delegate { @subject.project }
+
+ rule { can?(:public_access) | can?(:reporter_access) }.policy do
+ enable :read_release_sources
+ end
+
+ rule { ~can?(:read_release) }.prevent :read_release_sources
+ end
+end
diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb
new file mode 100644
index 00000000000..db2fc52a88b
--- /dev/null
+++ b/app/presenters/gitlab/blame_presenter.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Gitlab
+ class BlamePresenter < Gitlab::View::Presenter::Simple
+ include ActionView::Helpers::UrlHelper
+ include ActionView::Helpers::TranslationHelper
+ include ActionView::Context
+ include AvatarsHelper
+ include BlameHelper
+ include CommitsHelper
+ include ApplicationHelper
+ include TreeHelper
+ include IconsHelper
+
+ presents :blame
+
+ CommitData = Struct.new(
+ :author_avatar,
+ :age_map_class,
+ :commit_link,
+ :commit_author_link,
+ :project_blame_link,
+ :time_ago_tooltip)
+
+ def initialize(subject, **attributes)
+ super
+
+ @commits = {}
+ precalculate_data_by_commit!
+ end
+
+ def groups
+ @groups ||= blame.groups
+ end
+
+ def commit_data(commit)
+ @commits[commit.id] ||= get_commit_data(commit)
+ end
+
+ private
+
+ # Huge source files with a high churn rate (e.g. 'locale/gitlab.pot') could have
+ # 10x times more blame groups than unique commits across all the groups.
+ # That means we could cache per-commit data we need
+ # to avoid recalculating it multiple times.
+ # For such files, it could significantly improve the performance of the Blame.
+ def precalculate_data_by_commit!
+ groups.each { |group| commit_data(group[:commit]) }
+ end
+
+ def get_commit_data(commit)
+ CommitData.new.tap do |data|
+ data.author_avatar = author_avatar(commit, size: 36, has_tooltip: false)
+ data.age_map_class = age_map_class(commit.committed_date, project_duration)
+ data.commit_link = link_to commit.title, project_commit_path(project, commit.id), class: "cdark", title: commit.title
+ data.commit_author_link = commit_author_link(commit, avatar: false)
+ data.project_blame_link = project_blame_link(commit)
+ data.time_ago_tooltip = time_ago_with_tooltip(commit.committed_date)
+ end
+ end
+
+ def project_blame_link(commit)
+ previous_commit_id = commit.parent_id
+ return unless previous_commit_id
+
+ link_to project_blame_path(project, tree_join(previous_commit_id, path)),
+ title: _('View blame prior to this change'),
+ aria: { label: _('View blame prior to this change') },
+ data: { toggle: 'tooltip', placement: 'right', container: 'body' } do
+ versions_sprite_icon
+ end
+ end
+
+ def project_duration
+ @project_duration ||= age_map_duration(groups, project)
+ end
+
+ def versions_sprite_icon
+ @versions_sprite_icon ||= sprite_icon('doc-versions', size: 16, css_class: 'doc-versions align-text-bottom')
+ end
+ end
+end
diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb
index 2114e06a8c5..6009ee4c7be 100644
--- a/app/presenters/projects/prometheus/alert_presenter.rb
+++ b/app/presenters/projects/prometheus/alert_presenter.rb
@@ -45,8 +45,8 @@ module Projects
project_issues_url(project, label_name: INCIDENT_LABEL_NAME)
end
- def starts_at
- super&.rfc3339
+ def start_time
+ starts_at&.strftime('%d %B %Y, %-l:%M%p (%Z)')
end
def issue_summary_markdown
@@ -73,7 +73,7 @@ module Projects
def metadata_list
metadata = []
- metadata << list_item('Start time', starts_at) if starts_at
+ metadata << list_item('Start time', start_time) if start_time
metadata << list_item('full_query', backtick(full_query)) if full_query
metadata << list_item(service.label.humanize, service.value) if service
metadata << list_item(monitoring_tool.label.humanize, monitoring_tool.value) if monitoring_tool
@@ -149,7 +149,7 @@ module Projects
end
def embed_time
- starts_at ? Time.rfc3339(starts_at) : Time.current
+ starts_at || Time.current
end
def alert_embed_window_params(time)
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index ea46f0a234b..7b0a3d1e7b9 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -5,7 +5,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
presents :release
- delegate :project, :tag, to: :release
+ delegate :project, :tag, :assets_count, to: :release
def commit_path
return unless release.commit && can_download_code?
diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb
index faaf7568c72..62a90025ce1 100644
--- a/app/presenters/snippet_presenter.rb
+++ b/app/presenters/snippet_presenter.rb
@@ -36,10 +36,14 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def blob
+ blobs.first
+ end
+
+ def blobs
if snippet.empty_repo?
- snippet.blob
+ [snippet.blob]
else
- snippet.blobs.first
+ snippet.blobs
end
end
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index fac0fbd14b9..7a030372591 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -4,30 +4,28 @@ class BuildArtifactEntity < Grape::Entity
include RequestAwareEntity
include GitlabRoutingHelper
- expose :name do |job|
- job.name
- end
-
- expose :artifacts_expired?, as: :expired
- expose :artifacts_expire_at, as: :expire_at
+ alias_method :artifact, :object
- expose :path do |job|
- fast_download_project_job_artifacts_path(project, job)
+ expose :name do |artifact|
+ "#{artifact.job.name}:#{artifact.file_type}"
end
- expose :keep_path, if: -> (*) { job.has_expiring_archive_artifacts? } do |job|
- fast_keep_project_job_artifacts_path(project, job)
- end
+ expose :expire_at
+ expose :expired?, as: :expired
- expose :browse_path do |job|
- fast_browse_project_job_artifacts_path(project, job)
+ expose :path do |artifact|
+ fast_download_project_job_artifacts_path(
+ artifact.project,
+ artifact.job,
+ file_type: artifact.file_type
+ )
end
- private
-
- alias_method :job, :object
+ expose :keep_path, if: -> (*) { artifact.expiring? } do |artifact|
+ fast_keep_project_job_artifacts_path(artifact.project, artifact.job)
+ end
- def project
- job.project
+ expose :browse_path do |artifact|
+ fast_browse_project_job_artifacts_path(artifact.project, artifact.job)
end
end
diff --git a/app/serializers/ci/dag_job_entity.rb b/app/serializers/ci/dag_job_entity.rb
index b4947319ed1..ed4d4505152 100644
--- a/app/serializers/ci/dag_job_entity.rb
+++ b/app/serializers/ci/dag_job_entity.rb
@@ -3,6 +3,7 @@
module Ci
class DagJobEntity < Grape::Entity
expose :name
+ expose :scheduling_type
expose :needs, if: -> (job, _) { job.scheduling_type_dag? } do |job|
job.needs.pluck(:name) # rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/serializers/ci/dag_pipeline_entity.rb b/app/serializers/ci/dag_pipeline_entity.rb
index b615dd2b194..51aa487ec29 100644
--- a/app/serializers/ci/dag_pipeline_entity.rb
+++ b/app/serializers/ci/dag_pipeline_entity.rb
@@ -2,12 +2,12 @@
module Ci
class DagPipelineEntity < Grape::Entity
- expose :ordered_stages_with_preloads, as: :stages, using: Ci::DagStageEntity
+ expose :stages_with_preloads, as: :stages, using: Ci::DagStageEntity
private
- def ordered_stages_with_preloads
- object.ordered_stages.preload(preloaded_relations) # rubocop: disable CodeReuse/ActiveRecord
+ def stages_with_preloads
+ object.stages.preload(preloaded_relations) # rubocop: disable CodeReuse/ActiveRecord
end
def preloaded_relations
diff --git a/app/serializers/ci/daily_build_group_report_result_entity.rb b/app/serializers/ci/daily_build_group_report_result_entity.rb
new file mode 100644
index 00000000000..e4118db9b1f
--- /dev/null
+++ b/app/serializers/ci/daily_build_group_report_result_entity.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Ci
+ class DailyBuildGroupReportResultEntity < Grape::Entity
+ expose :date
+
+ ::Ci::DailyBuildGroupReportResult::PARAM_TYPES.each do |type|
+ expose type, if: lambda { |report_result, options| options[:param_type] == type } do |report_result, options|
+ report_result.data[options[:param_type]]
+ end
+ end
+ end
+end
diff --git a/app/serializers/ci/daily_build_group_report_result_serializer.rb b/app/serializers/ci/daily_build_group_report_result_serializer.rb
new file mode 100644
index 00000000000..fdc693d01b2
--- /dev/null
+++ b/app/serializers/ci/daily_build_group_report_result_serializer.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Ci
+ class DailyBuildGroupReportResultSerializer < BaseSerializer
+ entity ::Ci::DailyBuildGroupReportResultEntity
+
+ def represent(resource, opts = {})
+ group(resource).map do |group_name, data|
+ {
+ group_name: group_name,
+ data: super(data, opts)
+ }
+ end
+ end
+
+ private
+
+ def group(resource)
+ collect(resource).group_by(&:group_name)
+ end
+
+ def collect(resource)
+ return resource if resource.respond_to?(:group_by)
+
+ [resource]
+ end
+ end
+end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index 4f53ea30544..8a1d41dbd96 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -8,6 +8,7 @@ class ClusterEntity < Grape::Entity
expose :environment_scope
expose :name
expose :nodes
+ expose :provider_type
expose :status_name, as: :status
expose :status_reason
expose :applications, using: ClusterApplicationEntity
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
index f59b6a35a29..27156d3178f 100644
--- a/app/serializers/cluster_serializer.rb
+++ b/app/serializers/cluster_serializer.rb
@@ -13,6 +13,7 @@ class ClusterSerializer < BaseSerializer
:name,
:nodes,
:path,
+ :provider_type,
:status
]
})
diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb
index 46aa0adc5a0..4c87d1438b0 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, :name, :path, :location, :created_at, :status
+ expose :id, :name, :path, :location, :created_at, :status, :tags_count
expose :tags_path do |repository|
project_registry_repository_tags_path(project, repository, format: :json)
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index 8c2b3a65d57..33eb33d314b 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -67,10 +67,8 @@ class DiffFileBaseEntity < Grape::Entity
end
end
- expose :file_hash do |diff_file|
- Digest::SHA1.hexdigest(diff_file.file_path)
- end
-
+ expose :file_identifier_hash
+ expose :file_hash
expose :file_path
expose :old_path
expose :new_path
diff --git a/app/serializers/diff_file_metadata_entity.rb b/app/serializers/diff_file_metadata_entity.rb
index 05280518f39..460f4967e99 100644
--- a/app/serializers/diff_file_metadata_entity.rb
+++ b/app/serializers/diff_file_metadata_entity.rb
@@ -7,7 +7,6 @@ class DiffFileMetadataEntity < Grape::Entity
expose :old_path
expose :new_file?, as: :new_file
expose :deleted_file?, as: :deleted_file
- expose :file_hash do |diff_file|
- Digest::SHA1.hexdigest(diff_file.file_path)
- end
+ expose :file_identifier_hash
+ expose :file_hash
end
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index fb4fbe57130..6ef524b5bec 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -69,19 +69,17 @@ class DiffsEntity < Grape::Entity
expose :diff_files do |diffs, options|
submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
- code_navigation_path =
- Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs.head_sha)
DiffFileEntity.represent(diffs.diff_files,
- options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path))
+ options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs)))
end
expose :merge_request_diffs, using: MergeRequestDiffEntity, if: -> (_, options) { options[:merge_request_diffs]&.any? } do |diffs|
options[:merge_request_diffs]
end
- expose :definition_path_prefix, if: -> (diff_file) { Feature.enabled?(:code_navigation, merge_request.project) } do |diffs|
- project_blob_path(merge_request.project, diffs.diff_refs.head_sha)
+ expose :definition_path_prefix, if: -> (diff_file) { Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true) } do |diffs|
+ project_blob_path(merge_request.project, diffs.diff_refs&.head_sha)
end
def merge_request
@@ -90,6 +88,12 @@ class DiffsEntity < Grape::Entity
private
+ def code_navigation_path(diffs)
+ return unless Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true)
+
+ Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
+ end
+
def commit_ids
@commit_ids ||= merge_request.recent_commits.map(&:id)
end
diff --git a/app/serializers/draft_note_entity.rb b/app/serializers/draft_note_entity.rb
new file mode 100644
index 00000000000..cab4849ebc9
--- /dev/null
+++ b/app/serializers/draft_note_entity.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+class DraftNoteEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :author, using: NoteUserEntity
+ expose :merge_request_id
+ expose :position, if: -> (note, _) { note.on_diff? }
+ expose :line_code
+ expose :file_identifier_hash
+ expose :file_hash
+ expose :file_path
+ expose :note
+ expose :rendered_note, as: :note_html
+ expose :references
+ expose :discussion_id
+ expose :resolve_discussion
+ expose :noteable_type
+
+ expose :current_user do
+ expose :can_edit do |note|
+ can?(current_user, :admin_note, note)
+ end
+
+ expose :can_award_emoji do |note|
+ note.emoji_awardable?
+ end
+
+ expose :can_resolve do |note|
+ note.resolvable? && can?(current_user, :resolve_note, note)
+ end
+ end
+
+ private
+
+ def current_user
+ request.current_user
+ end
+end
diff --git a/app/serializers/draft_note_serializer.rb b/app/serializers/draft_note_serializer.rb
new file mode 100644
index 00000000000..282d7f9bdda
--- /dev/null
+++ b/app/serializers/draft_note_serializer.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+class DraftNoteSerializer < BaseSerializer
+ entity DraftNoteEntity
+end
diff --git a/app/serializers/import/base_provider_repo_entity.rb b/app/serializers/import/base_provider_repo_entity.rb
new file mode 100644
index 00000000000..88a831a1686
--- /dev/null
+++ b/app/serializers/import/base_provider_repo_entity.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class Import::BaseProviderRepoEntity < Grape::Entity
+ expose :id
+ expose :full_name
+ expose :sanitized_name
+ expose :provider_link
+end
diff --git a/app/serializers/import/bitbucket_provider_repo_entity.rb b/app/serializers/import/bitbucket_provider_repo_entity.rb
new file mode 100644
index 00000000000..e8c647d407e
--- /dev/null
+++ b/app/serializers/import/bitbucket_provider_repo_entity.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Import::BitbucketProviderRepoEntity < Import::BaseProviderRepoEntity
+ expose :id, override: true do |repo|
+ repo.full_name
+ end
+
+ expose :sanitized_name, override: true do |repo|
+ repo.name.gsub(/[^\s\w.-]/, '')
+ end
+
+ expose :provider_link, override: true do |repo, options|
+ repo.clone_url
+ end
+end
diff --git a/app/serializers/import/bitbucket_server_provider_repo_entity.rb b/app/serializers/import/bitbucket_server_provider_repo_entity.rb
new file mode 100644
index 00000000000..d818cac46cd
--- /dev/null
+++ b/app/serializers/import/bitbucket_server_provider_repo_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Import::BitbucketServerProviderRepoEntity < Import::BitbucketProviderRepoEntity
+ expose :provider_link, override: true do |repo, options|
+ repo.browse_url
+ end
+end
diff --git a/app/serializers/import/fogbugz_provider_repo_entity.rb b/app/serializers/import/fogbugz_provider_repo_entity.rb
new file mode 100644
index 00000000000..d420de141e1
--- /dev/null
+++ b/app/serializers/import/fogbugz_provider_repo_entity.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Import::FogbugzProviderRepoEntity < Import::BaseProviderRepoEntity
+ include ImportHelper
+
+ expose :full_name, override: true do |repo|
+ repo.name
+ end
+
+ expose :sanitized_name, override: true do |repo|
+ repo.safe_name
+ end
+
+ expose :provider_link, override: true do |repo, options|
+ provider_project_link_url(options[:provider_url], repo.path)
+ end
+end
diff --git a/app/serializers/provider_repo_entity.rb b/app/serializers/import/githubish_provider_repo_entity.rb
index d70aaa91324..d3e323053f9 100644
--- a/app/serializers/provider_repo_entity.rb
+++ b/app/serializers/import/githubish_provider_repo_entity.rb
@@ -1,19 +1,13 @@
# frozen_string_literal: true
-class ProviderRepoEntity < Grape::Entity
+class Import::GithubishProviderRepoEntity < Import::BaseProviderRepoEntity
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|
+ expose :sanitized_name, override: true do |provider_repo|
sanitize_project_name(provider_repo[:name])
end
- expose :provider_link do |provider_repo, options|
+ expose :provider_link, override: true do |provider_repo, options|
provider_project_link_url(options[:provider_url], provider_repo[:full_name])
end
diff --git a/app/serializers/import/gitlab_provider_repo_entity.rb b/app/serializers/import/gitlab_provider_repo_entity.rb
new file mode 100644
index 00000000000..5fecd0a1cd3
--- /dev/null
+++ b/app/serializers/import/gitlab_provider_repo_entity.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Import::GitlabProviderRepoEntity < Import::BaseProviderRepoEntity
+ expose :id, override: true do |repo|
+ repo["id"]
+ end
+
+ expose :full_name, override: true do |repo|
+ repo["path_with_namespace"]
+ end
+
+ expose :sanitized_name, override: true do |repo|
+ repo["path"]
+ end
+
+ expose :provider_link, override: true do |repo|
+ repo["web_url"]
+ end
+end
diff --git a/app/serializers/import/provider_repo_serializer.rb b/app/serializers/import/provider_repo_serializer.rb
new file mode 100644
index 00000000000..5a9549d79aa
--- /dev/null
+++ b/app/serializers/import/provider_repo_serializer.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Import::ProviderRepoSerializer < BaseSerializer
+ def represent(repo, opts = {})
+ entity =
+ case opts[:provider]
+ when :fogbugz
+ Import::FogbugzProviderRepoEntity
+ when :github, :gitea
+ Import::GithubishProviderRepoEntity
+ when :bitbucket
+ Import::BitbucketProviderRepoEntity
+ when :bitbucket_server
+ Import::BitbucketServerProviderRepoEntity
+ when :gitlab
+ Import::GitlabProviderRepoEntity
+ else
+ raise NotImplementedError
+ end
+
+ super(repo, opts, entity)
+ end
+end
diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb
index 8e7456ce059..a356b5b5cd4 100644
--- a/app/serializers/merge_request_noteable_entity.rb
+++ b/app/serializers/merge_request_noteable_entity.rb
@@ -50,6 +50,8 @@ class MergeRequestNoteableEntity < IssuableEntity
merge_request.project.archived?
end
+ expose :project_id
+
expose :archived_project_docs_path, if: -> (merge_request) { merge_request.project.archived? } do |merge_request|
help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project')
end
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
index a31c9d70d4b..37c48338e55 100644
--- a/app/serializers/paginated_diff_entity.rb
+++ b/app/serializers/paginated_diff_entity.rb
@@ -10,11 +10,9 @@ class PaginatedDiffEntity < Grape::Entity
expose :diff_files do |diffs, options|
submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
- code_navigation_path =
- Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs.head_sha)
DiffFileEntity.represent(diffs.diff_files,
- options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path))
+ options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs)))
end
expose :pagination do
@@ -38,6 +36,12 @@ class PaginatedDiffEntity < Grape::Entity
private
+ def code_navigation_path(diffs)
+ return unless Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true)
+
+ Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
+ end
+
%i[current_page next_page total_pages].each do |method|
define_method method do
pagination_data[method]
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index a58278cf4ef..50efa9ea15d 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -9,8 +9,7 @@ class PipelineDetailsEntity < PipelineEntity
expose :details do
expose :artifacts do |pipeline, options|
- rel = pipeline.artifacts
- rel = rel.eager_load_job_artifacts_archive if options.fetch(:preload_job_artifacts_archive, true)
+ rel = pipeline.downloadable_artifacts
BuildArtifactEntity.represent(rel, options)
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index b2c0ceb640b..21d49c6c292 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -7,10 +7,6 @@ class PipelineSerializer < BaseSerializer
# rubocop: disable CodeReuse/ActiveRecord
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
- # We don't want PipelineDetailsEntity to preload the job_artifacts_archive
- # because we do it with preloaded_relations in a more optimal way
- # if the given resource is a collection of multiple pipelines.
- opts[:preload_job_artifacts_archive] = false
resource = resource.preload(preloaded_relations)
end
@@ -44,35 +40,29 @@ class PipelineSerializer < BaseSerializer
def preloaded_relations
[
- :latest_statuses_ordered_by_stage,
- :project,
- :stages,
- {
- failed_builds: %i(project metadata)
- },
- :retryable_builds,
:cancelable_statuses,
- :trigger_requests,
+ :latest_statuses_ordered_by_stage,
:manual_actions,
+ :retryable_builds,
:scheduled_actions,
- :artifacts,
+ :stages,
+ :trigger_requests,
:user,
{
+ downloadable_artifacts: {
+ project: [:route, { namespace: :route }],
+ job: []
+ },
+ failed_builds: %i(project metadata),
merge_request: {
source_project: [:route, { namespace: :route }],
target_project: [:route, { namespace: :route }]
- }
- },
- {
+ },
pending_builds: :project,
project: [:route, { namespace: :route }],
- artifacts: {
- project: [:route, { namespace: :route }],
- job_artifacts_archive: []
- }
- },
- { triggered_by_pipeline: [:project, :user] },
- { triggered_pipelines: [:project, :user] }
+ triggered_by_pipeline: [:project, :user],
+ triggered_pipelines: [:project, :user]
+ }
]
end
end
diff --git a/app/serializers/provider_repo_serializer.rb b/app/serializers/provider_repo_serializer.rb
deleted file mode 100644
index 8a73f6fe6df..00000000000
--- a/app/serializers/provider_repo_serializer.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class ProviderRepoSerializer < BaseSerializer
- entity ProviderRepoEntity
-end
diff --git a/app/serializers/service_field_entity.rb b/app/serializers/service_field_entity.rb
new file mode 100644
index 00000000000..9929d7e2e5a
--- /dev/null
+++ b/app/serializers/service_field_entity.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ServiceFieldEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :type, :name, :title, :placeholder, :required, :choices, :help
+
+ expose :value do |field|
+ # field[:name] is not user input and so can assume is safe
+ value = service.public_send(field[:name]) # rubocop:disable GitlabSecurity/PublicSend
+
+ if field[:type] == 'password' && value.present?
+ 'true'
+ else
+ value
+ end
+ end
+
+ private
+
+ def service
+ request.service
+ end
+end
diff --git a/app/serializers/service_field_serializer.rb b/app/serializers/service_field_serializer.rb
new file mode 100644
index 00000000000..120d0f5820e
--- /dev/null
+++ b/app/serializers/service_field_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ServiceFieldSerializer < BaseSerializer
+ entity ServiceFieldEntity
+end
diff --git a/app/serializers/web_ide_terminal_entity.rb b/app/serializers/web_ide_terminal_entity.rb
new file mode 100644
index 00000000000..e2e90e824e7
--- /dev/null
+++ b/app/serializers/web_ide_terminal_entity.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class WebIdeTerminalEntity < Grape::Entity
+ expose :id
+ expose :status
+ expose :show_path
+ expose :cancel_path
+ expose :retry_path
+ expose :terminal_path
+ expose :services
+ expose :proxy_websocket_path, if: ->(_) { Feature.enabled?(:build_service_proxy) }
+end
diff --git a/app/serializers/web_ide_terminal_serializer.rb b/app/serializers/web_ide_terminal_serializer.rb
new file mode 100644
index 00000000000..5a9c4b99e0a
--- /dev/null
+++ b/app/serializers/web_ide_terminal_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class WebIdeTerminalSerializer < BaseSerializer
+ entity WebIdeTerminalEntity
+
+ def represent(resource, opts = {})
+ resource = WebIdeTerminal.new(resource) if resource.is_a?(Ci::Build)
+
+ super
+ end
+end
diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb
new file mode 100644
index 00000000000..084b103ee3b
--- /dev/null
+++ b/app/services/admin/propagate_integration_service.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+module Admin
+ class PropagateIntegrationService
+ BATCH_SIZE = 100
+
+ delegate :data_fields_present?, to: :integration
+
+ def self.propagate(integration:, overwrite:)
+ new(integration, overwrite).propagate
+ end
+
+ def initialize(integration, overwrite)
+ @integration = integration
+ @overwrite = overwrite
+ end
+
+ def propagate
+ if overwrite
+ update_integration_for_all_projects
+ else
+ update_integration_for_inherited_projects
+ end
+
+ create_integration_for_projects_without_integration
+ end
+
+ private
+
+ attr_reader :integration, :overwrite
+
+ # rubocop: disable Cop/InBatches
+ # rubocop: disable CodeReuse/ActiveRecord
+ def update_integration_for_inherited_projects
+ Service.where(type: integration.type, inherit_from_id: integration.id).in_batches(of: BATCH_SIZE) do |batch|
+ bulk_update_from_integration(batch)
+ end
+ end
+
+ def update_integration_for_all_projects
+ Service.where(type: integration.type).in_batches(of: BATCH_SIZE) do |batch|
+ bulk_update_from_integration(batch)
+ end
+ end
+ # rubocop: enable Cop/InBatches
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def bulk_update_from_integration(batch)
+ # Retrieving the IDs instantiates the ActiveRecord relation (batch)
+ # into concrete models, otherwise update_all will clear the relation.
+ # https://stackoverflow.com/q/34811646/462015
+ batch_ids = batch.pluck(:id)
+
+ Service.transaction do
+ batch.update_all(service_hash)
+
+ if data_fields_present?
+ integration.data_fields.class.where(service_id: batch_ids).update_all(data_fields_hash)
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def create_integration_for_projects_without_integration
+ loop do
+ batch = Project.uncached { project_ids_without_integration }
+
+ bulk_create_from_integration(batch) unless batch.empty?
+
+ break if batch.size < BATCH_SIZE
+ end
+ end
+
+ def bulk_create_from_integration(batch)
+ service_list = ServiceList.new(batch, service_hash, { 'inherit_from_id' => integration.id }).to_array
+
+ Project.transaction do
+ results = bulk_insert(*service_list)
+
+ if data_fields_present?
+ data_list = DataList.new(results, data_fields_hash, integration.data_fields.class).to_array
+
+ bulk_insert(*data_list)
+ end
+
+ run_callbacks(batch)
+ end
+ end
+
+ def bulk_insert(klass, columns, values_array)
+ items_to_insert = values_array.map { |array| Hash[columns.zip(array)] }
+
+ klass.insert_all(items_to_insert, returning: [:id])
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def run_callbacks(batch)
+ if active_external_issue_tracker?
+ Project.where(id: batch).update_all(has_external_issue_tracker: true)
+ end
+
+ if active_external_wiki?
+ Project.where(id: batch).update_all(has_external_wiki: true)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def active_external_issue_tracker?
+ integration.issue_tracker? && !integration.default
+ end
+
+ def active_external_wiki?
+ integration.type == 'ExternalWikiService'
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def project_ids_without_integration
+ services = Service
+ .select('1')
+ .where('services.project_id = projects.id')
+ .where(type: integration.type)
+
+ Project
+ .where('NOT EXISTS (?)', services)
+ .where(pending_delete: false)
+ .where(archived: false)
+ .limit(BATCH_SIZE)
+ .pluck(:id)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def service_hash
+ @service_hash ||= integration.to_service_hash
+ .tap { |json| json['inherit_from_id'] = integration.id }
+ end
+
+ def data_fields_hash
+ @data_fields_hash ||= integration.to_data_fields_hash
+ end
+ end
+end
diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb
new file mode 100644
index 00000000000..ffabbb37289
--- /dev/null
+++ b/app/services/alert_management/alerts/update_service.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ module Alerts
+ class UpdateService
+ include Gitlab::Utils::StrongMemoize
+
+ # @param alert [AlertManagement::Alert]
+ # @param current_user [User]
+ # @param params [Hash] Attributes of the alert
+ def initialize(alert, current_user, params)
+ @alert = alert
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+ return error_no_updates if params.empty?
+
+ filter_assignees
+ old_assignees = alert.assignees.to_a
+
+ if alert.update(params)
+ process_assignement(old_assignees)
+
+ success
+ else
+ error(alert.errors.full_messages.to_sentence)
+ end
+ end
+
+ private
+
+ attr_reader :alert, :current_user, :params
+
+ def allowed?
+ current_user&.can?(:update_alert_management_alert, alert)
+ end
+
+ def assignee_todo_allowed?
+ assignee&.can?(:read_alert_management_alert, alert)
+ end
+
+ def todo_service
+ strong_memoize(:todo_service) do
+ TodoService.new
+ end
+ end
+
+ def success
+ ServiceResponse.success(payload: { alert: alert })
+ end
+
+ def error(message)
+ ServiceResponse.error(payload: { alert: alert }, message: message)
+ end
+
+ def error_no_permissions
+ error(_('You have no permissions'))
+ end
+
+ def error_no_updates
+ error(_('Please provide attributes to update'))
+ end
+
+ # ----- Assignee-related behavior ------
+ def filter_assignees
+ return if params[:assignees].nil?
+
+ params[:assignees] = Array(assignee)
+ end
+
+ def assignee
+ strong_memoize(:assignee) do
+ # Take first assignee while multiple are not currently supported
+ params[:assignees]&.first
+ end
+ end
+
+ def process_assignement(old_assignees)
+ assign_todo
+ add_assignee_system_note(old_assignees)
+ end
+
+ def assign_todo
+ # Remove check in follow-up issue https://gitlab.com/gitlab-org/gitlab/-/issues/222672
+ return unless assignee_todo_allowed?
+
+ todo_service.assign_alert(alert, current_user)
+ end
+
+ def add_assignee_system_note(old_assignees)
+ SystemNoteService.change_issuable_assignees(alert, alert.project, current_user, old_assignees)
+ end
+ end
+ end
+end
diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb
index 0197f29145d..beacd240b08 100644
--- a/app/services/alert_management/create_alert_issue_service.rb
+++ b/app/services/alert_management/create_alert_issue_service.rb
@@ -29,8 +29,7 @@ module AlertManagement
delegate :project, to: :alert
def allowed?
- Feature.enabled?(:alert_management_create_alert_issue, project) &&
- user.can?(:create_issue, project)
+ user.can?(:create_issue, project)
end
def create_issue(alert, user, alert_payload)
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
index af28f1354b3..90fcbd95e4b 100644
--- a/app/services/alert_management/process_prometheus_alert_service.rb
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -29,6 +29,7 @@ module AlertManagement
def process_firing_alert_management_alert
if am_alert.present?
+ am_alert.register_new_event!
reset_alert_management_alert_status
else
create_alert_management_alert
@@ -47,7 +48,10 @@ module AlertManagement
def create_alert_management_alert
am_alert = AlertManagement::Alert.new(am_alert_params.merge(ended_at: nil))
- return if am_alert.save
+ if am_alert.save
+ am_alert.execute_services
+ return
+ end
logger.warn(
message: 'Unable to create AlertManagement::Alert',
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index 1de2f31f87c..c4109765a1c 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -6,19 +6,18 @@ module AutoMerge
include MergeRequests::AssignsMergeParams
def execute(merge_request)
- assign_allowed_merge_params(merge_request, params.merge(auto_merge_strategy: strategy))
-
- merge_request.auto_merge_enabled = true
- merge_request.merge_user = current_user
-
- return :failed unless merge_request.save
-
- yield if block_given?
+ ActiveRecord::Base.transaction do
+ register_auto_merge_parameters!(merge_request)
+ yield if block_given?
+ end
# Notify the event that auto merge is enabled or merge param is updated
AutoMergeProcessWorker.perform_async(merge_request.id)
strategy.to_sym
+ rescue => e
+ track_exception(e, merge_request)
+ :failed
end
def update(merge_request)
@@ -30,23 +29,27 @@ module AutoMerge
end
def cancel(merge_request)
- if clear_auto_merge_parameters(merge_request)
+ ActiveRecord::Base.transaction do
+ clear_auto_merge_parameters!(merge_request)
yield if block_given?
-
- success
- else
- error("Can't cancel the automatic merge", 406)
end
+
+ success
+ rescue => e
+ track_exception(e, merge_request)
+ error("Can't cancel the automatic merge", 406)
end
def abort(merge_request, reason)
- if clear_auto_merge_parameters(merge_request)
+ ActiveRecord::Base.transaction do
+ clear_auto_merge_parameters!(merge_request)
yield if block_given?
-
- success
- else
- error("Can't abort the automatic merge", 406)
end
+
+ success
+ rescue => e
+ track_exception(e, merge_request)
+ error("Can't abort the automatic merge", 406)
end
def available_for?(merge_request)
@@ -65,7 +68,14 @@ module AutoMerge
end
end
- def clear_auto_merge_parameters(merge_request)
+ def register_auto_merge_parameters!(merge_request)
+ assign_allowed_merge_params(merge_request, params.merge(auto_merge_strategy: strategy))
+ merge_request.auto_merge_enabled = true
+ merge_request.merge_user = current_user
+ merge_request.save!
+ end
+
+ def clear_auto_merge_parameters!(merge_request)
merge_request.auto_merge_enabled = false
merge_request.merge_user = nil
@@ -76,7 +86,11 @@ module AutoMerge
'auto_merge_strategy'
)
- merge_request.save
+ merge_request.save!
+ end
+
+ def track_exception(error, merge_request)
+ Gitlab::ErrorTracking.track_exception(error, merge_request_id: merge_request&.id)
end
end
end
diff --git a/app/services/award_emojis/destroy_service.rb b/app/services/award_emojis/destroy_service.rb
index a61a7911a9d..cfd194262f9 100644
--- a/app/services/award_emojis/destroy_service.rb
+++ b/app/services/award_emojis/destroy_service.rb
@@ -13,7 +13,7 @@ module AwardEmojis
return error("User has not awarded emoji of type #{name} on the awardable", status: :forbidden)
end
- award = awards.destroy_all.first # rubocop: disable DestroyAll
+ award = awards.destroy_all.first # rubocop: disable Cop/DestroyAll
after_destroy(award)
success(award: award)
diff --git a/app/services/ci/authorize_job_artifact_service.rb b/app/services/ci/authorize_job_artifact_service.rb
new file mode 100644
index 00000000000..893e92d427c
--- /dev/null
+++ b/app/services/ci/authorize_job_artifact_service.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Ci
+ class AuthorizeJobArtifactService
+ include Gitlab::Utils::StrongMemoize
+
+ # Max size of the zipped LSIF artifact
+ LSIF_ARTIFACT_MAX_SIZE = 20.megabytes
+ LSIF_ARTIFACT_TYPE = 'lsif'
+
+ def initialize(job, params, max_size:)
+ @job = job
+ @max_size = max_size
+ @size = params[:filesize]
+ @type = params[:artifact_type].to_s
+ end
+
+ def forbidden?
+ lsif? && !code_navigation_enabled?
+ end
+
+ def too_large?
+ size && max_size <= size.to_i
+ end
+
+ def headers
+ default_headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size)
+ default_headers.tap do |h|
+ h[:ProcessLsif] = true if lsif? && code_navigation_enabled?
+ end
+ end
+
+ private
+
+ attr_reader :job, :size, :type
+
+ def code_navigation_enabled?
+ strong_memoize(:code_navigation_enabled) do
+ Feature.enabled?(:code_navigation, job.project, default_enabled: true)
+ end
+ end
+
+ def lsif?
+ strong_memoize(:lsif) do
+ type == LSIF_ARTIFACT_TYPE
+ end
+ end
+
+ def max_size
+ lsif? ? LSIF_ARTIFACT_MAX_SIZE : @max_size.to_i
+ end
+ end
+end
diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb
new file mode 100644
index 00000000000..758ba1c73bf
--- /dev/null
+++ b/app/services/ci/build_report_result_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Ci
+ class BuildReportResultService
+ def execute(build)
+ return unless Feature.enabled?(:build_report_summary, build.project)
+ return unless build.has_test_reports?
+
+ build.report_results.create!(
+ project_id: build.project_id,
+ data: tests_params(build)
+ )
+ end
+
+ private
+
+ def generate_test_suite_report(build)
+ build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
+ end
+
+ def tests_params(build)
+ test_suite = generate_test_suite_report(build)
+
+ {
+ tests: {
+ name: test_suite.name,
+ duration: test_suite.total_time,
+ failed: test_suite.failed_count,
+ errored: test_suite.error_count,
+ skipped: test_suite.skipped_count,
+ success: test_suite.success_count
+ }
+ }
+ end
+ end
+end
diff --git a/app/services/ci/create_cross_project_pipeline_service.rb b/app/services/ci/create_cross_project_pipeline_service.rb
index a73a2e2b471..1700312b941 100644
--- a/app/services/ci/create_cross_project_pipeline_service.rb
+++ b/app/services/ci/create_cross_project_pipeline_service.rb
@@ -47,6 +47,7 @@ module Ci
# and update the status when the downstream pipeline completes.
subject.success! unless subject.dependent?
else
+ subject.options[:downstream_errors] = pipeline.errors.full_messages
subject.drop!(:downstream_pipeline_creation_failed)
end
end
diff --git a/app/services/ci/create_web_ide_terminal_service.rb b/app/services/ci/create_web_ide_terminal_service.rb
new file mode 100644
index 00000000000..29d40756ab4
--- /dev/null
+++ b/app/services/ci/create_web_ide_terminal_service.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+module Ci
+ class CreateWebIdeTerminalService < ::BaseService
+ include ::Gitlab::Utils::StrongMemoize
+
+ TerminalCreationError = Class.new(StandardError)
+
+ TERMINAL_NAME = 'terminal'.freeze
+
+ attr_reader :terminal
+
+ def execute
+ check_access!
+ validate_params!
+ load_terminal_config!
+
+ pipeline = create_pipeline!
+ success(pipeline: pipeline)
+ rescue TerminalCreationError => e
+ error(e.message)
+ rescue ActiveRecord::RecordInvalid => e
+ error("Failed to persist the pipeline: #{e.message}")
+ end
+
+ private
+
+ def create_pipeline!
+ build_pipeline.tap do |pipeline|
+ pipeline.stages << terminal_stage_seed(pipeline).to_resource
+ pipeline.save!
+
+ Ci::ProcessPipelineService
+ .new(pipeline)
+ .execute(nil, initial_process: true)
+
+ pipeline_created_counter.increment(source: :webide)
+ end
+ end
+
+ def build_pipeline
+ Ci::Pipeline.new(
+ project: project,
+ user: current_user,
+ source: :webide,
+ config_source: :webide_source,
+ ref: ref,
+ sha: sha,
+ tag: false,
+ before_sha: Gitlab::Git::BLANK_SHA
+ )
+ end
+
+ def terminal_stage_seed(pipeline)
+ attributes = {
+ name: TERMINAL_NAME,
+ index: 0,
+ builds: [terminal_build_seed]
+ }
+
+ Gitlab::Ci::Pipeline::Seed::Stage.new(pipeline, attributes, [])
+ end
+
+ def terminal_build_seed
+ terminal.merge(
+ name: TERMINAL_NAME,
+ stage: TERMINAL_NAME,
+ user: current_user,
+ scheduling_type: :stage)
+ end
+
+ def load_terminal_config!
+ result = ::Ci::WebIdeConfigService.new(project, current_user, sha: sha).execute
+ raise TerminalCreationError, result[:message] if result[:status] != :success
+
+ @terminal = result[:terminal]
+ raise TerminalCreationError, 'Terminal is not configured' unless terminal
+ end
+
+ def validate_params!
+ unless sha
+ raise TerminalCreationError, 'Ref does not exist'
+ end
+
+ unless branch_exists?
+ raise TerminalCreationError, 'Ref needs to be a branch'
+ end
+ end
+
+ def check_access!
+ unless can?(current_user, :create_web_ide_terminal, project)
+ raise TerminalCreationError, 'Insufficient permissions to create a terminal'
+ end
+
+ if terminal_active?
+ raise TerminalCreationError, 'There is already a terminal running'
+ end
+ end
+
+ def pipeline_created_counter
+ @pipeline_created_counter ||= Gitlab::Metrics
+ .counter(:pipelines_created_total, "Counter of pipelines created")
+ end
+
+ def terminal_active?
+ project.active_webide_pipelines(user: current_user).exists?
+ end
+
+ def ref
+ strong_memoize(:ref) do
+ Gitlab::Git.ref_name(params[:ref])
+ end
+ end
+
+ def branch_exists?
+ project.repository.branch_exists?(ref)
+ end
+
+ def sha
+ project.commit(params[:ref]).try(:id)
+ end
+ end
+end
diff --git a/app/services/ci/extract_sections_from_build_trace_service.rb b/app/services/ci/extract_sections_from_build_trace_service.rb
index 97f9918fdb7..c756e376901 100644
--- a/app/services/ci/extract_sections_from_build_trace_service.rb
+++ b/app/services/ci/extract_sections_from_build_trace_service.rb
@@ -5,7 +5,7 @@ module Ci
def execute(build)
return false unless build.trace_sections.empty?
- Gitlab::Database.bulk_insert(BuildTraceSection.table_name, extract_sections(build))
+ Gitlab::Database.bulk_insert(BuildTraceSection.table_name, extract_sections(build)) # rubocop:disable Gitlab/BulkInsert
true
end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 3f23e81dcdd..80ebe5f5eb6 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -11,7 +11,7 @@ module Ci
def execute(trigger_build_ids = nil, initial_process: false)
update_retried
- if Feature.enabled?(:ci_atomic_processing, pipeline.project)
+ if ::Gitlab::Ci::Features.atomic_processing?(pipeline.project)
Ci::PipelineProcessing::AtomicProcessingService
.new(pipeline)
.execute
diff --git a/app/services/ci/update_ci_ref_status_service.rb b/app/services/ci/update_ci_ref_status_service.rb
index 4f7ac4d11b0..22cc43232cc 100644
--- a/app/services/ci/update_ci_ref_status_service.rb
+++ b/app/services/ci/update_ci_ref_status_service.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# NOTE: This class is unused and to be removed in 13.1~
module Ci
class UpdateCiRefStatusService
include Gitlab::OptimisticLocking
diff --git a/app/services/ci/web_ide_config_service.rb b/app/services/ci/web_ide_config_service.rb
new file mode 100644
index 00000000000..ade9132f419
--- /dev/null
+++ b/app/services/ci/web_ide_config_service.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Ci
+ class WebIdeConfigService < ::BaseService
+ include ::Gitlab::Utils::StrongMemoize
+
+ ValidationError = Class.new(StandardError)
+
+ WEBIDE_CONFIG_FILE = '.gitlab/.gitlab-webide.yml'.freeze
+
+ attr_reader :config, :config_content
+
+ def execute
+ check_access!
+ load_config_content!
+ load_config!
+
+ success(terminal: config.terminal_value)
+ rescue ValidationError => e
+ error(e.message)
+ end
+
+ private
+
+ def check_access!
+ unless can?(current_user, :download_code, project)
+ raise ValidationError, 'Insufficient permissions to read configuration'
+ end
+ end
+
+ def load_config_content!
+ @config_content = webide_yaml_from_repo
+
+ unless config_content
+ raise ValidationError, "Failed to load Web IDE config file '#{WEBIDE_CONFIG_FILE}' for #{params[:sha]}"
+ end
+ end
+
+ def load_config!
+ @config = Gitlab::WebIde::Config.new(config_content)
+
+ unless @config.valid?
+ raise ValidationError, @config.errors.first
+ end
+ rescue Gitlab::WebIde::Config::ConfigError => e
+ raise ValidationError, e.message
+ end
+
+ def webide_yaml_from_repo
+ gitlab_webide_yml_for(params[:sha])
+ rescue GRPC::NotFound, GRPC::Internal
+ nil
+ end
+
+ def gitlab_webide_yml_for(sha)
+ project.repository.blob_data_at(sha, WEBIDE_CONFIG_FILE)
+ end
+ end
+end
diff --git a/app/services/clusters/applications/prometheus_config_service.rb b/app/services/clusters/applications/prometheus_config_service.rb
index 34d44ab881e..50c4e26b0d0 100644
--- a/app/services/clusters/applications/prometheus_config_service.rb
+++ b/app/services/clusters/applications/prometheus_config_service.rb
@@ -132,19 +132,21 @@ module Clusters
end
def alerts(environment)
- variables = Gitlab::Prometheus::QueryVariables.call(environment)
alerts = Projects::Prometheus::AlertsFinder
.new(environment: environment)
.execute
alerts.map do |alert|
- substitute_query_variables(alert.to_param, variables)
+ hash = alert.to_param
+ hash['expr'] = substitute_query_variables(hash['expr'], environment)
+ hash
end
end
- def substitute_query_variables(hash, variables)
- hash['expr'] %= variables
- hash
+ def substitute_query_variables(query, environment)
+ result = ::Prometheus::ProxyVariableSubstitutionService.new(environment, query: query).execute
+
+ result[:params][:query]
end
def environments
diff --git a/app/services/clusters/parse_cluster_applications_artifact_service.rb b/app/services/clusters/parse_cluster_applications_artifact_service.rb
index b8e1c80cfe7..35fba5f47c7 100644
--- a/app/services/clusters/parse_cluster_applications_artifact_service.rb
+++ b/app/services/clusters/parse_cluster_applications_artifact_service.rb
@@ -18,13 +18,9 @@ module Clusters
raise ArgumentError, 'Artifact is not cluster_applications file type' unless artifact&.cluster_applications?
- unless artifact.file.size < MAX_ACCEPTABLE_ARTIFACT_SIZE
- return error(too_big_error_message, :bad_request)
- end
-
- unless cluster
- return error(s_('ClusterIntegration|No deployment cluster found for this job'))
- end
+ return error(too_big_error_message, :bad_request) unless artifact.file.size < MAX_ACCEPTABLE_ARTIFACT_SIZE
+ return error(no_deployment_message, :bad_request) unless job.deployment
+ return error(no_deployment_cluster_message, :bad_request) unless cluster
parse!(artifact)
@@ -61,7 +57,8 @@ module Clusters
Clusters::Cluster.transaction do
RELEASE_NAMES.each do |release_name|
- application = find_or_build_application(release_name)
+ application_class = Clusters::Cluster::APPLICATIONS[release_name]
+ application = cluster.find_or_build_application(application_class)
release = release_by_name[release_name]
@@ -80,16 +77,18 @@ module Clusters
end
end
- def find_or_build_application(application_name)
- application_class = Clusters::Cluster::APPLICATIONS[application_name]
-
- cluster.find_or_build_application(application_class)
- end
-
def too_big_error_message
human_size = ActiveSupport::NumberHelper.number_to_human_size(MAX_ACCEPTABLE_ARTIFACT_SIZE)
s_('ClusterIntegration|Cluster_applications artifact too big. Maximum allowable size: %{human_size}') % { human_size: human_size }
end
+
+ def no_deployment_message
+ s_('ClusterIntegration|No deployment found for this job')
+ end
+
+ def no_deployment_cluster_message
+ s_('ClusterIntegration|No deployment cluster found for this job')
+ end
end
end
diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb
index bd238605ac1..d80d9bebe9c 100644
--- a/app/services/commits/create_service.rb
+++ b/app/services/commits/create_service.rb
@@ -30,12 +30,14 @@ module Commits
success(result: new_commit)
rescue ChangeError => ex
+ Gitlab::ErrorTracking.log_exception(ex)
error(ex.message, pass_back: { error_code: ex.error_code })
rescue ValidationError,
Gitlab::Git::Index::IndexError,
Gitlab::Git::CommitError,
Gitlab::Git::PreReceiveError,
Gitlab::Git::CommandError => ex
+ Gitlab::ErrorTracking.log_exception(ex)
error(ex.message)
end
diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb
index 0c5ecca3a50..4678d051d29 100644
--- a/app/services/concerns/exclusive_lease_guard.rb
+++ b/app/services/concerns/exclusive_lease_guard.rb
@@ -58,6 +58,6 @@ module ExclusiveLeaseGuard
end
def log_error(message, extra_args = {})
- Rails.logger.error(message) # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error(message)
end
end
diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb
new file mode 100644
index 00000000000..4d551430315
--- /dev/null
+++ b/app/services/concerns/integrations/project_test_data.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ProjectTestData
+ private
+
+ def push_events_data
+ Gitlab::DataBuilder::Push.build_sample(project, current_user)
+ end
+
+ def note_events_data
+ note = project.notes.first
+ return { 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
+ return { error: s_('TestHooks|Ensure the project has issues.') } unless issue.present?
+
+ issue.to_hook_data(current_user)
+ end
+
+ def merge_requests_events_data
+ merge_request = project.merge_requests.first
+ return { 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
+ return { 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.newest_first.first
+ return { 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.list_pages(limit: 1).first
+ if !project.wiki_enabled? || page.blank?
+ return { error: s_('TestHooks|Ensure the wiki is enabled and has pages.') }
+ end
+
+ Gitlab::DataBuilder::WikiPage.build(page, current_user, 'create')
+ end
+
+ def deployment_events_data
+ deployment = project.deployments.first
+ return { error: s_('TestHooks|Ensure the project has deployments.') } unless deployment.present?
+
+ Gitlab::DataBuilder::Deployment.build(deployment)
+ end
+ end
+end
diff --git a/app/services/concerns/measurable.rb b/app/services/concerns/measurable.rb
index 5a74f15506e..b099a58a9ae 100644
--- a/app/services/concerns/measurable.rb
+++ b/app/services/concerns/measurable.rb
@@ -4,8 +4,6 @@
# Example:
# ```
# class DummyService
-# prepend Measurable
-#
# def execute
# # ...
# end
diff --git a/app/services/concerns/spam_check_methods.rb b/app/services/concerns/spam_check_methods.rb
index 53e9e001463..939f8f183ab 100644
--- a/app/services/concerns/spam_check_methods.rb
+++ b/app/services/concerns/spam_check_methods.rb
@@ -22,15 +22,18 @@ module SpamCheckMethods
# a dirty instance, which means it should be already assigned with the new
# attribute values.
# rubocop:disable Gitlab/ModuleWithInstanceVariables
- def spam_check(spammable, user)
+ def spam_check(spammable, user, action:)
+ raise ArgumentError.new('Please provide an action, such as :create') unless action
+
Spam::SpamActionService.new(
spammable: spammable,
- request: @request
+ request: @request,
+ user: user,
+ context: { action: action }
).execute(
api: @api,
recaptcha_verified: @recaptcha_verified,
- spam_log_id: @spam_log_id,
- user: user)
+ spam_log_id: @spam_log_id)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/services/container_expiration_policies/update_service.rb b/app/services/container_expiration_policies/update_service.rb
new file mode 100644
index 00000000000..2f34941d692
--- /dev/null
+++ b/app/services/container_expiration_policies/update_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module ContainerExpirationPolicies
+ class UpdateService < BaseContainerService
+ include Gitlab::Utils::StrongMemoize
+
+ ALLOWED_ATTRIBUTES = %i[enabled cadence older_than keep_n name_regex name_regex_keep].freeze
+
+ def execute
+ return ServiceResponse.error(message: 'Access Denied', http_status: 403) unless allowed?
+
+ if container_expiration_policy.update(container_expiration_policy_params)
+ ServiceResponse.success(payload: { container_expiration_policy: container_expiration_policy })
+ else
+ ServiceResponse.error(
+ message: container_expiration_policy.errors.full_messages.to_sentence || 'Bad request',
+ http_status: 400
+ )
+ end
+ end
+
+ private
+
+ def container_expiration_policy
+ strong_memoize(:container_expiration_policy) do
+ @container.container_expiration_policy || @container.build_container_expiration_policy
+ end
+ end
+
+ def allowed?
+ Ability.allowed?(current_user, :destroy_container_image, @container)
+ end
+
+ def container_expiration_policy_params
+ @params.slice(*ALLOWED_ATTRIBUTES)
+ end
+ end
+end
diff --git a/app/services/container_expiration_policy_service.rb b/app/services/container_expiration_policy_service.rb
index 82274fd8668..80f32298323 100644
--- a/app/services/container_expiration_policy_service.rb
+++ b/app/services/container_expiration_policy_service.rb
@@ -1,7 +1,14 @@
# frozen_string_literal: true
class ContainerExpirationPolicyService < BaseService
+ InvalidPolicyError = Class.new(StandardError)
+
def execute(container_expiration_policy)
+ unless container_expiration_policy.valid?
+ container_expiration_policy.disable!
+ raise InvalidPolicyError
+ end
+
container_expiration_policy.schedule_next_run!
container_expiration_policy.container_repositories.find_each do |container_repository|
diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb
index e69f07db5bf..5d875c630a0 100644
--- a/app/services/design_management/delete_designs_service.rb
+++ b/app/services/design_management/delete_designs_service.rb
@@ -15,6 +15,7 @@ module DesignManagement
return error('Forbidden!') unless can_delete_designs?
version = delete_designs!
+ EventCreateService.new.destroy_designs(designs, current_user)
success(version: version)
end
@@ -48,7 +49,9 @@ module DesignManagement
end
def design_action(design)
- on_success { counter.count(:delete) }
+ on_success do
+ counter.count(:delete)
+ end
DesignManagement::DesignAction.new(design, :delete)
end
diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb
index a09c19bc885..0446d2f1ee8 100644
--- a/app/services/design_management/save_designs_service.rb
+++ b/app/services/design_management/save_designs_service.rb
@@ -20,6 +20,7 @@ module DesignManagement
uploaded_designs, version = upload_designs!
skipped_designs = designs - uploaded_designs
+ create_events
success({ designs: uploaded_designs, version: version, skipped_designs: skipped_designs })
rescue ::ActiveRecord::RecordInvalid => e
error(e.message)
@@ -47,7 +48,7 @@ module DesignManagement
end
def build_actions
- files.zip(designs).flat_map do |(file, design)|
+ @actions ||= files.zip(designs).flat_map do |(file, design)|
Array.wrap(build_design_action(file, design))
end
end
@@ -57,7 +58,9 @@ module DesignManagement
return if design_unchanged?(design, content)
action = new_file?(design) ? :create : :update
- on_success { ::Gitlab::UsageDataCounters::DesignsCounter.count(action) }
+ on_success do
+ ::Gitlab::UsageDataCounters::DesignsCounter.count(action)
+ end
DesignManagement::DesignAction.new(design, action, content)
end
@@ -67,6 +70,16 @@ module DesignManagement
content == existing_blobs[design]&.data
end
+ def create_events
+ by_action = @actions.group_by(&:action).transform_values { |grp| grp.map(&:design) }
+
+ event_create_service.save_designs(current_user, **by_action)
+ end
+
+ def event_create_service
+ @event_create_service ||= EventCreateService.new
+ end
+
def commit_message
<<~MSG
Updated #{files.size} #{'designs'.pluralize(files.size)}
diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb
index 816cd45b07a..946fb5f1372 100644
--- a/app/services/discussions/resolve_service.rb
+++ b/app/services/discussions/resolve_service.rb
@@ -2,25 +2,68 @@
module Discussions
class ResolveService < Discussions::BaseService
- def execute(one_or_more_discussions)
- Array(one_or_more_discussions).each { |discussion| resolve_discussion(discussion) }
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(project, user = nil, params = {})
+ @discussions = Array.wrap(params.fetch(:one_or_more_discussions))
+ @follow_up_issue = params[:follow_up_issue]
+ @resolved_count = 0
+
+ raise ArgumentError, 'Discussions must be all for the same noteable' \
+ unless noteable_is_same?
+
+ super
+ end
+
+ def execute
+ discussions.each(&method(:resolve_discussion))
+ process_auto_merge
+ end
+
+ private
+
+ attr_accessor :discussions, :follow_up_issue
+
+ def noteable_is_same?
+ return true unless discussions.size > 1
+
+ # Perform this check without fetching extra records
+ discussions.all? do |discussion|
+ discussion.noteable_type == first_discussion.noteable_type &&
+ discussion.noteable_id == first_discussion.noteable_id
+ end
end
def resolve_discussion(discussion)
return unless discussion.can_resolve?(current_user)
discussion.resolve!(current_user)
+ @resolved_count += 1
- MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request)
+ MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) if merge_request
SystemNoteService.discussion_continued_in_issue(discussion, project, current_user, follow_up_issue) if follow_up_issue
end
+ def first_discussion
+ @first_discussion ||= discussions.first
+ end
+
def merge_request
- params[:merge_request]
+ strong_memoize(:merge_request) do
+ first_discussion.noteable if first_discussion.for_merge_request?
+ end
+ end
+
+ def process_auto_merge
+ return unless merge_request
+ return unless @resolved_count.positive?
+ return unless discussions_ready_to_merge?
+
+ AutoMergeProcessWorker.perform_async(merge_request.id)
end
- def follow_up_issue
- params[:follow_up_issue]
+ def discussions_ready_to_merge?
+ merge_request.auto_merge_enabled? && merge_request.mergeable_discussions_state?
end
end
end
diff --git a/app/services/draft_notes/base_service.rb b/app/services/draft_notes/base_service.rb
new file mode 100644
index 00000000000..89daae0e8f4
--- /dev/null
+++ b/app/services/draft_notes/base_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module DraftNotes
+ class BaseService < ::BaseService
+ attr_accessor :merge_request, :current_user, :params
+
+ def initialize(merge_request, current_user, params = nil)
+ @merge_request, @current_user, @params = merge_request, current_user, params.dup
+ end
+
+ private
+
+ def draft_notes
+ @draft_notes ||= merge_request.draft_notes.order_id_asc.authored_by(current_user)
+ end
+
+ def project
+ merge_request.target_project
+ end
+ end
+end
diff --git a/app/services/draft_notes/create_service.rb b/app/services/draft_notes/create_service.rb
new file mode 100644
index 00000000000..501778b7d5f
--- /dev/null
+++ b/app/services/draft_notes/create_service.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module DraftNotes
+ class CreateService < DraftNotes::BaseService
+ attr_accessor :in_draft_mode, :in_reply_to_discussion_id
+
+ def initialize(merge_request, current_user, params = nil)
+ @in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id)
+ super
+ end
+
+ def execute
+ if in_reply_to_discussion_id.present?
+ unless discussion
+ return base_error(_('Thread to reply to cannot be found'))
+ end
+
+ params[:discussion_id] = discussion.reply_id
+ end
+
+ if params[:resolve_discussion] && !can_resolve_discussion?
+ return base_error(_('User is not allowed to resolve thread'))
+ end
+
+ draft_note = DraftNote.new(params)
+ draft_note.merge_request = merge_request
+ draft_note.author = current_user
+ draft_note.save
+
+ if in_reply_to_discussion_id.blank? && draft_note.diff_file&.unfolded?
+ merge_request.diffs.clear_cache
+ end
+
+ draft_note
+ end
+
+ private
+
+ def base_error(text)
+ DraftNote.new.tap do |draft|
+ draft.errors.add(:base, text)
+ end
+ end
+
+ def discussion
+ @discussion ||= merge_request.notes.find_discussion(in_reply_to_discussion_id)
+ end
+
+ def can_resolve_discussion?
+ note = discussion&.notes&.first
+ return false unless note
+
+ current_user && Ability.allowed?(current_user, :resolve_note, note)
+ end
+ end
+end
diff --git a/app/services/draft_notes/destroy_service.rb b/app/services/draft_notes/destroy_service.rb
new file mode 100644
index 00000000000..ddca0debb03
--- /dev/null
+++ b/app/services/draft_notes/destroy_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module DraftNotes
+ class DestroyService < DraftNotes::BaseService
+ # If no `draft` is given it fallsback to all
+ # draft notes of the given merge request and user.
+ def execute(draft = nil)
+ drafts = draft || draft_notes
+
+ clear_highlight_diffs_cache(Array.wrap(drafts))
+
+ drafts.is_a?(DraftNote) ? drafts.destroy! : drafts.delete_all
+ end
+
+ private
+
+ def clear_highlight_diffs_cache(drafts)
+ if drafts.any? { |draft| draft.diff_file&.unfolded? }
+ merge_request.diffs.clear_cache
+ end
+ end
+ end
+end
diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb
new file mode 100644
index 00000000000..a9a7304e5ed
--- /dev/null
+++ b/app/services/draft_notes/publish_service.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module DraftNotes
+ class PublishService < DraftNotes::BaseService
+ def execute(draft = nil)
+ return error('Not allowed to create notes') unless can?(current_user, :create_note, merge_request)
+
+ if draft
+ publish_draft_note(draft)
+ else
+ publish_draft_notes
+ end
+
+ success
+ rescue ActiveRecord::RecordInvalid => e
+ message = "Unable to save #{e.record.class.name}: #{e.record.errors.full_messages.join(", ")} "
+ error(message)
+ end
+
+ private
+
+ def publish_draft_note(draft)
+ create_note_from_draft(draft)
+ draft.delete
+
+ MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request)
+ end
+
+ def publish_draft_notes
+ return if draft_notes.empty?
+
+ review = Review.create!(author: current_user, merge_request: merge_request, project: project)
+
+ draft_notes.map do |draft_note|
+ draft_note.review = review
+ create_note_from_draft(draft_note)
+ end
+ draft_notes.delete_all
+
+ notification_service.async.new_review(review)
+ MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request)
+ end
+
+ def create_note_from_draft(draft)
+ # Make sure the diff file is unfolded in order to find the correct line
+ # codes.
+ draft.diff_file&.unfold_diff_lines(draft.original_position)
+
+ note = Notes::CreateService.new(draft.project, draft.author, draft.publish_params).execute
+ set_discussion_resolve_status(note, draft)
+
+ note
+ end
+
+ def set_discussion_resolve_status(note, draft_note)
+ return unless draft_note.discussion_id.present?
+
+ discussion = note.discussion
+
+ if draft_note.resolve_discussion && discussion.can_resolve?(current_user)
+ discussion.resolve!(current_user)
+ else
+ discussion.unresolve!
+ end
+ end
+ end
+end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 522f36cda46..89c3225dbcd 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -11,67 +11,81 @@ class EventCreateService
IllegalActionError = Class.new(StandardError)
def open_issue(issue, current_user)
- create_record_event(issue, current_user, Event::CREATED)
+ create_resource_event(issue, current_user, :opened)
+
+ create_record_event(issue, current_user, :created)
end
def close_issue(issue, current_user)
- create_record_event(issue, current_user, Event::CLOSED)
+ create_resource_event(issue, current_user, :closed)
+
+ create_record_event(issue, current_user, :closed)
end
def reopen_issue(issue, current_user)
- create_record_event(issue, current_user, Event::REOPENED)
+ create_resource_event(issue, current_user, :reopened)
+
+ create_record_event(issue, current_user, :reopened)
end
def open_mr(merge_request, current_user)
- create_record_event(merge_request, current_user, Event::CREATED)
+ create_resource_event(merge_request, current_user, :opened)
+
+ create_record_event(merge_request, current_user, :created)
end
def close_mr(merge_request, current_user)
- create_record_event(merge_request, current_user, Event::CLOSED)
+ create_resource_event(merge_request, current_user, :closed)
+
+ create_record_event(merge_request, current_user, :closed)
end
def reopen_mr(merge_request, current_user)
- create_record_event(merge_request, current_user, Event::REOPENED)
+ create_resource_event(merge_request, current_user, :reopened)
+
+ create_record_event(merge_request, current_user, :reopened)
end
def merge_mr(merge_request, current_user)
- create_record_event(merge_request, current_user, Event::MERGED)
+ create_resource_event(merge_request, current_user, :merged)
+
+ create_record_event(merge_request, current_user, :merged)
end
def open_milestone(milestone, current_user)
- create_record_event(milestone, current_user, Event::CREATED)
+ create_record_event(milestone, current_user, :created)
end
def close_milestone(milestone, current_user)
- create_record_event(milestone, current_user, Event::CLOSED)
+ create_record_event(milestone, current_user, :closed)
end
def reopen_milestone(milestone, current_user)
- create_record_event(milestone, current_user, Event::REOPENED)
+ create_record_event(milestone, current_user, :reopened)
end
def destroy_milestone(milestone, current_user)
- create_record_event(milestone, current_user, Event::DESTROYED)
+ create_record_event(milestone, current_user, :destroyed)
end
def leave_note(note, current_user)
- create_record_event(note, current_user, Event::COMMENTED)
+ create_record_event(note, current_user, :commented)
end
def join_project(project, current_user)
- create_event(project, current_user, Event::JOINED)
+ create_event(project, current_user, :joined)
end
def leave_project(project, current_user)
- create_event(project, current_user, Event::LEFT)
+ create_event(project, current_user, :left)
end
def expired_leave_project(project, current_user)
- create_event(project, current_user, Event::EXPIRED)
+ create_event(project, current_user, :expired)
end
def create_project(project, current_user)
- create_event(project, current_user, Event::CREATED)
+ create_event(project, current_user, :created)
end
def push(project, current_user, push_data)
@@ -82,11 +96,34 @@ class EventCreateService
create_push_event(BulkPushEventPayloadService, project, current_user, push_data)
end
+ def save_designs(current_user, create: [], update: [])
+ created = create.group_by(&:project).flat_map do |project, designs|
+ Feature.enabled?(:design_activity_events, project) ? designs : []
+ end.to_set
+ updated = update.group_by(&:project).flat_map do |project, designs|
+ Feature.enabled?(:design_activity_events, project) ? designs : []
+ end.to_set
+ return [] if created.empty? && updated.empty?
+
+ records = created.zip([:created].cycle) + updated.zip([:updated].cycle)
+
+ create_record_events(records, current_user)
+ end
+
+ def destroy_designs(designs, current_user)
+ designs = designs.select do |design|
+ Feature.enabled?(:design_activity_events, design.project)
+ end
+ return [] unless designs.present?
+
+ create_record_events(designs.zip([:destroyed].cycle), current_user)
+ end
+
# Create a new wiki page event
#
# @param [WikiPage::Meta] wiki_page_meta The event target
# @param [User] author The event author
- # @param [Integer] action One of the Event::WIKI_ACTIONS
+ # @param [Symbol] action One of the Event::WIKI_ACTIONS
#
# @return a tuple of event and either :found or :created
def wiki_event(wiki_page_meta, author, action)
@@ -100,7 +137,7 @@ class EventCreateService
event = create_record_event(wiki_page_meta, author, action)
# Ensure that the event is linked in time to the metadata, for non-deletes
- unless action == Event::DESTROYED
+ unless event.destroyed_action?
time_stamp = wiki_page_meta.updated_at
event.update_columns(updated_at: time_stamp, created_at: time_stamp)
end
@@ -111,16 +148,41 @@ class EventCreateService
private
def existing_wiki_event(wiki_page_meta, action)
- if action == Event::DESTROYED
+ if Event.actions.fetch(action) == Event.actions[:destroyed]
most_recent = Event.for_wiki_meta(wiki_page_meta).recent.first
- return most_recent if most_recent.present? && most_recent.action == action
+ return most_recent if most_recent.present? && Event.actions[most_recent.action] == Event.actions[action]
else
Event.for_wiki_meta(wiki_page_meta).created_at(wiki_page_meta.updated_at).first
end
end
def create_record_event(record, current_user, status)
- create_event(record.resource_parent, current_user, status, target_id: record.id, target_type: record.class.name)
+ create_event(record.resource_parent, current_user, status,
+ target_id: record.id, target_type: record.class.name)
+ end
+
+ # If creating several events, this method will insert them all in a single
+ # statement
+ #
+ # @param [[Eventable, Symbol]] a list of pairs of records and a valid status
+ # @param [User] the author of the event
+ def create_record_events(pairs, current_user)
+ base_attrs = {
+ created_at: Time.now.utc,
+ updated_at: Time.now.utc,
+ author_id: current_user.id
+ }
+
+ attribute_sets = pairs.map do |record, status|
+ action = Event.actions[status]
+ raise IllegalActionError, "#{status} is not a valid status" if action.nil?
+
+ parent_attrs(record.resource_parent)
+ .merge(base_attrs)
+ .merge(action: action, target_id: record.id, target_type: record.class.name)
+ end
+
+ Event.insert_all(attribute_sets, returning: %w[id])
end
def create_push_event(service_class, project, current_user, push_data)
@@ -128,7 +190,7 @@ class EventCreateService
# when creating push payload data will result in the event creation being
# rolled back as well.
event = Event.transaction do
- new_event = create_event(project, current_user, Event::PUSHED)
+ new_event = create_event(project, current_user, :pushed)
service_class.new(new_event, push_data).execute
@@ -146,16 +208,34 @@ class EventCreateService
action: status,
author_id: current_user.id
)
+ attributes.merge!(parent_attrs(resource_parent))
+
+ Event.create!(attributes)
+ end
+ def parent_attrs(resource_parent)
resource_parent_attr = case resource_parent
when Project
- :project
+ :project_id
when Group
- :group
+ :group_id
end
- attributes[resource_parent_attr] = resource_parent if resource_parent_attr
- Event.create!(attributes)
+ return {} unless resource_parent_attr
+
+ { resource_parent_attr => resource_parent.id }
+ end
+
+ def create_resource_event(issuable, current_user, status)
+ return unless state_change_tracking_enabled?(issuable)
+
+ ResourceEvents::ChangeStateService.new(resource: issuable, user: current_user)
+ .execute(status)
+ end
+
+ def state_change_tracking_enabled?(issuable)
+ issuable&.respond_to?(:resource_state_events) &&
+ ::Feature.enabled?(:track_resource_state_change_events, issuable&.project)
end
end
diff --git a/app/services/git/wiki_push_service/change.rb b/app/services/git/wiki_push_service/change.rb
index 8685850165a..14e622dd147 100644
--- a/app/services/git/wiki_push_service/change.rb
+++ b/app/services/git/wiki_push_service/change.rb
@@ -21,11 +21,11 @@ module Git
def event_action
case raw_change.operation
when :added
- Event::CREATED
+ :created
when :deleted
- Event::DESTROYED
+ :destroyed
else
- Event::UPDATED
+ :updated
end
end
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index 9437eb9eede..1bff70e6c2e 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -6,7 +6,7 @@ module Groups
def async_execute
job_id = GroupDestroyWorker.perform_async(group.id, current_user.id)
- Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}")
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb
index 2ce53fcfe4a..589ac7ccde7 100644
--- a/app/services/groups/group_links/create_service.rb
+++ b/app/services/groups/group_links/create_service.rb
@@ -5,7 +5,7 @@ module Groups
class CreateService < BaseService
def execute(shared_group)
unless group && shared_group &&
- can?(current_user, :admin_group, shared_group) &&
+ can?(current_user, :admin_group_member, shared_group) &&
can?(current_user, :read_group, group)
return error('Not Found', 404)
end
diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb
index 6835b6c4637..b0d496ae78c 100644
--- a/app/services/groups/group_links/destroy_service.rb
+++ b/app/services/groups/group_links/destroy_service.rb
@@ -3,7 +3,11 @@
module Groups
module GroupLinks
class DestroyService < BaseService
- def execute(one_or_more_links)
+ def execute(one_or_more_links, skip_authorization: false)
+ unless skip_authorization || group && can?(current_user, :admin_group_member, group)
+ return error('Not Found', 404)
+ end
+
links = Array(one_or_more_links)
if GroupGroupLink.delete(links)
diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb
index 0f2e3bb65f9..abac0ffc5d9 100644
--- a/app/services/groups/import_export/export_service.rb
+++ b/app/services/groups/import_export/export_service.rb
@@ -4,10 +4,11 @@ module Groups
module ImportExport
class ExportService
def initialize(group:, user:, params: {})
- @group = group
+ @group = group
@current_user = user
- @params = params
- @shared = @params[:shared] || Gitlab::ImportExport::Shared.new(@group)
+ @params = params
+ @shared = @params[:shared] || Gitlab::ImportExport::Shared.new(@group)
+ @logger = Gitlab::Export::Logger.build
end
def async_execute
@@ -21,7 +22,7 @@ module Groups
save!
ensure
- cleanup
+ remove_base_tmp_dir
end
private
@@ -80,8 +81,8 @@ module Groups
Gitlab::ImportExport::Saver.new(exportable: @group, shared: @shared)
end
- def cleanup
- FileUtils.rm_rf(shared.archive_path) if shared&.archive_path
+ def remove_base_tmp_dir
+ FileUtils.rm_rf(shared.base_path) if shared&.base_path
end
def notify_error!
@@ -91,21 +92,21 @@ module Groups
end
def notify_success
- @shared.logger.info(
- group_id: @group.id,
- group_name: @group.name,
- message: 'Group Import/Export: Export succeeded'
+ @logger.info(
+ message: 'Group Export succeeded',
+ group_id: @group.id,
+ group_name: @group.name
)
notification_service.group_was_exported(@group, @current_user)
end
def notify_error
- @shared.logger.error(
- group_id: @group.id,
+ @logger.error(
+ message: 'Group Export failed',
+ group_id: @group.id,
group_name: @group.name,
- error: @shared.errors.join(', '),
- message: 'Group Import/Export: Export failed'
+ errors: @shared.errors.join(', ')
)
notification_service.group_was_not_exported(@group, @current_user, @shared.errors)
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
index 6f692c98c38..a5c776f8fc2 100644
--- a/app/services/groups/import_export/import_service.rb
+++ b/app/services/groups/import_export/import_service.rb
@@ -9,6 +9,20 @@ module Groups
@group = group
@current_user = user
@shared = Gitlab::ImportExport::Shared.new(@group)
+ @logger = Gitlab::Import::Logger.build
+ end
+
+ def async_execute
+ group_import_state = GroupImportState.safe_find_or_create_by!(group: group)
+ jid = GroupImportWorker.perform_async(current_user.id, group.id)
+
+ if jid.present?
+ group_import_state.update!(jid: jid)
+ else
+ group_import_state.fail_op('Failed to schedule import job')
+
+ false
+ end
end
def execute
@@ -21,6 +35,7 @@ module Groups
end
ensure
+ remove_base_tmp_dir
remove_import_file
end
@@ -77,7 +92,7 @@ module Groups
end
def notify_success
- @shared.logger.info(
+ @logger.info(
group_id: @group.id,
group_name: @group.name,
message: 'Group Import/Export: Import succeeded'
@@ -85,7 +100,7 @@ module Groups
end
def notify_error
- @shared.logger.error(
+ @logger.error(
group_id: @group.id,
group_name: @group.name,
message: "Group Import/Export: Errors occurred, see '#{Gitlab::ErrorTracking::Logger.file_name}' for details"
@@ -97,6 +112,10 @@ module Groups
raise Gitlab::ImportExport::Error.new(@shared.errors.to_sentence)
end
+
+ def remove_base_tmp_dir
+ FileUtils.rm_rf(@shared.base_path)
+ end
end
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index fe3ab884302..fbbf4ce8baf 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -45,6 +45,7 @@ module Groups
raise_transfer_error(:invalid_policies) unless valid_policies?
raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path?
raise_transfer_error(:group_contains_images) if group_projects_contain_registry_images?
+ raise_transfer_error(:cannot_transfer_to_subgroup) if transfer_to_subgroup?
end
def group_is_already_root?
@@ -55,6 +56,11 @@ module Groups
@new_parent_group && @new_parent_group.id == @group.parent_id
end
+ def transfer_to_subgroup?
+ @new_parent_group && \
+ @group.self_and_descendants.pluck_primary_key.include?(@new_parent_group.id)
+ end
+
def valid_policies?
return false unless can?(current_user, :admin_group, @group)
@@ -82,6 +88,7 @@ module Groups
end
@group.parent = @new_parent_group
+ @group.clear_memoization(:self_and_ancestors_ids)
@group.save!
end
@@ -125,7 +132,8 @@ module Groups
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."),
- group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.')
+ group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.'),
+ cannot_transfer_to_subgroup: s_('TransferGroup|Cannot transfer group to one of its subgroup.')
}.freeze
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index 3c57fada677..0cf17568c78 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -10,15 +10,26 @@ module Import
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)
+ project = create_project(access_params, provider)
if project.persisted?
success(project)
else
error(project_save_error(project), :unprocessable_entity)
end
+ rescue Octokit::Error => e
+ log_error(e)
+ end
+
+ def create_project(access_params, provider)
+ Gitlab::LegacyGithubImport::ProjectCreator.new(
+ repo,
+ project_name,
+ target_namespace,
+ current_user,
+ access_params,
+ type: provider
+ ).execute(extra_project_attrs)
end
def repo
@@ -44,6 +55,18 @@ module Import
def authorized?
can?(current_user, :create_projects, target_namespace)
end
+
+ private
+
+ def log_error(exception)
+ Gitlab::Import::Logger.error(
+ message: 'Import failed due to a GitHub error',
+ status: exception.response_status,
+ error: exception.response_body
+ )
+
+ error(_('Import failed due to a GitHub error: %{original}') % { original: exception.response_body }, :unprocessable_entity)
+ end
end
end
diff --git a/app/services/integrations/test/base_service.rb b/app/services/integrations/test/base_service.rb
new file mode 100644
index 00000000000..a8a027092d5
--- /dev/null
+++ b/app/services/integrations/test/base_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Integrations
+ module Test
+ class BaseService
+ include BaseServiceUtility
+
+ attr_accessor :integration, :current_user, :event
+
+ # @param integration [Service] The external service that will be called
+ # @param current_user [User] The user calling the service
+ # @param event [String/nil] The event that triggered this
+ def initialize(integration, current_user, event = nil)
+ @integration = integration
+ @current_user = current_user
+ @event = event
+ end
+
+ def execute
+ if event && (integration.supported_events.exclude?(event) || data.blank?)
+ return error('Testing not available for this event')
+ end
+
+ return error(data[:error]) if data[:error].present?
+
+ integration.test(data)
+ end
+
+ private
+
+ def data
+ raise NotImplementedError
+ end
+ end
+ end
+end
diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb
new file mode 100644
index 00000000000..941d70c2cc4
--- /dev/null
+++ b/app/services/integrations/test/project_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Integrations
+ module Test
+ class ProjectService < Integrations::Test::BaseService
+ include Integrations::ProjectTestData
+ include Gitlab::Utils::StrongMemoize
+
+ def project
+ strong_memoize(:project) do
+ integration.project
+ end
+ end
+
+ private
+
+ def data
+ strong_memoize(:data) do
+ next pipeline_events_data if integration.is_a?(::PipelinesEmailService)
+
+ case event
+ when 'push', 'tag_push'
+ push_events_data
+ when 'note', 'confidential_note'
+ note_events_data
+ when 'issue', 'confidential_issue'
+ issues_events_data
+ when 'merge_request'
+ merge_requests_events_data
+ when 'job'
+ job_events_data
+ when 'pipeline'
+ pipeline_events_data
+ when 'wiki_page'
+ wiki_page_events_data
+ when 'deployment'
+ deployment_events_data
+ else
+ push_events_data
+ end
+ end
+ end
+ end
+ end
+end
+
+Integrations::Test::ProjectService.prepend_if_ee('::EE::Integrations::Test::ProjectService')
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index 2cd0e1e992d..2902385da4a 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -17,9 +17,8 @@ module Issuable
ids = params.delete(:issuable_ids).split(",")
items = find_issuables(parent, model_class, ids)
- permitted_attrs(type).each do |key|
- params.delete(key) unless params[key].present?
- end
+ params.slice!(*permitted_attrs(type))
+ params.delete_if { |k, v| v.blank? }
if params[:assignee_ids] == [IssuableFinder::Params::NONE.to_s]
params[:assignee_ids] = []
@@ -40,9 +39,13 @@ module Issuable
private
def permitted_attrs(type)
- attrs = %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event)
+ attrs = %i(state_event milestone_id add_label_ids remove_label_ids subscription_event)
+
+ issuable_specific_attrs(type, attrs)
+ end
- if type == 'issue'
+ def issuable_specific_attrs(type, attrs)
+ if type == 'issue' || type == 'merge_request'
attrs.push(:assignee_ids)
else
attrs.push(:assignee_id)
diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb
index a78e191c85f..b185ab592ff 100644
--- a/app/services/issuable/clone/attributes_rewriter.rb
+++ b/app/services/issuable/clone/attributes_rewriter.rb
@@ -105,7 +105,7 @@ module Issuable
yield(event)
end.compact
- Gitlab::Database.bulk_insert(table_name, events)
+ Gitlab::Database.bulk_insert(table_name, events) # rubocop:disable Gitlab/BulkInsert
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 18062bd60da..38b10996f44 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -129,15 +129,11 @@ class IssuableBaseService < BaseService
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 = label_ids || existing_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
- else
- new_label_ids |= add_label_ids if add_label_ids
- new_label_ids -= remove_label_ids if remove_label_ids
- end
+ new_label_ids |= add_label_ids if add_label_ids
+ new_label_ids -= remove_label_ids if remove_label_ids
new_label_ids.uniq
end
@@ -350,7 +346,7 @@ class IssuableBaseService < BaseService
todo_service.mark_todo(issuable, current_user)
when 'done'
todo = TodosFinder.new(current_user).find_by(target: issuable)
- todo_service.mark_todos_as_done_by_ids(todo, current_user) if todo
+ todo_service.resolve_todo(todo, current_user) if todo
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 7869509aa9c..c0194f5b847 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -15,7 +15,7 @@ module Issues
end
def before_create(issue)
- spam_check(issue, current_user)
+ spam_check(issue, current_user, action: :create)
issue.move_to_end
# current_user (defined in BaseService) is not available within run_after_commit block
@@ -38,9 +38,8 @@ module Issues
return if discussions_to_resolve.empty?
Discussions::ResolveService.new(project, current_user,
- merge_request: merge_request_to_resolve_discussions_of,
- follow_up_issue: issue)
- .execute(discussions_to_resolve)
+ one_or_more_discussions: discussions_to_resolve,
+ follow_up_issue: issue).execute
end
private
diff --git a/app/services/issues/import_csv_service.rb b/app/services/issues/import_csv_service.rb
index c01db5fcfe6..60790ba3547 100644
--- a/app/services/issues/import_csv_service.rb
+++ b/app/services/issues/import_csv_service.rb
@@ -46,7 +46,7 @@ module Issues
end
def email_results_to_user
- Notify.import_issues_csv_email(@user.id, @project.id, @results).deliver_now
+ Notify.import_issues_csv_email(@user.id, @project.id, @results).deliver_later
end
def detect_col_sep(header)
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index ee1a22634af..8d22f0edcdd 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -18,7 +18,7 @@ module Issues
end
def before_update(issue, skip_spam_check: false)
- spam_check(issue, current_user) unless skip_spam_check
+ spam_check(issue, current_user, action: :update) unless skip_spam_check
end
def after_update(issue)
@@ -32,7 +32,7 @@ module Issues
old_assignees = old_associations.fetch(:assignees, [])
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
- todo_service.mark_pending_todos_as_done(issue, current_user)
+ todo_service.resolve_todos_for_target(issue, current_user)
end
if issue.previous_changes.include?('title') ||
@@ -68,7 +68,7 @@ module Issues
end
def handle_task_changes(issuable)
- todo_service.mark_pending_todos_as_done(issuable, current_user)
+ todo_service.resolve_todos_for_target(issuable, current_user)
todo_service.update_issue(issuable, current_user)
end
diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb
new file mode 100644
index 00000000000..7521c7610cb
--- /dev/null
+++ b/app/services/jira/requests/base.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Jira
+ module Requests
+ class Base
+ include ProjectServicesLoggable
+
+ PER_PAGE = 50
+
+ attr_reader :jira_service, :project, :limit, :start_at, :query
+
+ def initialize(jira_service, limit: PER_PAGE, start_at: 0, query: nil)
+ @project = jira_service&.project
+ @jira_service = jira_service
+
+ @limit = limit
+ @start_at = start_at
+ @query = query
+ end
+
+ def execute
+ return ServiceResponse.error(message: _('Jira service not configured.')) unless jira_service&.active?
+ return ServiceResponse.success(payload: empty_payload) if limit.to_i <= 0
+
+ request
+ end
+
+ private
+
+ def client
+ @client ||= jira_service.client
+ end
+
+ def request
+ response = client.get(url)
+ build_service_response(response)
+ rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error
+ error_message = "Jira request error: #{error.message}"
+ log_error("Error sending message", client_url: client.options[:site], error: error_message)
+ ServiceResponse.error(message: error_message)
+ end
+
+ def url
+ raise NotImplementedError
+ end
+
+ def build_service_response(response)
+ raise NotImplementedError
+ end
+ end
+ end
+end
diff --git a/app/services/jira/requests/projects.rb b/app/services/jira/requests/projects.rb
new file mode 100644
index 00000000000..da464503211
--- /dev/null
+++ b/app/services/jira/requests/projects.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Jira
+ module Requests
+ class Projects < Base
+ extend ::Gitlab::Utils::Override
+
+ private
+
+ override :url
+ def url
+ '/rest/api/2/project/search?query=%{query}&maxResults=%{limit}&startAt=%{start_at}' %
+ { query: CGI.escape(query.to_s), limit: limit.to_i, start_at: start_at.to_i }
+ end
+
+ override :build_service_response
+ def build_service_response(response)
+ return ServiceResponse.success(payload: empty_payload) unless response['values'].present?
+
+ ServiceResponse.success(payload: { projects: map_projects(response), is_last: response['isLast'] })
+ end
+
+ def map_projects(response)
+ response['values'].map { |v| JIRA::Resource::Project.build(client, v) }
+ end
+
+ def empty_payload
+ { projects: [], is_last: true }
+ end
+ end
+ end
+end
diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb
index 59fd463022f..a06cc6df719 100644
--- a/app/services/jira_import/start_import_service.rb
+++ b/app/services/jira_import/start_import_service.rb
@@ -28,8 +28,8 @@ module JiraImport
rescue => ex
# in case project.save! raises an erorr
Gitlab::ErrorTracking.track_exception(ex, project_id: project.id)
+ jira_import&.do_fail!(error_message: ex.message)
build_error_response(ex.message)
- jira_import.do_fail!
end
def build_jira_import
@@ -62,7 +62,7 @@ module JiraImport
end
def validate
- project.validate_jira_import_settings!(user: user)
+ Gitlab::JiraImport.validate_project_settings!(project, user: user)
return build_error_response(_('Unable to find Jira project to import data from.')) if jira_project_key.blank?
return build_error_response(_('Jira import is already running.')) if import_in_progress?
diff --git a/app/services/jira_import/users_importer.rb b/app/services/jira_import/users_importer.rb
new file mode 100644
index 00000000000..579d3675073
--- /dev/null
+++ b/app/services/jira_import/users_importer.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module JiraImport
+ class UsersImporter
+ attr_reader :user, :project, :start_at, :result
+
+ MAX_USERS = 50
+
+ def initialize(user, project, start_at)
+ @project = project
+ @start_at = start_at
+ @user = user
+ end
+
+ def execute
+ Gitlab::JiraImport.validate_project_settings!(project, user: user)
+
+ return ServiceResponse.success(payload: nil) if users.blank?
+
+ result = UsersMapper.new(project, users).execute
+ ServiceResponse.success(payload: result)
+ rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error
+ Gitlab::ErrorTracking.track_exception(error, project_id: project.id, request: url)
+ ServiceResponse.error(message: "There was an error when communicating to Jira: #{error.message}")
+ rescue Projects::ImportService::Error => error
+ ServiceResponse.error(message: error.message)
+ end
+
+ private
+
+ def users
+ @users ||= client.get(url)
+ end
+
+ def url
+ "/rest/api/2/users?maxResults=#{MAX_USERS}&startAt=#{start_at.to_i}"
+ end
+
+ def client
+ @client ||= project.jira_service.client
+ end
+ end
+end
diff --git a/app/services/jira_import/users_mapper.rb b/app/services/jira_import/users_mapper.rb
new file mode 100644
index 00000000000..31a3f721556
--- /dev/null
+++ b/app/services/jira_import/users_mapper.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module JiraImport
+ class UsersMapper
+ attr_reader :project, :jira_users
+
+ def initialize(project, jira_users)
+ @project = project
+ @jira_users = jira_users
+ end
+
+ def execute
+ jira_users.to_a.map do |jira_user|
+ {
+ jira_account_id: jira_user['accountId'],
+ jira_display_name: jira_user['displayName'],
+ jira_email: jira_user['emailAddress'],
+ gitlab_id: match_user(jira_user)
+ }
+ end
+ end
+
+ private
+
+ # TODO: Matching user by email and displayName will be done as the part
+ # of follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/219023
+ def match_user(jira_user)
+ nil
+ end
+ end
+end
diff --git a/app/services/keys/create_service.rb b/app/services/keys/create_service.rb
index 32c4ab645df..c256de7b35d 100644
--- a/app/services/keys/create_service.rb
+++ b/app/services/keys/create_service.rb
@@ -2,6 +2,14 @@
module Keys
class CreateService < ::Keys::BaseService
+ attr_accessor :current_user
+
+ def initialize(current_user, params = {})
+ @current_user, @params = current_user, params
+ @ip_address = @params.delete(:ip_address)
+ @user = params.delete(:user) || current_user
+ end
+
def execute
key = user.keys.create(params)
notification_service.new_key(key) if key.persisted?
diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb
index 8886e58d6ef..979964e09fd 100644
--- a/app/services/labels/available_labels_service.rb
+++ b/app/services/labels/available_labels_service.rb
@@ -30,11 +30,13 @@ module Labels
end
def filter_labels_ids_in_param(key)
- return [] if params[key].to_a.empty?
+ ids = params[key].to_a
+ return [] if ids.empty?
# rubocop:disable CodeReuse/ActiveRecord
- available_labels.by_ids(params[key]).pluck(:id)
+ existing_ids = available_labels.by_ids(ids).pluck(:id)
# rubocop:enable CodeReuse/ActiveRecord
+ ids.map(&:to_i) & existing_ids
end
private
diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb
index c032985be42..a5b30e29e55 100644
--- a/app/services/labels/create_service.rb
+++ b/app/services/labels/create_service.rb
@@ -20,7 +20,7 @@ module Labels
label.save
label
else
- Rails.logger.warn("target_params should contain :project or :group or :template, actual value: #{target_params}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.warn("target_params should contain :project or :group or :template, actual value: #{target_params}")
end
end
end
diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb
index cc91fd4b4d2..9ed10f6a11b 100644
--- a/app/services/labels/promote_service.rb
+++ b/app/services/labels/promote_service.rb
@@ -90,7 +90,7 @@ module Labels
# rubocop: disable CodeReuse/ActiveRecord
def destroy_project_labels(label_ids)
- Label.where(id: label_ids).destroy_all # rubocop: disable DestroyAll
+ Label.where(id: label_ids).destroy_all # rubocop: disable Cop/DestroyAll
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 31097b9151a..8d57a76f7d0 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -121,12 +121,12 @@ module MergeRequests
end
def handle_merge_error(log_message:, save_message_on_model: false)
- Rails.logger.error("MergeService ERROR: #{merge_request_info} - #{log_message}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error("MergeService ERROR: #{merge_request_info} - #{log_message}")
@merge_request.update(merge_error: log_message) if save_message_on_model
end
def log_info(message)
- @logger ||= Rails.logger # rubocop:disable Gitlab/RailsLogger
+ @logger ||= Gitlab::AppLogger
@logger.info("#{merge_request_info} - #{message}")
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 2d33e87bf4b..561695baeab 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -27,7 +27,7 @@ module MergeRequests
old_assignees = old_associations.fetch(:assignees, [])
if has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees)
- todo_service.mark_pending_todos_as_done(merge_request, current_user)
+ todo_service.resolve_todos_for_target(merge_request, current_user)
end
if merge_request.previous_changes.include?('title') ||
@@ -73,7 +73,7 @@ module MergeRequests
end
def handle_task_changes(merge_request)
- todo_service.mark_pending_todos_as_done(merge_request, current_user)
+ todo_service.resolve_todos_for_target(merge_request, current_user)
todo_service.update_merge_request(merge_request, current_user)
end
diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb
index 514793694ba..c2a0f22e73e 100644
--- a/app/services/metrics/dashboard/base_service.rb
+++ b/app/services/metrics/dashboard/base_service.rb
@@ -13,7 +13,8 @@ module Metrics
STAGES::EndpointInserter,
STAGES::PanelIdsInserter,
STAGES::Sorter,
- STAGES::AlertsInserter
+ STAGES::AlertsInserter,
+ STAGES::UrlValidator
].freeze
def get_dashboard
diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
index d97668d1c7c..8599c23c206 100644
--- a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
+++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
@@ -6,7 +6,7 @@ module Metrics
module Dashboard
class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml'
- DASHBOARD_NAME = 'Default'
+ DASHBOARD_NAME = N_('Default dashboard')
SEQUENCE = [
STAGES::CustomMetricsInserter,
@@ -23,7 +23,7 @@ module Metrics
def all_dashboard_paths(_project)
[{
path: DASHBOARD_PATH,
- display_name: DASHBOARD_NAME,
+ display_name: _(DASHBOARD_NAME),
default: true,
system_dashboard: false
}]
diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb
index ed4b78ba159..db5599b4def 100644
--- a/app/services/metrics/dashboard/system_dashboard_service.rb
+++ b/app/services/metrics/dashboard/system_dashboard_service.rb
@@ -6,7 +6,7 @@ module Metrics
module Dashboard
class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
- DASHBOARD_NAME = 'Default'
+ DASHBOARD_NAME = N_('Default dashboard')
SEQUENCE = [
STAGES::CommonMetricsInserter,
@@ -22,7 +22,7 @@ module Metrics
def all_dashboard_paths(_project)
[{
path: DASHBOARD_PATH,
- display_name: DASHBOARD_NAME,
+ display_name: _(DASHBOARD_NAME),
default: true,
system_dashboard: true
}]
diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb
index 80e6456f729..2431318cbb2 100644
--- a/app/services/milestones/promote_service.rb
+++ b/app/services/milestones/promote_service.rb
@@ -76,7 +76,7 @@ module Milestones
# rubocop: disable CodeReuse/ActiveRecord
def destroy_old_milestones(milestone)
- Milestone.where(id: milestone_ids_for_merge(milestone)).destroy_all # rubocop: disable DestroyAll
+ Milestone.where(id: milestone_ids_for_merge(milestone)).destroy_all # rubocop: disable Cop/DestroyAll
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/namespaces/check_storage_size_service.rb b/app/services/namespaces/check_storage_size_service.rb
index b3cf17681ee..57d2645a0c8 100644
--- a/app/services/namespaces/check_storage_size_service.rb
+++ b/app/services/namespaces/check_storage_size_service.rb
@@ -41,7 +41,8 @@ module Namespaces
{
explanation_message: explanation_message,
usage_message: usage_message,
- alert_level: alert_level
+ alert_level: alert_level,
+ root_namespace: root_namespace
}
end
@@ -50,7 +51,7 @@ module Namespaces
end
def usage_message
- s_("You reached %{usage_in_percent} of %{namespace_name}'s capacity (%{used_storage} of %{storage_limit})" % current_usage_params)
+ s_("You reached %{usage_in_percent} of %{namespace_name}'s storage capacity (%{used_storage} of %{storage_limit})" % current_usage_params)
end
def alert_level
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 6c1f52ec866..935dbfb72dd 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -88,9 +88,11 @@ module Notes
end
end
- # EE::Notes::CreateService would override this method
def quick_action_options
- { merge_request_diff_head_sha: params[:merge_request_diff_head_sha] }
+ {
+ merge_request_diff_head_sha: params[:merge_request_diff_head_sha],
+ review_id: params[:review_id]
+ }
end
def tracking_data_for(note)
@@ -103,5 +105,3 @@ module Notes
end
end
end
-
-Notes::CreateService.prepend_if_ee('EE::Notes::CreateService')
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index bc86118a150..0e455c641ce 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -36,7 +36,7 @@ module Notes
return unless @note.project
note_data = hook_data
- hooks_scope = @note.confidential? ? :confidential_note_hooks : :note_hooks
+ hooks_scope = @note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks
@note.project.execute_hooks(note_data, hooks_scope)
@note.project.execute_services(note_data, hooks_scope)
diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb
index df807f11e1b..0fe0d26d7b2 100644
--- a/app/services/notification_recipients/build_service.rb
+++ b/app/services/notification_recipients/build_service.rb
@@ -32,7 +32,9 @@ module NotificationRecipients
def self.build_new_release_recipients(*args)
::NotificationRecipients::Builder::NewRelease.new(*args).notification_recipients
end
+
+ def self.build_new_review_recipients(*args)
+ ::NotificationRecipients::Builder::NewReview.new(*args).notification_recipients
+ end
end
end
-
-NotificationRecipients::BuildService.prepend_if_ee('EE::NotificationRecipients::BuildService')
diff --git a/app/services/notification_recipients/builder/new_review.rb b/app/services/notification_recipients/builder/new_review.rb
new file mode 100644
index 00000000000..3b1296f6967
--- /dev/null
+++ b/app/services/notification_recipients/builder/new_review.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module NotificationRecipients
+ module Builder
+ class NewReview < Base
+ attr_reader :review
+ def initialize(review)
+ @review = review
+ end
+
+ def target
+ review.merge_request
+ end
+
+ def project
+ review.project
+ end
+
+ def group
+ project.group
+ end
+
+ def build!
+ add_participants(review.author)
+ add_mentions(review.author, target: review)
+ add_project_watchers
+ add_custom_notifications
+ add_subscribed_users
+ end
+
+ # A new review is a batch of new notes
+ # therefore new_note subscribers should also
+ # receive incoming new reviews
+ def custom_action
+ :new_note
+ end
+
+ def acting_user
+ review.author
+ end
+ end
+ end
+end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 4c1db03fab8..73e60ac8420 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -68,10 +68,10 @@ class NotificationService
# Notify a user when a previously unknown IP or device is used to
# sign in to their account
- def unknown_sign_in(user, ip)
+ def unknown_sign_in(user, ip, time)
return unless user.can?(:receive_notifications)
- mailer.unknown_sign_in_email(user, ip).deliver_later
+ mailer.unknown_sign_in_email(user, ip, time).deliver_later
end
# When create an issue we should send an email to:
@@ -447,14 +447,14 @@ class NotificationService
# from the PipelinesEmailService integration.
return if pipeline.project.emails_disabled?
- ref_status ||= pipeline.status
- email_template = "pipeline_#{ref_status}_email"
+ status = pipeline_notification_status(ref_status, pipeline)
+ email_template = "pipeline_#{status}_email"
return unless mailer.respond_to?(email_template)
recipients ||= notifiable_users(
[pipeline.user], :watch,
- custom_action: :"#{ref_status}_pipeline",
+ custom_action: :"#{status}_pipeline",
target: pipeline
).map do |user|
user.notification_email_for(pipeline.project.group)
@@ -557,6 +557,15 @@ class NotificationService
mailer.group_was_not_exported_email(current_user, group, errors).deliver_later
end
+ # Notify users on new review in system
+ def new_review(review)
+ recipients = NotificationRecipients::BuildService.build_new_review_recipients(review)
+
+ recipients.each do |recipient|
+ mailer.new_review_email(recipient.user.id, review.id).deliver_later
+ end
+ end
+
protected
def new_resource_email(target, method)
@@ -652,6 +661,16 @@ class NotificationService
private
+ def pipeline_notification_status(ref_status, pipeline)
+ if Ci::Ref.failing_state?(ref_status)
+ 'failed'
+ elsif ref_status
+ ref_status
+ else
+ pipeline.status
+ end
+ end
+
def owners_and_maintainers_without_invites(project)
recipients = project.members.active_without_invites_and_requests.owners_and_maintainers
diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb
index d4de6bb750d..7408943e78c 100644
--- a/app/services/pages/delete_service.rb
+++ b/app/services/pages/delete_service.rb
@@ -4,7 +4,7 @@ module Pages
class DeleteService < BaseService
def execute
project.remove_pages
- project.pages_domains.destroy_all # rubocop: disable DestroyAll
+ project.pages_domains.destroy_all # rubocop: disable Cop/DestroyAll
end
end
end
diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb
index ee2dde8aa7f..fad2290a47b 100644
--- a/app/services/projects/after_import_service.rb
+++ b/app/services/projects/after_import_service.rb
@@ -22,8 +22,12 @@ module Projects
# causing GC to run every time.
service.increment!
rescue Projects::HousekeepingService::LeaseTaken => e
- Rails.logger.info( # rubocop:disable Gitlab/RailsLogger
- "Could not perform housekeeping for project #{@project.full_path} (#{@project.id}): #{e}")
+ Gitlab::Import::Logger.info(
+ message: 'Project housekeeping failed',
+ project_full_path: @project.full_path,
+ project_id: @project.id,
+ error: e.message
+ )
end
private
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index 76c89e85f17..86c408aeec8 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -10,7 +10,7 @@ module Projects
return forbidden unless alerts_service_activated?
return unauthorized unless valid_token?(token)
- alert = create_alert
+ alert = process_alert
return bad_request unless alert.persisted?
process_incident_issues(alert) if process_issues?
@@ -26,11 +26,36 @@ module Projects
delegate :alerts_service, :alerts_service_activated?, to: :project
def am_alert_params
- Gitlab::AlertManagement::AlertParams.from_generic_alert(project: project, payload: params.to_h)
+ strong_memoize(:am_alert_params) do
+ Gitlab::AlertManagement::AlertParams.from_generic_alert(project: project, payload: params.to_h)
+ end
+ end
+
+ def process_alert
+ existing_alert = find_alert_by_fingerprint(am_alert_params[:fingerprint])
+
+ if existing_alert
+ process_existing_alert(existing_alert)
+ else
+ create_alert
+ end
+ end
+
+ def process_existing_alert(alert)
+ alert.register_new_event!
end
def create_alert
- AlertManagement::Alert.create(am_alert_params)
+ alert = AlertManagement::Alert.create(am_alert_params)
+ alert.execute_services if alert.persisted?
+
+ alert
+ end
+
+ def find_alert_by_fingerprint(fingerprint)
+ return unless fingerprint
+
+ AlertManagement::Alert.for_fingerprint(project, fingerprint).first
end
def send_email?
@@ -38,6 +63,8 @@ module Projects
end
def process_incident_issues(alert)
+ return if alert.issue
+
IncidentManagement::ProcessAlertWorker
.perform_async(project.id, parsed_payload, alert.id)
end
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index b53a9c1561e..c5809c11ea9 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -6,6 +6,7 @@ module Projects
def execute(container_repository)
return error('feature disabled') unless can_use?
return error('access denied') unless can_destroy?
+ return error('invalid regex') unless valid_regex?
tags = container_repository.tags
tags = without_latest(tags)
@@ -76,6 +77,17 @@ module Projects
def can_use?
Feature.enabled?(:container_registry_cleanup, project, default_enabled: true)
end
+
+ def valid_regex?
+ %w(name_regex_delete name_regex name_regex_keep).each do |param_name|
+ regex = params[param_name]
+ Gitlab::UntrustedRegexp.new(regex) unless regex.blank?
+ end
+ true
+ rescue RegexpError => e
+ Gitlab::ErrorTracking.log_exception(e, project_id: project.id)
+ false
+ end
end
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 3233d1799b8..bffd443c49f 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -150,7 +150,7 @@ module Projects
if @project.save
unless @project.gitlab_project_import?
- create_services_from_active_templates(@project)
+ create_services_from_active_instances_or_templates(@project)
@project.create_labels
end
@@ -166,7 +166,7 @@ module Projects
log_message = message.dup
log_message << " Project ID: #{@project.id}" if @project&.id
- Rails.logger.error(log_message) # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error(log_message)
if @project && @project.persisted? && @project.import_state
@project.import_state.mark_as_failed(message)
@@ -175,15 +175,6 @@ module Projects
@project
end
- # rubocop: disable CodeReuse/ActiveRecord
- def create_services_from_active_templates(project)
- Service.where(template: true, active: true).each do |template|
- service = Service.build_from_template(project.id, template)
- service.save!
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def create_prometheus_service
service = @project.find_or_initialize_service(::PrometheusService.to_param)
@@ -225,6 +216,15 @@ module Projects
private
+ # rubocop: disable CodeReuse/ActiveRecord
+ def create_services_from_active_instances_or_templates(project)
+ Service.active.where(instance: true).or(Service.active.where(template: true)).group_by(&:type).each do |type, records|
+ service = records.find(&:instance?) || records.find(&:template?)
+ Service.build_from_integration(project.id, service).save!
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def project_namespace
@project_namespace ||= Namespace.find_by_id(@params[:namespace_id]) || current_user.namespace
end
@@ -249,9 +249,7 @@ module Projects
end
end
-# rubocop: disable Cop/InjectEnterpriseEditionModule
Projects::CreateService.prepend_if_ee('EE::Projects::CreateService')
-# rubocop: enable Cop/InjectEnterpriseEditionModule
# Measurable should be at the bottom of the ancestor chain, so it will measure execution of EE::Projects::CreateService as well
Projects::CreateService.prepend(Measurable)
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index fd1366d2c4a..2e949f2fc55 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -64,7 +64,7 @@ module Projects
end
def remove_snippets
- response = Snippets::BulkDestroyService.new(current_user, project.snippets).execute
+ response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute
response.success?
end
diff --git a/app/services/projects/detect_repository_languages_service.rb b/app/services/projects/detect_repository_languages_service.rb
index 942cd8162e4..c57773c3302 100644
--- a/app/services/projects/detect_repository_languages_service.rb
+++ b/app/services/projects/detect_repository_languages_service.rb
@@ -21,7 +21,7 @@ module Projects
.update_all(share: update[:share])
end
- Gitlab::Database.bulk_insert(
+ Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
RepositoryLanguage.table_name,
detection.insertions(matching_programming_languages)
)
diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb
index 241948b335b..2ba3cd6694f 100644
--- a/app/services/projects/group_links/create_service.rb
+++ b/app/services/projects/group_links/create_service.rb
@@ -13,6 +13,7 @@ module Projects
)
if link.save
+ group.refresh_members_authorized_projects
success(link: link)
else
error(link.errors.full_messages.to_sentence, 409)
diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb
index ea7d05551fd..229191e41f6 100644
--- a/app/services/projects/group_links/destroy_service.rb
+++ b/app/services/projects/group_links/destroy_service.rb
@@ -12,7 +12,9 @@ module Projects
TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, nil, project.id)
end
- group_link.destroy
+ group_link.destroy.tap do |link|
+ link.group.refresh_members_authorized_projects
+ end
end
end
end
diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb
new file mode 100644
index 00000000000..7de4b7a211d
--- /dev/null
+++ b/app/services/projects/group_links/update_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Projects
+ module GroupLinks
+ class UpdateService < BaseService
+ def initialize(group_link, user = nil)
+ super(group_link.project, user)
+
+ @group_link = group_link
+ end
+
+ def execute(group_link_params)
+ group_link.update!(group_link_params)
+
+ if requires_authorization_refresh?(group_link_params)
+ group_link.group.refresh_members_authorized_projects
+ end
+ end
+
+ private
+
+ attr_reader :group_link
+
+ def requires_authorization_refresh?(params)
+ params.include?(:group_access)
+ 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
index a2a7895ba17..d61a2af6c1c 100644
--- a/app/services/projects/hashed_storage/base_attachment_service.rb
+++ b/app/services/projects/hashed_storage/base_attachment_service.rb
@@ -19,7 +19,7 @@ module Projects
def initialize(project:, old_disk_path:, logger: nil)
@project = project
@old_disk_path = old_disk_path
- @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger
+ @logger = logger || Gitlab::AppLogger
end
# Return whether this operation was skipped or not
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index 86cb4f35206..031b99753c3 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -9,6 +9,7 @@ module Projects
super
@shared = project.import_export_shared
+ @logger = Gitlab::Export::Logger.build
end
def execute(after_export_strategy = nil)
@@ -115,11 +116,20 @@ module Projects
end
def notify_success
- Rails.logger.info("Import/Export - Project #{project.name} with ID: #{project.id} successfully exported") # rubocop:disable Gitlab/RailsLogger
+ @logger.info(
+ message: 'Project successfully exported',
+ project_name: project.name,
+ project_id: project.id
+ )
end
def notify_error
- Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{shared.errors.join(', ')}") # rubocop:disable Gitlab/RailsLogger
+ @logger.error(
+ message: 'Project export error',
+ export_errors: shared.errors.join(', '),
+ project_name: project.name,
+ project_id: project.id
+ )
notification_service.project_not_exported(project, current_user, shared.errors)
end
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index 449c4c3de6b..b4abb5b6df7 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -149,9 +149,7 @@ module Projects
end
end
-# rubocop: disable Cop/InjectEnterpriseEditionModule
Projects::ImportService.prepend_if_ee('EE::Projects::ImportService')
-# rubocop: enable Cop/InjectEnterpriseEditionModule
# Measurable should be at the bottom of the ancestor chain, so it will measure execution of EE::Projects::ImportService as well
Projects::ImportService.prepend(Measurable)
diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb
index 39cd553261f..e86106f0a09 100644
--- a/app/services/projects/lfs_pointers/lfs_link_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_link_service.rb
@@ -38,7 +38,7 @@ module Projects
rows = existent_lfs_objects
.not_linked_to_project(project)
.map { |existing_lfs_object| { project_id: project.id, lfs_object_id: existing_lfs_object.id } }
- Gitlab::Database.bulk_insert(:lfs_objects_projects, rows)
+ Gitlab::Database.bulk_insert(:lfs_objects_projects, rows) # rubocop:disable Gitlab/BulkInsert
iterations += 1
linked_existing_objects += existent_lfs_objects.map(&:oid)
diff --git a/app/services/projects/lsif_data_service.rb b/app/services/projects/lsif_data_service.rb
deleted file mode 100644
index 5e7055b3309..00000000000
--- a/app/services/projects/lsif_data_service.rb
+++ /dev/null
@@ -1,101 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- class LsifDataService
- attr_reader :file, :project, :commit_id, :docs,
- :doc_ranges, :ranges, :def_refs, :hover_refs
-
- CACHE_EXPIRE_IN = 1.hour
-
- def initialize(file, project, commit_id)
- @file = file
- @project = project
- @commit_id = commit_id
-
- fetch_data!
- end
-
- def execute(path)
- doc_id = find_doc_id(docs, path)
- dir_absolute_path = docs[doc_id]&.delete_suffix(path)
-
- doc_ranges[doc_id]&.map do |range_id|
- location, ref_id = ranges[range_id].values_at('loc', 'ref_id')
- line_data, column_data = location
-
- {
- start_line: line_data.first,
- end_line: line_data.last,
- start_char: column_data.first,
- end_char: column_data.last,
- definition_url: definition_url_for(def_refs[ref_id], dir_absolute_path),
- hover: highlighted_hover(hover_refs[ref_id])
- }
- end
- end
-
- private
-
- def fetch_data
- Rails.cache.fetch("project:#{project.id}:lsif:#{commit_id}", expires_in: CACHE_EXPIRE_IN) do
- data = nil
-
- file.open do |stream|
- Zlib::GzipReader.wrap(stream) do |gz_stream|
- data = Gitlab::Json.parse(gz_stream.read)
- end
- end
-
- data
- end
- end
-
- def fetch_data!
- data = fetch_data
-
- @docs = data['docs']
- @doc_ranges = data['doc_ranges']
- @ranges = data['ranges']
- @def_refs = data['def_refs']
- @hover_refs = data['hover_refs']
- end
-
- def find_doc_id(docs, path)
- docs.reduce(nil) do |doc_id, (id, doc_path)|
- next doc_id unless doc_path =~ /#{path}$/
-
- if doc_id.nil? || docs[doc_id].size > doc_path.size
- doc_id = id
- end
-
- doc_id
- end
- end
-
- def definition_url_for(ref_id, dir_absolute_path)
- return unless range = ranges[ref_id]
-
- def_doc_id, location = range.values_at('doc_id', 'loc')
- localized_doc_url = docs[def_doc_id].delete_prefix(dir_absolute_path)
-
- # location is stored as [[start_line, end_line], [start_char, end_char]]
- start_line = location.first.first
-
- line_anchor = "L#{start_line + 1}"
- definition_ref_path = [commit_id, localized_doc_url].join('/')
-
- Gitlab::Routing.url_helpers.project_blob_path(project, definition_ref_path, anchor: line_anchor)
- end
-
- def highlighted_hover(hovers)
- hovers&.map do |hover|
- # Documentation for a method which is added as comments on top of the method
- # is stored as a raw string value in LSIF file
- next { value: hover } unless hover.is_a?(Hash)
-
- value = Gitlab::Highlight.highlight(nil, hover['value'], language: hover['language'])
- { language: hover['language'], value: value }
- end
- end
- end
-end
diff --git a/app/services/projects/move_deploy_keys_projects_service.rb b/app/services/projects/move_deploy_keys_projects_service.rb
index 01419563538..51d84af249e 100644
--- a/app/services/projects/move_deploy_keys_projects_service.rb
+++ b/app/services/projects/move_deploy_keys_projects_service.rb
@@ -28,7 +28,7 @@ module Projects
# rubocop: enable CodeReuse/ActiveRecord
def remove_remaining_deploy_keys_projects
- source_project.deploy_keys_projects.destroy_all # rubocop: disable DestroyAll
+ source_project.deploy_keys_projects.destroy_all # rubocop: disable Cop/DestroyAll
end
end
end
diff --git a/app/services/projects/move_lfs_objects_projects_service.rb b/app/services/projects/move_lfs_objects_projects_service.rb
index 8cc420d7ba7..57a8d3d69c6 100644
--- a/app/services/projects/move_lfs_objects_projects_service.rb
+++ b/app/services/projects/move_lfs_objects_projects_service.rb
@@ -20,7 +20,7 @@ module Projects
end
def remove_remaining_lfs_objects_project
- source_project.lfs_objects_projects.destroy_all # rubocop: disable DestroyAll
+ source_project.lfs_objects_projects.destroy_all # rubocop: disable Cop/DestroyAll
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/services/projects/move_notification_settings_service.rb b/app/services/projects/move_notification_settings_service.rb
index 65a888fe26b..efe06f158cc 100644
--- a/app/services/projects/move_notification_settings_service.rb
+++ b/app/services/projects/move_notification_settings_service.rb
@@ -21,7 +21,7 @@ module Projects
# Remove remaining notification settings from source_project
def remove_remaining_notification_settings
- source_project.notification_settings.destroy_all # rubocop: disable DestroyAll
+ source_project.notification_settings.destroy_all # rubocop: disable Cop/DestroyAll
end
# Get users of current notification_settings
diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb
index d1aa9af2bcb..349953ff973 100644
--- a/app/services/projects/move_project_group_links_service.rb
+++ b/app/services/projects/move_project_group_links_service.rb
@@ -25,7 +25,7 @@ module Projects
# Remove remaining project group links from source_project
def remove_remaining_project_group_links
- source_project.reset.project_group_links.destroy_all # rubocop: disable DestroyAll
+ source_project.reset.project_group_links.destroy_all # rubocop: disable Cop/DestroyAll
end
def group_links_in_target_project
diff --git a/app/services/projects/move_project_members_service.rb b/app/services/projects/move_project_members_service.rb
index de4e7e5a1e3..9a1b7c6d1b6 100644
--- a/app/services/projects/move_project_members_service.rb
+++ b/app/services/projects/move_project_members_service.rb
@@ -25,7 +25,7 @@ module Projects
def remove_remaining_members
# Remove remaining members and authorizations from source_project
- source_project.project_members.destroy_all # rubocop: disable DestroyAll
+ source_project.project_members.destroy_all # rubocop: disable Cop/DestroyAll
end
def project_members_in_target_project
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index c06f572b52f..7aa7ea73639 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -41,9 +41,9 @@ module Projects
attribs = params[:metrics_setting_attributes]
return {} unless attribs
- destroy = attribs[:external_dashboard_url].blank?
+ attribs[:external_dashboard_url] = attribs[:external_dashboard_url].presence
- { metrics_setting_attributes: attribs.merge(_destroy: destroy) }
+ { metrics_setting_attributes: attribs }
end
def error_tracking_params
diff --git a/app/services/projects/prometheus/alerts/create_events_service.rb b/app/services/projects/prometheus/alerts/create_events_service.rb
index a29240947ff..4fcf841314b 100644
--- a/app/services/projects/prometheus/alerts/create_events_service.rb
+++ b/app/services/projects/prometheus/alerts/create_events_service.rb
@@ -40,17 +40,13 @@ module Projects
def create_managed_prometheus_alert_event(parsed_alert)
alert = find_alert(parsed_alert.metric_id)
- payload_key = PrometheusAlertEvent.payload_key_for(parsed_alert.metric_id, parsed_alert.starts_at_raw)
-
- event = PrometheusAlertEvent.find_or_initialize_by_payload_key(parsed_alert.project, alert, payload_key)
+ event = PrometheusAlertEvent.find_or_initialize_by_payload_key(parsed_alert.project, alert, parsed_alert.gitlab_fingerprint)
set_status(parsed_alert, event)
end
def create_self_managed_prometheus_alert_event(parsed_alert)
- payload_key = SelfManagedPrometheusAlertEvent.payload_key_for(parsed_alert.starts_at_raw, parsed_alert.title, parsed_alert.full_query)
-
- event = SelfManagedPrometheusAlertEvent.find_or_initialize_by_payload_key(parsed_alert.project, payload_key) do |event|
+ event = SelfManagedPrometheusAlertEvent.find_or_initialize_by_payload_key(parsed_alert.project, parsed_alert.gitlab_fingerprint) do |event|
event.environment = parsed_alert.environment
event.title = parsed_alert.title
event.query_expression = parsed_alert.full_query
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 2583a6cae9f..877a4f99a94 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -7,9 +7,19 @@ module Projects
include Gitlab::Utils::StrongMemoize
include IncidentManagement::Settings
+ # This set of keys identifies a payload as a valid Prometheus
+ # payload and thus processable by this service. See also
+ # https://prometheus.io/docs/alerting/configuration/#webhook_config
+ REQUIRED_PAYLOAD_KEYS = %w[
+ version groupKey status receiver groupLabels commonLabels
+ commonAnnotations externalURL alerts
+ ].to_set.freeze
+
+ SUPPORTED_VERSION = '4'
+
def execute(token)
return bad_request unless valid_payload_size?
- return unprocessable_entity unless valid_version?
+ return unprocessable_entity unless self.class.processable?(params)
return unauthorized unless valid_alert_manager_token?(token)
process_prometheus_alerts
@@ -20,6 +30,14 @@ module Projects
ServiceResponse.success
end
+ def self.processable?(params)
+ # Workaround for https://gitlab.com/gitlab-org/gitlab/-/issues/220496
+ return false unless params
+
+ REQUIRED_PAYLOAD_KEYS.subset?(params.keys.to_set) &&
+ params['version'] == SUPPORTED_VERSION
+ end
+
private
def valid_payload_size?
@@ -42,12 +60,10 @@ module Projects
params['alerts']
end
- def valid_version?
- params['version'] == '4'
- end
-
def valid_alert_manager_token?(token)
- valid_for_manual?(token) || valid_for_managed?(token)
+ valid_for_manual?(token) ||
+ valid_for_alerts_endpoint?(token) ||
+ valid_for_managed?(token)
end
def valid_for_manual?(token)
@@ -61,6 +77,13 @@ module Projects
end
end
+ def valid_for_alerts_endpoint?(token)
+ return false unless project.alerts_service_activated?
+
+ # Here we are enforcing the existence of the token
+ compare_token(token, project.alerts_service.token)
+ end
+
def valid_for_managed?(token)
prometheus_application = available_prometheus_application(project)
return false unless prometheus_application
diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb
index 0483c951f1e..4adcda042d1 100644
--- a/app/services/projects/propagate_service_template.rb
+++ b/app/services/projects/propagate_service_template.rb
@@ -26,7 +26,7 @@ module Projects
def propagate_projects_with_template
loop do
- batch = Project.uncached { project_ids_batch }
+ batch = Project.uncached { project_ids_without_integration }
bulk_create_from_template(batch) unless batch.empty?
@@ -35,40 +35,36 @@ module Projects
end
def bulk_create_from_template(batch)
- service_list = batch.map do |project_id|
- service_hash.values << project_id
- end
+ service_list = ServiceList.new(batch, service_hash).to_array
Project.transaction do
- results = bulk_insert(Service, service_hash.keys << 'project_id', service_list)
+ results = bulk_insert(*service_list)
if data_fields_present?
- data_list = results.map { |row| data_hash.values << row['id'] }
+ data_list = DataList.new(results, data_fields_hash, template.data_fields.class).to_array
- bulk_insert(template.data_fields.class, data_hash.keys << 'service_id', data_list)
+ bulk_insert(*data_list)
end
run_callbacks(batch)
end
end
- def project_ids_batch
- Project.connection.select_values(
- <<-SQL
- SELECT id
- FROM projects
- WHERE NOT EXISTS (
- SELECT true
- FROM services
- WHERE services.project_id = projects.id
- AND services.type = #{ActiveRecord::Base.connection.quote(template.type)}
- )
- AND projects.pending_delete = false
- AND projects.archived = false
- LIMIT #{BATCH_SIZE}
- SQL
- )
+ # rubocop: disable CodeReuse/ActiveRecord
+ def project_ids_without_integration
+ services = Service
+ .select('1')
+ .where('services.project_id = projects.id')
+ .where(type: template.type)
+
+ Project
+ .where('NOT EXISTS (?)', services)
+ .where(pending_delete: false)
+ .where(archived: false)
+ .limit(BATCH_SIZE)
+ .pluck(:id)
end
+ # rubocop: enable CodeReuse/ActiveRecord
def bulk_insert(klass, columns, values_array)
items_to_insert = values_array.map { |array| Hash[columns.zip(array)] }
@@ -77,11 +73,11 @@ module Projects
end
def service_hash
- @service_hash ||= template.as_json(methods: :type, except: %w[id template project_id])
+ @service_hash ||= template.to_service_hash
end
- def data_hash
- @data_hash ||= template.data_fields.as_json(only: template.data_fields.class.column_names).except('id', 'service_id')
+ def data_fields_hash
+ @data_fields_hash ||= template.to_data_fields_hash
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index e554bed6819..5f8ef75a8d7 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -27,7 +27,11 @@ module Projects
remote_mirror.update_start!
remote_mirror.ensure_remote!
- repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true)
+
+ # https://gitlab.com/gitlab-org/gitaly/-/issues/2670
+ if Feature.disabled?(:gitaly_ruby_remote_branches_ls_remote)
+ repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true)
+ end
response = remote_mirror.update_repository
diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb
index 0632df6f6d7..fa8d4c5aa5f 100644
--- a/app/services/projects/update_repository_storage_service.rb
+++ b/app/services/projects/update_repository_storage_service.rb
@@ -24,7 +24,7 @@ module Projects
mark_old_paths_for_archive
repository_storage_move.finish!
- project.update!(repository_storage: destination_storage_name, repository_read_only: false)
+
project.leave_pool_repository
project.track_project_repository
end
@@ -34,10 +34,7 @@ module Projects
ServiceResponse.success
rescue StandardError => e
- project.transaction do
- repository_storage_move.do_fail!
- project.update!(repository_read_only: false)
- end
+ repository_storage_move.do_fail!
Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path)
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index e10dede632a..58c9bce963b 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -13,8 +13,12 @@ module Projects
ensure_wiki_exists if enabling_wiki?
- if changing_storage_size?
- project.change_repository_storage(params.delete(:repository_storage))
+ if changing_repository_storage?
+ storage_move = project.repository_storage_moves.build(
+ source_storage_name: project.repository_storage,
+ destination_storage_name: params.delete(:repository_storage)
+ )
+ storage_move.schedule
end
yield if block_given?
@@ -132,7 +136,7 @@ module Projects
def ensure_wiki_exists
ProjectWiki.new(project, project.owner).wiki
- rescue ProjectWiki::CouldNotCreateWikiError
+ rescue Wiki::CouldNotCreateWikiError
log_error("Could not create wiki for #{project.full_name}")
Gitlab::Metrics.counter(:wiki_can_not_be_created_total, 'Counts the times we failed to create a wiki').increment
end
@@ -145,10 +149,11 @@ module Projects
project.previous_changes.include?(:pages_https_only)
end
- def changing_storage_size?
+ def changing_repository_storage?
new_repository_storage = params[:repository_storage]
new_repository_storage && project.repository.exists? &&
+ project.repository_storage != new_repository_storage &&
can?(current_user, :change_repository_storage, project)
end
end
diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb
index cc6ffa9eafc..a0793cff2df 100644
--- a/app/services/projects/update_statistics_service.rb
+++ b/app/services/projects/update_statistics_service.rb
@@ -5,7 +5,7 @@ module Projects
def execute
return unless project
- Rails.logger.info("Updating statistics for project #{project.id}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.info("Updating statistics for project #{project.id}")
project.statistics.refresh!(only: statistics.map(&:to_sym))
end
diff --git a/app/services/prometheus/create_default_alerts_service.rb b/app/services/prometheus/create_default_alerts_service.rb
index c87cbbbe3cf..53baf6a650e 100644
--- a/app/services/prometheus/create_default_alerts_service.rb
+++ b/app/services/prometheus/create_default_alerts_service.rb
@@ -33,6 +33,7 @@ module Prometheus
return ServiceResponse.error(message: 'Invalid environment') unless environment
create_alerts
+ schedule_prometheus_update
ServiceResponse.success
end
@@ -51,6 +52,16 @@ module Prometheus
end
end
+ def schedule_prometheus_update
+ return unless prometheus_application
+
+ ::Clusters::Applications::ScheduleUpdateService.new(prometheus_application, project).execute
+ end
+
+ def prometheus_application
+ environment.cluster_prometheus_adapter
+ end
+
def metrics_by_identifier
strong_memoize(:metrics_by_identifier) do
metric_identifiers = DEFAULT_ALERTS.map { |alert| alert[:identifier] }
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
index 085cfc76196..e0bc5518d30 100644
--- a/app/services/prometheus/proxy_service.rb
+++ b/app/services/prometheus/proxy_service.rb
@@ -30,6 +30,10 @@ module Prometheus
'query_range' => {
method: ['GET'],
params: %w(query start end step timeout)
+ },
+ 'series' => {
+ method: %w(GET),
+ params: %w(match start end)
}
}.freeze
diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb
index 7b98cfc592a..10fb3a8c1b5 100644
--- a/app/services/prometheus/proxy_variable_substitution_service.rb
+++ b/app/services/prometheus/proxy_variable_substitution_service.rb
@@ -58,7 +58,7 @@ module Prometheus
def substitute_variables(result)
return success(result) unless query(result)
- result[:params][:query] = gsub(query(result), full_context)
+ result[:params][:query] = gsub(query(result), full_context(result))
success(result)
end
@@ -75,12 +75,16 @@ module Prometheus
end
end
- def predefined_context
- Gitlab::Prometheus::QueryVariables.call(@environment).stringify_keys
+ def predefined_context(result)
+ Gitlab::Prometheus::QueryVariables.call(
+ @environment,
+ start_time: start_timestamp(result),
+ end_time: end_timestamp(result)
+ ).stringify_keys
end
- def full_context
- @full_context ||= predefined_context.reverse_merge(variables_hash)
+ def full_context(result)
+ @full_context ||= predefined_context(result).reverse_merge(variables_hash)
end
def variables
@@ -91,6 +95,16 @@ module Prometheus
variables.to_h
end
+ def start_timestamp(result)
+ Time.rfc3339(result[:params][:start])
+ rescue ArgumentError
+ end
+
+ def end_timestamp(result)
+ Time.rfc3339(result[:params][:end])
+ rescue ArgumentError
+ end
+
def query(result)
result[:params][:query]
end
diff --git a/app/services/protected_branches/legacy_api_update_service.rb b/app/services/protected_branches/legacy_api_update_service.rb
index 65dc3297ae8..0cad23f20f7 100644
--- a/app/services/protected_branches/legacy_api_update_service.rb
+++ b/app/services/protected_branches/legacy_api_update_service.rb
@@ -39,11 +39,11 @@ module ProtectedBranches
def delete_redundant_access_levels
unless developers_can_merge.nil?
- protected_branch.merge_access_levels.destroy_all # rubocop: disable DestroyAll
+ protected_branch.merge_access_levels.destroy_all # rubocop: disable Cop/DestroyAll
end
unless developers_can_push.nil?
- protected_branch.push_access_levels.destroy_all # rubocop: disable DestroyAll
+ protected_branch.push_access_levels.destroy_all # rubocop: disable Cop/DestroyAll
end
end
end
diff --git a/app/services/releases/create_evidence_service.rb b/app/services/releases/create_evidence_service.rb
new file mode 100644
index 00000000000..ac13dce1729
--- /dev/null
+++ b/app/services/releases/create_evidence_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Releases
+ class CreateEvidenceService
+ def initialize(release, pipeline: nil)
+ @release = release
+ @pipeline = pipeline
+ end
+
+ def execute
+ evidence = release.evidences.build
+
+ summary = Evidences::EvidenceSerializer.new.represent(evidence) # rubocop: disable CodeReuse/Serializer
+ evidence.summary = summary
+ # TODO: fix the sha generating https://gitlab.com/gitlab-org/gitlab/-/issues/209000
+ evidence.summary_sha = Gitlab::CryptoHelper.sha256(summary)
+
+ evidence.save!
+ end
+
+ private
+
+ attr_reader :release
+ end
+end
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index 81ca9d6d123..92a0b875dd4 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -9,11 +9,16 @@ module Releases
return error('Release already exists', 409) if release
return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
+ # should be found before the creation of new tag
+ # because tag creation can spawn new pipeline
+ # which won't have any data for evidence yet
+ evidence_pipeline = find_evidence_pipeline
+
tag = ensure_tag
return tag unless tag.is_a?(Gitlab::Git::Tag)
- create_release(tag)
+ create_release(tag, evidence_pipeline)
end
def find_or_build_release
@@ -42,13 +47,15 @@ module Releases
Ability.allowed?(current_user, :create_release, project)
end
- def create_release(tag)
+ def create_release(tag, evidence_pipeline)
release = build_release(tag)
release.save!
notify_create_release(release)
+ create_evidence!(release, evidence_pipeline)
+
success(tag: tag, release: release)
rescue => e
error(e.message, 400)
@@ -70,5 +77,27 @@ module Releases
milestones: milestones
)
end
+
+ def find_evidence_pipeline
+ # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245
+ return params[:evidence_pipeline] if params[:evidence_pipeline]
+
+ sha = existing_tag&.dereferenced_target&.sha
+ sha ||= repository.commit(ref)&.sha
+
+ return unless sha
+
+ project.ci_pipelines.for_sha(sha).last
+ end
+
+ def create_evidence!(release, pipeline)
+ return if release.historical_release?
+
+ if release.upcoming_release?
+ CreateEvidenceWorker.perform_at(release.released_at, release.id, pipeline&.id)
+ else
+ CreateEvidenceWorker.perform_async(release.id, pipeline&.id)
+ end
+ end
end
end
diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb
index e0d019f54be..dc23f727079 100644
--- a/app/services/resource_events/change_labels_service.rb
+++ b/app/services/resource_events/change_labels_service.rb
@@ -22,7 +22,7 @@ module ResourceEvents
label_hash.merge(label_id: label.id, action: ResourceLabelEvent.actions['remove'])
end
- Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, labels)
+ Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, labels) # rubocop:disable Gitlab/BulkInsert
resource.expire_note_etag_cache
end
diff --git a/app/services/resource_events/change_state_service.rb b/app/services/resource_events/change_state_service.rb
new file mode 100644
index 00000000000..8beb76d8aee
--- /dev/null
+++ b/app/services/resource_events/change_state_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module ResourceEvents
+ class ChangeStateService
+ attr_reader :resource, :user
+
+ def initialize(user:, resource:)
+ @user, @resource = user, resource
+ end
+
+ def execute(state)
+ ResourceStateEvent.create(
+ user: user,
+ issue: issue,
+ merge_request: merge_request,
+ state: ResourceStateEvent.states[state],
+ created_at: Time.zone.now)
+
+ resource.expire_note_etag_cache
+ end
+
+ private
+
+ def issue
+ return unless resource.is_a?(Issue)
+
+ resource
+ end
+
+ def merge_request
+ return unless resource.is_a?(MergeRequest)
+
+ resource
+ end
+ end
+end
diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb
index 4aa9bb80229..122bcb8550f 100644
--- a/app/services/resource_events/merge_into_notes_service.rb
+++ b/app/services/resource_events/merge_into_notes_service.rb
@@ -11,7 +11,8 @@ module ResourceEvents
SYNTHETIC_NOTE_BUILDER_SERVICES = [
SyntheticLabelNotesBuilderService,
- SyntheticMilestoneNotesBuilderService
+ SyntheticMilestoneNotesBuilderService,
+ SyntheticStateNotesBuilderService
].freeze
attr_reader :resource, :current_user, :params
@@ -23,7 +24,7 @@ module ResourceEvents
end
def execute(notes = [])
- (notes + synthetic_notes).sort_by { |n| n.created_at }
+ (notes + synthetic_notes).sort_by(&:created_at)
end
private
diff --git a/app/services/resource_events/synthetic_state_notes_builder_service.rb b/app/services/resource_events/synthetic_state_notes_builder_service.rb
new file mode 100644
index 00000000000..763134d98d8
--- /dev/null
+++ b/app/services/resource_events/synthetic_state_notes_builder_service.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module ResourceEvents
+ class SyntheticStateNotesBuilderService < BaseSyntheticNotesBuilderService
+ private
+
+ def synthetic_notes
+ state_change_events.map do |event|
+ StateNote.from_event(event, resource: resource, resource_parent: resource_parent)
+ end
+ end
+
+ def state_change_events
+ return [] unless resource.respond_to?(:resource_state_events)
+
+ events = resource.resource_state_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord
+ since_fetch_at(events)
+ end
+ end
+end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index bf21eba28f7..650dc197f8c 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -5,7 +5,6 @@ class SearchService
SEARCH_TERM_LIMIT = 64
SEARCH_CHAR_LIMIT = 4096
-
DEFAULT_PER_PAGE = Gitlab::SearchResults::DEFAULT_PER_PAGE
MAX_PER_PAGE = 200
@@ -62,8 +61,8 @@ class SearchService
@search_results ||= search_service.execute
end
- def search_objects
- @search_objects ||= redact_unauthorized_results(search_results.objects(scope, page: params[:page], per_page: per_page))
+ def search_objects(preload_method = nil)
+ @search_objects ||= redact_unauthorized_results(search_results.objects(scope, page: params[:page], per_page: per_page, preload_method: preload_method))
end
private
@@ -83,16 +82,21 @@ class SearchService
end
def redact_unauthorized_results(results_collection)
- results = results_collection.to_a
- permitted_results = results.select { |object| visible_result?(object) }
+ redacted_results = results_collection.reject { |object| visible_result?(object) }
+
+ if redacted_results.any?
+ redacted_log = redacted_results.each_with_object({}) do |object, memo|
+ memo[object.id] = { ability: :"read_#{object.to_ability_name}", id: object.id, class_name: object.class.name }
+ end
+
+ log_redacted_search_results(redacted_log.values)
- redacted_results = (results - permitted_results).each_with_object({}) do |object, memo|
- memo[object.id] = { ability: :"read_#{object.to_ability_name}", id: object.id, class_name: object.class.name }
+ return results_collection.id_not_in(redacted_log.keys) if results_collection.is_a?(ActiveRecord::Relation)
end
- log_redacted_search_results(redacted_results.values) if redacted_results.any?
+ return results_collection if results_collection.is_a?(ActiveRecord::Relation)
- return results_collection.id_not_in(redacted_results.keys) if results_collection.is_a?(ActiveRecord::Relation)
+ permitted_results = results_collection - redacted_results
Kaminari.paginate_array(
permitted_results,
diff --git a/app/services/service_response.rb b/app/services/service_response.rb
index 08b7e9d0831..74c0be22d46 100644
--- a/app/services/service_response.rb
+++ b/app/services/service_response.rb
@@ -26,6 +26,12 @@ class ServiceResponse
status == :error
end
+ def errors
+ return [] unless error?
+
+ Array.wrap(message)
+ end
+
private
attr_writer :status, :message, :http_status, :payload
diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb
index 81d12997335..5d1fe815d83 100644
--- a/app/services/snippets/base_service.rb
+++ b/app/services/snippets/base_service.rb
@@ -6,12 +6,13 @@ module Snippets
CreateRepositoryError = Class.new(StandardError)
- attr_reader :uploaded_files
+ attr_reader :uploaded_assets, :snippet_files
def initialize(project, user = nil, params = {})
super
- @uploaded_files = Array(@params.delete(:files).presence)
+ @uploaded_assets = Array(@params.delete(:files).presence)
+ @snippet_files = SnippetInputActionCollection.new(Array(@params.delete(:snippet_files).presence))
filter_spam_check_params
end
@@ -22,12 +23,30 @@ module Snippets
Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level)
end
- def error_forbidden_visibility(snippet)
+ def forbidden_visibility_error(snippet)
deny_visibility_level(snippet)
snippet_error_response(snippet, 403)
end
+ def valid_params?
+ return true if snippet_files.empty?
+
+ (params.keys & [:content, :file_name]).none? && snippet_files.valid?
+ end
+
+ def invalid_params_error(snippet)
+ if snippet_files.valid?
+ [:content, :file_name].each do |key|
+ snippet.errors.add(key, 'and snippet files cannot be used together') if params.key?(key)
+ end
+ else
+ snippet.errors.add(:snippet_files, 'have invalid data')
+ end
+
+ snippet_error_response(snippet, 403)
+ end
+
def snippet_error_response(snippet, http_status)
ServiceResponse.error(
message: snippet.errors.full_messages.to_sentence,
@@ -52,5 +71,13 @@ module Snippets
message
end
+
+ def files_to_commit(snippet)
+ snippet_files.to_commit_actions.presence || build_actions_from_params(snippet)
+ end
+
+ def build_actions_from_params(snippet)
+ raise NotImplementedError
+ end
end
end
diff --git a/app/services/snippets/bulk_destroy_service.rb b/app/services/snippets/bulk_destroy_service.rb
index d9cc383a5a6..a612d8f8dfc 100644
--- a/app/services/snippets/bulk_destroy_service.rb
+++ b/app/services/snippets/bulk_destroy_service.rb
@@ -14,12 +14,12 @@ module Snippets
@snippets = snippets
end
- def execute
+ def execute(options = {})
return ServiceResponse.success(message: 'No snippets found.') if snippets.empty?
- user_can_delete_snippets!
+ user_can_delete_snippets! unless options[:hard_delete]
attempt_delete_repositories!
- snippets.destroy_all # rubocop: disable DestroyAll
+ snippets.destroy_all # rubocop: disable Cop/DestroyAll
ServiceResponse.success(message: 'Snippets were deleted.')
rescue SnippetAccessError
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index ed6da3a0ad0..7b477621da3 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -5,13 +5,15 @@ module Snippets
def execute
@snippet = build_from_params
+ return invalid_params_error(@snippet) unless valid_params?
+
unless visibility_allowed?(@snippet, @snippet.visibility_level)
- return error_forbidden_visibility(@snippet)
+ return forbidden_visibility_error(@snippet)
end
@snippet.author = current_user
- spam_check(@snippet, current_user)
+ spam_check(@snippet, current_user, action: :create)
if save_and_commit
UserAgentDetailService.new(@snippet, @request).create
@@ -29,12 +31,21 @@ module Snippets
def build_from_params
if project
- project.snippets.build(params)
+ project.snippets.build(create_params)
else
- PersonalSnippet.new(params)
+ PersonalSnippet.new(create_params)
end
end
+ # If the snippet_files param is present
+ # we need to fill content and file_name from
+ # the model
+ def create_params
+ return params if snippet_files.empty?
+
+ params.merge(content: snippet_files[0].content, file_name: snippet_files[0].file_path)
+ end
+
def save_and_commit
snippet_saved = @snippet.save
@@ -75,19 +86,19 @@ module Snippets
message: 'Initial commit'
}
- @snippet.snippet_repository.multi_files_action(current_user, snippet_files, commit_attrs)
- end
-
- def snippet_files
- [{ file_path: params[:file_name], content: params[:content] }]
+ @snippet.snippet_repository.multi_files_action(current_user, files_to_commit(@snippet), commit_attrs)
end
def move_temporary_files
return unless @snippet.is_a?(PersonalSnippet)
- uploaded_files.each do |file|
+ uploaded_assets.each do |file|
FileMover.new(file, from_model: current_user, to_model: @snippet).execute
end
end
+
+ def build_actions_from_params(_snippet)
+ [{ file_path: params[:file_name], content: params[:content] }]
+ end
end
end
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 250120c1c19..6cdc2c374da 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -7,12 +7,14 @@ module Snippets
UpdateError = Class.new(StandardError)
def execute(snippet)
+ return invalid_params_error(snippet) unless valid_params?
+
if visibility_changed?(snippet) && !visibility_allowed?(snippet, visibility_level)
- return error_forbidden_visibility(snippet)
+ return forbidden_visibility_error(snippet)
end
- snippet.assign_attributes(params)
- spam_check(snippet, current_user)
+ update_snippet_attributes(snippet)
+ spam_check(snippet, current_user, action: :update)
if save_and_commit(snippet)
Gitlab::UsageDataCounters::SnippetCounter.count(:update)
@@ -29,6 +31,19 @@ module Snippets
visibility_level && visibility_level.to_i != snippet.visibility_level
end
+ def update_snippet_attributes(snippet)
+ # We can remove the following condition once
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/217801
+ # is implemented.
+ # Once we can perform different operations through this service
+ # we won't need to keep track of the `content` and `file_name` fields
+ if snippet_files.any?
+ params.merge!(content: snippet_files[0].content, file_name: snippet_files[0].file_path)
+ end
+
+ snippet.assign_attributes(params)
+ end
+
def save_and_commit(snippet)
return false unless snippet.save
@@ -81,15 +96,7 @@ module Snippets
message: 'Update snippet'
}
- snippet.snippet_repository.multi_files_action(current_user, snippet_files(snippet), commit_attrs)
- end
-
- def snippet_files(snippet)
- file_name_on_repo = snippet.file_name_on_repo
-
- [{ previous_path: file_name_on_repo,
- file_path: params[:file_name] || file_name_on_repo,
- content: params[:content] }]
+ snippet.snippet_repository.multi_files_action(current_user, files_to_commit(snippet), commit_attrs)
end
# Because we are removing repositories we don't want to remove
@@ -101,7 +108,15 @@ module Snippets
end
def committable_attributes?
- (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present?
+ (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present? || snippet_files.any?
+ end
+
+ def build_actions_from_params(snippet)
+ file_name_on_repo = snippet.file_name_on_repo
+
+ [{ previous_path: file_name_on_repo,
+ file_path: params[:file_name] || file_name_on_repo,
+ content: params[:content] }]
end
end
end
diff --git a/app/services/spam/akismet_service.rb b/app/services/spam/akismet_service.rb
index ab35fb8700f..e11a1dbdd96 100644
--- a/app/services/spam/akismet_service.rb
+++ b/app/services/spam/akismet_service.rb
@@ -27,7 +27,7 @@ module Spam
is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params)
is_spam || is_blatant
rescue => e
- Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error("Unable to connect to Akismet: #{e}, skipping check")
false
end
end
@@ -67,7 +67,7 @@ module Spam
akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) # rubocop:disable GitlabSecurity/PublicSend
true
rescue => e
- Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error("Unable to connect to Akismet: #{e}, skipping!")
false
end
end
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index f0a4aff4443..b745b67f566 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -7,9 +7,11 @@ module Spam
attr_accessor :target, :request, :options
attr_reader :spam_log
- def initialize(spammable:, request:)
+ def initialize(spammable:, request:, user:, context: {})
@target = spammable
@request = request
+ @user = user
+ @context = context
@options = {}
if @request
@@ -22,7 +24,7 @@ module Spam
end
end
- def execute(api: false, recaptcha_verified:, spam_log_id:, user:)
+ def execute(api: false, recaptcha_verified:, spam_log_id:)
if recaptcha_verified
# If it's a request which is already verified through reCAPTCHA,
# update the spam log accordingly.
@@ -40,6 +42,8 @@ module Spam
private
+ attr_reader :user, :context
+
def allowlisted?(user)
user.respond_to?(:gitlab_employee) && user.gitlab_employee?
end
@@ -49,7 +53,8 @@ module Spam
# ask the SpamVerdictService what to do with the target.
spam_verdict_service.execute.tap do |result|
case result
- when REQUIRE_RECAPTCHA
+ when CONDITIONAL_ALLOW
+ # at the moment, this means "ask for reCAPTCHA"
create_spam_log(api)
break if target.allow_possible_spam?
@@ -74,7 +79,7 @@ module Spam
description: target.spam_description,
source_ip: options[:ip_address],
user_agent: options[:user_agent],
- noteable_type: target.class.to_s,
+ noteable_type: notable_type,
via_api: api
}
)
@@ -84,8 +89,14 @@ module Spam
def spam_verdict_service
SpamVerdictService.new(target: target,
+ user: user,
request: @request,
- options: options)
+ options: options,
+ context: context.merge(target_type: notable_type))
+ end
+
+ def notable_type
+ @notable_type ||= target.class.to_s
end
end
end
diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb
index 085bac684c4..2a16cfae78b 100644
--- a/app/services/spam/spam_constants.rb
+++ b/app/services/spam/spam_constants.rb
@@ -2,8 +2,24 @@
module Spam
module SpamConstants
- REQUIRE_RECAPTCHA = :recaptcha
- DISALLOW = :disallow
- ALLOW = :allow
+ CONDITIONAL_ALLOW = "conditional_allow"
+ DISALLOW = "disallow"
+ ALLOW = "allow"
+ BLOCK_USER = "block"
+
+ SUPPORTED_VERDICTS = {
+ BLOCK_USER => {
+ priority: 1
+ },
+ DISALLOW => {
+ priority: 2
+ },
+ CONDITIONAL_ALLOW => {
+ priority: 3
+ },
+ ALLOW => {
+ priority: 4
+ }
+ }.freeze
end
end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 2b4d5f4a984..68f1135ae28 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -5,22 +5,90 @@ module Spam
include AkismetMethods
include SpamConstants
- def initialize(target:, request:, options:)
+ def initialize(user:, target:, request:, options:, context: {})
@target = target
@request = request
+ @user = user
@options = options
+ @verdict_params = assemble_verdict_params(context)
end
def execute
+ external_spam_check_result = spam_verdict
+ akismet_result = akismet_verdict
+
+ # filter out anything we don't recognise, including nils.
+ valid_results = [external_spam_check_result, akismet_result].compact.select { |r| SUPPORTED_VERDICTS.key?(r) }
+ # Treat nils - such as service unavailable - as ALLOW
+ return ALLOW unless valid_results.any?
+
+ # Favour the most restrictive result.
+ valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] }
+ end
+
+ private
+
+ attr_reader :user, :target, :request, :options, :verdict_params
+
+ def akismet_verdict
if akismet.spam?
- Gitlab::Recaptcha.enabled? ? REQUIRE_RECAPTCHA : DISALLOW
+ Gitlab::Recaptcha.enabled? ? CONDITIONAL_ALLOW : DISALLOW
else
ALLOW
end
end
- private
+ def spam_verdict
+ return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled
+ return if endpoint_url.blank?
+
+ begin
+ result = Gitlab::HTTP.post(endpoint_url, body: verdict_params.to_json, headers: { 'Content-Type' => 'application/json' })
+ return unless result
+
+ json_result = Gitlab::Json.parse(result).with_indifferent_access
+ # @TODO metrics/logging
+ # Expecting:
+ # error: (string or nil)
+ # result: (string or nil)
+ verdict = json_result[:verdict]
+ return unless SUPPORTED_VERDICTS.include?(verdict)
- attr_reader :target, :request, :options
+ # @TODO log if json_result[:error]
+
+ json_result[:verdict]
+ rescue *Gitlab::HTTP::HTTP_ERRORS => e
+ # @TODO: log error via try_post https://gitlab.com/gitlab-org/gitlab/-/issues/219223
+ Gitlab::ErrorTracking.log_exception(e)
+ return
+ rescue
+ # @TODO log
+ ALLOW
+ end
+ end
+
+ def assemble_verdict_params(context)
+ return {} unless endpoint_url.present?
+
+ project = target.try(:project)
+
+ context.merge({
+ target: {
+ title: target.spam_title,
+ description: target.spam_description,
+ type: target.class.to_s
+ },
+ user: {
+ created_at: user.created_at,
+ email: user.email,
+ username: user.username
+ },
+ user_in_project: user.authorized_project?(project)
+ })
+ end
+
+ def endpoint_url
+ @endpoint_url ||= Gitlab::CurrentSettings.current_application_settings.spam_check_endpoint_url
+ end
end
end
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 3265eb106eb..4bbde3a9648 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -28,7 +28,7 @@ class SubmitUsagePingService
true
rescue Gitlab::HTTP::Error => e
- Rails.logger.info "Unable to contact GitLab, Inc.: #{e}" # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.info("Unable to contact GitLab, Inc.: #{e}")
false
end
diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
index 479eed3ce57..ab80b23a37b 100644
--- a/app/services/suggestions/apply_service.rb
+++ b/app/services/suggestions/apply_service.rb
@@ -2,109 +2,49 @@
module Suggestions
class ApplyService < ::BaseService
- DEFAULT_SUGGESTION_COMMIT_MESSAGE = 'Apply suggestion to %{file_path}'
-
- PLACEHOLDERS = {
- 'project_path' => ->(suggestion, user) { suggestion.project.path },
- 'project_name' => ->(suggestion, user) { suggestion.project.name },
- 'file_path' => ->(suggestion, user) { suggestion.file_path },
- 'branch_name' => ->(suggestion, user) { suggestion.branch },
- 'username' => ->(suggestion, user) { user.username },
- 'user_full_name' => ->(suggestion, user) { user.name }
- }.freeze
-
- # This regex is built dynamically using the keys from the PLACEHOLDER struct.
- # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
- # This regex will build the new PLACEHOLDER_REGEX with the new information
- PLACEHOLDERS_REGEX = Regexp.union(PLACEHOLDERS.keys.map { |key| Regexp.new(Regexp.escape(key)) }).freeze
-
- attr_reader :current_user
-
- def initialize(current_user)
+ def initialize(current_user, *suggestions)
@current_user = current_user
+ @suggestion_set = Gitlab::Suggestions::SuggestionSet.new(suggestions)
end
- def execute(suggestion)
- unless suggestion.appliable?(cached: false)
- return error('Suggestion is not appliable')
- end
-
- unless latest_source_head?(suggestion)
- return error('The file has been changed')
+ def execute
+ if suggestion_set.valid?
+ result
+ else
+ error(suggestion_set.error_message)
end
+ end
- diff_file = suggestion.diff_file
-
- unless diff_file
- return error('The file was not found')
- end
+ private
- params = file_update_params(suggestion, diff_file)
- result = ::Files::UpdateService.new(suggestion.project, current_user, params).execute
+ attr_reader :current_user, :suggestion_set
- if result[:status] == :success
- suggestion.update(commit_id: result[:result], applied: true)
+ def result
+ multi_service.execute.tap do |result|
+ update_suggestions(result)
end
-
- result
- rescue Files::UpdateService::FileChangedError
- error('The file has been changed')
end
- private
+ def update_suggestions(result)
+ return unless result[:status] == :success
- # 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
+ Suggestion.id_in(suggestion_set.suggestions)
+ .update_all(commit_id: result[:result], applied: true)
end
- def file_update_params(suggestion, diff_file)
- blob = diff_file.new_blob
- project = suggestion.project
- file_path = suggestion.file_path
- branch_name = suggestion.branch
- file_content = new_file_content(suggestion, blob)
- commit_message = processed_suggestion_commit_message(suggestion)
-
- file_last_commit =
- Gitlab::Git::Commit.last_for_path(project.repository,
- blob.commit_id,
- blob.path)
-
- {
- file_path: file_path,
- branch_name: branch_name,
- start_branch: branch_name,
+ def multi_service
+ params = {
commit_message: commit_message,
- file_content: file_content,
- last_commit_sha: file_last_commit&.id
+ branch_name: suggestion_set.branch,
+ start_branch: suggestion_set.branch,
+ actions: suggestion_set.actions
}
- end
-
- def new_file_content(suggestion, blob)
- range = suggestion.from_line_index..suggestion.to_line_index
- blob.load_all_data!
- content = blob.data.lines
- content[range] = suggestion.to_content
-
- content.join
- end
-
- def suggestion_commit_message(project)
- project.suggestion_commit_message.presence || DEFAULT_SUGGESTION_COMMIT_MESSAGE
+ ::Files::MultiService.new(suggestion_set.project, current_user, params)
end
- def processed_suggestion_commit_message(suggestion)
- message = suggestion_commit_message(suggestion.project)
-
- Gitlab::StringPlaceholderReplacer.replace_string_placeholders(message, PLACEHOLDERS_REGEX) do |key|
- PLACEHOLDERS[key].call(suggestion, current_user)
- end
+ def commit_message
+ Gitlab::Suggestions::CommitMessage.new(current_user, suggestion_set).message
end
end
end
diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb
index 1d3338c1b45..93d2bd11426 100644
--- a/app/services/suggestions/create_service.rb
+++ b/app/services/suggestions/create_service.rb
@@ -25,7 +25,7 @@ module Suggestions
end
rows.in_groups_of(100, false) do |rows|
- Gitlab::Database.bulk_insert('suggestions', rows)
+ Gitlab::Database.bulk_insert('suggestions', rows) # rubocop:disable Gitlab/BulkInsert
end
end
end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 275c64bea89..7d7ee8d829e 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -128,7 +128,7 @@ module SystemNotes
body = cross_reference_note_content(gfm_reference)
if noteable.is_a?(ExternalIssue)
- noteable.project.issues_tracker.create_cross_reference_note(noteable, mentioner, author)
+ noteable.project.external_issue_tracker.create_cross_reference_note(noteable, mentioner, author)
else
create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference'))
end
@@ -225,7 +225,12 @@ module SystemNotes
action = status == 'reopened' ? 'opened' : status
- create_note(NoteSummary.new(noteable, project, author, body, action: action))
+ # A state event which results in a synthetic note will be
+ # created by EventCreateService if change event tracking
+ # is enabled.
+ unless state_change_tracking_enabled?
+ create_note(NoteSummary.new(noteable, project, author, body, action: action))
+ end
end
# Check if a cross reference to a noteable from a mentioner already exists
@@ -318,6 +323,11 @@ module SystemNotes
def self.cross_reference?(note_text)
note_text =~ /\A#{cross_reference_note_prefix}/i
end
+
+ def state_change_tracking_enabled?
+ noteable.respond_to?(:resource_state_events) &&
+ ::Feature.enabled?(:track_resource_state_change_events, noteable.project)
+ end
end
end
diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb
index ebebf29c28b..0fda6fb1ed0 100644
--- a/app/services/test_hooks/base_service.rb
+++ b/app/services/test_hooks/base_service.rb
@@ -2,6 +2,8 @@
module TestHooks
class BaseService
+ include BaseServiceUtility
+
attr_accessor :hook, :current_user, :trigger
def initialize(hook, current_user, trigger)
@@ -12,31 +14,11 @@ module TestHooks
def execute
trigger_key = hook.class.triggers.key(trigger.to_sym)
- trigger_data_method = "#{trigger}_data"
-
- if trigger_key.nil? || !self.respond_to?(trigger_data_method, true)
- return error('Testing not available for this hook')
- end
-
- error_message = catch(:validation_error) do # rubocop:disable Cop/BanCatchThrow
- sample_data = self.__send__(trigger_data_method) # rubocop:disable GitlabSecurity/PublicSend
-
- return hook.execute(sample_data, trigger_key) # rubocop:disable Cop/AvoidReturnFromBlocks
- end
-
- error(error_message)
- end
-
- private
- def error(message, http_status = nil)
- result = {
- message: message,
- status: :error
- }
+ return error('Testing not available for this hook') if trigger_key.nil? || data.blank?
+ return error(data[:error]) if data[:error].present?
- result[:http_status] = http_status if http_status
- result
+ hook.execute(data, trigger_key)
end
end
end
diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb
index aa80cc928b9..4e554dce357 100644
--- a/app/services/test_hooks/project_service.rb
+++ b/app/services/test_hooks/project_service.rb
@@ -2,6 +2,9 @@
module TestHooks
class ProjectService < TestHooks::BaseService
+ include Integrations::ProjectTestData
+ include Gitlab::Utils::StrongMemoize
+
attr_writer :project
def project
@@ -10,58 +13,25 @@ module TestHooks
private
- def push_events_data
- throw(:validation_error, s_('TestHooks|Ensure the project has at least one commit.')) if project.empty_repo? # rubocop:disable Cop/BanCatchThrow
-
- Gitlab::DataBuilder::Push.build_sample(project, current_user)
- end
-
- alias_method :tag_push_events_data, :push_events_data
-
- def note_events_data
- note = project.notes.first
- throw(:validation_error, s_('TestHooks|Ensure the project has notes.')) unless note.present? # rubocop:disable Cop/BanCatchThrow
-
- Gitlab::DataBuilder::Note.build(note, current_user)
- end
-
- def issues_events_data
- issue = project.issues.first
- throw(:validation_error, s_('TestHooks|Ensure the project has issues.')) unless issue.present? # rubocop:disable Cop/BanCatchThrow
-
- issue.to_hook_data(current_user)
- end
-
- alias_method :confidential_issues_events_data, :issues_events_data
-
- def merge_requests_events_data
- merge_request = project.merge_requests.first
- throw(:validation_error, s_('TestHooks|Ensure the project has merge requests.')) unless merge_request.present? # rubocop:disable Cop/BanCatchThrow
-
- merge_request.to_hook_data(current_user)
- end
-
- def job_events_data
- build = project.builds.first
- throw(:validation_error, s_('TestHooks|Ensure the project has CI jobs.')) unless build.present? # rubocop:disable Cop/BanCatchThrow
-
- Gitlab::DataBuilder::Build.build(build)
- end
-
- def pipeline_events_data
- pipeline = project.ci_pipelines.first
- throw(:validation_error, s_('TestHooks|Ensure the project has CI pipelines.')) unless pipeline.present? # rubocop:disable Cop/BanCatchThrow
-
- Gitlab::DataBuilder::Pipeline.build(pipeline)
- end
-
- def wiki_page_events_data
- page = project.wiki.list_pages(limit: 1).first
- if !project.wiki_enabled? || page.blank?
- throw(:validation_error, s_('TestHooks|Ensure the wiki is enabled and has pages.')) # rubocop:disable Cop/BanCatchThrow
+ def data
+ strong_memoize(:data) do
+ case trigger
+ when 'push_events', 'tag_push_events'
+ push_events_data
+ when 'note_events'
+ note_events_data
+ when 'issues_events', 'confidential_issues_events'
+ issues_events_data
+ when 'merge_requests_events'
+ merge_requests_events_data
+ when 'job_events'
+ job_events_data
+ when 'pipeline_events'
+ pipeline_events_data
+ when 'wiki_page_events'
+ wiki_page_events_data
+ end
end
-
- Gitlab::DataBuilder::WikiPage.build(page, current_user, 'create')
end
end
end
diff --git a/app/services/test_hooks/system_service.rb b/app/services/test_hooks/system_service.rb
index 5c7961f417d..66d78bfc578 100644
--- a/app/services/test_hooks/system_service.rb
+++ b/app/services/test_hooks/system_service.rb
@@ -2,23 +2,26 @@
module TestHooks
class SystemService < TestHooks::BaseService
- private
-
- def push_events_data
- Gitlab::DataBuilder::Push.sample_data
- end
+ include Gitlab::Utils::StrongMemoize
- def tag_push_events_data
- Gitlab::DataBuilder::Push.sample_data
- end
+ private
- def repository_update_events_data
- Gitlab::DataBuilder::Repository.sample_data
+ def data
+ strong_memoize(:data) do
+ case trigger
+ when 'push_events', 'tag_push_events'
+ Gitlab::DataBuilder::Push.sample_data
+ when 'repository_update_events'
+ Gitlab::DataBuilder::Repository.sample_data
+ when 'merge_requests_events'
+ merge_requests_events_data
+ end
+ end
end
def merge_requests_events_data
merge_request = MergeRequest.of_projects(current_user.projects.select(:id)).first
- throw(:validation_error, s_('TestHooks|Ensure one of your projects has merge requests.')) unless merge_request.present? # rubocop:disable Cop/BanCatchThrow
+ return { 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 55f888d5664..e6fb0d3c72e 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -30,7 +30,7 @@ class TodoService
# * mark all pending todos related to the target for the current user as done
#
def close_issue(issue, current_user)
- mark_pending_todos_as_done(issue, current_user)
+ resolve_todos_for_target(issue, current_user)
end
# When we destroy a todo target we should:
@@ -79,7 +79,7 @@ class TodoService
# * mark all pending todos related to the target for the current user as done
#
def close_merge_request(merge_request, current_user)
- mark_pending_todos_as_done(merge_request, current_user)
+ resolve_todos_for_target(merge_request, current_user)
end
# When merge a merge request we should:
@@ -87,7 +87,7 @@ class TodoService
# * mark all pending todos related to the target for the current user as done
#
def merge_merge_request(merge_request, current_user)
- mark_pending_todos_as_done(merge_request, current_user)
+ resolve_todos_for_target(merge_request, current_user)
end
# When a build fails on the HEAD of a merge request we should:
@@ -105,7 +105,7 @@ class TodoService
# * mark all pending todos related to the merge request for that user as done
#
def merge_request_push(merge_request, current_user)
- mark_pending_todos_as_done(merge_request, current_user)
+ resolve_todos_for_target(merge_request, current_user)
end
# When a build is retried to a merge request we should:
@@ -114,7 +114,7 @@ class TodoService
#
def merge_request_build_retried(merge_request)
merge_request.merge_participants.each do |user|
- mark_pending_todos_as_done(merge_request, user)
+ resolve_todos_for_target(merge_request, user)
end
end
@@ -151,76 +151,68 @@ class TodoService
# * mark all pending todos related to the awardable for the current user as done
#
def new_award_emoji(awardable, current_user)
- mark_pending_todos_as_done(awardable, current_user)
+ resolve_todos_for_target(awardable, current_user)
end
- # When marking pending todos as done we should:
+ # When assigning an alert we should:
#
- # * mark all pending todos related to the target for the current user as done
+ # * create a pending todo for new assignee if alert is assigned
#
- def mark_pending_todos_as_done(target, user)
- attributes = attributes_for_target(target)
- pending_todos(user, attributes).update_all(state: :done)
- user.update_todos_count_cache
+ def assign_alert(alert, current_user)
+ create_assignment_todo(alert, current_user, [])
end
- # When user marks some todos as done
- def mark_todos_as_done(todos, current_user)
- update_todos_state(todos, current_user, :done)
+ # When user marks an issue as todo
+ def mark_todo(issuable, current_user)
+ attributes = attributes_for_todo(issuable.project, issuable, current_user, Todo::MARKED)
+ create_todos(current_user, attributes)
end
- def mark_todos_as_done_by_ids(ids, current_user)
- todos = todos_by_ids(ids, current_user)
- mark_todos_as_done(todos, current_user)
+ def todo_exist?(issuable, current_user)
+ TodosFinder.new(current_user).any_for_target?(issuable, :pending)
end
- def mark_all_todos_as_done_by_user(current_user)
- todos = TodosFinder.new(current_user).execute
- mark_todos_as_done(todos, current_user)
- end
+ # Resolves all todos related to target
+ def resolve_todos_for_target(target, current_user)
+ attributes = attributes_for_target(target)
- def mark_todo_as_done(todo, current_user)
- return if todo.done?
+ resolve_todos(pending_todos(current_user, attributes), current_user)
+ end
- todo.update(state: :done)
+ def resolve_todos(todos, current_user, resolution: :done, resolved_by_action: :system_done)
+ todos_ids = todos.batch_update(state: resolution, resolved_by_action: resolved_by_action)
current_user.update_todos_count_cache
- end
- # When user marks some todos as pending
- def mark_todos_as_pending(todos, current_user)
- update_todos_state(todos, current_user, :pending)
+ todos_ids
end
- def mark_todos_as_pending_by_ids(ids, current_user)
- todos = todos_by_ids(ids, current_user)
- mark_todos_as_pending(todos, current_user)
- end
+ def resolve_todo(todo, current_user, resolution: :done, resolved_by_action: :system_done)
+ return if todo.done?
- # When user marks an issue as todo
- def mark_todo(issuable, current_user)
- attributes = attributes_for_todo(issuable.project, issuable, current_user, Todo::MARKED)
- create_todos(current_user, attributes)
- end
+ todo.update(state: resolution, resolved_by_action: resolved_by_action)
- def todo_exist?(issuable, current_user)
- TodosFinder.new(current_user).any_for_target?(issuable, :pending)
+ current_user.update_todos_count_cache
end
- private
+ def restore_todos(todos, current_user)
+ todos_ids = todos.batch_update(state: :pending)
- def todos_by_ids(ids, current_user)
- current_user.todos_limited_to(Array(ids))
+ current_user.update_todos_count_cache
+
+ todos_ids
end
- def update_todos_state(todos, current_user, state)
- todos_ids = todos.update_state(state)
+ def restore_todo(todo, current_user)
+ return if todo.pending?
- current_user.update_todos_count_cache
+ todo.update(state: :pending)
- todos_ids
+ current_user.update_todos_count_cache
end
+ private
+
def create_todos(users, attributes)
Array(users).map do |user|
next if pending_todos(user, attributes).exists?
@@ -252,16 +244,16 @@ class TodoService
return unless note.can_create_todo?
project = note.project
- target = note.noteable
+ target = note.noteable
- mark_pending_todos_as_done(target, author)
+ resolve_todos_for_target(target, author)
create_mention_todos(project, target, author, note, skip_users)
end
- def create_assignment_todo(issuable, author, old_assignees = [])
- if issuable.assignees.any?
- assignees = issuable.assignees - old_assignees
- attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED)
+ def create_assignment_todo(target, author, old_assignees = [])
+ if target.assignees.any?
+ assignees = target.assignees - old_assignees
+ attributes = attributes_for_todo(target.project, target, author, Todo::ASSIGNED)
create_todos(assignees, attributes)
end
end
diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb
index 66f1ccfab70..11727f05f35 100644
--- a/app/services/user_project_access_changed_service.rb
+++ b/app/services/user_project_access_changed_service.rb
@@ -19,7 +19,8 @@ class UserProjectAccessChangedService
if priority == HIGH_PRIORITY
AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext
else
- AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.bulk_perform_in(DELAY, bulk_args) # rubocop:disable Scalability/BulkPerformWithContext
+ AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.bulk_perform_in( # rubocop:disable Scalability/BulkPerformWithContext
+ DELAY, bulk_args, batch_size: 100, batch_delay: 30.seconds)
end
end
end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 3938d675596..f06f00a5c3f 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -82,7 +82,8 @@ module Users
:organization,
:location,
:public_email,
- :user_type
+ :user_type,
+ :note
]
end
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 587a8516394..436d4fb3985 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -56,7 +56,7 @@ module Users
MigrateToGhostUserService.new(user).execute unless options[:hard_delete]
- response = Snippets::BulkDestroyService.new(current_user, user.snippets).execute
+ response = Snippets::BulkDestroyService.new(current_user, user.snippets).execute(options)
raise DestroyError, response.message if response.error?
# Rails attempts to load all related records into memory before
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index 5ca9ed67e56..1b46edd4d7d 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -53,6 +53,7 @@ module Users
migrate_abuse_reports
migrate_award_emoji
migrate_snippets
+ migrate_reviews
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -85,6 +86,10 @@ module Users
snippets = user.snippets.only_project_snippets
snippets.update_all(author_id: ghost_user.id)
end
+
+ def migrate_reviews
+ user.reviews.update_all(author_id: ghost_user.id)
+ end
end
end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 178a321e20c..91a26ff45b1 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -63,7 +63,7 @@ class WebHookService
error_message: e.to_s
)
- Rails.logger.error("WebHook Error => #{e}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error("WebHook Error => #{e}")
{
status: :error,
diff --git a/app/services/wiki_pages/create_service.rb b/app/services/wiki_pages/create_service.rb
index 4ef19676d82..63107445782 100644
--- a/app/services/wiki_pages/create_service.rb
+++ b/app/services/wiki_pages/create_service.rb
@@ -22,7 +22,7 @@ module WikiPages
end
def event_action
- Event::CREATED
+ :created
end
end
end
diff --git a/app/services/wiki_pages/destroy_service.rb b/app/services/wiki_pages/destroy_service.rb
index eb162223723..d59c27bb92a 100644
--- a/app/services/wiki_pages/destroy_service.rb
+++ b/app/services/wiki_pages/destroy_service.rb
@@ -19,7 +19,7 @@ module WikiPages
end
def event_action
- Event::DESTROYED
+ :destroyed
end
end
end
diff --git a/app/services/wiki_pages/update_service.rb b/app/services/wiki_pages/update_service.rb
index 0a056f1ec33..5ac6902e0b0 100644
--- a/app/services/wiki_pages/update_service.rb
+++ b/app/services/wiki_pages/update_service.rb
@@ -22,7 +22,7 @@ module WikiPages
end
def event_action
- Event::UPDATED
+ :updated
end
def slug_for_page(page)
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
index 7c7953c8a0e..887cb702acf 100644
--- a/app/uploaders/file_mover.rb
+++ b/app/uploaders/file_mover.rb
@@ -98,7 +98,7 @@ class FileMover
end
def revert
- Rails.logger.warn("Markdown not updated, file move reverted for #{to_model}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.warn("Markdown not updated, file move reverted for #{to_model}")
if temp_file_uploader.file_storage?
FileUtils.move(file_path, temp_file_path)
diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb
new file mode 100644
index 00000000000..f8c1727035c
--- /dev/null
+++ b/app/validators/json_schema_validator.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+#
+# JsonSchemaValidator
+#
+# Custom validator for json schema.
+# Create a json schema within the json_schemas directory
+#
+# class Project < ActiveRecord::Base
+# validates :data, json_schema: { filename: "file" }
+# end
+#
+class JsonSchemaValidator < ActiveModel::EachValidator
+ FILENAME_ALLOWED = /\A[a-z0-9_-]*\Z/.freeze
+ FilenameError = Class.new(StandardError)
+
+ def initialize(options)
+ raise ArgumentError, "Expected 'filename' as an argument" unless options[:filename]
+ raise FilenameError, "Must be a valid 'filename'" unless options[:filename].match?(FILENAME_ALLOWED)
+
+ super(options)
+ end
+
+ def validate_each(record, attribute, value)
+ unless valid_schema?(value)
+ record.errors.add(attribute, "must be a valid json schema")
+ end
+ end
+
+ private
+
+ def valid_schema?(value)
+ JSON::Validator.validate(schema_path, value)
+ end
+
+ def schema_path
+ Rails.root.join('app', 'validators', 'json_schemas', "#{options[:filename]}.json").to_s
+ end
+end
diff --git a/app/validators/json_schemas/build_report_result_data.json b/app/validators/json_schemas/build_report_result_data.json
new file mode 100644
index 00000000000..0fb4fd6d0b7
--- /dev/null
+++ b/app/validators/json_schemas/build_report_result_data.json
@@ -0,0 +1,12 @@
+{
+ "description": "Build report result data",
+ "type": "object",
+ "properties": {
+ "coverage": { "type": "float" },
+ "tests": {
+ "type": "object",
+ "items": { "$ref": "./build_report_result_data_tests.json" }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/app/validators/json_schemas/build_report_result_data_tests.json b/app/validators/json_schemas/build_report_result_data_tests.json
new file mode 100644
index 00000000000..b38559e727f
--- /dev/null
+++ b/app/validators/json_schemas/build_report_result_data_tests.json
@@ -0,0 +1,13 @@
+{
+ "description": "Build report result data tests",
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "duration": { "type": "string" },
+ "failed": { "type": "integer" },
+ "errored": { "type": "integer" },
+ "skipped": { "type": "integer" },
+ "success": { "type": "integer" }
+ },
+ "additionalProperties": false
+}
diff --git a/app/validators/json_schemas/daily_build_group_report_result_data.json b/app/validators/json_schemas/daily_build_group_report_result_data.json
new file mode 100644
index 00000000000..2524ac63050
--- /dev/null
+++ b/app/validators/json_schemas/daily_build_group_report_result_data.json
@@ -0,0 +1,8 @@
+{
+ "description": "Daily build group report result data",
+ "type": "object",
+ "properties": {
+ "coverage": { "type": "float" }
+ },
+ "additionalProperties": false
+}
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index 5bb05bcba26..aa47daf4a57 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -6,7 +6,7 @@
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0 Navigation bar
+ %h4.gl-mt-0 Navigation bar
.col-lg-8
.form-group
@@ -25,7 +25,7 @@
%hr
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0 Favicon
+ %h4.gl-mt-0 Favicon
.col-lg-8
.form-group
@@ -49,7 +49,7 @@
%hr
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0 Sign in/Sign up pages
+ %h4.gl-mt-0 Sign in/Sign up pages
.col-lg-8
.form-group
@@ -77,7 +77,7 @@
%hr
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0 New project pages
+ %h4.gl-mt-0 New project pages
.col-lg-8
.form-group
@@ -90,7 +90,7 @@
%hr
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0 Profile image guideline
+ %h4.gl-mt-0 Profile image guideline
.col-lg-8
.form-group
diff --git a/app/views/admin/appearances/_system_header_footer_form.html.haml b/app/views/admin/appearances/_system_header_footer_form.html.haml
index 4301ebd05af..7f53b2baa32 100644
--- a/app/views/admin/appearances/_system_header_footer_form.html.haml
+++ b/app/views/admin/appearances/_system_header_footer_form.html.haml
@@ -3,7 +3,7 @@
%hr
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _('System header and footer')
.col-lg-8
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 94a7a1be455..ceec8901951 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -29,11 +29,16 @@
= 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', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body' }
.form-group
+ = f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light'
+ = f.number_field :max_import_size, class: 'form-control qa-receive-max-import-size-field', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' }
+ %span.form-text.text-muted= _('0 for unlimited, only effective with remote storage enabled.')
+ .form-group
= f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light'
= f.number_field :session_expire_delay, class: 'form-control', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' }
%span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes.')
= render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f
+ = render_if_exists 'admin/application_settings/enforce_pat_expiration', form: f
.form-group
= f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold'
diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml
index 5906358fbb1..b0bdc204f64 100644
--- a/app/views/admin/application_settings/_issue_limits.html.haml
+++ b/app/views/admin/application_settings/_issue_limits.html.haml
@@ -3,7 +3,7 @@
%fieldset
.form-group
- = f.label :issues_create_limit, 'Max requests per second per user', class: 'label-bold'
+ = f.label :issues_create_limit, 'Max requests per minute per user', class: 'label-bold'
= f.number_field :issues_create_limit, class: 'form-control'
= f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index 42528f40123..b0593b3bfa2 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -14,10 +14,10 @@
.form-group
= f.label :outbound_local_requests_whitelist_raw, class: 'label-bold' do
- = _('Whitelist to allow requests to the local network from hooks and services')
+ = _('Local IP addresses and domain names that hooks and services may access.')
= f.text_area :outbound_local_requests_whitelist_raw, placeholder: "example.com, 192.168.1.1", class: 'form-control', rows: 8
%span.form-text.text-muted
- = _('Requests to these domain(s)/address(es) on the local network will be allowed when local requests from hooks and services are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 or 127.0.0.0/28 are supported. Domain wildcards are not supported currently. Use comma, semicolon, or newline to separate multiple entries. The whitelist can hold a maximum of 1000 entries. Domains should use IDNA encoding. Ex: example.com, 192.168.1.1, 127.0.0.0/28, xn--itlab-j1a.com.')
+ = _('Requests to these domain(s)/address(es) on the local network will be allowed when local requests from hooks and services are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 or 127.0.0.0/28 are supported. Domain wildcards are not supported currently. Use comma, semicolon, or newline to separate multiple entries. The allowlist can hold a maximum of 1000 entries. Domains should use IDNA encoding. Ex: example.com, 192.168.1.1, 127.0.0.0/28, xn--itlab-j1a.com.')
.form-group
.form-check
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index 6fabafe3fc1..ed276da08f2 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -15,12 +15,12 @@
.form-group
.form-text
%p.text-secondary
- = _('Select the configured storage available for new repositories to be placed on.')
+ = _('Select a weight for the storage new repositories will be placed on.')
= link_to icon('question-circle'), help_page_path('administration/repository_storage_paths')
.form-check
- = f.collection_check_boxes :repository_storages, Gitlab.config.repositories.storages, :first, :first, include_hidden: false do |b|
- = b.check_box class: 'form-check-input'
- = b.label class: 'label-bold form-check-label'
+ - storage_weights.each do |attribute|
+ = f.text_field attribute[:name], class: 'form-text-input', value: attribute[:value]
+ = f.label attribute[:label], attribute[:label], class: 'label-bold form-check-label'
%br
= f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button"
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
index f0a19075115..ab9368e723e 100644
--- a/app/views/admin/application_settings/_spam.html.haml
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -62,4 +62,13 @@
.form-text.text-muted
How many seconds an IP will be counted towards the limit
+ .form-group
+ .form-check
+ = f.check_box :spam_check_endpoint_enabled, class: 'form-check-input'
+ = f.label :spam_check_endpoint_enabled, _('Enable Spam Check via external API endpoint'), class: 'form-check-label'
+ .form-text.text-muted= _('Define custom rules for what constitutes spam, independent of Akismet')
+ .form-group
+ = f.label :spam_check_endpoint_url, _('URL of the external Spam Check endpoint'), class: 'label-bold'
+ = f.text_field :spam_check_endpoint_url, class: 'form-control'
+
= f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml
new file mode 100644
index 00000000000..9f03936f64a
--- /dev/null
+++ b/app/views/admin/application_settings/ci/_header.html.haml
@@ -0,0 +1,20 @@
+- 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
+ = _('Environment variables are applied to all project environments in this instance via the Runner. You can use environment variables for passwords, secret keys, etc. Make variables available to the running application by prepending the variable key with <code>K8S_SECRET_</code>. You can set variables to be:').html_safe
+
+%ul
+ %li
+ = _('<code>Protected</code> to expose them to protected branches or tags only.').html_safe
+ %li
+ = _('<code>Masked</code> to prevent the values from being displayed in job logs (must match certain regexp requirements).').html_safe
+
+%p
+ = link_to _('More information'), help_page_path('ci/variables/README', anchor: 'instance-level-cicd-environment-variables')
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index 81f06926188..2452ab794fc 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -2,6 +2,17 @@
- page_title _("CI/CD")
- @content_class = "limit-container-width" unless fluid_layout
+- if ::Gitlab::Ci::Features.instance_variables_ui_enabled?
+ %section.settings.no-animate#js-ci-cd-variables{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ = render 'admin/application_settings/ci/header', expanded: expanded_by_default?
+ .settings-content
+ - 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 on this GitLab instance are configured to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ #js-instance-variables{ data: { endpoint: admin_ci_variables_path, group: 'true', maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} }
+
%section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 8b86a024a6e..79d758cf10b 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -1,4 +1,4 @@
-.broadcast-banner-message.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) }
+.broadcast-message.broadcast-banner-message.alert-warning.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) }
= sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top')
.js-broadcast-message-preview
- if @broadcast_message.message.present?
@@ -6,7 +6,7 @@
- else
Your message here
.d-flex.justify-content-center
- .broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) }
+ .broadcast-message.broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) }
= sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top')
.js-broadcast-message-preview
- if @broadcast_message.message.present?
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 6f2433e3306..e7a7ee96508 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -12,7 +12,7 @@
%br.clearfix
- if @broadcast_messages.any?
- %table.table
+ %table.table.table-responsive
%thead
%tr
%th Status
@@ -37,7 +37,7 @@
= message.target_path
%td
= message.broadcast_type.capitalize
- %td
+ %td.gl-white-space-nowrap
= link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn'
= link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger'
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index 5fe8f9b4bbc..bbeeb1be929 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -1,34 +1,37 @@
- group = local_assigns.fetch(:group)
-- css_class = 'no-description' if group.description.blank?
+- css_class = "gl-display-flex!#{' no-description' if group.description.blank?}"
-%li.group-row.py-3{ class: css_class, data: { qa_selector: 'group_row_content' } }
- .controls
- = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn'
- = link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'btn btn-remove'
- .stats
+%li.group-row.gl-py-3.gl-align-items-center{ class: css_class, data: { qa_selector: 'group_row_content' } }
+ .avatar-container.rect-avatar.s40.gl-flex-shrink-0
+ = group_icon(group, class: "avatar s40")
+
+ .gl-min-w-0.gl-flex-grow-1
+ .title
+ = link_to [:admin, group], class: 'group-name', data: { qa_selector: 'group_name_link' } do
+ = group.full_name
+
+ - if group.description.present?
+ .description
+ = markdown_field(group, :description)
+
+ .stats.gl-text-gray-700.gl-flex-shrink-0.gl-display-none.gl-display-sm-flex
%span.badge.badge-pill
= storage_counter(group.storage_size)
- = render_if_exists 'admin/namespace_plan_badge', namespace: group
- = render_if_exists 'admin/groups/marked_for_deletion_badge', group: group
+ = render_if_exists 'admin/namespace_plan_badge', namespace: group, css_class: 'gl-ml-5 gl-mr-0'
+ = render_if_exists 'admin/groups/marked_for_deletion_badge', group: group, css_class: 'gl-ml-5'
- %span
+ %span.gl-ml-5
= icon('bookmark')
= number_with_delimiter(group.projects.count)
- %span
+ %span.gl-ml-5
= icon('users')
= number_with_delimiter(group.users.count)
- %span.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) }
+ %span.gl-ml-5.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) }
= visibility_level_icon(group.visibility_level, fw: false)
- .avatar-container.rect-avatar.s40
- = group_icon(group, class: "avatar s40 d-none d-sm-block")
- .title
- = link_to [:admin, group], class: 'group-name', data: { qa_selector: 'group_name_link' } do
- = group.full_name
-
- - if group.description.present?
- .description
- = markdown_field(group, :description)
+ .controls.gl-flex-shrink-0.gl-ml-5
+ = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn'
+ = link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'btn btn-remove'
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index ebed558f11b..e105091e773 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -59,6 +59,8 @@
= render_if_exists 'namespaces/shared_runner_status', namespace: @group
+ = render 'shared/custom_attributes', custom_attributes: @group.custom_attributes
+
= render_if_exists 'ldap_group_links/ldap_group_links_show', group: @group
.card
diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml
index 1d7c9930b6a..841640efad2 100644
--- a/app/views/admin/hook_logs/_index.html.haml
+++ b/app/views/admin/hook_logs/_index.html.haml
@@ -1,6 +1,6 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.gl-mt-0
Recent Deliveries
%p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.
.col-lg-9
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 9ce0fa8d401..636dd6bdfc1 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -9,7 +9,7 @@
= form_for @hook, as: :hook, url: admin_hook_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
- %span>= f.submit _('Save changes'), class: 'btn btn-success append-right-8'
+ %span>= f.submit _('Save changes'), class: 'btn btn-success gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
= link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') }
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 8abc4c37e70..f9d42d3f53b 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -108,6 +108,8 @@
= visibility_level_icon(@project.visibility_level)
= visibility_level_label(@project.visibility_level)
+ = render 'shared/custom_attributes', custom_attributes: @project.custom_attributes
+
= render_if_exists 'admin/projects/geo_status_widget', locals: { project: @project }
.card
diff --git a/app/views/admin/sessions/_two_factor_u2f.html.haml b/app/views/admin/sessions/_two_factor_u2f.html.haml
deleted file mode 100644
index 09b91d76295..00000000000
--- a/app/views/admin/sessions/_two_factor_u2f.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-#js-authenticate-u2f
-%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
-
-%script#js-authenticate-u2f-in-progress{ type: "text/template" }
- %p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
-
--# haml-lint:disable NoPlainNodes
-%script#js-authenticate-u2f-error{ type: "text/template" }
- %div
- %p <%= error_message %> (#{_("error code:")} <%= error_code %>)
- %a.btn.btn-block.btn-warning#js-u2f-try-again= _("Try again?")
-
-%script#js-authenticate-u2f-authenticated{ type: "text/template" }
- %div
- %p= _("We heard back from your U2F device. You have been authenticated.")
- = form_tag(admin_session_path, method: :post, id: 'js-login-u2f-form') do |f|
- = hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml
index 57a3452cf35..746d57dbad1 100644
--- a/app/views/admin/sessions/two_factor.html.haml
+++ b/app/views/admin/sessions/two_factor.html.haml
@@ -12,4 +12,4 @@
- if current_user.two_factor_otp_enabled?
= render 'admin/sessions/two_factor_otp'
- if current_user.two_factor_u2f_enabled?
- = render 'admin/sessions/two_factor_u2f'
+ = render 'u2f/authenticate', render_remember_me: false, target_path: admin_session_path
diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml
new file mode 100644
index 00000000000..5d91ba1a1ca
--- /dev/null
+++ b/app/views/admin/users/_admin_notes.html.haml
@@ -0,0 +1,7 @@
+%fieldset
+ %legend= _('Admin notes')
+ .form-group.row
+ .col-sm-2.col-form-label.text-right
+ = f.label :note, s_('AdminNote|Note')
+ .col-sm-10
+ = f.text_area :note, class: 'form-control'
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 3281718071c..38c6c8b2a62 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -83,7 +83,7 @@
.col-sm-10
= f.text_field :website_url, class: 'form-control'
- = render_if_exists 'admin/users/admin_notes', f: f
+ = render 'admin/users/admin_notes', f: f
.form-actions
- if @user.new_record?
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
index 3cc3fc6fa92..3839231cb95 100644
--- a/app/views/admin/users/_user_detail.html.haml
+++ b/app/views/admin/users/_user_detail.html.haml
@@ -3,10 +3,10 @@
= 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) }
+ = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 gl-mt-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, qa_selector: 'username_link' }
- = render_if_exists 'admin/users/user_listing_note', user: user
+ = render '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?
diff --git a/app/views/admin/users/_user_detail_note.html.haml b/app/views/admin/users/_user_detail_note.html.haml
new file mode 100644
index 00000000000..4f2a682c5ca
--- /dev/null
+++ b/app/views/admin/users/_user_detail_note.html.haml
@@ -0,0 +1,7 @@
+- if @user.note.present?
+ - text = @user.note
+ .card.border-info
+ .card-header.bg-info.text-white
+ = _('Admin Note')
+ .card-body
+ %p= text
diff --git a/app/views/admin/users/_user_listing_note.html.haml b/app/views/admin/users/_user_listing_note.html.haml
new file mode 100644
index 00000000000..df4af009c5c
--- /dev/null
+++ b/app/views/admin/users/_user_listing_note.html.haml
@@ -0,0 +1,3 @@
+- if user.note.present?
+ %span.has-tooltip.user-note{ title: user.note }
+ = icon("sticky-note-o cgrey")
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index cd07fee8e59..e76f1f6444c 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -86,7 +86,7 @@
%li
%span.light Current sign-in IP:
%strong
- - if @user.current_sign_in_ip
+ - if @user.current_sign_in_ip # rubocop:disable Style/RedundantCondition
= @user.current_sign_in_ip
- else
never
@@ -102,7 +102,7 @@
%li
%span.light Last sign-in IP:
%strong
- - if @user.last_sign_in_ip
+ - if @user.last_sign_in_ip # rubocop:disable Style/RedundantCondition
= @user.last_sign_in_ip
- else
never
@@ -141,6 +141,8 @@
= render_if_exists 'namespaces/shared_runner_status', namespace: @user.namespace
+ = render 'shared/custom_attributes', custom_attributes: @user.custom_attributes
+
.col-md-6
- unless @user == current_user
- unless @user.confirmed?
@@ -154,7 +156,7 @@
%br
= link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' }
- = render_if_exists 'admin/users/user_detail_note'
+ = render 'admin/users/user_detail_note'
- if @user.deactivated?
.card.border-info
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 26051261715..fa5f2c514ae 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -8,7 +8,18 @@
- if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true)
- is_group = !@group.nil?
- #js-ci-project-variables{ data: { endpoint: save_endpoint, project_id: @project&.id || '', group: is_group.to_s, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} }
+ #js-ci-project-variables{ data: { endpoint: save_endpoint,
+ project_id: @project&.id || '',
+ group: is_group.to_s,
+ maskable_regex: ci_variable_maskable_regex,
+ protected_by_default: ci_variable_protected_by_default?.to_s,
+ aws_logo_svg_path: image_path('aws_logo.svg'),
+ aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-the-aws-elastic-container-service-ecs'),
+ aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'),
+ aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
+ protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable'),
+ masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'),
+ } }
- else
.row
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 82057fd0463..e9ad0c6a4e0 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -4,7 +4,7 @@
.hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' }
%span.spinner.spinner-dark.spinner-sm{ 'aria-label': 'Loading' }
- %span.prepend-left-4= s_('ClusterIntegration|Kubernetes cluster is being created...')
+ %span.gl-ml-2= s_('ClusterIntegration|Kubernetes cluster is being created...')
.hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' }
= sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
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 617e5d1d5d3..486625c790b 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -2,7 +2,7 @@
.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
+ .gcp-signup-offer--icon.gl-mr-3
= sprite_icon("information", size: 16)
.gcp-signup-offer--copy
%h4= s_('ClusterIntegration|Did you know?')
diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
index 2489f78b403..c5b54997407 100644
--- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml
+++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
@@ -4,7 +4,7 @@
.d-flex.align-items-center
%h4.pr-2.m-0
= s_('ClusterIntegration|GitLab Integration')
- %label.append-bottom-0.js-cluster-enable-toggle-area{ title: s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.'), data: { toggle: 'tooltip', container: 'body' } }
+ %label.gl-mb-0.js-cluster-enable-toggle-area{ title: s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.'), data: { toggle: 'tooltip', container: 'body' } }
= render "shared/buttons/project_feature_toggle", is_checked: @cluster.enabled?, label: s_("ClusterIntegration|Toggle Kubernetes cluster"), disabled: !can?(current_user, :update_cluster, @cluster), data: { qa_selector: 'integration_status_toggle' } do
= field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml
index 60ccad5b943..24a74c59b97 100644
--- a/app/views/clusters/clusters/_sidebar.html.haml
+++ b/app/views/clusters/clusters/_sidebar.html.haml
@@ -1,4 +1,4 @@
-%h4.prepend-top-0
+%h4.gl-mt-0
= s_('ClusterIntegration|Add a Kubernetes cluster integration')
%p
= clusterable.sidebar_text
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index 86194842664..a654a8741a4 100644
--- a/app/views/clusters/clusters/index.html.haml
+++ b/app/views/clusters/clusters/index.html.haml
@@ -19,7 +19,7 @@
= link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')
- if Feature.enabled?(:clusters_list_redesign)
- #js-clusters-list-app{ data: { endpoint: clusterable.index_path(format: :json) } }
+ #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) }
- else
.clusters-table.js-clusters-list
.gl-responsive-table-row.table-row-header{ role: "row" }
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 1cc68d927bd..83b8092fb48 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -30,7 +30,7 @@
ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'),
ingress_dns_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint'),
ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'),
- environments_help_path: help_page_path('ci/environments', anchor: 'defining-environments'),
+ environments_help_path: help_page_path('ci/environments/index.md', anchor: 'defining-environments'),
clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'),
deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'),
cloud_run_help_path: help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'),
diff --git a/app/views/dashboard/milestones/_milestone.html.haml b/app/views/dashboard/milestones/_milestone.html.haml
index 89212eb6bf9..4921de32f65 100644
--- a/app/views/dashboard/milestones/_milestone.html.haml
+++ b/app/views/dashboard/milestones/_milestone.html.haml
@@ -1,5 +1,4 @@
= render 'shared/milestones/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/show.html.haml b/app/views/dashboard/milestones/show.html.haml
deleted file mode 100644
index 2129920afd2..00000000000
--- a/app/views/dashboard/milestones/show.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- header_title "Milestones", dashboard_milestones_path
-
-= render 'shared/milestones/top', milestone: @milestone
-= render 'shared/milestones/tabs', milestone: @milestone, show_full_project_name: true
-= render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 51
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index 79826a364db..a1fcbea5bf2 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -1,5 +1,5 @@
.well-confirmation.text-center.append-bottom-20
- %h1.prepend-top-0
+ %h1.gl-mt-0
Almost there...
%p.lead.append-bottom-20
Please check your email to confirm your account
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index f49cdfbf8da..126d8450568 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -14,4 +14,4 @@
= f.submit "Verify code", class: "btn btn-success"
- if @user.two_factor_u2f_enabled?
- = render "u2f/authenticate", locals: { params: params, resource: resource, resource_name: resource_name }
+ = render "u2f/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 5c3e4ccbfe5..e99d0ac1105 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -7,7 +7,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_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
+ = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}" do
- if has_icon
= provider_image_tag(provider)
%span
diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml
index 5d85d9e431f..9aab1556373 100644
--- a/app/views/doorkeeper/applications/index.html.haml
+++ b/app/views/doorkeeper/applications/index.html.haml
@@ -3,7 +3,7 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
- if user_oauth_applications?
@@ -12,7 +12,7 @@
= _("Manage applications that you've authorized to use your account.")
.col-lg-8
- if user_oauth_applications?
- %h5.prepend-top-0
+ %h5.gl-mt-0
= _('Add new application')
= render 'form', application: @application
%hr
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index 7e2103287f7..21e8b1401ca 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -7,8 +7,10 @@
%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{ dir: "auto" }
- = "&quot;".html_safe + event.target.title + "&quot".html_safe
+ - title = note_target_title(event.target)
+ - if title.present?
+ %span.event-target-title.append-right-4{ dir: "auto" }
+ = "&quot;".html_safe + title + "&quot".html_safe
= render "events/event_scope", event: event
diff --git a/app/views/groups/_flash_messages.html.haml b/app/views/groups/_flash_messages.html.haml
index fa1a9d2cca4..ca951f28fcf 100644
--- a/app/views/groups/_flash_messages.html.haml
+++ b/app/views/groups/_flash_messages.html.haml
@@ -1,2 +1,3 @@
= content_for :flash_message do
= render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)]
+ = render 'shared/namespace_storage_limit_alert', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)]
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index d083288edc8..9bf7ad228d9 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -9,15 +9,15 @@
= 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
+ %h1.home-panel-title.gl-mt-3.append-bottom-5
= @group.name
- %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
+ %span.visibility-icon.text-secondary.gl-ml-2.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
+ %span.access-request-links.gl-ml-3
= render 'shared/members/access_request_links', source: @group
.home-panel-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
diff --git a/app/views/groups/_import_group_pane.html.haml b/app/views/groups/_import_group_pane.html.haml
new file mode 100644
index 00000000000..adfac7d59a5
--- /dev/null
+++ b/app/views/groups/_import_group_pane.html.haml
@@ -0,0 +1,52 @@
+- parent = @group.parent
+- group_path = root_url
+- group_path << parent.full_path + '/' if parent
+
+= form_with url: import_gitlab_group_path, class: 'group-form gl-show-field-errors', multipart: true do |f|
+ = form_errors(@group)
+
+ .row
+ .form-group.group-name.col-sm-12
+ = f.label :name, _('Group name'), class: 'label-bold'
+ = f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name form-control input-lg',
+ required: true,
+ title: _('Please fill in a descriptive name for your group.'),
+ autofocus: true
+
+ .row
+ .form-group.col-xs-12.col-sm-8
+ = f.label :path, _('Group URL'), class: 'label-bold'
+ .input-group.gl-field-error-anchor
+ .group-root-path.input-group-prepend.has-tooltip{ title: group_path, :'data-placement' => 'bottom' }
+ .input-group-text
+ %span
+ = root_url
+ - if parent
+ %strong= parent.full_path + '/'
+ = f.hidden_field :parent_id, value: parent&.id
+ = f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control js-validate-group-path js-autofill-group-path',
+ id: 'import_group_path',
+ required: true,
+ pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
+ title: _('Please choose a group URL with no special characters.'),
+ "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
+ %p.validation-error.gl-field-error.field-validation.hide
+ = _('Group path is already taken. Suggestions: ')
+ %span.gl-path-suggestions
+ %p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.')
+ %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...')
+
+ .row
+ .form-group.col-md-12
+ = s_('GroupsNew|To copy a GitLab group between installations, navigate to the group settings page for the original installation, generate an export file, and upload it here.')
+ .row
+ .form-group.col-sm-12
+ = f.label :file, s_('GroupsNew|GitLab group export'), class: 'label-bold'
+ %div
+ = render 'shared/file_picker_button', f: f, field: :file, help_text: nil
+
+ .row
+ .form-actions.col-sm-12
+ = f.submit s_('GroupsNew|Import group'), class: 'btn btn-success'
+ = link_to _('Cancel'), new_group_path, class: 'btn btn-cancel'
+
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
new file mode 100644
index 00000000000..d9706556e79
--- /dev/null
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -0,0 +1,22 @@
+= form_errors(@group)
+= render 'shared/group_form', f: f, autofocus: true
+
+.row
+ .form-group.group-description-holder.col-sm-12
+ = f.label :avatar, _("Group avatar"), class: 'label-bold'
+ %div
+ = render 'shared/choose_avatar_button', f: f
+
+ .form-group.col-sm-12
+ %label.label-bold
+ = _('Visibility level')
+ %p
+ = _('Who will be able to see this group?')
+ = link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank'
+ = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
+
+ = render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled
+
+ .form-actions.col-sm-12
+ = f.submit _('Create group'), class: "btn btn-success"
+ = link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel'
diff --git a/app/views/groups/imports/show.html.haml b/app/views/groups/imports/show.html.haml
new file mode 100644
index 00000000000..ac8ca8797fe
--- /dev/null
+++ b/app/views/groups/imports/show.html.haml
@@ -0,0 +1,10 @@
+- page_title _('Import in progress')
+- @content_class = "limit-container-width" unless fluid_layout
+
+.save-group-loader
+ .center
+ %h2
+ %i.loading.spinner.spinner-sm
+ = page_title
+ %p
+ = s_('GroupImport|Please wait while we import the group for you. Refresh at will.')
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index b0ba846f204..7a35bc12eee 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -11,8 +11,8 @@
.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
+ = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do
+ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false
.clearfix
.error-alert
= render "shared/milestones/form_dates", f: f
diff --git a/app/views/groups/milestones/_milestone.html.haml b/app/views/groups/milestones/_milestone.html.haml
index bae8997e24c..b73626dab81 100644
--- a/app/views/groups/milestones/_milestone.html.haml
+++ b/app/views/groups/milestones/_milestone.html.haml
@@ -1,6 +1,4 @@
-
= render 'shared/milestones/milestone',
- milestone_path: group_milestone_route(milestone),
issues_path: issues_group_path(@group, milestone_title: milestone.title),
merge_requests_path: merge_requests_group_path(@group, milestone_title: milestone.title),
milestone: milestone
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index b6fb908c8f6..03407adb57d 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -16,5 +16,8 @@
.nothing-here-block No milestones to show
- else
- @milestones.each do |milestone|
- = render 'milestone', milestone: milestone
+ - if milestone.project_milestone?
+ = render 'projects/milestones/milestone', milestone: milestone
+ - else
+ = render 'milestone', milestone: milestone
= paginate @milestones, theme: "gitlab"
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 376624f4786..ed016206310 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -2,43 +2,44 @@
- @hide_top_links = true
- page_title _('New Group')
- header_title _("Groups"), dashboard_groups_path
+- active_tab = local_assigns.fetch(:active_tab, 'create')
-.page-title-holder.d-flex.align-items-center
- %h1.page-title= _('New group')
-.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %p
- - group_docs_path = help_page_path('user/group/index')
- - group_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_docs_path }
- = s_('%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.').html_safe % { group_docs_link_start: group_docs_link_start, group_docs_link_end: '</a>'.html_safe }
- %p
- - subgroup_docs_path = help_page_path('user/group/subgroups/index')
- - subgroup_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: subgroup_docs_path }
- = s_('Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}.').html_safe % { subgroup_docs_link_start: subgroup_docs_link_start, subgroup_docs_link_end: '</a>'.html_safe }
- %p
- = _('Projects that belong to a group are prefixed with the group namespace. Existing projects may be moved into a group.')
+.group-edit-container.prepend-top-default
+ .row
+ .col-lg-3.group-settings-sidebar
+ %h4.prepend-top-0
+ = _('New group')
+ %p
+ - group_docs_path = help_page_path('user/group/index')
+ - group_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_docs_path }
+ = s_('%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.').html_safe % { group_docs_link_start: group_docs_link_start, group_docs_link_end: '</a>'.html_safe }
+ %p
+ - subgroup_docs_path = help_page_path('user/group/subgroups/index')
+ - subgroup_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: subgroup_docs_path }
+ = s_('Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}.').html_safe % { subgroup_docs_link_start: subgroup_docs_link_start, subgroup_docs_link_end: '</a>'.html_safe }
+ %p
+ = _('Projects that belong to a group are prefixed with the group namespace. Existing projects may be moved into a group.')
- .col-lg-9
- = form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f|
- = form_errors(@group)
- = render 'shared/group_form', f: f, autofocus: true
+ .col-lg-9.js-toggle-container
+ %ul.nav.nav-tabs.nav-links.gitlab-tabs{ role: 'tablist' }
+ %li.nav-item{ role: 'presentation' }
+ %a.nav-link.active{ href: '#create-group-pane', id: 'create-group-tab', role: 'tab', data: { toggle: 'tab', track_label: 'create_group', track_event: 'click_tab', track_value: '' } }
+ %span.d-none.d-sm-block= s_('GroupsNew|Create group')
+ %span.d-block.d-sm-none= s_('GroupsNew|Create')
+ %li.nav-item{ role: 'presentation' }
+ %a.nav-link{ href: '#import-group-pane', id: 'import-group-tab', role: 'tab', data: { toggle: 'tab', track_label: 'import_group', track_event: 'click_tab', track_value: '' } }
+ %span.d-none.d-sm-block= s_('GroupsNew|Import group')
+ %span.d-block.d-sm-none= s_('GroupsNew|Import')
- .row
- .form-group.group-description-holder.col-sm-12
- = f.label :avatar, _("Group avatar"), class: 'label-bold'
- %div
- = render 'shared/choose_avatar_button', f: f
+ .tab-content.gitlab-tab-content
+ .tab-pane.js-toggle-container{ id: 'create-group-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
+ = form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f|
+ = render 'new_group_fields', f: f, group_name_id: 'create-group-name'
- .form-group.col-sm-12
- %label.label-bold
- = _('Visibility level')
- %p
- = _('Who will be able to see this group?')
- = link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank'
- = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
-
- = render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled
-
- .form-actions
- = f.submit _('Create group'), class: "btn btn-success"
- = link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel'
+ .tab-pane.js-toggle-container{ id: 'import-group-pane', class: active_when(active_tab) == 'import', role: 'tabpanel' }
+ - if import_sources_enabled?
+ = render 'import_group_pane', active_tab: active_tab, autofocus: true
+ - else
+ .nothing-here-block
+ %h4= s_('GroupsNew|No import options available')
+ %p= s_('GroupsNew|Contact an administrator to enable options for importing your group.')
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index 41cb073686a..2cac8e653e5 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -12,6 +12,6 @@
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
- "is_admin": current_user&.admin,
- is_group_page: true,
+ "is_admin": current_user&.admin.to_s,
+ is_group_page: "true",
character_error: @character_error.to_s } }
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index ef7bf562c69..94466b76ac8 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -7,7 +7,7 @@
%p= _('Export this group with all related data to a new GitLab instance. Once complete, you can import the data file from the "New Group" page.')
.bs-callout.bs-callout-info
- %p.append-bottom-0
+ %p.gl-mb-0
%p= _('The following items will be exported:')
%ul
- group_export_descriptions.each do |description|
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 73a0c8ff02b..742bf50fb89 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -12,7 +12,7 @@
= f.label :id, _('Group ID'), class: 'label-bold'
= f.text_field :id, class: 'form-control w-auto', readonly: true
- .row.prepend-top-8
+ .row.gl-mt-3
.form-group.col-md-9
= f.label :description, _('Group description (optional)'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 032766327ca..7e5bf6ddde1 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,10 +1,6 @@
- breadcrumb_title _("Details")
- @content_class = "limit-container-width" unless fluid_layout
-= content_for :flash_message do
- - if Feature.enabled?(:subscribable_banner_subscription)
- = render_if_exists "layouts/header/ee_subscribable_banner", subscription: true
-
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml
index 67e759a4d63..59061a048b3 100644
--- a/app/views/groups/sidebar/_packages.html.haml
+++ b/app/views/groups/sidebar/_packages.html.haml
@@ -1,16 +1,16 @@
- if group_container_registry_nav?
- = nav_link(path: group_packages_nav_link_paths) do
+ = nav_link(controller: 'groups/registry/repositories') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
= _('Packages & Registries')
%ul.sidebar-sub-level-items
- = nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(controller: 'groups/registry/repositories', html_options: { class: "fly-out-top-item" } ) do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%strong.fly-out-top-item-name
= _('Packages & Registries')
%li.divider.fly-out-top-item
- = nav_link(controller: 'groups/container_registries') do
+ = nav_link(controller: 'groups/registry/repositories') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%span= _('Container Registry')
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index b05c039c85c..9bf1f0c61bb 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -1,4 +1,6 @@
- provider = local_assigns.fetch(:provider)
+- extra_data = local_assigns.fetch(:extra_data, {})
+- filterable = local_assigns.fetch(:filterable, true)
- provider_title = Gitlab::ImportSources.title(provider)
#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title,
@@ -6,4 +8,5 @@
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]) } }
+ import_path: url_for([:import, provider, format: :json]),
+ filterable: filterable.to_s }.merge(extra_data) }
diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml
index 7399ff937ce..d405acef75c 100644
--- a/app/views/import/bitbucket/status.html.haml
+++ b/app/views/import/bitbucket/status.html.haml
@@ -5,90 +5,93 @@
%i.fa.fa-bitbucket
= _('Import projects from Bitbucket')
-- if @repos.any?
- %p.light
- = _('Select projects you want to import.')
- %p
- - if @incompatible_repos.any?
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all compatible projects')
- = icon('spinner spin', class: 'loading-icon')
- - else
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all projects')
- = icon('spinner spin', class: 'loading-icon')
+- if Feature.enabled?(:new_import_ui)
+ = render 'import/githubish_status', provider: 'bitbucket'
+- else
+ - if @repos.any?
+ %p.light
+ = _('Select projects you want to import.')
+ %p
+ - if @incompatible_repos.any?
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all compatible projects')
+ = icon('spinner spin', class: 'loading-icon')
+ - else
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all projects')
+ = icon('spinner spin', class: 'loading-icon')
-.position-relative.ms-no-clear.d-flex.flex-fill.float-right.append-bottom-10
- = form_tag status_import_bitbucket_path, method: 'get' do
- = text_field_tag :filter, @filter, class: 'form-control pr-5', placeholder: _('Filter projects'), size: 40, autofocus: true, 'aria-label': _('Search')
- .position-absolute.position-top-0.d-flex.align-items-center.text-muted.position-right-0.h-100
- .border-left
- %button{ class: 'btn btn-transparent btn-secondary', 'aria-label': _('Search Button'), type: 'submit' }
- %i{ class: 'fa fa-search', 'aria-hidden': true }
+ .position-relative.ms-no-clear.d-flex.flex-fill.float-right.append-bottom-10
+ = form_tag status_import_bitbucket_path, method: 'get' do
+ = text_field_tag :filter, @filter, class: 'form-control pr-5', placeholder: _('Filter projects'), size: 40, autofocus: true, 'aria-label': _('Search')
+ .position-absolute.position-top-0.d-flex.align-items-center.text-muted.position-right-0.h-100
+ .border-left
+ %button{ class: 'btn btn-transparent btn-secondary', 'aria-label': _('Search Button'), type: 'submit' }
+ %i{ class: 'fa fa-search', 'aria-hidden': true }
-.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 Bitbucket')
- %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
- = link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank', rel: 'noopener noreferrer'
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- - case project.import_status
- - when 'finished'
- %span
- %i.fa.fa-check
- = _('done')
- - when 'started'
- %i.fa.fa-spinner.fa-spin
- = _('started')
- - else
- = project.human_import_status_name
+ .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 Bitbucket')
+ %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
+ = link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank', rel: 'noopener noreferrer'
+ %td
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
+ %td.job-status
+ - case project.import_status
+ - when 'finished'
+ %span
+ %i.fa.fa-check
+ = _('done')
+ - when 'started'
+ %i.fa.fa-spinner.fa-spin
+ = _('started')
+ - else
+ = project.human_import_status_name
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo.owner}___#{repo.slug}" }
- %td
- = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer'
- %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, path: repo.owner) } : {}
- = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: '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
- %span.input-group-prepend
- .input-group-text /
- = text_field_tag :path, sanitize_project_name(repo.slug), 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
- = _('Import')
- = icon('spinner spin', class: 'loading-icon')
- - @incompatible_repos.each do |repo|
- %tr{ id: "repo_#{repo.owner}___#{repo.slug}" }
- %td
- = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer'
- %td.import-target
- %td.import-actions-job-status
- = label_tag _('Incompatible Project'), nil, class: 'label badge-danger'
+ - @repos.each do |repo|
+ %tr{ id: "repo_#{repo.owner}___#{repo.slug}" }
+ %td
+ = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer'
+ %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, path: repo.owner) } : {}
+ = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: '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
+ %span.input-group-prepend
+ .input-group-text /
+ = text_field_tag :path, sanitize_project_name(repo.slug), 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
+ = _('Import')
+ = icon('spinner spin', class: 'loading-icon')
+ - @incompatible_repos.each do |repo|
+ %tr{ id: "repo_#{repo.owner}___#{repo.slug}" }
+ %td
+ = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer'
+ %td.import-target
+ %td.import-actions-job-status
+ = label_tag _('Incompatible Project'), nil, class: 'label badge-danger'
-- if @incompatible_repos.any?
- %p
- = _("One or more of your Bitbucket projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.")
- - link_to_git = link_to(_('Git'), 'https://www.atlassian.com/git/tutorials/migrating-overview')
- - link_to_import_flow = link_to(_('import flow'), status_import_bitbucket_path)
- = _("Please convert them to %{link_to_git}, and go through the %{link_to_import_flow} again.").html_safe % { link_to_git: link_to_git, link_to_import_flow: link_to_import_flow }
+ - if @incompatible_repos.any?
+ %p
+ = _("One or more of your Bitbucket projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.")
+ - link_to_git = link_to(_('Git'), 'https://www.atlassian.com/git/tutorials/migrating-overview')
+ - link_to_import_flow = link_to(_('import flow'), status_import_bitbucket_path)
+ = _("Please convert them to %{link_to_git}, and go through the %{link_to_import_flow} again.").html_safe % { link_to_git: link_to_git, link_to_import_flow: link_to_import_flow }
-.js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_path}", import_path: "#{import_bitbucket_path}" } }
+ .js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_path}", import_path: "#{import_bitbucket_path}" } }
diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml
index ac86be8fa7a..2eac8d0c5a1 100644
--- a/app/views/import/bitbucket_server/new.html.haml
+++ b/app/views/import/bitbucket_server/new.html.haml
@@ -13,14 +13,14 @@
.form-group.row
= label_tag :bitbucket_server_url, 'Bitbucket Server URL', class: 'col-form-label col-md-2'
.col-md-4
- = text_field_tag :bitbucket_server_url, '', class: 'form-control append-right-8', placeholder: _('https://your-bitbucket-server'), size: 40
+ = text_field_tag :bitbucket_server_url, '', class: 'form-control gl-mr-3', placeholder: _('https://your-bitbucket-server'), size: 40
.form-group.row
= label_tag :bitbucket_server_url, 'Username', class: 'col-form-label col-md-2'
.col-md-4
- = text_field_tag :bitbucket_username, '', class: 'form-control append-right-8', placeholder: _('username'), size: 40
+ = text_field_tag :bitbucket_username, '', class: 'form-control gl-mr-3', placeholder: _('username'), size: 40
.form-group.row
= label_tag :personal_access_token, 'Password/Personal Access Token', class: 'col-form-label col-md-2'
.col-md-4
- = password_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40
+ = password_field_tag :personal_access_token, '', class: 'form-control gl-mr-3', placeholder: _('Personal Access Token'), size: 40
.form-actions
= submit_tag _('List your Bitbucket Server repositories'), class: 'btn btn-success'
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
index 1aaf5883bf4..7523b8f7b1c 100644
--- a/app/views/import/bitbucket_server/status.html.haml
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -5,91 +5,94 @@
%i.fa.fa-bitbucket-square
= _('Import projects from Bitbucket Server')
-- if @repos.any?
- %p.light
- = _('Select projects you want to import.')
- .btn-group
- - if @incompatible_repos.any?
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all compatible projects')
- = icon('spinner spin', class: 'loading-icon')
- - else
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all projects')
- = icon('spinner spin', class: 'loading-icon')
+- if Feature.enabled?(:new_import_ui)
+ = render 'import/githubish_status', provider: 'bitbucket_server', extra_data: { reconfigure_path: configure_import_bitbucket_server_path }
+- else
+ - if @repos.any?
+ %p.light
+ = _('Select projects you want to import.')
+ .btn-group
+ - if @incompatible_repos.any?
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all compatible projects')
+ = icon('spinner spin', class: 'loading-icon')
+ - else
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all projects')
+ = icon('spinner spin', class: 'loading-icon')
-.btn-group
- = link_to('Reconfigure', configure_import_bitbucket_server_path, class: 'btn btn-primary', method: :post)
+ .btn-group
+ = link_to('Reconfigure', configure_import_bitbucket_server_path, class: 'btn btn-primary', method: :post)
-.input-btn-group.float-right
- = form_tag status_import_bitbucket_server_path, :method => 'get' do
- = text_field_tag :filter, sanitize(params[:filter]), class: 'form-control append-bottom-10', placeholder: _('Filter your projects by name'), size: 40, autoFocus: true
+ .input-btn-group.float-right
+ = form_tag status_import_bitbucket_server_path, :method => 'get' do
+ = text_field_tag :filter, sanitize(params[:filter]), class: 'form-control append-bottom-10', placeholder: _('Filter your projects by name'), size: 40, autoFocus: true
-.table-responsive.prepend-top-10
- %table.table.import-jobs
- %colgroup.import-jobs-from-col
- %colgroup.import-jobs-to-col
- %colgroup.import-jobs-status-col
- %thead
- %tr
- %th= _('From Bitbucket Server')
- %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
- = link_to project.import_source, project.import_source, target: '_blank', rel: 'noopener noreferrer'
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- - case project.import_status
- - when 'finished'
- = icon('check', text: 'Done')
- - when 'started'
- = icon('spin', text: 'started')
- - else
- = project.human_import_status_name
+ .table-responsive.prepend-top-10
+ %table.table.import-jobs
+ %colgroup.import-jobs-from-col
+ %colgroup.import-jobs-to-col
+ %colgroup.import-jobs-status-col
+ %thead
+ %tr
+ %th= _('From Bitbucket Server')
+ %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
+ = link_to project.import_source, project.import_source, target: '_blank', rel: 'noopener noreferrer'
+ %td
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
+ %td.job-status
+ - case project.import_status
+ - when 'finished'
+ = icon('check', text: 'Done')
+ - when 'started'
+ = icon('spin', text: 'started')
+ - else
+ = project.human_import_status_name
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo.project_key}___#{repo.slug}", data: { project: repo.project_key, repository: repo.slug } }
- %td
- = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'
- %td.import-target
- %fieldset.row
- .input-group
- .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: 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: '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
- %span.input-group-prepend
- .input-group-text /
- = text_field_tag :path, sanitize_project_name(repo.slug), class: "input-mini form-control", tabindex: 2, required: true
- %td.import-actions.job-status
- = button_tag class: 'btn btn-import js-add-to-import' do
- Import
- = icon('spinner spin', class: 'loading-icon')
- - @incompatible_repos.each do |repo|
- %tr{ id: "repo_#{repo.project_key}___#{repo.slug}" }
- %td
- = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'
- %td.import-target
- %td.import-actions-job-status
- = label_tag 'Incompatible Project', nil, class: 'label badge-danger'
+ - @repos.each do |repo|
+ %tr{ id: "repo_#{repo.project_key}___#{repo.slug}", data: { project: repo.project_key, repository: repo.slug } }
+ %td
+ = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'
+ %td.import-target
+ %fieldset.row
+ .input-group
+ .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: 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: '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
+ %span.input-group-prepend
+ .input-group-text /
+ = text_field_tag :path, sanitize_project_name(repo.slug), class: "input-mini form-control", tabindex: 2, required: true
+ %td.import-actions.job-status
+ = button_tag class: 'btn btn-import js-add-to-import' do
+ Import
+ = icon('spinner spin', class: 'loading-icon')
+ - @incompatible_repos.each do |repo|
+ %tr{ id: "repo_#{repo.project_key}___#{repo.slug}" }
+ %td
+ = link_to repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'
+ %td.import-target
+ %td.import-actions-job-status
+ = label_tag 'Incompatible Project', nil, class: 'label badge-danger'
-- if @incompatible_repos.any?
- %p
- One or more of your Bitbucket Server projects cannot be imported into GitLab
- directly because they use Subversion or Mercurial for version control,
- rather than Git. Please convert
- = link_to 'them to Git,', 'https://www.atlassian.com/git/tutorials/migrating-overview'
- and go through the
- = link_to 'import flow', status_import_bitbucket_server_path
- again.
+ - if @incompatible_repos.any?
+ %p
+ One or more of your Bitbucket Server projects cannot be imported into GitLab
+ directly because they use Subversion or Mercurial for version control,
+ rather than Git. Please convert
+ = link_to 'them to Git,', 'https://www.atlassian.com/git/tutorials/migrating-overview'
+ and go through the
+ = link_to 'import flow', status_import_bitbucket_server_path
+ again.
-= paginate_without_count(@collection)
+ = paginate_without_count(@collection)
-.js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_server_path}", import_path: "#{import_bitbucket_server_path}" } }
+ .js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_server_path}", import_path: "#{import_bitbucket_server_path}" } }
diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml
index eca67582d6f..75529487aa4 100644
--- a/app/views/import/fogbugz/status.html.haml
+++ b/app/views/import/fogbugz/status.html.haml
@@ -4,56 +4,63 @@
%i.fa.fa-bug
= _('Import projects from FogBugz')
-- if @repos.any?
- %p.light
- = _('Select projects you want to import.')
+- if Feature.enabled?(:new_import_ui)
%p.light
- link_to_customize = link_to('customize', new_user_map_import_fogbugz_path)
= _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize }
%hr
- %p
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all projects')
- = icon("spinner spin", class: "loading-icon")
+ = render 'import/githubish_status', provider: 'fogbugz', filterable: false
+- else
+ - if @repos.any?
+ %p.light
+ = _('Select projects you want to import.')
+ %p.light
+ - link_to_customize = link_to('customize', new_user_map_import_fogbugz_path)
+ = _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize }
+ %hr
+ %p
+ = button_tag class: 'btn btn-import btn-success js-import-all' do
+ = _('Import all projects')
+ = 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 FogBugz")
- %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
- = project.import_source
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- - case project.import_status
- - when 'finished'
- %span
- %i.fa.fa-check
- = _("done")
- - when 'started'
- %i.fa.fa-spinner.fa-spin
- = _("started")
- - else
- = project.human_import_status_name
+ .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 FogBugz")
+ %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
+ = project.import_source
+ %td
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
+ %td.job-status
+ - case project.import_status
+ - when 'finished'
+ %span
+ %i.fa.fa-check
+ = _("done")
+ - when 'started'
+ %i.fa.fa-spinner.fa-spin
+ = _("started")
+ - else
+ = project.human_import_status_name
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo.id}" }
- %td
- = repo.name
- %td.import-target
- #{current_user.username}/#{repo.name}
- %td.import-actions.job-status
- = button_tag class: "btn btn-import js-add-to-import" do
- = _("Import")
- = icon("spinner spin", class: "loading-icon")
+ - @repos.each do |repo|
+ %tr{ id: "repo_#{repo.id}" }
+ %td
+ = repo.name
+ %td.import-target
+ #{current_user.username}/#{repo.name}
+ %td.import-actions.job-status
+ = button_tag class: "btn btn-import js-add-to-import" do
+ = _("Import")
+ = icon("spinner spin", class: "loading-icon")
-.js-importer-status{ data: { jobs_import_path: "#{jobs_import_fogbugz_path}", import_path: "#{import_fogbugz_path}" } }
+ .js-importer-status{ data: { jobs_import_path: "#{jobs_import_fogbugz_path}", import_path: "#{import_fogbugz_path}" } }
diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml
index a5fa12fe7df..a12b69ae5f9 100644
--- a/app/views/import/gitlab/status.html.haml
+++ b/app/views/import/gitlab/status.html.haml
@@ -4,52 +4,55 @@
%i.fa.fa-heart
= _('Import projects from GitLab.com')
-%p.light
- = _('Select projects you want to import.')
-%hr
-%p
- = button_tag class: "btn btn-import btn-success js-import-all" do
- = _('Import all projects')
- = icon("spinner spin", class: "loading-icon")
+- if Feature.enabled?(:new_import_ui)
+ = render 'import/githubish_status', provider: 'gitlab', filterable: false
+- else
+ %p.light
+ = _('Select projects you want to import.')
+ %hr
+ %p
+ = button_tag class: "btn btn-import btn-success js-import-all" do
+ = _('Import all projects')
+ = 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 GitLab.com')
- %th= _('To this GitLab instance')
- %th= _('Status')
- %tbody
- - @already_added_projects.each do |project|
- %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
- %td
- = link_to project.import_source, "https://gitlab.com/#{project.import_source}", target: "_blank"
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- - case project.import_status
- - when 'finished'
- %span
- %i.fa.fa-check
- = _('done')
- - when 'started'
- %i.fa.fa-spinner.fa-spin
- = _('started')
- - else
- = project.human_import_status_name
+ .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 GitLab.com')
+ %th= _('To this GitLab instance')
+ %th= _('Status')
+ %tbody
+ - @already_added_projects.each do |project|
+ %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
+ %td
+ = link_to project.import_source, "https://gitlab.com/#{project.import_source}", target: "_blank"
+ %td
+ = link_to project.full_path, [project.namespace.becomes(Namespace), project]
+ %td.job-status
+ - case project.import_status
+ - when 'finished'
+ %span
+ %i.fa.fa-check
+ = _('done')
+ - when 'started'
+ %i.fa.fa-spinner.fa-spin
+ = _('started')
+ - else
+ = project.human_import_status_name
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo["id"]}" }
- %td
- = link_to repo["path_with_namespace"], "https://gitlab.com/#{repo["path_with_namespace"]}", target: "_blank", rel: 'noopener noreferrer'
- %td.import-target
- = import_project_target(repo['namespace']['path'], repo['name'])
- %td.import-actions.job-status
- = button_tag class: "btn btn-import js-add-to-import" do
- = _('Import')
- = icon("spinner spin", class: "loading-icon")
+ - @repos.each do |repo|
+ %tr{ id: "repo_#{repo["id"]}" }
+ %td
+ = link_to repo["path_with_namespace"], "https://gitlab.com/#{repo["path_with_namespace"]}", target: "_blank", rel: 'noopener noreferrer'
+ %td.import-target
+ = import_project_target(repo['namespace']['path'], repo['name'])
+ %td.import-actions.job-status
+ = button_tag class: "btn btn-import js-add-to-import" do
+ = _('Import')
+ = icon("spinner spin", class: "loading-icon")
-.js-importer-status{ data: { jobs_import_path: "#{jobs_import_gitlab_path}", import_path: "#{import_gitlab_path}" } }
+ .js-importer-status{ data: { jobs_import_path: "#{jobs_import_gitlab_path}", import_path: "#{import_gitlab_path}" } }
diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml
index 811e126579e..3dfc7c37d98 100644
--- a/app/views/import/phabricator/new.html.haml
+++ b/app/views/import/phabricator/new.html.haml
@@ -11,15 +11,15 @@
= form_tag import_phabricator_path, class: 'new_project', method: :post do
= render 'import/shared/new_project_form'
- %h4.prepend-top-0= _('Enter in your Phabricator Server URL and personal access token below')
+ %h4.gl-mt-0= _('Enter in your Phabricator Server URL and personal access token below')
.form-group.row
= label_tag :phabricator_server_url, _('Phabricator Server URL'), class: 'col-form-label col-md-2'
.col-md-4
- = text_field_tag :phabricator_server_url, params[:phabricator_server_url], class: 'form-control append-right-8', placeholder: 'https://your-phabricator-server', size: 40
+ = text_field_tag :phabricator_server_url, params[:phabricator_server_url], class: 'form-control gl-mr-3', placeholder: 'https://your-phabricator-server', size: 40
.form-group.row
= label_tag :api_token, _('API Token'), class: 'col-form-label col-md-2'
.col-md-4
- = password_field_tag :api_token, params[:api_token], class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40
+ = password_field_tag :api_token, params[:api_token], class: 'form-control gl-mr-3', placeholder: _('Personal Access Token'), size: 40
.form-actions
= submit_tag _('Import tasks'), class: 'btn btn-success'
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 99c4fc0d1b6..886d4109ff5 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -18,7 +18,10 @@
- if ActionController::Base.asset_host
%link{ rel: 'dns-prefetch', href: ActionController::Base.asset_host }
- %link{ rel: 'preconnnect', href: ActionController::Base.asset_host, crossorigin: '' }
+ %link{ rel: 'preconnect', href: ActionController::Base.asset_host, crossorigin: '' }
+
+ - if Gitlab::CurrentSettings.snowplow_enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname
+ %link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' }
%meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' }
@@ -44,7 +47,10 @@
= favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png'
- = stylesheet_link_tag "application", media: "all"
+ - if user_application_theme == 'gl-dark'
+ = stylesheet_link_tag "application_dark", media: "all"
+ - else
+ = stylesheet_link_tag "application", media: "all"
= stylesheet_link_tag "print", media: "print"
= stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations']
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 3885fa311ba..d1cf83b2a9f 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -6,14 +6,14 @@
.alert-wrapper
= render 'shared/outdated_browser'
= render_if_exists 'layouts/header/users_over_license_banner'
- - if Feature.enabled?(:subscribable_banner_license, default_enabled: true)
- = render_if_exists "layouts/header/ee_subscribable_banner"
+ = render_if_exists "layouts/header/licensed_user_count_threshold"
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
= render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/ping_consent"
= render_account_recovery_regular_check
+ = render_if_exists "layouts/header/ee_subscribable_banner"
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
.d-flex
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index ba5cd0fdd41..97d00bce11b 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -1,12 +1,8 @@
-- if @group && @group.persisted? && @group.path
- - group_data_attrs = { group_path: j(@group.path), name: j(@group.name), issues_path: issues_group_path(@group), mr_path: merge_requests_group_path(@group) }
-- 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{ data: { track_label: "navbar_search", track_event: "activate_form_input", track_value: "" } }
= form_tag search_path, method: :get, class: 'form-inline' do |f|
.search-input-container
.search-input-wrap
- .dropdown{ data: { url: search_autocomplete_path } }
+ .dropdown
= search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false,
@@ -27,27 +23,17 @@
= sprite_icon('search', size: 16, css_class: 'search-icon')
= sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input')
- = hidden_field_tag :group_id, @group.try(:id), class: 'js-search-group-options', data: group_data_attrs
+ = hidden_field_tag :group_id, search_context.for_group? ? search_context.group.id : '', class: 'js-search-group-options', data: search_context.group_metadata
+ = hidden_field_tag :project_id, search_context.for_project? ? search_context.project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: search_context.project_metadata
- = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: project_data_attrs
+ - if search_context.for_project?
+ = hidden_field_tag :scope, search_context.scope
+ = hidden_field_tag :search_code, search_context.code_search?
- - if @project && @project.persisted?
- - if current_controller?(:issues)
- = hidden_field_tag :scope, 'issues'
- - elsif current_controller?(:merge_requests)
- = hidden_field_tag :scope, 'merge_requests'
- - elsif current_controller?(:wikis)
- = hidden_field_tag :scope, 'wiki_blobs'
- - elsif current_controller?(:commits)
- = hidden_field_tag :scope, 'commits'
- - else
- = hidden_field_tag :search_code, true
-
- - if @snippet || @snippets
- = hidden_field_tag :snippets, true
- = hidden_field_tag :repository_ref, @ref
+ = hidden_field_tag :snippets, search_context.for_snippets?
+ = hidden_field_tag :repository_ref, search_context.ref
= hidden_field_tag :nav_source, 'navbar'
+
-# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb
- if ENV['RAILS_ENV'] == 'test'
%noscript= button_tag 'Search'
- .search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path, :'data-autocomplete-project-id' => @project.try(:id), :'data-autocomplete-project-ref' => @ref }
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 49de821f1c2..36b664e5888 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -2,6 +2,7 @@
- page_description @group.description unless page_description
- header_title group_title(@group) unless header_title
- nav "group"
+- display_subscription_banner!
- @left_sidebar = true
- content_for :page_specific_javascripts do
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 7d9924719a2..d568086f4a4 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -6,7 +6,7 @@
= current_user.name
= current_user.to_reference
- if current_user.status
- .user-status.d-flex.align-items-center.prepend-top-2.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } }
+ .user-status.d-flex.align-items-center.gl-mt-2.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } }
%span.user-status-emoji.d-flex.align-items-center
= emoji_icon current_user.status.emoji
%span.user-status-message.str-truncated
@@ -26,7 +26,7 @@
- if current_user_menu?(:settings)
%li
= link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' }
- = render_if_exists 'layouts/header/buy_ci_minutes', project: @project, namespace: @group
+ = render_if_exists 'layouts/header/buy_pipeline_minutes', project: @project, namespace: @group
= render_if_exists 'layouts/header/upgrade'
- if current_user_menu?(:help)
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index f6255dac7cf..b4e25956f16 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,9 +1,3 @@
-- if project
- - search_path_url = search_path(project_id: project.id)
-- elsif group
- - search_path_url = search_path(group_id: group.id)
-- else
- - search_path_url = search_path
- has_impersonation_link = header_link?(:admin_impersonation)
%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { qa_selector: 'navbar' } }
@@ -12,11 +6,12 @@
.header-content
.title-container
%h1.title
+ %span.gl-sr-only GitLab
= link_to root_path, title: _('Dashboard'), id: 'logo' do
= brand_header_logo
- logo_text = brand_header_logo_type
- if logo_text.present?
- %span.logo-text.d-none.d-lg-block.prepend-left-8
+ %span.logo-text.d-none.d-lg-block.gl-ml-3
= logo_text
- if Gitlab.com_and_canary?
= link_to 'https://next.gitlab.com', class: 'label-link canary-badge bg-transparent', target: :_blank do
@@ -36,7 +31,7 @@
%li.nav-item.d-none.d-lg-block.m-auto
= render 'layouts/search' unless current_controller?(:search)
%li.nav-item.d-inline-block.d-lg-none
- = link_to search_path_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to search_context.search_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('search', size: 16)
- if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
@@ -60,6 +55,8 @@
= todos_count_format(todos_pending_count)
%li.nav-item.header-help.dropdown.d-none.d-md-block
= link_to help_path, class: 'header-help-dropdown-toggle', data: { toggle: "dropdown" } do
+ %span.gl-sr-only
+ = s_('Nav|Help')
= sprite_icon('question', size: 16)
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
@@ -67,7 +64,7 @@
- if header_link?(:user_dropdown)
%li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) }
= 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"
+ = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name
= render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
diff --git a/app/views/layouts/header/_logo_with_title.html.haml b/app/views/layouts/header/_logo_with_title.html.haml
index 1ea6168fc9a..9b5a47306d2 100644
--- a/app/views/layouts/header/_logo_with_title.html.haml
+++ b/app/views/layouts/header/_logo_with_title.html.haml
@@ -1,4 +1,4 @@
%header.navbar.fixed-top.navbar-gitlab.justify-content-center
= render 'shared/logo.svg'
- %span.logo-text.d-none.d-lg-block.prepend-left-8.pt-1
+ %span.logo-text.d-none.d-lg-block.gl-ml-3.pt-1
= render 'shared/logo_type.svg'
diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml
index ad0d51d28f9..8ea75087fed 100644
--- a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml
+++ b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml
@@ -3,7 +3,7 @@
- if defined?(@breadcrumb_dropdown_links) && @breadcrumb_dropdown_links.key?(dropdown_location)
%li.dropdown
%button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { toggle: "dropdown", container: "body" }, "aria-label": button_tooltip, title: button_tooltip }
- = icon("ellipsis-h")
+ = sprite_icon("ellipsis_h", size: 12)
= sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle")
.dropdown-menu
%ul
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 92b6174795b..cd9765289a4 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -55,7 +55,7 @@
%span.badge.badge-pill.count= number_with_delimiter(issues_count)
%ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} }
- = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index'], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index', 'iterations#index'], html_options: { class: "fly-out-top-item" } ) do
= link_to issues_group_path(@group) do
%strong.fly-out-top-item-name
= _('Issues')
@@ -85,6 +85,8 @@
%span
= _('Milestones')
+ = render_if_exists 'layouts/nav/sidebar/iterations_link'
+
- if group_sidebar_link?(:merge_requests)
= nav_link(path: 'groups#merge_requests') do
= link_to merge_requests_group_path(@group) do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index a67860e8e2e..16902ebe1d4 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -216,7 +216,7 @@
= _('Operations')
%li.divider.fly-out-top-item
- - if project_nav_tab? :environments
+ - if project_nav_tab? :metrics_dashboards
= nav_link(controller: :environments, action: [:metrics, :metrics_redirect]) do
= link_to metrics_project_environments_path(@project), title: _('Metrics'), class: 'shortcuts-metrics', data: { qa_selector: 'operations_metrics_link' } do
%span
@@ -290,7 +290,7 @@
= render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user)
- if project_nav_tab? :wiki
- - wiki_url = project_wiki_path(@project, :home)
+ - wiki_url = wiki_path(@project.wiki)
= nav_link(controller: :wikis) do
= link_to wiki_url, class: 'shortcuts-wiki', data: { qa_selector: 'wiki_link' } do
.nav-icon-container
@@ -319,7 +319,7 @@
- if project_nav_tab? :snippets
= nav_link(controller: :snippets) do
- = link_to project_snippets_path(@project), class: 'shortcuts-snippets' do
+ = link_to project_snippets_path(@project), class: 'shortcuts-snippets', data: { qa_selector: 'snippets_link' } do
.nav-icon-container
= sprite_icon('snippet')
%span.nav-item-name
@@ -330,6 +330,18 @@
%strong.fly-out-top-item-name
= _('Snippets')
+ = nav_link(controller: :project_members) do
+ = link_to project_project_members_path(@project), title: _('Members'), class: 'qa-members-link', id: 'js-onboarding-members-link' do
+ .nav-icon-container
+ = sprite_icon('users')
+ %span.nav-item-name
+ = _('Members')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_project_members_path(@project) do
+ %strong.fly-out-top-item-name
+ = _('Members')
+
- if project_nav_tab? :settings
= nav_link(path: sidebar_settings_paths) do
= link_to edit_project_path(@project), class: 'shortcuts-tree' do
@@ -350,10 +362,6 @@
= link_to edit_project_path(@project), title: _('General'), class: 'qa-general-settings-link' do
%span
= _('General')
- = nav_link(controller: :project_members) do
- = link_to project_project_members_path(@project), title: _('Members'), class: 'qa-link-members-settings', id: 'js-onboarding-settings-members-link' do
- %span
- = _('Members')
- if can_edit
= nav_link(controller: [:integrations, :services]) do
= link_to project_settings_integrations_path(@project), title: _('Integrations'), data: { qa_selector: 'integrations_settings_link' } do
@@ -389,19 +397,6 @@
= render_if_exists 'projects/sidebar/settings_audit_events'
- - else
- = nav_link(controller: :project_members) do
- = link_to project_settings_members_path(@project), title: _('Members'), class: 'shortcuts-tree' do
- .nav-icon-container
- = sprite_icon('users')
- %span.nav-item-name
- = _('Members')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do
- = link_to project_project_members_path(@project) do
- %strong.fly-out-top-item-name
- = _('Members')
-
= render 'shared/sidebar_toggle_button'
-# Shortcut to Project > Activity
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index b8ef38272fc..820cb9eea47 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -2,7 +2,8 @@
- page_description @project.description unless page_description
- header_title project_title(@project) unless header_title
- nav "project"
-- @left_sidebar = true
+- display_subscription_banner!
+- @left_sidebar = true
- content_for :project_javascripts do
- project = @target_project || @project
diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml
index 1f7cf486b2c..e39cb5ee0a2 100644
--- a/app/views/layouts/terms.html.haml
+++ b/app/views/layouts/terms.html.haml
@@ -5,7 +5,7 @@
%body{ data: { page: body_data_page } }
.layout-page.terms{ class: page_class }
- .content-wrapper.prepend-top-0
+ .content-wrapper.gl-mt-0
.mobile-overlay
.alert-wrapper
= render "layouts/broadcast"
@@ -19,7 +19,7 @@
= brand_header_logo
- logo_text = brand_header_logo_type
- if logo_text.present?
- %span.logo-text.prepend-left-8
+ %span.logo-text.gl-ml-3
= logo_text
- if header_link?(:user_dropdown)
.navbar-collapse
diff --git a/app/views/notify/merged_merge_request_email.html.haml b/app/views/notify/merged_merge_request_email.html.haml
index 0fe54e73313..341aa6f8103 100644
--- a/app/views/notify/merged_merge_request_email.html.haml
+++ b/app/views/notify/merged_merge_request_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{@merge_request.to_reference} was merged
+ Merge Request #{link_to @merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)} was merged
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index d623e701a30..74e6f86f603 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was merged
-Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index 78afb42c9cf..52e110a98f6 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -1,5 +1,5 @@
%p.details
- #{link_to @issue.author_name, user_url(@issue.author)} created an issue:
+ #{link_to @issue.author_name, user_url(@issue.author)} created an issue #{link_to @issue.to_reference(full: false), issue_url(@issue)}:
- if @issue.assignees.any?
%p
diff --git a/app/views/notify/new_review_email.html.haml b/app/views/notify/new_review_email.html.haml
new file mode 100644
index 00000000000..ad870473681
--- /dev/null
+++ b/app/views/notify/new_review_email.html.haml
@@ -0,0 +1,16 @@
+%table{ border: "0", cellpadding:"0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" }
+ %tbody
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;overflow:hidden;" }
+ %table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" }
+ %tbody
+ %tr
+ %td{ style: "color:#333333;border-bottom:1px solid #ededed;font-size:15px;font-weight:bold;line-height:1.4;padding: 20px 0;" }
+ - mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request))
+ - mr_author_link = link_to(@author_name, user_url(@author))
+ = _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link }
+ %tr
+ %td{ style: "overflow:hidden;font-size:14px;line-height:1.4;display:grid;" }
+ - @notes.each do |note|
+ - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}")
+ = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed;"
diff --git a/app/views/notify/new_review_email.text.erb b/app/views/notify/new_review_email.text.erb
new file mode 100644
index 00000000000..164735abad0
--- /dev/null
+++ b/app/views/notify/new_review_email.text.erb
@@ -0,0 +1,13 @@
+<% mr_url = merge_request_url(@merge_request) %>
+<% mr_author_name = sanitize_name(@author_name) %>
+<%= _('Merge request %{mr_link} was reviewed by %{mr_author}') % { mr_link: mr_url, mr_author: mr_author_name } %>
+
+--
+<% @notes.each_with_index do |note, index| %>
+ <% target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") %>
+ <%= render 'note_email', note: note, diff_limit: 3, target_url: target_url %>
+
+ <% if index != @notes.length-1 %>
+--
+ <% end %>
+<% end %>
diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml
index a4123fada1b..914242da5c6 100644
--- a/app/views/notify/unknown_sign_in_email.html.haml
+++ b/app/views/notify/unknown_sign_in_email.html.haml
@@ -1,14 +1,54 @@
-%p
- = _('Hi %{username}!') % { username: sanitize_name(@user.name) }
-%p
- = _('A sign-in to your account has been made from the following IP address: %{ip}.') % { ip: @ip }
-%p
- - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' }
- = _('If you recently signed in and recognize the IP address, you may disregard this email.')
- = _('If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}.').html_safe % { password_link_start: password_link_start, password_link_end: '</a>'.html_safe }
- = _('Passwords should be unique and not used for any other sites or services.')
+- default_font = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;"
+- default_style = "#{default_font}font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;"
+- spacer_style = "#{default_font};height:18px;font-size:18px;line-height:18px;"
-- unless @user.two_factor_enabled?
- %p
- - mfa_link_start = '<a href="https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html" target="_blank">'.html_safe
- = _('To further protect your account, consider configuring a %{mfa_link_start}two-factor authentication%{mfa_link_end} method.').html_safe % { mfa_link_start: mfa_link_start, mfa_link_end: '</a>'.html_safe }
+%tr.alert
+ %td{ style: "#{default_font}padding:10px;border-radius:3px;font-size:14px;line-height:1.3;text-align:center;overflow:hidden;color:#ffffff;background-color:#FC6D26;" }
+ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" }
+ %tbody
+ %tr
+ %td{ style: "#{default_font}vertical-align:middle;color:#ffffff;text-align:center;" }
+ %span
+ = _("Your %{host} account was signed in to from a new location") % { host: Gitlab.config.gitlab.host }
+%tr.spacer
+ %td{ style: spacer_style }
+ &nbsp;
+%tr.section
+ %td{ style: "#{default_font};padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" }
+ %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" }
+ %tbody
+ %tr
+ %td{ style: default_style }
+ = _('Hostname')
+ %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;" }
+ = Gitlab.config.gitlab.host
+ %tr
+ %td{ style: "#{default_style}border-top:1px solid #ededed;" }
+ = _('IP Address')
+ %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
+ %span.muted{ style: "color:#333333;text-decoration:none;" }
+ = @ip
+ %tr
+ %td{ style: "#{default_style}border-top:1px solid #ededed;" }
+ = _('Time')
+ %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
+ = @time.strftime('%Y-%m-%d %l:%M:%S %p %Z')
+%tr.spacer
+ %td{ style: spacer_style }
+ &nbsp;
+%tr.section
+ %td{ style: "#{default_font};line-height:1.4;text-align:center;padding:0 15px;overflow:hidden;" }
+ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;width:100%;" }
+ %tbody
+ %tr{ style: 'width:100%;' }
+ %td{ style: "#{default_style}text-align:center;" }
+ - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' }
+ = _('If you recently signed in and recognize the IP address, you may disregard this email.')
+ %p
+ = _('If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}.').html_safe % { password_link_start: password_link_start, password_link_end: '</a>'.html_safe }
+ = _('Passwords should be unique and not used for any other sites or services.')
+
+ - unless @user.two_factor_enabled?
+ %p
+ - mfa_link_start = '<a href="https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html" target="_blank">'.html_safe
+ = _('To further protect your account, consider configuring a %{mfa_link_start}two-factor authentication%{mfa_link_end} method.').html_safe % { mfa_link_start: mfa_link_start, mfa_link_end: '</a>'.html_safe }
diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml
index 977ff30d5a6..c65c4fd0d81 100644
--- a/app/views/profiles/_event_table.html.haml
+++ b/app/views/profiles/_event_table.html.haml
@@ -1,4 +1,4 @@
-%h5.prepend-top-0
+%h5.gl-mt-0
= _('History of authentications')
%ul.content-list
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index e6380817c8f..f4a97206a19 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -7,7 +7,7 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('Profiles|Two-Factor Authentication')
%p
= s_("Profiles|Increase your account's security by enabling Two-Factor Authentication (2FA)")
@@ -24,7 +24,7 @@
- if display_providers_on_profile?
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('Profiles|Social sign-in')
%p
= s_('Profiles|Activate signin with one of the following services')
@@ -34,7 +34,7 @@
- if current_user.can_change_username?
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0.warning-title
+ %h4.gl-mt-0.warning-title
= s_('Profiles|Change username')
%p
= s_('Profiles|Changing your username can have unintended side effects.')
@@ -47,7 +47,7 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0.danger-title
+ %h4.gl-mt-0.danger-title
= s_('Profiles|Delete account')
.col-lg-8
- if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user)
diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml
index d651319fc3f..6d01d055f0c 100644
--- a/app/views/profiles/active_sessions/index.html.haml
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -3,7 +3,7 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.')
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index 275c0428d34..02aadcc5c8b 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -3,7 +3,7 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('This is a security log of important events involving your account.')
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 0c8098a97d5..05870e0e221 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -3,7 +3,7 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('You can see your chat accounts.')
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index e28c74dd650..e90bda0e187 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -3,12 +3,12 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('Control emails linked to your account')
.col-lg-8
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _('Add email address')
= form_for 'email', url: profile_emails_path do |f|
.form-group
@@ -17,7 +17,7 @@
.prepend-top-default
= f.submit _('Add email address'), class: 'btn btn-success', data: { qa_selector: 'add_email_address_button' }
%hr
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _('Linked emails (%{email_count})') % { email_count: @emails.load.size + 1 }
.account-well.append-bottom-default
%ul
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index f9f898a9225..31610e7505b 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -3,12 +3,12 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('GPG keys allow you to verify signed commits.')
.col-lg-8
- %h5.prepend-top-0
+ %h5.gl-mt-0
= _('Add a GPG key')
%p.profile-settings-content
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') }
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index da6aa0fce3a..788c67b3704 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -3,12 +3,12 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('SSH keys allow you to establish a secure connection between your computer and GitLab.')
.col-lg-8
- %h5.prepend-top-0
+ %h5.gl-mt-0
= _('Add an SSH key')
%p.profile-settings-content
- generate_link_url = help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair')
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 73f6a821b51..498f80aed2b 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -11,14 +11,14 @@
= hidden_field_tag :notification_type, 'global'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('You can specify notification level per group or per project.')
%p
= _('By default, all projects and groups will use the global notifications setting.')
.col-lg-8
- %h5.prepend-top-0
+ %h5.gl-mt-0
= _('Global notification settings')
= form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f|
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index af6fa6b1b61..9deaf7f84be 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -4,12 +4,12 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('After a successful password update, you will be redirected to the login page where you can log in with your new password.')
.col-lg-8
- %h5.prepend-top-0
+ %h5.gl-mt-0
- if @user.password_automatically_set
= _('Change your password')
- else
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 81b22d964a5..769502e0026 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -6,7 +6,7 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= s_('AccessTokens|You can generate a personal access token for each application you use that needs access to the GitLab API.')
@@ -35,7 +35,7 @@
%hr
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('AccessTokens|Feed token')
%p
= s_('AccessTokens|Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.')
@@ -53,7 +53,7 @@
%hr
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('AccessTokens|Incoming email token')
%p
= s_('AccessTokens|Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses.')
@@ -71,7 +71,7 @@
%hr
.row.prepend-top-default
.col-lg-4
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('AccessTokens|Static object token')
%p
= s_('AccessTokens|Your static object token is used to authenticate you when repository static objects (e.g. archives, blobs, ...) are being served from an external storage.')
diff --git a/app/views/profiles/preferences/_sourcegraph.html.haml b/app/views/profiles/preferences/_sourcegraph.html.haml
index 20a904694ca..7328d36b0fb 100644
--- a/app/views/profiles/preferences/_sourcegraph.html.haml
+++ b/app/views/profiles/preferences/_sourcegraph.html.haml
@@ -5,7 +5,7 @@
%hr
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('Preferences|Integrations')
%p
= s_('Preferences|Customize integrations with third party services.')
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 12d42ce9892..cc44d137848 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -3,7 +3,7 @@
= 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
+ %h4.gl-mt-0
= s_('Preferences|Navigation theme')
%p
= s_('Preferences|Customize the appearance of the application header and navigation sidebar.')
@@ -18,7 +18,7 @@
%hr
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('Preferences|Syntax highlighting theme')
%p
= s_('Preferences|This setting allows you to customize the appearance of the syntax.')
@@ -35,7 +35,7 @@
%hr
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('Preferences|Behavior')
%p
= s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.')
@@ -83,7 +83,7 @@
%hr
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _('Localization')
%p
= _('Customize language and region related settings.')
@@ -104,7 +104,7 @@
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0= s_('Preferences|Time preferences')
+ %h4.gl-mt-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
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 43fc9150e99..78fdcdef3c4 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -7,7 +7,7 @@
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_("Profiles|Public Avatar")
%p
- if @user.avatar?
@@ -27,7 +27,7 @@
.clearfix.avatar-image.append-bottom-default
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160'
- %h5.prepend-top-0= s_("Profiles|Upload new avatar")
+ %h5.gl-mt-0= s_("Profiles|Upload new avatar")
.prepend-top-5.append-bottom-10
%button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
%span.avatar-file-name.prepend-left-default.js-avatar-filename= s_("Profiles|No file chosen")
@@ -40,7 +40,7 @@
%hr
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0= s_("Profiles|Current status")
+ %h4.gl-mt-0= s_("Profiles|Current status")
%p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
.col-lg-8
= f.fields_for :status, @user.status do |status_form|
@@ -71,7 +71,7 @@
%hr
.row.user-time-preferences
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0= s_("Profiles|Time settings")
+ %h4.gl-mt-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
@@ -83,7 +83,7 @@
%hr
.row
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_("Profiles|Main settings")
%p
= s_("Profiles|This information will appear on your profile")
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 4a2d0a4f8ce..7e566361848 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -5,7 +5,7 @@
.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
.row.prepend-top-default
.col-lg-4
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _('Register Two-Factor Authenticator')
%p
= _('Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA).')
@@ -33,13 +33,13 @@
= raw @qr_code
.col-md-8
.account-well
- %p.prepend-top-0.append-bottom-0
+ %p.gl-mt-0.gl-mb-0
= _("Can't scan the code?")
- %p.prepend-top-0.append-bottom-0
+ %p.gl-mt-0.gl-mb-0
= _('To add the entry manually, provide the following details to the application on your phone.')
- %p.prepend-top-0.append-bottom-0
+ %p.gl-mt-0.gl-mb-0
= _('Account: %{account}') % { account: @account_string }
- %p.prepend-top-0.append-bottom-0
+ %p.gl-mt-0.gl-mb-0
= _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') }
%p.two-factor-new-manual-content
= _('Time based: Yes')
@@ -57,7 +57,7 @@
.row.prepend-top-default
.col-lg-4
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _('Register Universal Two-Factor (U2F) Device')
%p
= _('Use a hardware device to add the second factor of authentication.')
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index 2e00632892b..7da15e0d8a5 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -7,7 +7,7 @@
%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.gl-mb-0
%p= _('The following items will be exported:')
%ul
- project_export_descriptions.each do |desc|
diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml
index da1b2d7f9b6..74cdb0f7409 100644
--- a/app/views/projects/_find_file_link.html.haml
+++ b/app/views/projects/_find_file_link.html.haml
@@ -1,3 +1,2 @@
= link_to project_find_file_path(@project, @ref), class: 'btn shortcuts-find-file', rel: 'nofollow' do
- = icon('search')
- %span= _('Find file')
+ = _('Find file')
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
index 8217608db4e..4739689b419 100644
--- a/app/views/projects/_flash_messages.html.haml
+++ b/app/views/projects/_flash_messages.html.haml
@@ -8,6 +8,5 @@
- unless project.empty_repo?
= render 'shared/auto_devops_implicitly_enabled_banner', project: project
= render_if_exists 'projects/above_size_limit_warning', project: project
- - if Feature.enabled?(:subscribable_banner_subscription)
- = render_if_exists "layouts/header/ee_subscribable_banner", subscription: true
= render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)]
+ = render 'shared/namespace_storage_limit_alert', namespace: project.namespace, classes: [container_class, ("limit-container-width" unless fluid_layout)]
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index be58ecb3572..6f8375f80be 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -4,15 +4,15 @@
- emails_disabled = @project.emails_disabled?
.project-home-panel{ class: [("empty-project" if empty_repo), ("js-show-on-project-root" if vue_file_list_enabled?)] }
- .row.append-bottom-8
+ .row.gl-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
= 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.home-panel-title.prepend-top-8.append-bottom-5{ data: { qa_selector: 'project_name_content' } }
+ %h1.home-panel-title.gl-mt-3.append-bottom-5{ data: { qa_selector: 'project_name_content' } }
= @project.name
- %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
+ %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project
.home-panel-metadata.d-flex.flex-wrap.text-secondary
@@ -20,7 +20,7 @@
%span.text-secondary
= s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
- if current_user
- %span.access-request-links.prepend-left-8
+ %span.access-request-links.gl-ml-3
= render 'shared/members/access_request_links', source: @project
- if @project.tag_list.present?
%span.home-panel-topic-list.mt-2.w-100.d-inline-flex
@@ -80,7 +80,7 @@
- if @project.badges.present?
.project-badges.mb-2
- @project.badges.each do |badge|
- %a.append-right-8{ href: badge.rendered_link_url(@project),
+ %a.gl-mr-3{ href: badge.rendered_link_url(@project),
target: '_blank',
rel: 'noopener noreferrer' }>
%img.project-badge{ src: badge.rendered_image_url(@project),
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 28d4f8eb201..3ae37254e39 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -22,7 +22,7 @@
**tracking_attrs(track_label, 'click_button', 'bitbucket_cloud') do
= icon('bitbucket', text: 'Bitbucket Cloud')
- unless bitbucket_import_configured?
- = render 'bitbucket_import_modal'
+ = render 'projects/bitbucket_import_modal'
- if bitbucket_server_import_enabled?
%div
= link_to status_import_bitbucket_server_path, class: "btn import_bitbucket", **tracking_attrs(track_label, 'click_button', 'bitbucket_server') do
@@ -34,7 +34,7 @@
**tracking_attrs(track_label, 'click_button', 'gitlab_com') do
= icon('gitlab', text: 'GitLab.com')
- unless gitlab_import_configured?
- = render 'gitlab_import_modal'
+ = render 'projects/gitlab_import_modal'
- if google_code_import_enabled?
%div
@@ -73,4 +73,4 @@
= form_for @project, html: { class: 'new_project' } do |f|
%hr
= render "shared/import_form", f: f
- = render 'new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label
+ = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label
diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml
index d3fcb52422b..9cebb191346 100644
--- a/app/views/projects/_merge_request_merge_checks_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml
@@ -14,6 +14,13 @@
anchor: 'pipelines-for-merge-requests'),
target: '_blank'
.form-check.mb-2
+ .gl-pl-6
+ = form.check_box :allow_merge_on_skipped_pipeline, class: 'form-check-input'
+ = form.label :allow_merge_on_skipped_pipeline, class: 'form-check-label' do
+ = s_('ProjectSettings|Skipped pipelines are considered successful')
+ .text-secondary
+ = s_('ProjectSettings|This introduces the risk of merging changes that will not pass the pipeline.')
+ .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_suggestions_settings.html.haml b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
index 06bb9056e61..12f26a7e315 100644
--- a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
@@ -9,9 +9,9 @@
anchor: 'configure-the-commit-message-for-applied-suggestions'),
target: '_blank'
.mb-2
- = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Suggestions::ApplyService::DEFAULT_SUGGESTION_COMMIT_MESSAGE
+ = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
%p.form-text.text-muted
= s_('ProjectSettings|The variables GitLab supports:')
- - Suggestions::ApplyService::PLACEHOLDERS.keys.each do |placeholder|
+ - Gitlab::Suggestions::CommitMessage::PLACEHOLDERS.keys.each do |placeholder|
%code
= "%{#{placeholder}}".html_safe
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 5d88be0925e..e0a426607d4 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -23,7 +23,7 @@
display_path: true,
extra_group: namespace_id),
{},
- { 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: "" }})
+ { class: 'select2 js-select-namespace qa-project-namespace-select block-truncated', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "" }})
- else
.input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' }
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 57a5d3e2e83..6f90bf50b91 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -14,4 +14,4 @@
- if can_create_wiki
%p
= _("Add a homepage to your wiki that contains information about your project and GitLab will display it here instead of this message.")
- = link_to _("Create your first page"), project_wiki_path(@project, :home) + '?view=create', class: "btn btn-primary"
+ = link_to _("Create your first page"), wiki_path(@project.wiki) + '?view=create', class: "btn btn-primary"
diff --git a/app/views/projects/blame/_blame_group.html.haml b/app/views/projects/blame/_blame_group.html.haml
new file mode 100644
index 00000000000..e9967814833
--- /dev/null
+++ b/app/views/projects/blame/_blame_group.html.haml
@@ -0,0 +1,26 @@
+%tr
+ %td.blame-commit{ class: commit_data.age_map_class }
+ .commit
+ = commit_data.author_avatar
+ .commit-row-title
+ %span.item-title.str-truncated-100
+ = commit_data.commit_link
+ %span
+ = commit_data.project_blame_link
+ &nbsp;
+ .light
+ = commit_data.commit_author_link
+ = _('committed')
+ #{commit_data.time_ago_tooltip}
+ %td.line-numbers
+ - line_count = blame_group[:lines].count
+ - (current_line...(current_line + line_count)).each do |i|
+ %a.diff-line-num{ href: "#L#{i}", id: "L#{i}", 'data-line-number' => i }
+ = link_icon
+ = i
+ \
+ %td.lines
+ %pre.code.highlight
+ %code
+ - blame_group[:lines].each do |line|
+ #{line}
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index b17207c0da6..0591c3180ea 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,5 +1,5 @@
-- project_duration = age_map_duration(@blame_groups, @project)
- page_title "Blame", @blob.path, @ref
+- link_icon = icon("link")
#blob-content-holder.tree-holder
= render "projects/blob/breadcrumb", blob: @blob, blame: true
@@ -11,38 +11,13 @@
.table-responsive.file-content.blame.code.js-syntax-highlight
%table
- current_line = 1
- - @blame_groups.each do |blame_group|
- %tr
- - commit = blame_group[:commit]
- %td.blame-commit{ class: age_map_class(commit.committed_date, project_duration) }
- .commit
- = author_avatar(commit, size: 36, has_tooltip: false)
- .commit-row-title
- %span.item-title.str-truncated-100
- = link_to commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title
- %span
- - previous_commit_id = commit.parent_id
- - if previous_commit_id
- = link_to project_blame_path(@project, tree_join(previous_commit_id, @path)),
- title: _('View blame prior to this change'),
- aria: { label: _('View blame prior to this change') },
- data: { toggle: 'tooltip', placement: 'right', container: 'body' } do
- = sprite_icon('doc-versions', size: 16, css_class: 'doc-versions align-text-bottom')
- &nbsp;
- .light
- = commit_author_link(commit, avatar: false)
- committed
- #{time_ago_with_tooltip(commit.committed_date)}
- %td.line-numbers
- - line_count = blame_group[:lines].count
- - (current_line...(current_line + line_count)).each do |i|
- %a.diff-line-num{ href: "#L#{i}", id: "L#{i}", 'data-line-number' => i }
- = icon("link")
- = i
- \
- - current_line += line_count
- %td.lines
- %pre.code.highlight
- %code
- - blame_group[:lines].each do |line|
- #{line}
+ - @blame.groups.each do |blame_group|
+ - commit_data = @blame.commit_data(blame_group[:commit])
+
+ = render 'blame_group',
+ blame_group: blame_group,
+ current_line: current_line,
+ link_icon: link_icon,
+ commit_data: commit_data
+
+ - current_line += blame_group[:lines].count
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index b67f9d0cd08..032df24a603 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -28,7 +28,7 @@
.file-buttons
- if is_markdown
- = render 'projects/blob/markdown_buttons', show_fullscreen_button: false
+ = render 'shared/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 7ed71a7d43c..6527c6021a0 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.qa-file-title-name
+ %strong.file-title-name
= blob.name
= copy_file_path_button(blob.path)
diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml
new file mode 100644
index 00000000000..fc8683e1d19
--- /dev/null
+++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml
@@ -0,0 +1,11 @@
+- if viewer.valid?
+ = icon('check fw')
+ = _('Metrics Dashboard YAML definition is valid.')
+- else
+ = icon('warning fw')
+ = _('Metrics Dashboard YAML definition is invalid:')
+ %ul
+ - viewer.errors.messages.each do |error|
+ %li= error.join(': ')
+
+= link_to _('Learn more'), help_page_path('user/project/integrations/prometheus.md', anchor: 'defining-custom-dashboards-per-project')
diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml
new file mode 100644
index 00000000000..31a0d514444
--- /dev/null
+++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml
@@ -0,0 +1,4 @@
+= icon('spinner spin fw')
+= _('Metrics Dashboard YAML definition') + '…'
+
+= link_to _('Learn more'), help_page_path('user/project/integrations/prometheus.md')
diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml
index c2329a7aa66..6cbd26e2271 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", project_wiki_path(viewer.project, :home)
+ = link_to "the wiki", wiki_path(viewer.project.wiki)
diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml
index 07b9378ba97..024e9b4ddb2 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: 'going-from-source-files-to-public-pages')
+= link_to 'Learn more', help_page_path('ci/environments/index.md', 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 f11c047e85a..1d768bd1ca4 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: 'going-from-source-files-to-public-pages')
+= link_to 'Learn more', help_page_path('ci/environments/index.md', 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 3e53cb510b0..2e9be28df86 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -5,7 +5,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 qa-branch-name' do
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3 qa-branch-name' do
= branch.name
- if branch.name == @repository.root_ref
%span.badge.badge-primary.prepend-left-5 default
@@ -29,6 +29,12 @@
.js-branch-divergence-graph
.controls.d-none.d-md-block<
+ - if commit_status
+ = render 'ci/status/icon', size: 24, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5'
+ - elsif show_commit_status
+ .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
+ %svg.s24
+
- if merge_project && create_mr_button?(@repository.root_ref, branch.name)
= link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do
= _('Merge request')
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
index 93061452e12..828371e9656 100644
--- a/app/views/projects/branches/_panel.html.haml
+++ b/app/views/projects/branches/_panel.html.haml
@@ -12,7 +12,7 @@
= panel_title
%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)
+ = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any?
- if branches.size > overview_max_branches
.card-footer.text-center
= link_to show_more_text, project_branches_filtered_path(project, state: state), id: "state-#{state}", data: { state: state }
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 6bdc6f716fe..ba42f43088f 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -59,7 +59,7 @@
- elsif @branches.any?
%ul.content-list.all-branches
- @branches.each do |branch|
- = render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name)
+ = render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any?
= paginate @branches, theme: 'gitlab'
- else
.nothing-here-block
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 4b82eb2c5ef..2d9c7f9848f 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -1,6 +1,6 @@
- unless @project.empty_repo?
- if current_user && can?(current_user, :fork_project, @project)
- .count-badge.d-inline-flex.align-item-stretch.append-right-8
+ .count-badge.d-inline-flex.align-item-stretch.gl-mr-3
- 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: s_('ProjectOverview|Go to your fork'), class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center fork-btn' do
= sprite_icon('fork', { css_class: 'icon' })
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 02e5297528b..3dac38d1356 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -1,5 +1,5 @@
- if current_user
- .count-badge.d-inline-flex.align-item-stretch.append-right-8
+ .count-badge.d-inline-flex.align-item-stretch.gl-mr-3
%button.count-badge-button.btn.btn-default.btn-xs.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' })
@@ -12,7 +12,7 @@
= @project.star_count
- else
- .count-badge.d-inline-flex.align-item-stretch.append-right-8
+ .count-badge.d-inline-flex.align-item-stretch.gl-mr-3
= link_to new_user_session_path, class: 'btn btn-default btn-xs 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/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index ed3c9890efd..02e8bad69b9 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -6,7 +6,7 @@
%button.btn.js-settings-toggle
= expanded ? _('Collapse') : _('Expand')
%p
- = _("Clean up after running %{bfg} on the repository" % { bfg: link_to_bfg }).html_safe
+ = _("Clean up after running %{filter_repo} on the repository" % { filter_repo: link_to_filter_repo }).html_safe
= link_to icon('question-circle'),
help_page_path('user/project/repository/reducing_the_repo_size_using_git.md'),
target: '_blank', rel: 'noopener noreferrer'
@@ -14,15 +14,15 @@
.settings-content
- url = cleanup_namespace_project_settings_repository_path(@project.namespace, @project)
= form_for @project, url: url, method: :post, authenticity_token: true, html: { class: 'js-requires-input' } do |f|
- %fieldset.prepend-top-0.append-bottom-10
+ %fieldset.gl-mt-0.append-bottom-10
.append-bottom-10
- %h5.prepend-top-0
+ %h5.gl-mt-0
= _("Upload object map")
%button.btn.btn-default.js-choose-file{ type: "button" }
= _("Choose a file")
%span.prepend-left-default.js-filename
= _("No file selected")
- = f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true
+ = f.file_field :bfg_object_map, class: "hidden js-object-map-input", required: true
.form-text.text-muted
= _("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/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 88d1ec54cb0..4442bdcdf1d 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -22,8 +22,8 @@
.header-action-buttons
- if defined?(@notes_count) && @notes_count > 0
- %span.btn.disabled.btn-grouped.d-none.d-sm-block.append-right-10
- = icon('comment')
+ %span.btn.disabled.btn-grouped.d-none.d-sm-block.append-right-10.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
+ = sprite_icon('comment')
= @notes_count
= link_to project_tree_path(@project, @commit), class: "btn btn-default append-right-10 d-none d-sm-none d-md-inline" do
#{ _('Browse files') }
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index b42eef32a76..ab1d855a6e0 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -41,7 +41,7 @@
= 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
+ %pre.commit-row-description.js-toggle-content.gl-mb-3
= preserve(markdown_field(commit, :description))
.commit-actions.flex-row
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 855b719dc45..7395c16c38b 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -14,7 +14,7 @@
.file-actions.d-none.d-sm-block
- if blob&.readable_text?
= link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do
- = icon('comment')
+ = sprite_icon('comment', size: 16)
\
- if editable_diff?(diff_file)
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index 86e6e732610..17c1764e8a4 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -22,8 +22,8 @@
- diff_files.each do |diff_file|
%li
%a.diff-changed-file{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path }
- = sprite_icon(diff_file_changed_icon(diff_file), size: 16, css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon append-right-8")
- %span.diff-changed-file-content.append-right-8
+ = sprite_icon(diff_file_changed_icon(diff_file), size: 16, css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon gl-mr-3")
+ %span.diff-changed-file-content.gl-mr-3
- if diff_file.file_path
%strong.diff-changed-file-name
= diff_file.file_path
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 9e06358beba..6b1455acd08 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -6,11 +6,11 @@
%div{ class: [("limit-container-width" unless fluid_layout)] }
= render "home_panel"
- %h4.prepend-top-0.append-bottom-8
+ %h4.gl-mt-0.gl-mb-3
= _('The repository for this project is empty')
- if @project.can_current_user_push_code?
- %p.append-bottom-0
+ %p.gl-mb-0
= _('You can get started by cloning the repository or start adding files to it with one of the following options.')
.project-buttons.qa-quick-actions
@@ -22,7 +22,7 @@
= render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
- if can?(current_user, :push_code, @project)
- .empty-wrapper.prepend-top-32
+ .empty-wrapper.gl-mt-7
%h3#repo-command-line-instructions.page-title-empty
= _('Command line instructions')
%p
diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml
index 1fbe34cfff3..efe80a4877c 100644
--- a/app/views/projects/environments/_form.html.haml
+++ b/app/views/projects/environments/_form.html.haml
@@ -1,9 +1,9 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _("Environments")
%p
- - link_to_read_more = link_to(_("Read more about environments"), help_page_path("ci/environments"))
+ - link_to_read_more = link_to(_("Read more about environments"), help_page_path("ci/environments/index.md"))
= _("Environments allow you to track deployments of your application %{link_to_read_more}.").html_safe % { link_to_read_more: link_to_read_more }
= form_for [@project.namespace.becomes(Namespace), @project, @environment], html: { class: 'col-lg-9' } do |f|
diff --git a/app/views/projects/environments/empty_metrics.html.haml b/app/views/projects/environments/empty_metrics.html.haml
index dad93290fbd..5642fb34da9 100644
--- a/app/views/projects/environments/empty_metrics.html.haml
+++ b/app/views/projects/environments/empty_metrics.html.haml
@@ -11,4 +11,4 @@
%p.state-description
= s_('Metrics|Check out the CI/CD documentation on deploying to an environment')
.text-center
- = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments'), class: 'btn btn-success'
+ = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments/index.md'), class: 'btn btn-success'
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 2ba88da3375..445196ed449 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -4,5 +4,5 @@
"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),
- "help-page-path" => help_page_path("ci/environments"),
+ "help-page-path" => help_page_path("ci/environments/index.md"),
"deploy-boards-help-path" => help_page_path("user/project/deploy_boards", anchor: "enabling-deploy-boards") } }
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
index aab30af5ed4..cd7339edd1a 100644
--- a/app/views/projects/environments/metrics.html.haml
+++ b/app/views/projects/environments/metrics.html.haml
@@ -1,4 +1,4 @@
-- page_title _("Metrics for environment"), @environment.name
+- page_title _("Metrics Dashboard"), @environment.name
.prometheus-container
#prometheus-graphs{ data: metrics_data(@project, @environment) }
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 3a7a93dc4e6..d5249662dde 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -23,7 +23,7 @@
emphasis_end: '</strong>'.html_safe,
ci_config_link_start: '<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">'.html_safe,
ci_config_link_end: '</a>'.html_safe }
- %a{ href: 'https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment',
+ %a{ href: 'https://docs.gitlab.com/ee/ci/environments/index.html#stopping-an-environment',
target: '_blank',
rel: 'noopener noreferrer' }
= s_('Environments|Learn more about stopping environments')
@@ -39,7 +39,7 @@
.d-flex
%h3.page-title= @environment.name
- if @environment.auto_stop_at?
- %p.align-self-end.prepend-left-8
+ %p.align-self-end.gl-ml-3
= s_('Environments|Auto stops %{auto_stop_time}').html_safe % {auto_stop_time: time_ago_with_tooltip(@environment.auto_stop_at)}
.nav-controls.my-2
= render 'projects/environments/pin_button', environment: @environment
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
index c7ed6a5094d..70064722832 100644
--- a/app/views/projects/forks/_fork_button.html.haml
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -22,5 +22,5 @@
- else
.avatar-container.s100.mx-auto
= image_tag(avatar, class: "avatar s100")
- %h5.prepend-top-default
+ %h5.prepend-top-default{ data: { qa_selector: 'fork_namespace_content', qa_name: namespace.human_name } }
= namespace.human_name
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 8a5b08a19c8..763e31c4a8b 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -2,14 +2,14 @@
.row.prepend-top-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _("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.").html_safe
.col-lg-9
- if @namespaces.present?
.fork-thumbnail-container.js-fork-content
- %h5.prepend-top-0.append-bottom-0.prepend-left-default.append-right-default
+ %h5.gl-mt-0.gl-mb-0.prepend-left-default.append-right-default
= _("Select a namespace to fork the project")
- @namespaces.each do |namespace|
= render 'fork_button', namespace: namespace
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index 7257dacf680..24d92e947bc 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -28,7 +28,7 @@
%a.btn.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" }
%small
= _("Download raw data (.csv)")
- #js-code-coverage-chart{ data: { daily_coverage_options: @daily_coverage_options.to_json.html_safe } }
+ #js-code-coverage-chart{ data: { graph_endpoint: "#{@daily_coverage_options[:graph_api_path]}?#{@daily_coverage_options[:base_params].to_query}" } }
.repo-charts
.sub-header-block.border-top
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
index f3cea6bea68..e7b924c65bf 100644
--- a/app/views/projects/hook_logs/_index.html.haml
+++ b/app/views/projects/hook_logs/_index.html.haml
@@ -1,6 +1,6 @@
-.row.prepend-top-32.append-bottom-default
+.row.gl-mt-7.append-bottom-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.gl-mt-0
Recent Deliveries
%p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.
.col-lg-9
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index 873fb4d47b7..a6a3f56c28c 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -4,7 +4,7 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.gl-mt-0
Request details
.col-lg-9
= link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right prepend-left-10"
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index f7eae802dac..15100840c0a 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -10,7 +10,7 @@
= form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- %span>= f.submit 'Save changes', class: 'btn btn-success append-right-8'
+ %span>= f.submit 'Save changes', class: 'btn btn-success gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
= link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') }
diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml
index cddd97cbc84..fe6cc6fa828 100644
--- a/app/views/projects/import/jira/show.html.haml
+++ b/app/views/projects/import/jira/show.html.haml
@@ -1,33 +1,6 @@
-- if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
- .js-jira-import-root{ data: { project_path: @project.full_path,
- issues_path: project_issues_path(@project),
- jira_integration_path: edit_project_service_path(@project, :jira),
- is_jira_configured: @project.jira_service.present?.to_s,
- jira_projects: @jira_projects.to_json,
- in_progress_illustration: image_path('illustrations/export-import.svg'),
- setup_illustration: image_path('illustrations/manual_action.svg') } }
-- else
- - title = _('Jira Issue Import')
- - page_title title
- - breadcrumb_title title
- - header_title _("Projects"), root_path
-
- = render 'import/shared/errors'
-
- - if @project.import_state&.in_progress?
- %h3.page-title.d-flex.align-items-center
- = sprite_icon('issues', size: 16, css_class: 'mr-1')
- = _('Import in progress')
- - elsif @jira_projects.present?
- %h3.page-title.d-flex.align-items-center
- = sprite_icon('issues', size: 16, css_class: 'mr-1')
- = _('Import issues from Jira')
-
- = form_tag import_project_import_jira_path(@project), method: :post do
- .form-group.row
- = label_tag :jira_project_key, _('From project'), class: 'col-form-label col-md-2'
- .col-md-4
- = select_tag :jira_project_key, options_for_select(@jira_projects, ''), { class: 'select2' }
- .form-actions
- = submit_tag _('Import issues'), class: 'btn btn-success'
- = link_to _('Cancel'), project_issues_path(@project), class: 'btn btn-cancel'
+.js-jira-import-root{ data: { project_path: @project.full_path,
+ issues_path: project_issues_path(@project),
+ jira_integration_path: edit_project_service_path(@project, :jira),
+ is_jira_configured: @project.jira_service&.active? && @project.jira_service&.valid_connection?.to_s,
+ in_progress_illustration: image_path('illustrations/export-import.svg'),
+ setup_illustration: image_path('illustrations/manual_action.svg') } }
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 1bf0c8eb031..e325d585d0c 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -43,6 +43,7 @@
= link_to_label(label, small: true)
= render_if_exists "projects/issues/issue_weight", issue: issue
+ = render "projects/issues/issue_estimate", issue: issue
.issuable-meta
%ul.controls
diff --git a/app/views/projects/issues/_issue_estimate.html.haml b/app/views/projects/issues/_issue_estimate.html.haml
new file mode 100644
index 00000000000..46797d0f1a0
--- /dev/null
+++ b/app/views/projects/issues/_issue_estimate.html.haml
@@ -0,0 +1,7 @@
+- issue = local_assigns.fetch(:issue)
+
+- if issue.time_estimate > 0
+ %span.issuable-estimate.d-none.d-sm-inline-block.has-tooltip{ data: { container: 'body', qa_selector: 'issuable_estimate' }, title: _('Estimate') }
+ &nbsp;
+ = sprite_icon('timer', size: 16, css_class: 'issue-estimate-icon')
+ = Gitlab::TimeTrackingFormatter.output(issue.time_estimate)
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index f3a1edd2571..73904354a12 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -41,7 +41,7 @@
= _('Create branch')
%li.divider.droplab-item-ignore
- %li.droplab-item-ignore.prepend-left-8.append-right-8.prepend-top-16
+ %li.droplab-item-ignore.gl-ml-3.gl-mr-3.prepend-top-16
- if can_create_confidential_merge_request?
#js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests') } }
.form-group
diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml
index 07c34b51037..7119b22daef 100644
--- a/app/views/projects/issues/import_csv/_button.html.haml
+++ b/app/views/projects/issues/import_csv/_button.html.haml
@@ -1,22 +1,16 @@
- type = local_assigns.fetch(:type, :icon)
+- can_edit = can?(current_user, :admin_project, @project)
-- if @project.jira_issues_import_feature_flag_enabled?
- .dropdown.btn-group
- %button.btn.rounded-right.text-center{ class: ('has-tooltip' if type == :icon), title: (_('Import issues') if type == :icon),
- data: { toggle: 'dropdown' }, 'aria-label' => _('Import issues'), 'aria-haspopup' => 'true', 'aria-expanded' => 'false' }
- - if type == :icon
- = sprite_icon('import')
- - else
- = _('Import issues')
- %ul.dropdown-menu
- %li
- %button.btn{ data: { toggle: 'modal', target: '.issues-import-modal' } }
- = _('Import CSV')
- %li= link_to _('Import from Jira'), project_import_jira_path(@project)
-- else
- %button.csv-import-button.btn{ title: _('Import CSV'), class: ('has-tooltip' if type == :icon),
- data: { toggle: 'modal', target: '.issues-import-modal' } }
+.dropdown.btn-group
+ %button.btn.rounded-right.text-center{ class: ('has-tooltip' if type == :icon), title: (_('Import issues') if type == :icon),
+ data: { toggle: 'dropdown' }, 'aria-label' => _('Import issues'), 'aria-haspopup' => 'true', 'aria-expanded' => 'false' }
- if type == :icon
= sprite_icon('import')
- else
- = _('Import CSV')
+ = _('Import issues')
+ %ul.dropdown-menu
+ %li
+ %button{ data: { toggle: 'modal', target: '.issues-import-modal' } }
+ = _('Import CSV')
+ - if can_edit
+ %li= link_to _('Import from Jira'), project_import_jira_path(@project)
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 0aef4e39466..826a62e39d3 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -6,11 +6,10 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
-- if @project.jira_issues_import_feature_flag_enabled?
- .js-projects-issues-root{ data: { can_edit: can?(current_user, :admin_project, @project).to_s,
- is_jira_configured: @project.jira_service.present?.to_s,
- issues_path: project_issues_path(@project),
- project_path: @project.full_path } }
+.js-projects-issues-root{ data: { can_edit: can?(current_user, :admin_project, @project).to_s,
+ is_jira_configured: @project.jira_service.present?.to_s,
+ issues_path: project_issues_path(@project),
+ project_path: @project.full_path } }
- if project_issues(@project).exists?
.top-area
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 525eb4b90c1..4d24b510267 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -11,6 +11,7 @@
- can_create_issue = show_new_issue_link?(@project)
= render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user
+= render_if_exists "projects/issues/alert_moved_from_service_desk", issue: @issue
.detail-page-header
.detail-page-header-body
diff --git a/app/views/projects/logs/empty_logs.html.haml b/app/views/projects/logs/empty_logs.html.haml
index 52598e0be8d..afae2d30f6e 100644
--- a/app/views/projects/logs/empty_logs.html.haml
+++ b/app/views/projects/logs/empty_logs.html.haml
@@ -11,4 +11,4 @@
%p.state-description.text-center
= s_('Logs|To see the logs, deploy your code to an environment.')
.text-center
- = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments'), class: 'btn btn-success'
+ = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments/index.md'), class: 'btn btn-success'
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 7e146a36d84..90bc2504cb4 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -100,3 +100,5 @@
= render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
- if @merge_request.can_be_cherry_picked?
= render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title
+
+#js-review-bar
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index e1797e6db2a..a3083fa2081 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -12,8 +12,8 @@
.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 layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do
+ = render 'shared/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: _('Write milestone description...')
= render 'shared/notes/hints'
.clearfix
.error-alert
diff --git a/app/views/projects/milestones/_milestone.html.haml b/app/views/projects/milestones/_milestone.html.haml
index bc82b45f902..00937c5bf73 100644
--- a/app/views/projects/milestones/_milestone.html.haml
+++ b/app/views/projects/milestones/_milestone.html.haml
@@ -1,5 +1,4 @@
= render 'shared/milestones/milestone',
- milestone_path: project_milestone_path(milestone.project, milestone),
issues_path: project_issues_path(milestone.project, milestone_title: milestone.title),
merge_requests_path: project_merge_requests_path(milestone.project, milestone_title: milestone.title),
milestone: milestone
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index c18af6a267b..81a778f76f4 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -7,9 +7,13 @@
.project-edit-container.prepend-top-default
.project-edit-errors
= render 'projects/errors'
- .row
+
+ - if experiment_enabled?(:new_create_project_ui)
+ .js-experiment-new-project-creation{ data: { is_ci_cd_available: ci_cd_projects_available?, has_errors: @project.errors.any? } }
+
+ .row{ 'v-cloak': experiment_enabled?(:new_create_project_ui) }
.col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _('New project')
%p
- among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank'
@@ -32,15 +36,15 @@
.col-lg-9.js-toggle-container
%ul.nav.nav-tabs.nav-links.gitlab-tabs{ role: 'tablist' }
%li.nav-item{ role: 'presentation' }
- %a.nav-link.active{ href: '#blank-project-pane', id: 'blank-project-tab', data: { toggle: 'tab', track_label: 'blank_project', track_event: "click_tab", track_value: "" }, role: 'tab' }
+ %a.nav-link.active{ href: '#blank-project-pane', id: 'blank-project-tab', data: { toggle: 'tab', experiment_track_label: 'blank_project' }, role: 'tab' }
%span.d-none.d-sm-block= s_('ProjectsNew|Blank project')
%span.d-block.d-sm-none= s_('ProjectsNew|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", track_value: "" }, role: 'tab' }
+ %a.nav-link{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab', experiment_track_label: 'create_from_template' }, role: 'tab' }
%span.d-none.d-sm-block.qa-project-create-from-template-tab= s_('ProjectsNew|Create from template')
%span.d-block.d-sm-none= s_('ProjectsNew|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", track_value: "" }, role: 'tab' }
+ %a.nav-link{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab', experiment_track_label: 'import_project' }, role: 'tab' }
%span.d-none.d-sm-block= s_('ProjectsNew|Import project')
%span.d-block.d-sm-none= s_('ProjectsNew|Import')
= render_if_exists 'projects/new_ci_cd_only_project_tab', active_tab: active_tab
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 396e5da87bc..20cf2ed63b5 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -37,7 +37,7 @@
= f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold'
%div
= f.check_box :active, required: false, value: @schedule.active?
- = _('Active')
+ = f.label :active, _('Active'), class: 'gl-font-weight-normal'
.footer-block.row-content-block
= f.submit _('Save pipeline schedule'), class: 'btn btn-success', tabindex: 3
= link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index e39f543d42e..92edde034a6 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -1,19 +1,20 @@
- test_reports_enabled = Feature.enabled?(:junit_pipeline_view, @project)
-- dag_pipeline_tab_enabled = Feature.enabled?(:dag_pipeline_tab)
+- dag_pipeline_tab_enabled = Feature.enabled?(:dag_pipeline_tab, @project, default_enabled: false)
.tabs-holder
%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')
- %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')
- %span.badge.badge-pill.js-builds-counter= pipeline.total_size
- if dag_pipeline_tab_enabled
%li.js-dag-tab-link
= link_to dag_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-dag', action: 'dag', toggle: 'tab' }, class: 'dag-tab' do
= _('DAG')
+ %span.badge-pill.gl-badge.sm.gl-bg-blue-500.gl-text-white.gl-ml-2= _('Beta')
+ %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')
+ %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
@@ -82,6 +83,7 @@
- if dag_pipeline_tab_enabled
#js-tab-dag.tab-pane
+ #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline) } }
#js-tab-tests.tab-pane
#js-pipeline-tests-detail
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 7496ca97d56..55f1b9098c3 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,4 +1,4 @@
-- page_title _('CI / CD Charts')
+- page_title _('CI / CD Analytics')
#js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts),
times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times },
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 64789c7c263..fa4a77a692a 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -4,6 +4,7 @@
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
project_id: @project.id,
+ params: params.to_json,
"help-page-path" => help_page_path('ci/quick_start/README'),
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
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 a583eb39eb3..eb41a3e0785 100644
--- a/app/views/projects/project_templates/_built_in_templates.html.haml
+++ b/app/views/projects/project_templates/_built_in_templates.html.haml
@@ -11,7 +11,7 @@
.controls.d-flex.align-items-center
%a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } }
= _("Preview")
- %label.btn.btn-success.template-button.choose-template.append-bottom-0{ for: template.name }
+ %label.btn.btn-success.template-button.choose-template.gl-mb-0{ for: template.name }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } }
%span{ data: { qa_selector: 'use_template_button' } }
= _("Use template")
diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml
index 1012ceefe93..ffaf118a5e3 100644
--- a/app/views/projects/protected_branches/show.html.haml
+++ b/app/views/projects/protected_branches/show.html.haml
@@ -2,7 +2,7 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0.ref-name
+ %h4.gl-mt-0.ref-name
= @protected_ref.name
.col-lg-9
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
index 86629f1753b..6f4535a0b3f 100644
--- a/app/views/projects/protected_tags/show.html.haml
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -2,7 +2,7 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0.ref-name
+ %h4.gl-mt-0.ref-name
= @protected_ref.name
.col-lg-9.edit_protected_tag
diff --git a/app/views/projects/registry/repositories/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml
deleted file mode 100644
index 9594c9184a2..00000000000
--- a/app/views/projects/registry/repositories/_tag.html.haml
+++ /dev/null
@@ -1,33 +0,0 @@
-%tr.tag
- %td
- = escape_once(tag.name)
- = clipboard_button(text: "#{tag.location}")
- %td
- - if tag.revision
- %span.has-tooltip{ title: "#{tag.revision}" }
- = tag.short_revision
- - else
- \-
- %td
- - if tag.total_size
- = number_to_human_size(tag.total_size)
- &middot;
- = pluralize(tag.layers.size, "layer")
- - else
- .light
- \-
- %td
- - if tag.created_at
- = time_ago_with_tooltip tag.created_at
- - else
- .light
- \-
- - if can?(current_user, :update_container_image, @project)
- %td.content
- .controls.d-none.d-sm-block.float-right
- = link_to project_registry_repository_tag_path(@project, tag.repository, tag.name),
- method: :delete,
- class: 'btn btn-remove has-tooltip',
- title: 'Remove tag',
- data: { confirm: 'Are you sure you want to delete this tag?' } do
- = icon('trash cred')
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 650e63eb406..8540ce30060 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -5,7 +5,6 @@
.row.registry-placeholder.prepend-bottom-10
.col-12
#js-container-registry{ data: { endpoint: project_container_registry_index_path(@project),
- settings_path: project_settings_ci_cd_path(@project),
expiration_policy: @project.container_expiration_policy.to_json,
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
@@ -16,5 +15,5 @@
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
- "is_admin": current_user&.admin,
+ "is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index 3f91bdc4266..e6761807409 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -1,6 +1,6 @@
.row.prepend-top-default.append-bottom-default
.col-lg-4
- %h4.prepend-top-0
+ %h4.gl-mt-0
= @service.title
- [true, false].each do |value|
- hide_class = 'd-none' if @service.operating? != value
@@ -13,6 +13,7 @@
= form_for(@service, as: :service, url: scoped_integration_path(@service), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form|
= render 'shared/service_settings', form: form, service: @service
.footer-block.row-content-block
+ %input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referrer }
= service_save_button
&nbsp;
= link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/services/alerts/_help.html.haml b/app/views/projects/services/alerts/_help.html.haml
index ef3ab8d8d04..4b09d1d9d0e 100644
--- a/app/views/projects/services/alerts/_help.html.haml
+++ b/app/views/projects/services/alerts/_help.html.haml
@@ -1,3 +1,6 @@
.js-alerts-service-settings{ data: { activated: @service.activated?.to_s,
form_path: scoped_integration_path(@service),
- authorization_key: @service.token, url: @service.url || _('<namespace / project>'), learn_more_url: 'https://docs.gitlab.com/ee/user/project/integrations/generic_alerts.html' } }
+ authorization_key: @service.token,
+ url: @service.url || _('<namespace / project>'),
+ alerts_setup_url: help_page_path('user/project/integrations/generic_alerts.html', anchor: 'setting-up-generic-alerts'),
+ alerts_usage_url: help_page_path('user/project/operations/alert_management.html') } }
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
index 926671845c7..728a52f024f 100644
--- a/app/views/projects/services/prometheus/_show.html.haml
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -1,6 +1,6 @@
.row
.col-lg-3
- %h4.prepend-top-0
+ %h4.gl-mt-0
= s_('PrometheusService|Metrics')
.row.append-bottom-default.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 07784dce677..092f9c2333c 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -6,7 +6,7 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4.prepend-top-0
+ %h4.gl-mt-0
= page_title
%p
= _('You can generate an access token scoped to this project for each application to use the GitLab API.')
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index b50f712922f..a1809cecafb 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -4,7 +4,7 @@
= form_errors(@project)
%fieldset.builds-feature
.form-group
- %h5.prepend-top-0
+ %h5.gl-mt-0
= _("Git strategy for pipelines")
%p
= _("Choose between <code>clone</code> or <code>fetch</code> to get the recent application code").html_safe
diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml
index 4372763fcf7..e7a509abc8b 100644
--- a/app/views/projects/settings/integrations/show.html.haml
+++ b/app/views/projects/settings/integrations/show.html.haml
@@ -12,6 +12,8 @@
.gl-alert-actions
= link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'btn gl-alert-action btn-info new-gl-button'
-%h4= s_('Integrations')
-%p= s_('Integrations allow you to integrate GitLab with other applications')
+%h4= _('Integrations')
+- integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/overview') }
+- webhooks_link_start = '<a href="%{url}">'.html_safe % { url: project_hooks_path(@project) }
+%p= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe }
= render 'shared/integrations/index', integrations: @services
diff --git a/app/views/projects/settings/operations/_external_dashboard.html.haml b/app/views/projects/settings/operations/_external_dashboard.html.haml
deleted file mode 100644
index 08d50a336fd..00000000000
--- a/app/views/projects/settings/operations/_external_dashboard.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-.js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project),
- external_dashboard: { url: metrics_external_dashboard_url,
- help_page_path: help_page_path('user/project/operations/linking_to_an_external_dashboard') } } }
diff --git a/app/views/projects/settings/operations/_incidents.html.haml b/app/views/projects/settings/operations/_incidents.html.haml
index 92fffa42b73..3b1b0a00380 100644
--- a/app/views/projects/settings/operations/_incidents.html.haml
+++ b/app/views/projects/settings/operations/_incidents.html.haml
@@ -9,7 +9,7 @@
= _('Expand')
%p
= _('Action to take when receiving an alert.')
- = link_to help_page_path('user/project/integrations/prometheus', anchor: 'taking-action-on-an-alert-ultimate') do
+ = link_to help_page_path('user/project/integrations/prometheus', anchor: 'taking-action-on-incidents-ultimate') do
= _('More information')
.settings-content
= form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
diff --git a/app/views/projects/settings/operations/_metrics_dashboard.html.haml b/app/views/projects/settings/operations/_metrics_dashboard.html.haml
new file mode 100644
index 00000000000..edbada8444a
--- /dev/null
+++ b/app/views/projects/settings/operations/_metrics_dashboard.html.haml
@@ -0,0 +1,5 @@
+.js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project),
+ help_page: help_page_path('user/project/operations/dashboard_settings'),
+ external_dashboard: { url: metrics_external_dashboard_url,
+ help_page: help_page_path('user/project/operations/linking_to_an_external_dashboard') },
+ dashboard_timezone: { setting: metrics_dashboard_timezone.upcase } } }
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index ee47d70171b..9e4fbf81ca4 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -5,7 +5,7 @@
= render 'projects/settings/operations/incidents'
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service)
-= render 'projects/settings/operations/external_dashboard'
+= render 'projects/settings/operations/metrics_dashboard'
= render 'projects/settings/operations/grafana_integration'
= render_if_exists 'projects/settings/operations/tracing'
= render_if_exists 'projects/settings/operations/status_page'
diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml
index 41c9bac0102..6aedab36e1b 100644
--- a/app/views/projects/snippets/_actions.html.haml
+++ b/app/views/projects/snippets/_actions.html.haml
@@ -14,7 +14,7 @@
= link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
- if can?(current_user, :create_snippet, @project) || can?(current_user, :update_snippet, @snippet)
.d-block.d-sm-none.dropdown
- %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } }
+ %button.btn.btn-default.btn-block.gl-mb-0.prepend-top-5{ data: { toggle: "dropdown" } }
= _('Options')
= icon('caret-down')
.dropdown-menu.dropdown-menu-full-width
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index da693a15ec2..79a00b00fa6 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -3,10 +3,10 @@
%li.flex-row.allow-wrap
.row-main-content
= icon('tag')
- = link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name prepend-left-4'
+ = link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name gl-ml-2'
- if protected_tag?(@project, tag)
- %span.badge.badge-success.prepend-left-4
+ %span.badge.badge-success.gl-ml-2
= s_('TagsPage|protected')
- if tag.message.present?
@@ -22,7 +22,7 @@
- if release
.text-secondary
- = icon('rocket')
+ = sprite_icon("rocket", size: 12)
= _("Release")
= link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link'
- if release.description.present?
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 1b3b0972744..5aabfdd022a 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -46,8 +46,8 @@
- replacements = { releases_page_link_start: releases_page_link_start, docs_link_start: docs_link_start, link_end: link_end }
= s_('TagsPage|Optionally, create a public Release of your project, based on this tag. Release notes are displayed on the %{releases_page_link_start}Releases%{link_end} page. %{docs_link_start}More information%{link_end}').html_safe % replacements
- = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
- = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description
+ = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
+ = render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description
= render 'shared/notes/hints'
.form-actions
= button_tag s_('TagsPage|Create tag'), class: 'btn btn-success'
diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
index 40d886ff1af..a3746808440 100644
--- a/app/views/projects/tags/releases/edit.html.haml
+++ b/app/views/projects/tags/releases/edit.html.haml
@@ -10,8 +10,8 @@
= form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name),
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 layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
+ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…"
= render 'shared/notes/hints'
.error-alert
.prepend-top-default
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index 55a9234f01a..4ca070cb162 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -96,6 +96,6 @@
%p.light
With webhook:
- %pre.append-bottom-0
+ %pre.gl-mb-0
:plain
#{builds_trigger_url(@project.id, ref: 'REF_NAME')}?token=TOKEN&variables[RUN_NIGHTLY_BUILD]=true
diff --git a/app/views/projects/triggers/edit.html.haml b/app/views/projects/triggers/edit.html.haml
index 0f74d733c06..e287f05fe6a 100644
--- a/app/views/projects/triggers/edit.html.haml
+++ b/app/views/projects/triggers/edit.html.haml
@@ -2,6 +2,6 @@
.row.prepend-top-default.append-bottom-default
.col-lg-12
- %h4.prepend-top-0
+ %h4.gl-mt-0
Update trigger
= render "form", btn_text: "Save trigger"
diff --git a/app/views/projects/wikis/_main_links.html.haml b/app/views/projects/wikis/_main_links.html.haml
deleted file mode 100644
index 2e1e176c42a..00000000000
--- a/app/views/projects/wikis/_main_links.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- if (@page && @page.persisted?)
- - if can?(current_user, :create_wiki, @project)
- = link_to project_wikis_new_path(@project), class: "add-new-wiki btn btn-success", role: "button" do
- = s_("Wiki|New page")
- = link_to project_wiki_history_path(@project, @page), class: "btn", role: "button" do
- = s_("Wiki|Page history")
- - if can?(current_user, :create_wiki, @project) && @page.latest? && @valid_encoding
- = link_to project_wiki_edit_path(@project, @page), class: "btn js-wiki-edit", role: "button" do
- = _("Edit")
diff --git a/app/views/projects/wikis/_wiki_page.html.haml b/app/views/projects/wikis/_wiki_page.html.haml
deleted file mode 100644
index c84d06dad02..00000000000
--- a/app/views/projects/wikis/_wiki_page.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render "#{context}_wiki_page", wiki_page: wiki_page
diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml
index 72c9f45779a..208dedc988b 100644
--- a/app/views/projects/wikis/git_access.html.haml
+++ b/app/views/projects/wikis/git_access.html.haml
@@ -8,10 +8,10 @@
.git-access-header.w-100.d-flex.flex-column.justify-content-center
%span
= _("Clone repository")
- %strong= @project_wiki.full_path
+ %strong= @wiki.full_path
.pt-3.pt-lg-0.w-100
- = render "shared/clone_panel", project: @project_wiki
+ = render "shared/clone_panel", project: @wiki
.wiki-git-access
%h3= s_("WikiClone|Install Gollum")
@@ -22,8 +22,8 @@
%h3= s_("WikiClone|Clone your wiki")
%pre.dark
:preserve
- git clone #{ content_tag(:span, h(default_url_to_repo(@project_wiki)), class: 'clone')}
- cd #{h @project_wiki.path}
+ git clone #{ content_tag(:span, h(default_url_to_repo(@wiki)), class: 'clone')}
+ cd #{h @wiki.path}
%h3= s_("WikiClone|Start Gollum and edit locally")
%pre.dark
@@ -34,4 +34,4 @@
>> Maximum connections set to 1024
>> Listening on 0.0.0.0:4567, CTRL+C to stop
-= render 'sidebar'
+= render 'shared/wikis/sidebar'
diff --git a/app/views/registrations/experience_levels/show.html.haml b/app/views/registrations/experience_levels/show.html.haml
new file mode 100644
index 00000000000..24b87790e18
--- /dev/null
+++ b/app/views/registrations/experience_levels/show.html.haml
@@ -0,0 +1,28 @@
+- page_title _('What’s your experience level?')
+
+.gl-display-flex.gl-flex-direction-column.gl-align-items-center
+ = image_tag 'learn-gitlab-avatar.jpg', width: '90'
+
+ %h2.gl-text-center.gl-mt-3.gl-mb-3= _('Hello there')
+ %p.gl-text-center.gl-font-lg.gl-mb-6= _('Welcome to the guided GitLab tour')
+
+ %h3.gl-text-center.gl-font-lg.gl-mt-6.gl-mb-0= _('What describes you best?')
+
+ .card-deck.gl-mt-6
+ .card
+ .card-body.gl-display-flex.gl-py-8.gl-pr-5.gl-pl-7
+ .gl-align-self-center.gl-pr-6
+ = image_tag 'novice.svg', width: '78', height: '78', alt: ''
+ %div
+ %p.gl-font-lg.gl-font-weight-bold.gl-mb-2= _('Novice')
+ %p= _('I’m not very familiar with the basics of project management and DevOps.')
+ = link_to _('Show me everything'), users_sign_up_experience_level_path(experience_level: :novice, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link'
+
+ .card
+ .card-body.gl-display-flex.gl-py-8.gl-pr-5.gl-pl-7
+ .gl-align-self-center.gl-pr-6
+ = image_tag 'experienced.svg', width: '78', height: '78', alt: ''
+ %div
+ %p.gl-font-lg.gl-font-weight-bold.gl-mb-2= _('Experienced')
+ %p= _('I’m familiar with the basics of project management and DevOps.')
+ = link_to _('Show me more advanced stuff'), users_sign_up_experience_level_path(experience_level: :experienced, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link'
diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
index b809696cccb..3e889900981 100644
--- a/app/views/shared/_broadcast_message.html.haml
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -1,4 +1,6 @@
-%div{ class: "broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'} js-broadcast-notification-#{message.id} d-flex",
+- is_banner = message.broadcast_type == 'banner'
+
+%div{ class: "broadcast-message #{'alert-warning' if is_banner} broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'} js-broadcast-notification-#{message.id} gl-display-flex",
style: broadcast_message_style(message), dir: 'auto' }
.flex-grow-1.text-right.pr-2
= sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top')
diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml
index 0d46d047134..caf2bdce899 100644
--- a/app/views/shared/_choose_avatar_button.html.haml
+++ b/app/views/shared/_choose_avatar_button.html.haml
@@ -1,4 +1 @@
-%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.")
+= render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_custom_attributes.html.haml b/app/views/shared/_custom_attributes.html.haml
new file mode 100644
index 00000000000..966ab8e3cb1
--- /dev/null
+++ b/app/views/shared/_custom_attributes.html.haml
@@ -0,0 +1,12 @@
+- return unless custom_attributes.present?
+
+.card
+ .card-header
+ = link_to(_('Custom Attributes'), help_page_path('api/custom_attributes.md'))
+ %ul.content-list
+ - custom_attributes.each do |custom_attribute|
+ %li
+ %span.light
+ = custom_attribute.key
+ %strong
+ = custom_attribute.value
diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml
index 4f416c483f2..2480014ea42 100644
--- a/app/views/shared/_field.html.haml
+++ b/app/views/shared/_field.html.haml
@@ -3,6 +3,7 @@
- value = @service.send(name)
- type = field[:type]
- placeholder = field[:placeholder]
+- autocomplete = field[:autocomplete]
- required = field[:required]
- choices = field[:choices]
- default_choice = field[:default_choice]
@@ -15,13 +16,13 @@
= form.label name, title, class: "col-form-label col-sm-2"
.col-sm-10
- if type == 'text'
- = form.text_field name, class: "form-control", placeholder: placeholder, required: required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" }
+ = form.text_field name, class: "form-control", autocomplete: autocomplete, placeholder: placeholder, required: required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" }
- elsif type == 'textarea'
= form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required
- elsif type == 'checkbox'
= form.check_box name
- elsif type == 'select'
- = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control"}
+ = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control"} # rubocop:disable Style/RedundantCondition
- elsif type == 'password'
= form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" }
- if help
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index 18f51f0c0c8..b9952d6832f 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.qa-file-content
+.file-content.code.js-syntax-highlight
.line-numbers
- if blob.data.present?
- link_icon = icon('link')
diff --git a/app/views/shared/_file_picker_button.html.haml b/app/views/shared/_file_picker_button.html.haml
new file mode 100644
index 00000000000..7c9a3bd3d31
--- /dev/null
+++ b/app/views/shared/_file_picker_button.html.haml
@@ -0,0 +1,6 @@
+%span.js-filepicker
+ %button.btn.js-filepicker-button{ type: 'button' }= _("Choose file…")
+ %span.file_name.js-filepicker-filename= _("No file chosen")
+ = f.file_field field, class: "js-filepicker-input hidden"
+ - if help_text.present?
+ .form-text.text-muted= help_text
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 019b2ef89a4..09b9cd448bb 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -6,7 +6,7 @@
.form-group.group-name-holder.col-sm-12
= f.label :name, class: 'label-bold' do
= _("Group name")
- = f.text_field :name, placeholder: _('My Awesome Group'), class: 'form-control input-lg',
+ = f.text_field :name, placeholder: _('My Awesome Group'), class: 'js-autofill-group-name form-control input-lg',
required: true,
title: _('Please fill in a descriptive name for your group.'),
autofocus: true
@@ -22,7 +22,7 @@
- if parent
%strong= parent.full_path + '/'
= f.hidden_field :parent_id
- = f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path',
+ = f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path js-autofill-group-path',
autofocus: local_assigns[:autofocus] || false, required: true,
pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
title: _('Please choose a group URL with no special characters.'),
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml
index 10575aa68b1..f5f24b2f0ce 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/shared/_md_preview.html.haml
@@ -18,7 +18,7 @@
= _("Preview")
%li.md-header-toolbar.active
- = render 'projects/blob/markdown_buttons', show_fullscreen_button: true
+ = render 'shared/blob/markdown_buttons', show_fullscreen_button: true
.md-write-holder
= yield
diff --git a/app/views/shared/_namespace_storage_limit_alert.html.haml b/app/views/shared/_namespace_storage_limit_alert.html.haml
new file mode 100644
index 00000000000..95f27cde15b
--- /dev/null
+++ b/app/views/shared/_namespace_storage_limit_alert.html.haml
@@ -0,0 +1,26 @@
+- return unless current_user
+
+- payload = namespace_storage_alert(namespace)
+- return if payload.empty?
+
+- alert_level = payload[:alert_level]
+- root_namespace = payload[:root_namespace]
+
+- style = namespace_storage_alert_style(alert_level)
+- icon = namespace_storage_alert_icon(alert_level)
+- link = namespace_storage_usage_link(root_namespace)
+
+%div{ class: [classes, 'js-namespace-storage-alert'] }
+ .gl-pt-5.gl-pb-3
+ .gl-alert{ class: "gl-alert-#{style}", role: 'alert' }
+ = sprite_icon(icon, css_class: "gl-icon gl-alert-icon")
+ .gl-alert-title
+ %h4.gl-alert-title= payload[:usage_message]
+ - if alert_level != :error
+ %button.js-namespace-storage-alert-dismiss.gl-alert-dismiss.gl-cursor-pointer{ type: 'button', 'aria-label' => _('Dismiss'), data: { id: root_namespace.id, level: alert_level } }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .gl-alert-body
+ = payload[:explanation_message]
+ - if link
+ .gl-alert-actions
+ = link_to(_('Manage storage usage'), link, class: "btn gl-alert-action btn-md gl-button btn-#{style}")
diff --git a/app/views/shared/_new_merge_request_checkbox.html.haml b/app/views/shared/_new_merge_request_checkbox.html.haml
index 24c0dfe247f..6bc6d0943c9 100644
--- a/app/views/shared/_new_merge_request_checkbox.html.haml
+++ b/app/views/shared/_new_merge_request_checkbox.html.haml
@@ -1,4 +1,4 @@
-.form-check.prepend-top-8
+.form-check.gl-mt-3
- nonce = SecureRandom.hex
= check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request form-check-input', id: "create_merge_request-#{nonce}"
= label_tag "create_merge_request-#{nonce}", class: 'form-check-label' do
diff --git a/app/views/shared/_promo.html.haml b/app/views/shared/_promo.html.haml
index 0f31b60d8d3..855f6b9c1f4 100644
--- a/app/views/shared/_promo.html.haml
+++ b/app/views/shared/_promo.html.haml
@@ -1,5 +1,5 @@
.gitlab-promo
- = link_to 'Homepage', promo_url
- = link_to 'Blog', promo_url + '/blog/'
+ = link_to _('Homepage'), promo_url
+ = link_to _('Blog'), promo_url + '/blog/'
= link_to '@gitlab', 'https://twitter.com/gitlab'
- = link_to 'Requests', 'https://gitlab.com/gitlab-org/gitlab-foss/blob/master/CONTRIBUTING.md#feature-proposals'
+ = link_to _('Requests'), 'https://gitlab.com/gitlab-org/gitlab-foss/blob/master/CONTRIBUTING.md#feature-proposals'
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index a9203459914..92b86c6fec1 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -1,5 +1,4 @@
= form_errors(@service)
-- trigger_events = Feature.enabled?(:integration_form_refactor) ? ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json : []
- if lookup_context.template_exists?('help', "projects/services/#{@service.to_param}", true)
= render "projects/services/#{@service.to_param}/help", subject: @service
@@ -10,9 +9,9 @@
.service-settings
.js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, type: @service.to_param, merge_request_events: @service.merge_requests_events.to_s,
-commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events } }
+commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events_for_service, fields: fields_for_service } }
- - if @service.configurable_events.present? && !@service.is_a?(JiraService) && Feature.disabled?(:integration_form_refactor)
+ - if show_service_trigger_events?
.form-group.row
%label.col-form-label.col-sm-2= _('Trigger')
@@ -33,5 +32,6 @@ commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on
%p.text-muted
= @service.class.event_description(event)
- - @service.global_fields.each do |field|
- = render 'shared/field', form: form, field: field
+ - unless integration_form_refactor?
+ - @service.global_fields.each do |field|
+ = render 'shared/field', form: form, field: field
diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml
index 2f42a877beb..84ce40e240c 100644
--- a/app/views/shared/_visibility_level.html.haml
+++ b/app/views/shared/_visibility_level.html.haml
@@ -2,7 +2,7 @@
.form-group.visibility-level-setting
- if with_label
- = f.label :visibility_level, _('Visibility level'), class: 'label-bold append-bottom-0'
+ = f.label :visibility_level, _('Visibility level'), class: 'label-bold gl-mb-0'
%p
= _('Who can see this group?')
- visibility_docs_path = help_page_path('public_access/public_access')
diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml
index 80532c9187b..90b12557bc8 100644
--- a/app/views/shared/_visibility_radios.html.haml
+++ b/app/views/shared/_visibility_radios.html.haml
@@ -1,8 +1,7 @@
-- Gitlab::VisibilityLevel.values.each do |level|
- - disallowed = disallowed_visibility_level?(form_model, level)
- - restricted = restricted_visibility_levels.include?(level)
- - next if disallowed || restricted
+- available_visibility_levels = available_visibility_levels(form_model)
+- selected_level = snippets_selected_visibility_level(available_visibility_levels, selected_level)
+- available_visibility_levels.each do |level|
.form-check
= form.radio_button model_method, level, checked: (selected_level == level), class: 'form-check-input', data: { track_label: "blank_project", track_event: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" }
= form.label "#{model_method}_#{level}", class: 'form-check-label' do
diff --git a/app/views/projects/_zen.html.haml b/app/views/shared/_zen.html.haml
index 744aef3cad4..8dd0e5a92a7 100644
--- a/app/views/projects/_zen.html.haml
+++ b/app/views/shared/_zen.html.haml
@@ -15,5 +15,5 @@
qa_selector: qa_selector }
- else
= text_area_tag attr, current_text, class: classes, placeholder: placeholder
- %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
- = icon('compress')
+ %a.zen-control.zen-control-leave.js-zen-leave.gl-text-gray-700{ href: "#" }
+ = sprite_icon('compress', size: 16)
diff --git a/app/views/shared/access_tokens/_created_container.html.haml b/app/views/shared/access_tokens/_created_container.html.haml
index f11ef1e01de..c5a18d98b89 100644
--- a/app/views/shared/access_tokens/_created_container.html.haml
+++ b/app/views/shared/access_tokens/_created_container.html.haml
@@ -1,5 +1,5 @@
.created-personal-access-token-container
- %h5.prepend-top-0
+ %h5.gl-mt-0
= _('Your new %{type}') % { type: type }
.form-group
.input-group
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index cb7f907308f..680626f7880 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -1,7 +1,7 @@
- title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type })
- prefix = local_assigns.fetch(:prefix, :personal_access_token)
-%h5.prepend-top-0
+%h5.gl-mt-0
= title
%p.profile-settings-content
= _("Enter the name of your application, and we'll return a unique %{type}.") % { type: type }
diff --git a/app/views/projects/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml
index 44ec2fa69cb..c1ffdc7184a 100644
--- a/app/views/projects/blob/_markdown_buttons.html.haml
+++ b/app/views/shared/blob/_markdown_buttons.html.haml
@@ -4,9 +4,9 @@
= 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-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: "list-task", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") })
+ = markdown_toolbar_button({ icon: "list-task", 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" } }
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index cf42ac3dd37..902b6d19f82 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -20,20 +20,31 @@
#board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
= render 'shared/issuable/search_bar', type: :boards, board: board
- .boards-list.w-100.py-3.px-2.text-nowrap{ data: { qa_selector: "boards_list" } }
- .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",
+ - if Feature.enabled?(:boards_with_swimlanes, current_board_parent)
+ %board-content{ "v-cloak" => "true",
+ "ref" => "board_content",
+ ":lists" => "state.lists",
":can-admin-list" => can_admin_list,
":group-id" => group_id,
- ":list" => "list",
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
- ":board-id" => "boardId",
- ":key" => "list.id" }
+ ":board-id" => "boardId" }
+ - else
+ .boards-list.w-100.py-3.px-2.text-nowrap{ data: { qa_selector: "boards_list" } }
+ .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",
+ ":can-admin-list" => can_admin_list,
+ ":group-id" => group_id,
+ ":list" => "list",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase",
+ ":root-path" => "rootPath",
+ ":board-id" => "boardId",
+ ":key" => "list.id" }
= render "shared/boards/components/sidebar", group: group
= render_if_exists 'shared/boards/components/board_settings_sidebar'
- if @project
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 1944c293be1..94742d96af7 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -30,5 +30,5 @@
= deploy_keys_project_form.label :can_push do
= deploy_keys_project_form.check_box :can_push
%strong= _('Write access allowed')
- %p.light.append-bottom-0
+ %p.light.gl-mb-0
= _('Allow this key to push to repository as well? (Default only allows pull access.)')
diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml
index f28e745f4c5..358075b9e44 100644
--- a/app/views/shared/deploy_keys/_index.html.haml
+++ b/app/views/shared/deploy_keys/_index.html.haml
@@ -7,7 +7,7 @@
%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.')
.settings-content
- %h5.prepend-top-0
+ %h5.gl-mt-0
= _('Create a new deploy key for this project')
= render @deploy_keys.form_partial_path
%hr
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index 8edd1d9deb8..4e569050827 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -7,7 +7,7 @@
= f.label :key, class: "label-bold"
= f.text_area :key, class: "form-control", rows: 5, required: true
.form-group.row
- %p.light.append-bottom-0
+ %p.light.gl-mb-0
= _('Paste a machine public key here. Read more about how to generate it')
= link_to "here", help_page_path("ssh/README")
@@ -17,7 +17,7 @@
= deploy_keys_project_form.check_box :can_push
%strong= _('Write access allowed')
.form-group.row
- %p.light.append-bottom-0
+ %p.light.gl-mb-0
= _('Allow this key to push to repository as well? (Default only allows pull access.)')
.form-group.row
diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml
index b0c9c72dfaa..8203b378297 100644
--- a/app/views/shared/deploy_tokens/_index.html.haml
+++ b/app/views/shared/deploy_tokens/_index.html.haml
@@ -10,7 +10,7 @@
.settings-content
- if @new_deploy_token.persisted?
= render 'shared/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token
- %h5.prepend-top-0
+ %h5.gl-mt-0
= s_('DeployTokens|Add a deploy token')
= render 'shared/deploy_tokens/form', group_or_project: group_or_project, token: @new_deploy_token, presenter: @deploy_tokens
%hr
diff --git a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml
index f295fa82192..a9728dc841f 100644
--- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml
+++ b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml
@@ -1,6 +1,6 @@
.qa-created-deploy-token-section.created-deploy-token-container.info-well
.well-segment
- %h5.prepend-top-0
+ %h5.gl-mt-0
= s_('DeployTokens|Your New Deploy Token')
.form-group
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index efd9bceedc5..db8da50d868 100644
--- a/app/views/shared/empty_states/_snippets.html.haml
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -3,7 +3,7 @@
.row.empty-state.mt-0
.col-12
.svg-content
- = image_tag 'illustrations/snippets_empty.svg'
+ = image_tag 'illustrations/snippets_empty.svg', data: { qa_selector: 'svg_content' }
.text-content.text-center.pt-0
- if current_user
%h4
@@ -12,7 +12,7 @@
= s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.')
.mt-2<
- if button_path
- = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link'
+ = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }
= link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn btn-default', title: s_('SnippetsEmptyState|Documentation')
- 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 73eedcc1dc9..ff5ee801969 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -1,30 +1,31 @@
- layout_path = 'shared/empty_states/wikis_layout'
+- messages = wiki_empty_state_messages(@wiki)
-- if can?(current_user, :create_wiki, @project)
- - create_path = project_wiki_path(@project, params[:id], { view: 'create' })
+- if can?(current_user, :create_wiki, @wiki.container)
+ - create_path = wiki_page_path(@wiki, params[:id], view: 'create')
- 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
- = s_('WikiEmpty|The wiki lets you write documentation for your project')
+ = messages.dig(:writable, :title)
%p.text-left
- = s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on.")
+ = messages.dig(:writable, :body)
= create_link
-- elsif can?(current_user, :read_issue, @project)
+- elsif @project && can?(current_user, :read_issue, @project)
- issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project)
- new_issue_link = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn btn-success', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement')
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
%h4
- = s_('WikiEmpty|This project has no wiki pages')
+ = messages.dig(:issuable, :title)
%p.text-left
- = s_('WikiEmptyIssueMessage|You must be a project member in order to add wiki pages. If you have suggestions for how to improve the wiki for this project, consider opening an issue in the %{issues_link}.').html_safe % { issues_link: issues_link }
+ = messages.dig(:issuable, :body).html_safe % { issues_link: issues_link }
= new_issue_link
- else
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
%h4
- = s_('WikiEmpty|This project has no wiki pages')
+ = messages.dig(:readonly, :title)
%p
- = s_('WikiEmpty|You must be a project member in order to add wiki pages.')
+ = messages.dig(:readonly, :body)
diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml
index 0e1f41bbbf6..436bd305df1 100644
--- a/app/views/shared/file_hooks/_index.html.haml
+++ b/app/views/shared/file_hooks/_index.html.haml
@@ -2,7 +2,7 @@
.row.prepend-top-default
.col-lg-4
- %h4.prepend-top-0
+ %h4.gl-mt-0
= _('File Hooks')
%p
= _('File hooks are similar to system hooks but are executed as files instead of sending data to a URL.')
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 2f2e6d83f9f..77af4f09408 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -18,8 +18,8 @@
- if model.is_a?(Issuable)
= render 'shared/issuable/form/template_selector', issuable: model
- = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
- = render 'projects/zen', f: form, attr: :description,
+ = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do
+ = render 'shared/zen', f: form, attr: :description,
classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description',
placeholder: placeholder,
supports_quick_actions: supports_quick_actions
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index 60c9c076a70..5dac400bd5e 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -1,28 +1,29 @@
- user = local_assigns.fetch(:user, current_user)
- access = user&.max_member_access_for_group(group.id)
-%li.group-row.py-3{ class: ('no-description' if group.description.blank?) }
- .stats
- %span
+%li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!#{' no-description' if group.description.blank?}" }
+ .avatar-container.rect-avatar.s40.gl-flex-shrink-0
+ = link_to group do
+ = group_icon(group, class: "avatar s40")
+ .gl-min-w-0.gl-flex-grow-1
+ .title
+ = link_to group.full_name, group, class: 'group-name'
+
+ - if access&.nonzero?
+ %span.user-access-role= Gitlab::Access.human_access(access)
+
+ - if group.description.present?
+ .description
+ = markdown_field(group, :description)
+
+ .stats.gl-text-gray-700.gl-flex-shrink-0
+ %span.gl-ml-5
= icon('bookmark')
= number_with_delimiter(group.projects.non_archived.count)
- %span
+ %span.gl-ml-5
= icon('users')
= number_with_delimiter(group.users.count)
- %span.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) }
+ %span.gl-ml-5.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) }
= visibility_level_icon(group.visibility_level, fw: false)
-
- .avatar-container.rect-avatar.s40
- = link_to group do
- = group_icon(group, class: "avatar s40")
- .title
- = link_to group.full_name, group, class: 'group-name'
-
- - if access&.nonzero?
- %span.user-access-role= Gitlab::Access.human_access(access)
-
- - if group.description.present?
- .description
- = markdown_field(group, :description)
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index a020a04e366..1b3ad484bcc 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -31,7 +31,7 @@
= form.label :confidential, class: 'form-check-label' do
This issue is confidential and should only be visible to team members with at least Reporter access.
-= render 'shared/issuable/form/metadata', issuable: issuable, form: form
+= render 'shared/issuable/form/metadata', issuable: issuable, form: form, project: project
= render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 34be9291f1f..d53ec4d4eeb 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -173,6 +173,8 @@
= render 'shared/issuable/board_create_list_dropdown', board: board
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
+ - if Feature.enabled?(:boards_with_swimlanes, @group)
+ #js-board-epics-swimlanes-toggle
#js-toggle-focus-btn
- elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown
= render 'shared/issuable/sort_dropdown'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index a1c56cdb64f..ab4bd88cfe5 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -53,7 +53,8 @@
.selectbox.hide-collapsed
= 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' }})
-
+ - if @project.group.present?
+ = render_if_exists 'shared/issuable/iteration_select', { can_edit: can_edit_issuable, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type }
#issuable-time-tracker.block
// Fallback while content is loading
.title.hide-collapsed
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 4192ecd2238..cf239a5d04c 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -40,4 +40,17 @@
- data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
- options[:data].merge!(data)
- = dropdown_tag(title, options: options)
+ - if experiment_enabled?(:invite_members_version_a) && can_import_members?
+ - options[:dropdown_class] += ' dropdown-extended-height'
+ - options[:footer_content] = true
+ - options[:wrapper_class] = 'js-sidebar-assignee-dropdown'
+
+ = dropdown_tag(title, options: options) do
+ %ul.dropdown-footer-list
+ %li
+ = link_to _('Invite Members'),
+ project_project_members_path(@project),
+ title: _('Invite Members'),
+ data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_assignee' }
+ - else
+ = dropdown_tag(title, options: options)
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 90a6a98235d..1389bc2ab4d 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -1,3 +1,4 @@
+- project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable)
- return unless can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project)
@@ -10,6 +11,9 @@
%div{ class: (has_due_date ? "col-lg-6" : "col-12") }
.form-group.row.merge-request-assignee
= render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date
+
+ = render_if_exists "shared/issuable/form/epic", issuable: issuable, form: form, project: project
+
.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) }
@@ -22,11 +26,11 @@
.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"
- = render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form
= render_if_exists "shared/issuable/form/merge_request_blocks", issuable: issuable, form: form
- - if has_due_date
+ - if has_due_date || issuable.supports_weight?
.col-lg-6
+ = render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form
.form-group.row
= form.label :due_date, "Due date", class: "col-form-label col-md-2 col-lg-4"
.col-8
diff --git a/app/views/shared/milestones/_deprecation_message.html.haml b/app/views/shared/milestones/_deprecation_message.html.haml
index acd90fa9178..ba5eb54f017 100644
--- a/app/views/shared/milestones/_deprecation_message.html.haml
+++ b/app/views/shared/milestones/_deprecation_message.html.haml
@@ -1,14 +1,14 @@
.banner-callout.compact.milestone-deprecation-message.js-milestone-deprecation-message.prepend-top-20
.banner-graphic= image_tag 'illustrations/milestone_removing-page.svg'
.banner-body.prepend-left-10.append-right-10
- %h5.banner-title.prepend-top-0= _('This page will be removed in a future release.')
+ %h5.banner-title.gl-mt-0= _('This page will be removed in a future release.')
%p.milestone-banner-text= _('Use group milestones to manage issues from multiple projects in the same milestone.')
= button_tag _('Promote these project milestones into a group milestone.'), class: 'btn btn-link js-popover-link text-align-left milestone-banner-link'
.milestone-banner-buttons.prepend-top-20= link_to _('Learn more'), help_page_url('user/project/milestones/index', anchor: 'promoting-project-milestones-to-group-milestones'), class: 'btn btn-default', target: '_blank'
%template.js-milestone-deprecation-message-template
.milestone-popover-body
- %ol.milestone-popover-instructions-list.append-bottom-0
+ %ol.milestone-popover-instructions-list.gl-mb-0
%li= _('Click any <strong>project name</strong> in the project list below to navigate to the project milestone.').html_safe
%li= _('Click the <strong>Promote</strong> button in the top right corner to promote it to a group milestone.').html_safe
%hr.popover-hr
diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml
index 4de89d7c7a0..6dbc460d9bf 100644
--- a/app/views/shared/milestones/_form_dates.html.haml
+++ b/app/views/shared/milestones/_form_dates.html.haml
@@ -1,13 +1,13 @@
.col-md-6
.form-group.row
.col-form-label.col-sm-2
- = f.label :start_date, "Start Date"
+ = 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
+ = 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
.col-form-label.col-sm-2
- = f.label :due_date, "Due Date"
+ = 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
+ = 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/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index 2da857261d1..99a46f1fb85 100644
--- a/app/views/shared/milestones/_header.html.haml
+++ b/app/views/shared/milestones/_header.html.haml
@@ -11,8 +11,7 @@
.milestone-buttons
- if can?(current_user, :admin_milestone, @group || @project)
- - unless milestone.legacy_group_milestone?
- = link_to _('Edit'), edit_milestone_path(milestone), class: 'btn btn-grouped'
+ = link_to _('Edit'), edit_milestone_path(milestone), class: 'btn btn-grouped'
- if milestone.project_milestone? && milestone.project.group
%button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal',
@@ -31,8 +30,7 @@
- else
= link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn btn-grouped btn-reopen'
- - unless milestone.legacy_group_milestone?
- = render 'shared/milestones/delete_button'
+ = render 'shared/milestones/delete_button'
%button.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' }
= icon('angle-double-left')
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 9f61082d605..31505d2d9fb 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -6,39 +6,33 @@
.row
.col-sm-6
.append-bottom-5
- %strong= link_to truncate(milestone.title, length: 100), milestone_path
+ %strong= link_to truncate(milestone.title, length: 100), milestone_path(milestone)
- if @group
= " - #{milestone_type}"
- - if @project || milestone.is_a?(GlobalMilestone) || milestone.group_milestone?
- - if milestone.due_date || milestone.start_date
- .text-tertiary.append-bottom-5
- = milestone_date_range(milestone)
- - recent_releases, total_count, more_count = recent_releases_with_counts(milestone)
- - unless total_count.zero?
- .text-tertiary.append-bottom-5.milestone-release-links
- = icon('rocket')
- = n_('Release', 'Releases', total_count)
- - recent_releases.each do |release|
- = link_to release.name, project_releases_path(release.project, anchor: release.tag)
- - unless release == recent_releases.last
- &bull;
- - if total_count > recent_releases.count
+ - if milestone.due_date || milestone.start_date
+ .text-tertiary.append-bottom-5
+ = milestone_date_range(milestone)
+ - recent_releases, total_count, more_count = recent_releases_with_counts(milestone)
+ - unless total_count.zero?
+ .text-tertiary.append-bottom-5.milestone-release-links
+ = sprite_icon("rocket", size: 12)
+ = n_('Release', 'Releases', total_count)
+ - recent_releases.each do |release|
+ = link_to release.name, project_releases_path(release.project, anchor: release.tag)
+ - unless release == recent_releases.last
&bull;
- = link_to n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }, project_releases_path(milestone.project)
- %div
- = render('shared/milestone_expired', milestone: milestone)
- - if milestone.group_milestone?
- .label-badge.label-badge-blue.d-inline-block
- = milestone.group.full_name
- - if milestone.legacy_group_milestone?
- .projects
- - 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
+ - if total_count > recent_releases.count
+ &bull;
+ = link_to n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }, project_releases_path(milestone.project)
+ %div
+ = render('shared/milestone_expired', milestone: milestone)
+ - if milestone.group_milestone?
+ .label-badge.label-badge-blue.d-inline-block
+ = milestone.group.full_name
+ - if milestone.project_milestone?
+ .label-badge.label-badge-gray.d-inline-block
+ = milestone.project.full_name
.col-sm-4.milestone-progress
= milestone_progress_bar(milestone)
@@ -49,29 +43,25 @@
.float-lg-right.light #{milestone.percent_complete}% complete
.col-sm-2
.milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end
- - if @project
- - if can_admin_project_milestones? and milestone.active?
- - if can_admin_group_milestones?
- %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: s_('Milestones|Promote to Group Milestone'),
- disabled: true,
- type: 'button',
- data: { url: promote_project_milestone_path(milestone.project, milestone),
- milestone_title: milestone.title,
- group_name: @project.group.name,
- target: '#promote-milestone-modal',
- container: 'body',
- toggle: 'modal' } }
- = sprite_icon('level-up', size: 14)
+ - if @project # if in milestones list on project level
+ - if can_admin_group_milestones?
+ %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: s_('Milestones|Promote to Group Milestone'),
+ disabled: true,
+ type: 'button',
+ data: { url: promote_project_milestone_path(milestone.project, milestone),
+ milestone_title: milestone.title,
+ group_name: @project.group.name,
+ target: '#promote-milestone-modal',
+ container: 'body',
+ toggle: 'modal' } }
+ = sprite_icon('level-up', size: 14)
+
+ - if can?(current_user, :admin_milestone, milestone)
+ - if milestone.closed?
+ = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen"
+ - else
+ = link_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close"
- = link_to s_('Milestones|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 s_('Milestones|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?
- = link_to s_('Milestones|Reopen Milestone'), group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen"
- - else
- = link_to s_('Milestones|Close Milestone'), group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close"
- if dashboard
.label-badge.label-badge-gray
= milestone_type
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index ba1629bd99a..160f6487439 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -4,12 +4,12 @@
%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar.milestone-sidebar
.block.milestone-progress.issuable-sidebar-header
- %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' } }
+ %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => s_('MilestoneSidebar|Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
= sidebar_gutter_toggle_icon
.title.hide-collapsed
%strong.bold== #{milestone.percent_complete}%
%span.hide-collapsed
- complete
+ = s_('MilestoneSidebar|complete')
.value.hide-collapsed
= milestone_progress_bar(milestone)
@@ -20,15 +20,15 @@
.block.start_date.hide-collapsed
.title
- Start date
+ = s_('MilestoneSidebar|Start date')
- if @project && can?(current_user, :admin_milestone, @project)
- = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right'
+ = link_to s_('MilestoneSidebar|Edit'), edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value
%span.value-content
- if milestone.start_date
%span.bold= milestone.start_date.to_s(:medium)
- else
- %span.no-value No start date
+ %span.no-value= s_('MilestoneSidebar|No start date')
.block.due_date
.sidebar-collapsed-icon
@@ -45,26 +45,26 @@
.due_date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= milestone.due_date.strftime('%b %-d %Y')
- elsif milestone.start_date
- From
+ = s_('MilestoneSidebar|From')
.milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= milestone.start_date.strftime('%b %-d %Y')
- elsif milestone.due_date
- Until
+ = s_('MilestoneSidebar|Until')
.milestone-date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= milestone.due_date.strftime('%b %-d %Y')
- else
.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
- None
+ = s_('MilestoneSidebar|None')
.title.hide-collapsed
- Due date
+ = s_('MilestoneSidebar|Due date')
- if @project && can?(current_user, :admin_milestone, @project)
- = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right'
+ = link_to s_('MilestoneSidebar|Edit'), edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value.hide-collapsed
%span.value-content
- if milestone.due_date
%span.bold= milestone.due_date.to_s(:medium)
- else
- %span.no-value No due date
+ %span.no-value= s_('MilestoneSidebar|No due date')
- remaining_days = remaining_days_in_words(milestone.due_date, milestone.start_date)
- if remaining_days.present?
= surround '(', ')' do
@@ -77,19 +77,19 @@
= custom_icon('issues')
%span= milestone.issues_visible_to_user(current_user).count
.title.hide-collapsed
- Issues
+ = s_('MilestoneSidebar|Issues')
%span.badge.badge-pill= milestone.issues_visible_to_user(current_user).count
- if show_new_issue_link?(project)
- = link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "float-right", title: "New Issue" do
- New issue
+ = link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "float-right", title: s_('MilestoneSidebar|New Issue') do
+ = s_('MilestoneSidebar|New issue')
.value.hide-collapsed.bold
%span.milestone-stat
= link_to milestones_browse_issuables_path(milestone, type: :issues) do
- Open:
+ = s_('MilestoneSidebar|Open:')
= milestone.issues_visible_to_user(current_user).opened.count
%span.milestone-stat
= link_to milestones_browse_issuables_path(milestone, type: :issues, state: 'closed') do
- Closed:
+ = s_('MilestoneSidebar|Closed:')
= milestone.issues_visible_to_user(current_user).closed.count
.block
@@ -108,31 +108,31 @@
= custom_icon('mr_bold')
%span= milestone.merge_requests.count
.title.hide-collapsed
- Merge requests
+ = s_('MilestoneSidebar|Merge requests')
%span.badge.badge-pill= milestone.merge_requests.count
.value.hide-collapsed.bold
- if !project || can?(current_user, :read_merge_request, project)
%span.milestone-stat
= link_to milestones_browse_issuables_path(milestone, type: :merge_requests) do
- Open:
+ = s_('MilestoneSidebar|Open:')
= milestone.merge_requests.opened.count
%span.milestone-stat
= link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'closed') do
- Closed:
+ = s_('MilestoneSidebar|Closed:')
= milestone.merge_requests.closed.count
%span.milestone-stat
= link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'merged') do
- Merged:
+ = s_('MilestoneSidebar|Merged:')
= milestone.merge_requests.merged.count
- else
%span.milestone-stat
- Open:
+ = s_('MilestoneSidebar|Open:')
= milestone.merge_requests.opened.count
%span.milestone-stat
- Closed:
+ = s_('MilestoneSidebar|Closed:')
= milestone.merge_requests.closed.count
%span.milestone-stat
- Merged:
+ = s_('MilestoneSidebar|Merged:')
= milestone.merge_requests.merged.count
- if project
@@ -140,12 +140,12 @@
.block.releases
.sidebar-collapsed-icon.has-tooltip{ title: milestone_releases_tooltip_text(milestone), data: { container: 'body', placement: 'left', boundary: 'viewport' } }
%strong
- = icon('rocket')
+ = sprite_icon("rocket", size: 16)
%span= total_count
.title.hide-collapsed= n_('Release', 'Releases', total_count)
.hide-collapsed
- if total_count.zero?
- .no-value= _('None')
+ .no-value= s_('MilestoneSidebar|None')
- else
.font-weight-bold
- recent_releases.each do |release|
@@ -160,10 +160,10 @@
- if milestone_ref.present?
.block.reference
.sidebar-collapsed-icon.dont-change-state
- = clipboard_button(text: milestone_ref, title: _("Copy reference"), placement: "left", boundary: 'viewport')
+ = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport')
.cross-project-reference.hide-collapsed
%span
- Reference:
+ = s_('MilestoneSidebar|Reference:')
%cite{ title: milestone_ref }
= milestone_ref
- = clipboard_button(text: milestone_ref, title: _("Copy reference"), placement: "left", boundary: 'viewport')
+ = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport')
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 5f53e6316af..49df00940b7 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -1,11 +1,9 @@
- page_title milestone.title
-- @breadcrumb_link = dashboard_milestone_path(milestone.safe_title, title: milestone.title)
+- @breadcrumb_link = milestone_path(milestone)
- group = local_assigns[:group]
-- is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone?
= render 'shared/milestones/header', milestone: milestone
-= render 'shared/milestones/deprecation_message' if is_dynamic_milestone
= render 'shared/milestones/description', milestone: milestone
- if milestone.complete? && milestone.active?
@@ -15,26 +13,3 @@
= group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.')
= render_if_exists 'shared/milestones/burndown', milestone: milestone, project: @project
-
-- if is_dynamic_milestone
- .table-holder
- %table.table
- %thead
- %tr
- %th= _('Project')
- %th= _('Open issues')
- %th= _('State')
- %th= _('Due date')
- %tr
- %td
- - project_name = group ? milestone.project.name : milestone.project.full_name
- = link_to project_name, milestone_path(milestone.milestone)
- %td
- = milestone.milestone.issues_visible_to_user(current_user).opened.count
- %td
- - if milestone.closed?
- = _('Closed')
- - else
- = _('Open')
- %td
- = milestone.expires_at
diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml
index 6fe511c2999..244c191af12 100644
--- a/app/views/shared/notes/_edit_form.html.haml
+++ b/app/views/shared/notes/_edit_form.html.haml
@@ -2,8 +2,8 @@
= form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do
= 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 layout: 'shared/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do
+ = render 'shared/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
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index 327745e4f4d..40e36728642 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -25,8 +25,8 @@
= f.hidden_field :position
.discussion-form-container.discussion-with-resolve-btn.flex-column.p-0
- = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
- = render 'projects/zen', f: f,
+ = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do
+ = render 'shared/zen', f: f,
attr: :note,
classes: 'note-textarea js-note-text',
placeholder: _("Write a comment or drag your files here…"),
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index 0142deb47f8..9bd08c2296f 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -16,7 +16,7 @@
= hidden_field_tag("hide_label", true) if hide_label
.row
.col-lg-4
- %h4.prepend-top-0= _('Notification events')
+ %h4.gl-mt-0= _('Notification events')
%p
- notification_link = link_to _('notification emails'), help_page_path('user/profile/notifications'), target: '_blank'
- paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe }
@@ -26,7 +26,7 @@
- next if notification_event_disabled?(event)
- field_id = "#{notifications_menu_identifier("modal", notification_setting)}_notification_setting[#{event}]"
.form-group
- .form-check{ class: ("prepend-top-0" if index == 0) }
+ .form-check{ class: ("gl-mt-0" if index == 0) }
= check_box("notification_setting", event, id: field_id, class: "js-custom-notification-event form-check-input", checked: notification_setting.public_send(event))
%label.form-check-label{ for: field_id }
%strong
diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml
index 566f08b94ce..796ff095eea 100644
--- a/app/views/shared/notifications/_new_button.html.haml
+++ b/app/views/shared/notifications/_new_button.html.haml
@@ -8,7 +8,7 @@
- else
- button_title = _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }
- .js-notification-dropdown.notification-dropdown.home-panel-action-button.prepend-top-default.append-right-8.dropdown.inline
+ .js-notification-dropdown.notification-dropdown.home-panel-action-button.prepend-top-default.gl-mr-3.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
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 3d61943193f..fc3f1a8d1c1 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -30,9 +30,9 @@
.project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project', qa_project_name: project.name } }
.flex-wrapper
.d-flex.align-items-center.flex-wrap.project-title
- %h2.d-flex.prepend-top-8
+ %h2.d-flex.gl-mt-3
= link_to project_path(project), class: 'text-plain' do
- %span.project-full-name.append-right-8><
+ %span.project-full-name.gl-mr-3><
%span.namespace-name
- if project.namespace && !skip_namespace
= project.namespace.human_name
@@ -40,22 +40,22 @@
%span.project-name<
= project.name
- %span.metadata-info.visibility-icon.append-right-10.prepend-top-8.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
+ %span.metadata-info.visibility-icon.append-right-10.gl-mt-3.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
= visibility_level_icon(project.visibility_level, fw: true)
- if explore_projects_tab? && license_name
- %span.metadata-info.d-inline-flex.align-items-center.append-right-10.prepend-top-8
+ %span.metadata-info.d-inline-flex.align-items-center.append-right-10.gl-mt-3
= sprite_icon('scale', size: 14, css_class: 'append-right-4')
= license_name
- if !explore_projects_tab? && access&.nonzero?
-# haml-lint:disable UnnecessaryStringOutput
= ' ' # prevent haml from eating the space between elements
- .metadata-info.prepend-top-8
+ .metadata-info.gl-mt-3
%span.user-access-role.d-block= Gitlab::Access.human_access(access)
- if !explore_projects_tab?
- .metadata-info.prepend-top-8
+ .metadata-info.gl-mt-3
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project
- if show_last_commit_as_description
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 4695692fb53..7f307f33b51 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -9,7 +9,7 @@
.form-group
= f.label :title, class: 'label-bold'
- = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
+ = f.text_field :title, class: 'form-control', required: true, autofocus: true, data: { qa_selector: 'snippet_title_field' }
.form-group.js-description-input
- description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...')
@@ -19,15 +19,15 @@
.js-collapsed{ class: ('d-none' if is_expanded) }
= text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' }
.js-expanded{ class: ('d-none' if !is_expanded) }
- = 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: description_placeholder, qa_selector: 'snippet_description_field'
+ = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
+ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'snippet_description_field'
= render 'shared/notes/hints'
.form-group.file-editor
= f.label :file_name, s_('Snippets|File')
.file-holder.snippet
.js-file-title.file-title-flex-parent
- = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name'
+ = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name', data: { qa_selector: 'file_name_field' }
.file-content.code
%pre#editor{ data: { 'editor-loading': true } }= @snippet.content
= f.hidden_field :content, class: 'snippet-file-content'
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index e663d57ae6a..7f213c50de2 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.qa-snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
+ .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,11 +17,11 @@
= render "snippets/actions"
.snippet-header.limited-header-width
- %h2.snippet-title.prepend-top-0.mb-3{ data: { qa_selector: 'snippet_title' } }
+ %h2.snippet-title.gl-mt-0.mb-3
= markdown_field(@snippet, :title)
- if @snippet.description.present?
- .description{ data: { qa_selector: 'snippet_description_field' } }
+ .description
.md
= markdown_field(@snippet, :description)
%textarea.hidden.js-task-list-field
diff --git a/app/views/shared/tokens/_scopes_list.html.haml b/app/views/shared/tokens/_scopes_list.html.haml
index 913392be510..558cf67f201 100644
--- a/app/views/shared/tokens/_scopes_list.html.haml
+++ b/app/views/shared/tokens/_scopes_list.html.haml
@@ -6,7 +6,7 @@
%td
= _('Scopes')
%td
- %ul.scopes-list.append-bottom-0
+ %ul.scopes-list.gl-mb-0
- token.scopes.each do |scope|
%li
%span.bold= scope
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index 34a62340966..470e2f6b904 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -11,6 +11,6 @@
= hook.enable_ssl_verification ? _('enabled') : _('disabled')
.col-md-4.col-lg-5.text-right-md.prepend-top-5
- %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm append-right-8'
- %span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn btn-sm append-right-8'
+ %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm gl-mr-3'
+ %span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn btn-sm gl-mr-3'
= link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm'
diff --git a/app/views/shared/web_hooks/_title_and_docs.html.haml b/app/views/shared/web_hooks/_title_and_docs.html.haml
index 359f5f34f5b..f00f3473efa 100644
--- a/app/views/shared/web_hooks/_title_and_docs.html.haml
+++ b/app/views/shared/web_hooks/_title_and_docs.html.haml
@@ -1,5 +1,10 @@
-%h4.prepend-top-0
+- webhooks_link_start = '<a href="%{url}">'.html_safe % { url: help_page_path(hook.help_path) }
+
+%h4.gl-mt-0
= page_title
-%p
- - link = link_to(hook.pluralized_name, help_page_path(hook.help_path))
- = _('%{link} can be used for binding events when something is happening within the project.').html_safe % { link: link }
+
+- if @project
+ - integrations_link_start = '<a href="%{url}">'.html_safe % { url: scoped_integrations_path }
+ %p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{link_end} in preference to a webhook.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe }
+- else
+ %p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, link_end: '</a>'.html_safe }
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index d29abfa937d..8ea06d4d6c3 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -1,7 +1,14 @@
-- form_classes = 'wiki-form common-note-form prepend-top-default js-quick-submit'
-- form_classes += ' js-new-wiki-page' unless @page.persisted?
+- form_classes = %w[wiki-form common-note-form prepend-top-default js-quick-submit]
-= form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post,
+- if @page.persisted?
+ - form_action = wiki_page_path(@wiki, @page)
+ - form_method = :put
+- else
+ - form_action = wiki_path(@wiki, action: :create)
+ - form_method = :post
+ - form_classes << 'js-new-wiki-page'
+
+= form_for @page, url: form_action, method: form_method,
html: { class: form_classes },
data: { uploads_path: uploads_path } do |f|
= form_errors(@page, truncate: :title)
@@ -28,14 +35,14 @@
.col-sm-12= f.label :format, class: 'control-label-full-width'
.col-sm-12
.select-wrapper
- = f.select :format, options_for_select(ProjectWiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control'
+ = f.select :format, options_for_select(Wiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control'
= icon('chevron-down')
.form-group.row
.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 qa-wiki-content-textarea', placeholder: s_("WikiPage|Write your content or drag files here…")
+ = render layout: 'shared/md_preview', locals: { url: wiki_page_path(@wiki, @page, action: :preview_markdown) } do
+ = render 'shared/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
@@ -65,8 +72,8 @@
- if @page && @page.persisted?
= 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'
+ = link_to _("Cancel"), wiki_page_path(@wiki, @page), class: 'btn btn-cancel btn-grouped'
- else
= f.submit s_("Wiki|Create page"), class: 'btn-success btn qa-create-page-button rspec-create-page-button'
.float-right
- = link_to _("Cancel"), project_wiki_path(@project, :home), class: 'btn btn-cancel'
+ = link_to _("Cancel"), wiki_path(@wiki), class: 'btn btn-cancel'
diff --git a/app/views/shared/wikis/_main_links.html.haml b/app/views/shared/wikis/_main_links.html.haml
new file mode 100644
index 00000000000..e173ef72d11
--- /dev/null
+++ b/app/views/shared/wikis/_main_links.html.haml
@@ -0,0 +1,9 @@
+- if @page&.persisted?
+ - if can?(current_user, :create_wiki, @wiki.container)
+ = link_to wiki_path(@wiki, action: :new), class: "btn btn-success", role: "button", data: { qa_selector: 'new_page_button' } do
+ = s_("Wiki|New page")
+ = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn", role: "button", data: { qa_selector: 'page_history_button' } do
+ = s_("Wiki|Page history")
+ - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
+ = link_to wiki_page_path(@wiki, @page, action: :edit), class: "btn js-wiki-edit", role: "button", data: { qa_selector: 'edit_page_button' } do
+ = _("Edit")
diff --git a/app/views/projects/wikis/_pages_wiki_page.html.haml b/app/views/shared/wikis/_pages_wiki_page.html.haml
index c156f8cbf50..534884eb848 100644
--- a/app/views/projects/wikis/_pages_wiki_page.html.haml
+++ b/app/views/shared/wikis/_pages_wiki_page.html.haml
@@ -1,5 +1,5 @@
%li
- = link_to wiki_page.title, project_wiki_path(@project, wiki_page)
+ = link_to wiki_page.title, wiki_page_path(@wiki, wiki_page)
%small (#{wiki_page.format})
.float-right
- if wiki_page.last_version
diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml
index 2b8da83b126..8cfb95cdcf5 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/shared/wikis/_sidebar.html.haml
@@ -4,8 +4,8 @@
%a.gutter-toggle.float-right.d-block.d-sm-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" }
= icon('angle-double-right')
- - git_access_url = project_wikis_git_access_path(@project)
- = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '' do
+ - git_access_url = wiki_path(@wiki, action: :git_access)
+ = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do
= icon('cloud-download', class: 'append-right-5')
%span= _("Clone repository")
@@ -18,5 +18,5 @@
= render @sidebar_wiki_entries, context: 'sidebar'
.block.w-100
- if @sidebar_limited
- = link_to project_wikis_pages_path(@project), class: 'btn btn-block' do
+ = link_to wiki_path(@wiki, action: :pages), class: 'btn btn-block' do
= s_("Wiki|View All Pages")
diff --git a/app/views/projects/wikis/_sidebar_wiki_page.html.haml b/app/views/shared/wikis/_sidebar_wiki_page.html.haml
index 769d869bd53..2573471f9f9 100644
--- a/app/views/projects/wikis/_sidebar_wiki_page.html.haml
+++ b/app/views/shared/wikis/_sidebar_wiki_page.html.haml
@@ -1,3 +1,3 @@
%li{ class: active_when(params[:id] == wiki_page.slug) }
- = link_to project_wiki_path(@project, wiki_page) do
+ = link_to wiki_page_path(@wiki, wiki_page) do
= wiki_page.human_title
diff --git a/app/views/projects/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml
index 0e5f32ed859..0e5f32ed859 100644
--- a/app/views/projects/wikis/_wiki_directory.html.haml
+++ b/app/views/shared/wikis/_wiki_directory.html.haml
diff --git a/app/views/shared/wikis/_wiki_page.html.haml b/app/views/shared/wikis/_wiki_page.html.haml
new file mode 100644
index 00000000000..b27feac86cc
--- /dev/null
+++ b/app/views/shared/wikis/_wiki_page.html.haml
@@ -0,0 +1 @@
+= render "shared/wikis/#{context}_wiki_page", wiki_page: wiki_page
diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml
index 9ccf5acfefc..5bda8d85627 100644
--- a/app/views/projects/wikis/edit.html.haml
+++ b/app/views/shared/wikis/edit.html.haml
@@ -1,5 +1,5 @@
- @content_class = "limit-container-width" unless fluid_layout
-- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, @page)
+- add_to_breadcrumbs _("Wiki"), wiki_page_path(@wiki, @page)
- breadcrumb_title @page.persisted? ? _("Edit") : _("New")
- page_title @page.persisted? ? _("Edit") : _("New"), @page.human_title, _("Wiki")
@@ -12,7 +12,7 @@
.nav-text
%h2.wiki-page-title
- if @page.persisted?
- = link_to @page.human_title, project_wiki_path(@project, @page)
+ = link_to @page.human_title, wiki_page_path(@wiki, @page)
%span.light
&middot;
= s_("Wiki|Edit Page")
@@ -21,11 +21,11 @@
.nav-controls.pb-md-3.pb-lg-0
- if @page.persisted?
- = link_to project_wiki_history_path(@project, @page), class: "btn" do
+ = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn" do
= s_("Wiki|Page history")
- - if can?(current_user, :admin_wiki, @project)
- #delete-wiki-modal-wrapper{ data: { delete_wiki_url: project_wiki_path(@project, @page), page_title: @page.human_title } }
+ - if can?(current_user, :admin_wiki, @wiki.container)
+ #delete-wiki-modal-wrapper{ data: { delete_wiki_url: wiki_page_path(@wiki, @page), page_title: @page.human_title } }
-= render 'form', uploads_path: wiki_attachment_upload_url
+= render 'shared/wikis/form', uploads_path: wiki_attachment_upload_url
-= render 'sidebar'
+= render 'shared/wikis/sidebar'
diff --git a/app/views/projects/wikis/empty.html.haml b/app/views/shared/wikis/empty.html.haml
index 62fa6e1907b..62fa6e1907b 100644
--- a/app/views/projects/wikis/empty.html.haml
+++ b/app/views/shared/wikis/empty.html.haml
diff --git a/app/views/projects/wikis/history.html.haml b/app/views/shared/wikis/history.html.haml
index d3a55c53649..ec07082bd02 100644
--- a/app/views/projects/wikis/history.html.haml
+++ b/app/views/shared/wikis/history.html.haml
@@ -6,7 +6,7 @@
.nav-text
%h2.wiki-page-title
- = link_to @page.human_title, project_wiki_path(@project, @page)
+ = link_to @page.human_title, wiki_page_path(@wiki, @page)
%span.light
&middot;
= _("History")
@@ -25,8 +25,7 @@
- commit = version
%tr
%td
- = link_to project_wiki_path_with_version(@project, @page,
- commit.id, index == 0) do
+ = link_to wiki_page_path(@wiki, @page, version_id: index == 0 ? nil : commit.id) do
= truncate_sha(commit.id)
%td
= commit.author_name
@@ -39,4 +38,4 @@
= version.format
= paginate @page_versions, theme: 'gitlab'
-= render 'sidebar'
+= render 'shared/wikis/sidebar'
diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml
index d9dcd8f9acd..987c696cdfe 100644
--- a/app/views/projects/wikis/pages.html.haml
+++ b/app/views/shared/wikis/pages.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home)
+- add_to_breadcrumbs "Wiki", wiki_path(@wiki)
- breadcrumb_title s_("Wiki|Pages")
- page_title s_("Wiki|Pages"), _("Wiki")
- sort_title = wiki_sort_title(params[:sort])
@@ -10,7 +10,7 @@
= s_("Wiki|Wiki Pages")
.nav-controls.pb-md-3.pb-lg-0
- = link_to project_wikis_git_access_path(@project), class: 'btn' do
+ = link_to wiki_path(@wiki, action: :git_access), class: 'btn' do
= icon('cloud-download')
= _("Clone repository")
@@ -22,9 +22,9 @@
= 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])
+ = sortable_item(s_("Wiki|Title"), wiki_path(@wiki, action: :pages, sort: Wiki::TITLE_ORDER), sort_title)
+ = sortable_item(s_("Wiki|Created date"), wiki_path(@wiki, action: :pages, sort: Wiki::CREATED_AT_ORDER), sort_title)
+ = wiki_sort_controls(@wiki, 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/shared/wikis/show.html.haml
index 74798311c2e..a4f3996e5de 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -2,31 +2,31 @@
- breadcrumb_title @page.human_title
- wiki_breadcrumb_dropdown_links(@page.slug)
- page_title @page.human_title, _("Wiki")
-- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, :home)
+- add_to_breadcrumbs _("Wiki"), wiki_path(@wiki)
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
= icon('angle-double-left')
.nav-text.flex-fill
- %h2.wiki-page-title= @page.human_title
+ %h2.wiki-page-title{ data: { qa_selector: 'wiki_page_title' } }= @page.human_title
%span.wiki-last-edit-by
- if @page.last_version
= (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe
#{time_ago_with_tooltip(@page.last_version.authored_date)}
.nav-controls.pb-md-3.pb-lg-0
- = render 'main_links'
+ = render 'shared/wikis/main_links'
- if @page.historical?
.warning_message
= s_("WikiHistoricalPage|This is an old version of this page.")
- - most_recent_link = link_to s_("WikiHistoricalPage|most recent version"), project_wiki_path(@project, @page)
- - history_link = link_to s_("WikiHistoricalPage|history"), project_wiki_history_path(@project, @page)
+ - most_recent_link = link_to s_("WikiHistoricalPage|most recent version"), wiki_page_path(@wiki, @page)
+ - history_link = link_to s_("WikiHistoricalPage|history"), wiki_page_path(@wiki, @page, action: :history)
= (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
.md{ data: { qa_selector: 'wiki_page_content' } }
= render_wiki_content(@page)
-= render 'sidebar'
+= render 'shared/wikis/sidebar'
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
index 979821a3846..2ff174971cc 100644
--- a/app/views/snippets/_actions.html.haml
+++ b/app/views/snippets/_actions.html.haml
@@ -13,7 +13,7 @@
- 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')
.d-block.d-sm-none.dropdown
- %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } }
+ %button.btn.btn-default.btn-block.gl-mb-0.prepend-top-5{ data: { toggle: "dropdown" } }
= _("Options")
= icon('caret-down')
.dropdown-menu.dropdown-menu-full-width
diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml
index 51018428b1b..6658d70df8d 100644
--- a/app/views/u2f/_authenticate.html.haml
+++ b/app/views/u2f/_authenticate.html.haml
@@ -1,18 +1,19 @@
-#js-authenticate-u2f
+#js-authenticate-token-2fa
%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
-%script#js-authenticate-u2f-in-progress{ type: "text/template" }
+%script#js-authenticate-token-2fa-in-progress{ type: "text/template" }
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
-%script#js-authenticate-u2f-error{ type: "text/template" }
+%script#js-authenticate-token-2fa-error{ type: "text/template" }
%div
%p <%= error_message %> (#{_("error code:")} <%= error_code %>)
- %a.btn.btn-block.btn-warning#js-u2f-try-again= _("Try again?")
+ %a.btn.btn-block.btn-warning#js-token-2fa-try-again= _("Try again?")
-%script#js-authenticate-u2f-authenticated{ type: "text/template" }
+%script#js-authenticate-token-2fa-authenticated{ type: "text/template" }
%div
- %p= _("We heard back from your U2F device. You have been authenticated.")
- = form_tag(new_user_session_path, method: :post, id: 'js-login-u2f-form') do |f|
- - resource_params = params[resource_name].presence || params
- = hidden_field_tag 'user[remember_me]', resource_params.fetch(:remember_me, 0)
+ %p= _("We heard back from your device. You have been authenticated.")
+ = form_tag(target_path, method: :post, id: 'js-login-token-2fa-form') do |f|
+ - if render_remember_me
+ - resource_params = params[resource_name].presence || params
+ = hidden_field_tag 'user[remember_me]', resource_params.fetch(:remember_me, 0)
= hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml
index ef3835332a7..6f3f4c4981c 100644
--- a/app/views/u2f/_register.html.haml
+++ b/app/views/u2f/_register.html.haml
@@ -25,7 +25,7 @@
%div
%p
%span <%= error_message %> (#{_("error code:")} <%= error_code %>)
- %a.btn.btn-warning#js-u2f-try-again= _("Try again?")
+ %a.btn.btn-warning#js-token-2fa-try-again= _("Try again?")
%script#js-register-u2f-registered{ type: "text/template" }
.row.append-bottom-10
diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml
index 7169aebea74..0024801dbf6 100644
--- a/app/views/users/_deletion_guidance.html.haml
+++ b/app/views/users/_deletion_guidance.html.haml
@@ -8,4 +8,4 @@
- personal_projects_count = user.personal_projects.count
- unless personal_projects_count.zero?
%li
- = n_('personal project will be removed and cannot be restored', '%d personal projects will be removed and cannot be restored', personal_projects_count)
+ = n_('%d personal project will be removed and cannot be restored.', '%d personal projects will be removed and cannot be restored.', personal_projects_count) % personal_projects_count
diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml
index 4fa04402a1c..bc4861d6ae8 100644
--- a/app/views/users/terms/index.html.haml
+++ b/app/views/users/terms/index.html.haml
@@ -6,13 +6,13 @@
.card-footer.footer-block.clearfix
- if can?(current_user, :accept_terms, @term)
.float-right
- = button_to accept_term_path(@term, redirect_params), class: 'btn btn-success prepend-left-8', data: { qa_selector: 'accept_terms_button' } do
+ = button_to accept_term_path(@term, redirect_params), class: 'btn btn-success gl-ml-3', data: { qa_selector: 'accept_terms_button' } do
= _('Accept terms')
- else
.pull-right
- = link_to root_path, class: 'btn btn-success prepend-left-8' do
+ = link_to root_path, class: 'btn btn-success gl-ml-3' do
= _('Continue')
- if can?(current_user, :decline_terms, @term)
.float-right
- = button_to decline_term_path(@term, redirect_params), class: 'btn btn-default prepend-left-8' do
+ = button_to decline_term_path(@term, redirect_params), class: 'btn btn-default gl-ml-3' do
= _('Decline and sign out')
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 1f9a53d64d9..0699be0f4cb 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -10,6 +10,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -17,6 +18,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
- :name: auto_devops:auto_devops_disable
:feature_category: :auto_devops
:has_external_dependencies:
@@ -24,6 +26,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: auto_merge:auto_merge_process
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -31,6 +34,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
+ :tags: []
- :name: chaos:chaos_cpu_spin
:feature_category: :not_owned
:has_external_dependencies:
@@ -38,6 +42,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: chaos:chaos_db_spin
:feature_category: :not_owned
:has_external_dependencies:
@@ -45,6 +50,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: chaos:chaos_kill
:feature_category: :not_owned
:has_external_dependencies:
@@ -52,6 +58,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: chaos:chaos_leak_mem
:feature_category: :not_owned
:has_external_dependencies:
@@ -59,6 +66,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: chaos:chaos_sleep
:feature_category: :not_owned
:has_external_dependencies:
@@ -66,6 +74,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: container_repository:cleanup_container_repository
:feature_category: :container_registry
:has_external_dependencies:
@@ -73,6 +82,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: container_repository:delete_container_repository
:feature_category: :container_registry
:has_external_dependencies:
@@ -80,6 +90,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:admin_email
:feature_category: :source_code_management
:has_external_dependencies:
@@ -87,6 +98,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:ci_archive_traces_cron
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -94,6 +106,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:container_expiration_policy
:feature_category: :container_registry
:has_external_dependencies:
@@ -101,6 +114,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:environments_auto_stop_cron
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -108,6 +122,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:expire_build_artifacts
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -115,6 +130,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:gitlab_usage_ping
:feature_category: :collection
:has_external_dependencies:
@@ -122,6 +138,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:import_export_project_cleanup
:feature_category: :importers
:has_external_dependencies:
@@ -129,6 +146,15 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
+- :name: cronjob:import_stuck_project_import_jobs
+ :feature_category: :importers
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: cronjob:issue_due_scheduler
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -136,6 +162,23 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
+- :name: cronjob:jira_import_stuck_jira_import_jobs
+ :feature_category: :importers
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent:
+ :tags: []
+- :name: cronjob:metrics_dashboard_schedule_annotations_prune
+ :feature_category: :metrics
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:namespaces_prune_aggregation_schedules
:feature_category: :source_code_management
:has_external_dependencies:
@@ -143,6 +186,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:pages_domain_removal_cron
:feature_category: :pages
:has_external_dependencies:
@@ -150,6 +194,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:pages_domain_ssl_renewal_cron
:feature_category: :pages
:has_external_dependencies:
@@ -157,6 +202,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:pages_domain_verification_cron
:feature_category: :pages
:has_external_dependencies:
@@ -164,6 +210,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:personal_access_tokens_expiring
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -171,6 +218,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:pipeline_schedule
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -178,6 +226,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:prune_old_events
:feature_category: :users
:has_external_dependencies:
@@ -185,6 +234,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:prune_web_hook_logs
:feature_category: :integrations
:has_external_dependencies:
@@ -192,6 +242,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:remove_expired_group_links
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -199,6 +250,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:remove_expired_members
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -206,6 +258,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:remove_unreferenced_lfs_objects
:feature_category: :git_lfs
:has_external_dependencies:
@@ -213,6 +266,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:repository_archive_cache
:feature_category: :source_code_management
:has_external_dependencies:
@@ -220,6 +274,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:repository_check_dispatch
:feature_category: :source_code_management
:has_external_dependencies:
@@ -227,6 +282,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:requests_profiles
:feature_category: :source_code_management
:has_external_dependencies:
@@ -234,6 +290,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:schedule_migrate_external_diffs
:feature_category: :source_code_management
:has_external_dependencies:
@@ -241,6 +298,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:stuck_ci_jobs
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -248,6 +306,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:stuck_export_jobs
:feature_category: :importers
:has_external_dependencies:
@@ -255,6 +314,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:stuck_import_jobs
:feature_category: :importers
:has_external_dependencies:
@@ -262,6 +322,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:stuck_merge_jobs
:feature_category: :source_code_management
:has_external_dependencies:
@@ -269,6 +330,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:trending_projects
:feature_category: :source_code_management
:has_external_dependencies:
@@ -276,6 +338,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:users_create_statistics
:feature_category: :users
:has_external_dependencies:
@@ -283,6 +346,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: cronjob:x509_issuer_crl_check
:feature_category: :source_code_management
:has_external_dependencies: true
@@ -290,6 +354,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
- :name: deployment:deployments_finished
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -297,6 +362,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
+ :tags: []
- :name: deployment:deployments_forward_deployment
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -304,6 +370,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent:
+ :tags: []
- :name: deployment:deployments_success
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -311,6 +378,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_configure_istio
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -318,6 +386,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_install_app
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -325,6 +394,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_patch_app
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -332,6 +402,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_provision
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -339,6 +410,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_update_app
:feature_category: :kubernetes_management
:has_external_dependencies:
@@ -346,6 +418,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_upgrade_app
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -353,6 +426,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_wait_for_app_installation
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -360,6 +434,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_wait_for_app_update
:feature_category: :kubernetes_management
:has_external_dependencies:
@@ -367,6 +442,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:cluster_wait_for_ingress_ip_address
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -374,6 +450,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:clusters_applications_activate_service
:feature_category: :kubernetes_management
:has_external_dependencies:
@@ -381,6 +458,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:clusters_applications_deactivate_service
:feature_category: :kubernetes_management
:has_external_dependencies:
@@ -388,6 +466,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:clusters_applications_uninstall
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -395,6 +474,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:clusters_applications_wait_for_uninstall_app
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -402,6 +482,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:clusters_cleanup_app
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -409,6 +490,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:clusters_cleanup_project_namespace
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -416,6 +498,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:clusters_cleanup_service_account
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -423,6 +506,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gcp_cluster:wait_for_cluster_creation
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -430,6 +514,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_import_diff_note
:feature_category: :importers
:has_external_dependencies: true
@@ -437,6 +522,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_import_issue
:feature_category: :importers
:has_external_dependencies: true
@@ -444,6 +530,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_import_lfs_object
:feature_category: :importers
:has_external_dependencies: true
@@ -451,6 +538,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_import_note
:feature_category: :importers
:has_external_dependencies: true
@@ -458,6 +546,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_import_pull_request
:feature_category: :importers
:has_external_dependencies: true
@@ -465,6 +554,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_refresh_import_jid
:feature_category: :importers
:has_external_dependencies:
@@ -472,6 +562,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_stage_finish_import
:feature_category: :importers
:has_external_dependencies:
@@ -479,6 +570,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_stage_import_base_data
:feature_category: :importers
:has_external_dependencies:
@@ -486,6 +578,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_stage_import_issues_and_diff_notes
:feature_category: :importers
:has_external_dependencies:
@@ -493,6 +586,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_stage_import_lfs_objects
:feature_category: :importers
:has_external_dependencies:
@@ -500,6 +594,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_stage_import_notes
:feature_category: :importers
:has_external_dependencies:
@@ -507,6 +602,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_stage_import_pull_requests
:feature_category: :importers
:has_external_dependencies:
@@ -514,6 +610,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_importer:github_import_stage_import_repository
:feature_category: :importers
:has_external_dependencies:
@@ -521,6 +618,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: hashed_storage:hashed_storage_migrator
:feature_category: :source_code_management
:has_external_dependencies:
@@ -528,6 +626,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: hashed_storage:hashed_storage_project_migrate
:feature_category: :source_code_management
:has_external_dependencies:
@@ -535,6 +634,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: hashed_storage:hashed_storage_project_rollback
:feature_category: :source_code_management
:has_external_dependencies:
@@ -542,6 +642,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: hashed_storage:hashed_storage_rollbacker
:feature_category: :source_code_management
:has_external_dependencies:
@@ -549,6 +650,15 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
+- :name: incident_management:clusters_applications_check_prometheus_health
+ :feature_category: :incident_management
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 2
+ :idempotent: true
+ :tags: []
- :name: incident_management:incident_management_process_alert
:feature_category: :incident_management
:has_external_dependencies:
@@ -556,6 +666,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: incident_management:incident_management_process_prometheus_alert
:feature_category: :incident_management
:has_external_dependencies:
@@ -563,6 +674,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
+ :tags: []
- :name: jira_importer:jira_import_advance_stage
:feature_category: :importers
:has_external_dependencies:
@@ -570,6 +682,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: jira_importer:jira_import_import_issue
:feature_category: :importers
:has_external_dependencies:
@@ -577,6 +690,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: jira_importer:jira_import_stage_finish_import
:feature_category: :importers
:has_external_dependencies:
@@ -584,6 +698,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: jira_importer:jira_import_stage_import_attachments
:feature_category: :importers
:has_external_dependencies:
@@ -591,6 +706,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: jira_importer:jira_import_stage_import_issues
:feature_category: :importers
:has_external_dependencies:
@@ -598,6 +714,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: jira_importer:jira_import_stage_import_labels
:feature_category: :importers
:has_external_dependencies:
@@ -605,6 +722,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: jira_importer:jira_import_stage_import_notes
:feature_category: :importers
:has_external_dependencies:
@@ -612,6 +730,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: jira_importer:jira_import_stage_start_import
:feature_category: :importers
:has_external_dependencies:
@@ -619,6 +738,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: mail_scheduler:mail_scheduler_issue_due
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -626,6 +746,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: mail_scheduler:mail_scheduler_notification_service
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -633,6 +754,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
+ :tags: []
- :name: notifications:new_release
:feature_category: :release_orchestration
:has_external_dependencies:
@@ -640,6 +762,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: object_pool:object_pool_create
:feature_category: :gitaly
:has_external_dependencies:
@@ -647,6 +770,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: object_pool:object_pool_destroy
:feature_category: :gitaly
:has_external_dependencies:
@@ -654,6 +778,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: object_pool:object_pool_join
:feature_category: :gitaly
:has_external_dependencies:
@@ -661,6 +786,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: object_pool:object_pool_schedule_join
:feature_category: :gitaly
:has_external_dependencies:
@@ -668,6 +794,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: object_storage:object_storage_background_move
:feature_category: :not_owned
:has_external_dependencies:
@@ -675,6 +802,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: object_storage:object_storage_migrate_uploads
:feature_category: :not_owned
:has_external_dependencies:
@@ -682,6 +810,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: pipeline_background:archive_trace
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -689,6 +818,15 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
+- :name: pipeline_background:ci_build_report_result
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pipeline_background:ci_build_trace_chunk_flush
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -696,6 +834,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: pipeline_background:ci_daily_build_group_report_results
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -703,6 +842,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
- :name: pipeline_cache:expire_job_cache
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -710,6 +850,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent: true
+ :tags: []
- :name: pipeline_cache:expire_pipeline_cache
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -717,6 +858,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent: true
+ :tags: []
- :name: pipeline_creation:create_pipeline
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -724,6 +866,7 @@
:resource_boundary: :cpu
:weight: 4
:idempotent:
+ :tags: []
- :name: pipeline_creation:run_pipeline_schedule
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -731,6 +874,7 @@
:resource_boundary: :unknown
:weight: 4
:idempotent:
+ :tags: []
- :name: pipeline_default:build_coverage
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -738,6 +882,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent:
+ :tags: []
- :name: pipeline_default:build_trace_sections
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -745,6 +890,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent:
+ :tags: []
- :name: pipeline_default:ci_create_cross_project_pipeline
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -752,6 +898,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
+ :tags: []
- :name: pipeline_default:ci_pipeline_bridge_status
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -759,6 +906,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
+ :tags: []
- :name: pipeline_default:pipeline_metrics
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -766,6 +914,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent:
+ :tags: []
- :name: pipeline_default:pipeline_notification
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -773,6 +922,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
+ :tags: []
- :name: pipeline_default:pipeline_update_ci_ref_status
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -780,6 +930,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
+ :tags: []
- :name: pipeline_hooks:build_hooks
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -787,6 +938,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: pipeline_hooks:pipeline_hooks
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -794,6 +946,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
+ :tags: []
- :name: pipeline_processing:build_finished
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -801,6 +954,7 @@
:resource_boundary: :cpu
:weight: 5
:idempotent:
+ :tags: []
- :name: pipeline_processing:build_queue
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -808,6 +962,7 @@
:resource_boundary: :cpu
:weight: 5
:idempotent:
+ :tags: []
- :name: pipeline_processing:build_success
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -815,6 +970,7 @@
:resource_boundary: :unknown
:weight: 5
:idempotent:
+ :tags: []
- :name: pipeline_processing:ci_build_prepare
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -822,6 +978,7 @@
:resource_boundary: :unknown
:weight: 5
:idempotent:
+ :tags: []
- :name: pipeline_processing:ci_build_schedule
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -829,6 +986,7 @@
:resource_boundary: :cpu
:weight: 5
:idempotent:
+ :tags: []
- :name: pipeline_processing:ci_resource_groups_assign_resource_from_resource_group
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -836,6 +994,7 @@
:resource_boundary: :unknown
:weight: 5
:idempotent:
+ :tags: []
- :name: pipeline_processing:pipeline_process
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -843,6 +1002,7 @@
:resource_boundary: :unknown
:weight: 5
:idempotent:
+ :tags: []
- :name: pipeline_processing:pipeline_update
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -850,6 +1010,7 @@
:resource_boundary: :unknown
:weight: 5
:idempotent:
+ :tags: []
- :name: pipeline_processing:stage_update
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -857,6 +1018,7 @@
:resource_boundary: :unknown
:weight: 5
:idempotent: true
+ :tags: []
- :name: pipeline_processing:update_head_pipeline_for_merge_request
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -864,6 +1026,7 @@
:resource_boundary: :cpu
:weight: 5
:idempotent: true
+ :tags: []
- :name: repository_check:repository_check_batch
:feature_category: :source_code_management
:has_external_dependencies:
@@ -871,6 +1034,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: repository_check:repository_check_clear
:feature_category: :source_code_management
:has_external_dependencies:
@@ -878,6 +1042,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: repository_check:repository_check_single_repository
:feature_category: :source_code_management
:has_external_dependencies:
@@ -885,6 +1050,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: todos_destroyer:todos_destroyer_confidential_issue
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -892,6 +1058,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: todos_destroyer:todos_destroyer_entity_leave
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -899,6 +1066,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: todos_destroyer:todos_destroyer_group_private
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -906,6 +1074,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: todos_destroyer:todos_destroyer_private_features
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -913,6 +1082,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: todos_destroyer:todos_destroyer_project_private
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -920,6 +1090,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: update_namespace_statistics:namespaces_root_statistics
:feature_category: :source_code_management
:has_external_dependencies:
@@ -927,6 +1098,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
- :name: update_namespace_statistics:namespaces_schedule_aggregation
:feature_category: :source_code_management
:has_external_dependencies:
@@ -934,6 +1106,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
- :name: authorized_keys
:feature_category: :source_code_management
:has_external_dependencies:
@@ -941,6 +1114,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent: true
+ :tags: []
- :name: authorized_projects
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -948,6 +1122,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent: true
+ :tags: []
- :name: background_migration
:feature_category: :database
:has_external_dependencies:
@@ -955,6 +1130,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: chat_notification
:feature_category: :chatops
:has_external_dependencies: true
@@ -962,6 +1138,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: create_commit_signature
:feature_category: :source_code_management
:has_external_dependencies:
@@ -969,6 +1146,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent: true
+ :tags: []
- :name: create_evidence
:feature_category: :release_evidence
:has_external_dependencies:
@@ -976,6 +1154,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: create_note_diff_file
:feature_category: :source_code_management
:has_external_dependencies:
@@ -983,6 +1162,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: default
:feature_category:
:has_external_dependencies:
@@ -990,6 +1170,7 @@
:resource_boundary:
:weight: 1
:idempotent:
+ :tags: []
- :name: delete_diff_files
:feature_category: :source_code_management
:has_external_dependencies:
@@ -997,6 +1178,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: delete_merged_branches
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1004,6 +1186,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: delete_stored_files
:feature_category: :not_owned
:has_external_dependencies:
@@ -1011,6 +1194,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: delete_user
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -1018,6 +1202,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: design_management_new_version
:feature_category: :design_management
:has_external_dependencies:
@@ -1025,6 +1210,7 @@
:resource_boundary: :memory
:weight: 1
:idempotent:
+ :tags: []
- :name: detect_repository_languages
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1032,6 +1218,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: email_receiver
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1039,6 +1226,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: emails_on_push
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1046,6 +1234,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
+ :tags: []
- :name: error_tracking_issue_link
:feature_category: :error_tracking
:has_external_dependencies: true
@@ -1053,6 +1242,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: expire_build_instance_artifacts
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1060,6 +1250,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: export_csv
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1067,6 +1258,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: external_service_reactive_caching
:feature_category: :not_owned
:has_external_dependencies: true
@@ -1074,6 +1266,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: file_hook
:feature_category: :integrations
:has_external_dependencies:
@@ -1081,6 +1274,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: git_garbage_collect
:feature_category: :gitaly
:has_external_dependencies:
@@ -1088,6 +1282,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: github_import_advance_stage
:feature_category: :importers
:has_external_dependencies:
@@ -1095,6 +1290,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: gitlab_shell
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1102,6 +1298,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: group_destroy
:feature_category: :subgroups
:has_external_dependencies:
@@ -1109,6 +1306,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: group_export
:feature_category: :importers
:has_external_dependencies:
@@ -1116,6 +1314,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: group_import
:feature_category: :importers
:has_external_dependencies:
@@ -1123,6 +1322,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: import_issues_csv
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1130,6 +1330,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
+ :tags: []
- :name: invalid_gpg_signature_update
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1137,6 +1338,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: irker
:feature_category: :integrations
:has_external_dependencies:
@@ -1144,6 +1346,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: mailers
:feature_category:
:has_external_dependencies:
@@ -1151,6 +1354,7 @@
:resource_boundary:
:weight: 2
:idempotent:
+ :tags: []
- :name: merge
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1158,6 +1362,7 @@
:resource_boundary: :unknown
:weight: 5
:idempotent:
+ :tags: []
- :name: merge_request_mergeability_check
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1165,6 +1370,15 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
+- :name: metrics_dashboard_prune_old_annotations
+ :feature_category: :metrics
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: migrate_external_diffs
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1172,6 +1386,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: namespaceless_project_destroy
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -1179,6 +1394,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: new_issue
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1186,6 +1402,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
+ :tags: []
- :name: new_merge_request
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1193,6 +1410,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
+ :tags: []
- :name: new_note
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1200,6 +1418,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
+ :tags: []
- :name: pages
:feature_category: :pages
:has_external_dependencies:
@@ -1207,6 +1426,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: pages_domain_ssl_renewal
:feature_category: :pages
:has_external_dependencies:
@@ -1214,6 +1434,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: pages_domain_verification
:feature_category: :pages
:has_external_dependencies:
@@ -1221,6 +1442,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: phabricator_import_import_tasks
:feature_category: :importers
:has_external_dependencies:
@@ -1228,6 +1450,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: post_receive
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1235,6 +1458,7 @@
:resource_boundary: :cpu
:weight: 5
:idempotent:
+ :tags: []
- :name: process_commit
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1242,6 +1466,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent: true
+ :tags: []
- :name: project_cache
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1249,6 +1474,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: project_daily_statistics
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1256,6 +1482,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: project_destroy
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1263,6 +1490,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: project_export
:feature_category: :importers
:has_external_dependencies:
@@ -1270,6 +1498,7 @@
:resource_boundary: :memory
:weight: 1
:idempotent:
+ :tags: []
- :name: project_service
:feature_category: :integrations
:has_external_dependencies: true
@@ -1277,6 +1506,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: project_update_repository_storage
:feature_category: :gitaly
:has_external_dependencies:
@@ -1284,6 +1514,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: prometheus_create_default_alerts
:feature_category: :incident_management
:has_external_dependencies:
@@ -1291,6 +1522,15 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
+- :name: propagate_integration
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: propagate_service_template
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1298,6 +1538,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: reactive_caching
:feature_category: :not_owned
:has_external_dependencies:
@@ -1305,6 +1546,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+ :tags: []
- :name: rebase
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1312,6 +1554,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: remote_mirror_notification
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1319,6 +1562,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: repository_cleanup
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1326,6 +1570,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: repository_fork
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1333,6 +1578,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: repository_import
:feature_category: :importers
:has_external_dependencies: true
@@ -1340,6 +1586,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: repository_remove_remote
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1347,6 +1594,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: repository_update_remote_mirror
:feature_category: :source_code_management
:has_external_dependencies: true
@@ -1354,6 +1602,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: self_monitoring_project_create
:feature_category: :metrics
:has_external_dependencies:
@@ -1361,6 +1610,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: self_monitoring_project_delete
:feature_category: :metrics
:has_external_dependencies:
@@ -1368,6 +1618,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags: []
- :name: system_hook_push
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1375,6 +1626,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: update_external_pull_requests
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1382,6 +1634,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent:
+ :tags: []
- :name: update_highest_role
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -1389,6 +1642,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent: true
+ :tags: []
- :name: update_merge_requests
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1396,6 +1650,7 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
+ :tags: []
- :name: update_project_statistics
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1403,6 +1658,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: upload_checksum
:feature_category: :geo_replication
:has_external_dependencies:
@@ -1410,6 +1666,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: web_hook
:feature_category: :integrations
:has_external_dependencies: true
@@ -1417,6 +1674,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+ :tags: []
- :name: x509_certificate_revoke
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1424,3 +1682,4 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
+ :tags: []
diff --git a/app/workers/authorized_keys_worker.rb b/app/workers/authorized_keys_worker.rb
index eabfd89ba60..ab0e7fc4921 100644
--- a/app/workers/authorized_keys_worker.rb
+++ b/app/workers/authorized_keys_worker.rb
@@ -9,6 +9,7 @@ class AuthorizedKeysWorker
urgency :high
weight 2
idempotent!
+ loggable_arguments 0
def perform(action, *args)
return unless Gitlab::CurrentSettings.authorized_keys_enabled?
diff --git a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
index 19038cb8900..7ca59a72adf 100644
--- a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
@@ -5,6 +5,7 @@ module AuthorizedProjectUpdate
feature_category :authentication_and_authorization
urgency :low
queue_namespace :authorized_project_update
+ deduplicate :until_executing, including_scheduled: true
idempotent!
end
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index a35e0320553..62eea8e0462 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -7,8 +7,8 @@ class AuthorizedProjectsWorker
feature_category :authentication_and_authorization
urgency :high
weight 2
-
idempotent!
+ loggable_arguments 1 # For the job waiter key
# This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the
# visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index 6a64afe47de..bff864ba420 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -5,6 +5,7 @@ class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :database
urgency :throttled
+ loggable_arguments 0, 1
# The minimum amount of time between processing two jobs of the same migration
# class.
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index b6ef9ab4710..d38780dd08d 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -28,6 +28,7 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
# We execute these in sync to reduce IO.
BuildTraceSectionsWorker.new.perform(build.id)
BuildCoverageWorker.new.perform(build.id)
+ Ci::BuildReportResultWorker.new.perform(build.id)
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
diff --git a/app/workers/ci/build_report_result_worker.rb b/app/workers/ci/build_report_result_worker.rb
new file mode 100644
index 00000000000..60387936d0b
--- /dev/null
+++ b/app/workers/ci/build_report_result_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Ci
+ class BuildReportResultWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ idempotent!
+
+ def perform(build_id)
+ Ci::Build.find_by_id(build_id).try do |build|
+ Ci::BuildReportResultService.new.execute(build)
+ end
+ end
+ end
+end
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
index c3fac453e73..4469ea8cff9 100644
--- a/app/workers/cleanup_container_repository_worker.rb
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -5,6 +5,7 @@ class CleanupContainerRepositoryWorker # rubocop:disable Scalability/IdempotentW
queue_namespace :container_repository
feature_category :container_registry
+ loggable_arguments 2
attr_reader :container_repository, :current_user
diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb
index 002932a0fa5..f3da4d5c4bb 100644
--- a/app/workers/cluster_install_app_worker.rb
+++ b/app/workers/cluster_install_app_worker.rb
@@ -6,6 +6,7 @@ class ClusterInstallAppWorker # rubocop:disable Scalability/IdempotentWorker
include ClusterApplications
worker_has_external_dependencies!
+ loggable_arguments 0
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb
index f75004aa3e5..b0393809802 100644
--- a/app/workers/cluster_patch_app_worker.rb
+++ b/app/workers/cluster_patch_app_worker.rb
@@ -6,6 +6,7 @@ class ClusterPatchAppWorker # rubocop:disable Scalability/IdempotentWorker
include ClusterApplications
worker_has_external_dependencies!
+ loggable_arguments 0
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
diff --git a/app/workers/cluster_update_app_worker.rb b/app/workers/cluster_update_app_worker.rb
index 7ceeb167b33..29feb813043 100644
--- a/app/workers/cluster_update_app_worker.rb
+++ b/app/workers/cluster_update_app_worker.rb
@@ -9,6 +9,7 @@ class ClusterUpdateAppWorker # rubocop:disable Scalability/IdempotentWorker
include ExclusiveLeaseGuard
sidekiq_options retry: 3, dead: false
+ loggable_arguments 0, 3
LEASE_TIMEOUT = 10.minutes.to_i
diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb
index 99f48415f08..d4650ab3a85 100644
--- a/app/workers/cluster_upgrade_app_worker.rb
+++ b/app/workers/cluster_upgrade_app_worker.rb
@@ -6,6 +6,7 @@ class ClusterUpgradeAppWorker # rubocop:disable Scalability/IdempotentWorker
include ClusterApplications
worker_has_external_dependencies!
+ loggable_arguments 0
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb
index e098c3b86b5..4bc29807ea4 100644
--- a/app/workers/cluster_wait_for_app_installation_worker.rb
+++ b/app/workers/cluster_wait_for_app_installation_worker.rb
@@ -10,6 +10,7 @@ class ClusterWaitForAppInstallationWorker # rubocop:disable Scalability/Idempote
worker_has_external_dependencies!
worker_resource_boundary :cpu
+ loggable_arguments 0
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
diff --git a/app/workers/cluster_wait_for_app_update_worker.rb b/app/workers/cluster_wait_for_app_update_worker.rb
index 9f1d83c2c7b..c0a11eb93a7 100644
--- a/app/workers/cluster_wait_for_app_update_worker.rb
+++ b/app/workers/cluster_wait_for_app_update_worker.rb
@@ -8,6 +8,8 @@ class ClusterWaitForAppUpdateWorker # rubocop:disable Scalability/IdempotentWork
INTERVAL = 10.seconds
TIMEOUT = 20.minutes
+ loggable_arguments 0
+
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
::Clusters::Applications::CheckUpgradeProgressService.new(app).execute
diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
index c7336ee515d..fa46135d279 100644
--- a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
+++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
@@ -6,6 +6,7 @@ class ClusterWaitForIngressIpAddressWorker # rubocop:disable Scalability/Idempot
include ClusterApplications
worker_has_external_dependencies!
+ loggable_arguments 0
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
diff --git a/app/workers/clusters/applications/activate_service_worker.rb b/app/workers/clusters/applications/activate_service_worker.rb
index abd7f8eaea4..c92f978a7d2 100644
--- a/app/workers/clusters/applications/activate_service_worker.rb
+++ b/app/workers/clusters/applications/activate_service_worker.rb
@@ -6,6 +6,8 @@ module Clusters
include ApplicationWorker
include ClusterQueue
+ loggable_arguments 1
+
def perform(cluster_id, service_name)
cluster = Clusters::Cluster.find_by_id(cluster_id)
return unless cluster
diff --git a/app/workers/clusters/applications/check_prometheus_health_worker.rb b/app/workers/clusters/applications/check_prometheus_health_worker.rb
new file mode 100644
index 00000000000..2e8ee739946
--- /dev/null
+++ b/app/workers/clusters/applications/check_prometheus_health_worker.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class CheckPrometheusHealthWorker
+ include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ queue_namespace :incident_management
+ feature_category :incident_management
+ urgency :low
+
+ idempotent!
+ worker_has_external_dependencies!
+
+ def perform
+ demo_project_ids = Gitlab::Monitor::DemoProjects.primary_keys
+
+ clusters = Clusters::Cluster.with_application_prometheus
+ .with_project_alert_service_data(demo_project_ids)
+
+ # Move to a seperate worker with scoped context if expanded to do work on customer projects
+ clusters.each { |cluster| Clusters::Applications::PrometheusHealthCheckService.new(cluster).execute }
+ end
+ end
+ end
+end
diff --git a/app/workers/clusters/applications/deactivate_service_worker.rb b/app/workers/clusters/applications/deactivate_service_worker.rb
index fecbb6dde45..4d103bb0edc 100644
--- a/app/workers/clusters/applications/deactivate_service_worker.rb
+++ b/app/workers/clusters/applications/deactivate_service_worker.rb
@@ -6,6 +6,8 @@ module Clusters
include ApplicationWorker
include ClusterQueue
+ loggable_arguments 1
+
def perform(cluster_id, service_name)
cluster = Clusters::Cluster.find_by_id(cluster_id)
raise cluster_missing_error(service_name) unless cluster
diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb
index 977a25e8442..a9307931b59 100644
--- a/app/workers/clusters/applications/uninstall_worker.rb
+++ b/app/workers/clusters/applications/uninstall_worker.rb
@@ -8,6 +8,7 @@ module Clusters
include ClusterApplications
worker_has_external_dependencies!
+ loggable_arguments 0
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
index a486cfa90b7..dc842788374 100644
--- a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
+++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
@@ -12,6 +12,7 @@ module Clusters
worker_has_external_dependencies!
worker_resource_boundary :cpu
+ loggable_arguments 0
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 7ab9a0c2a02..9c942228111 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -76,6 +76,22 @@ module ApplicationWorker
get_sidekiq_options['queue'].to_s
end
+ # Set/get which arguments can be logged and sent to Sentry.
+ #
+ # Numeric arguments are logged by default, so there is no need to
+ # list those.
+ #
+ # Non-numeric arguments must be listed by position, as Sidekiq
+ # cannot see argument names.
+ #
+ def loggable_arguments(*args)
+ if args.any?
+ @loggable_arguments = args
+ else
+ @loggable_arguments || []
+ end
+ end
+
def queue_size
Sidekiq::Queue.new(queue).size
end
@@ -84,7 +100,7 @@ module ApplicationWorker
Sidekiq::Client.push_bulk('class' => self, 'args' => args_list)
end
- def bulk_perform_in(delay, args_list)
+ def bulk_perform_in(delay, args_list, batch_size: nil, batch_delay: nil)
now = Time.now.to_i
schedule = now + delay.to_i
@@ -92,7 +108,14 @@ module ApplicationWorker
raise ArgumentError, _('The schedule time must be in the future!')
end
- Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule)
+ if batch_size && batch_delay
+ args_list.each_slice(batch_size.to_i).with_index do |args_batch, idx|
+ batch_schedule = schedule + idx * batch_delay.to_i
+ Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch, 'at' => batch_schedule)
+ end
+ else
+ Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule)
+ end
end
end
end
diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb
index 7cc23dd7c0b..05eb7fbc2cb 100644
--- a/app/workers/concerns/gitlab/github_import/queue.rb
+++ b/app/workers/concerns/gitlab/github_import/queue.rb
@@ -15,6 +15,17 @@ module Gitlab
# this is better than a project being stuck in the "import" state
# forever.
sidekiq_options dead: false, retry: 5
+
+ sidekiq_retries_exhausted do |msg, e|
+ Gitlab::Import::Logger.error(
+ event: :github_importer_exhausted,
+ message: msg['error_message'],
+ class: msg['class'],
+ args: msg['args'],
+ exception_message: e.message,
+ exception_backtrace: e.backtrace
+ )
+ end
end
end
end
diff --git a/app/workers/concerns/gitlab/jira_import/import_worker.rb b/app/workers/concerns/gitlab/jira_import/import_worker.rb
index 537300e6eba..fdc6e64bbaa 100644
--- a/app/workers/concerns/gitlab/jira_import/import_worker.rb
+++ b/app/workers/concerns/gitlab/jira_import/import_worker.rb
@@ -7,6 +7,7 @@ module Gitlab
included do
include ApplicationWorker
+ include ProjectImportOptions
include Gitlab::JiraImport::QueueOptions
end
@@ -26,7 +27,6 @@ module Gitlab
def can_import?(project)
return false unless project
- return false unless project.jira_issues_import_feature_flag_enabled?
project.latest_jira_import&.started?
end
diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb
index c8ee5539441..4df209da29d 100644
--- a/app/workers/concerns/project_import_options.rb
+++ b/app/workers/concerns/project_import_options.rb
@@ -6,7 +6,7 @@ module ProjectImportOptions
IMPORT_RETRY_COUNT = 5
included do
- sidekiq_options retry: IMPORT_RETRY_COUNT, status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION
+ sidekiq_options retry: IMPORT_RETRY_COUNT, status_expiration: Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION
# We only want to mark the project as failed once we exhausted all retries
sidekiq_retries_exhausted do |job|
diff --git a/app/workers/concerns/reactive_cacheable_worker.rb b/app/workers/concerns/reactive_cacheable_worker.rb
index e73707c2b43..189b0607605 100644
--- a/app/workers/concerns/reactive_cacheable_worker.rb
+++ b/app/workers/concerns/reactive_cacheable_worker.rb
@@ -7,6 +7,7 @@ module ReactiveCacheableWorker
include ApplicationWorker
feature_category_not_owned!
+ loggable_arguments 0
def self.context_for_arguments(arguments)
class_name, *_other_args = arguments
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index b60179531af..b19217b15de 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -111,6 +111,28 @@ module WorkerAttributes
1
end
+ def tags(*values)
+ worker_attributes[:tags] = values
+ end
+
+ def get_tags
+ Array(worker_attributes[:tags])
+ end
+
+ def deduplicate(strategy, options = {})
+ worker_attributes[:deduplication_strategy] = strategy
+ worker_attributes[:deduplication_options] = options
+ end
+
+ def get_deduplicate_strategy
+ worker_attributes[:deduplication_strategy] ||
+ Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DEFAULT_STRATEGY
+ end
+
+ def get_deduplication_options
+ worker_attributes[:deduplication_options] || {}
+ end
+
protected
# Returns a worker attribute declared on this class or its parent class.
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index e1544be5aed..96590e165ae 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -12,6 +12,8 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
user: container_expiration_policy.project.owner) do |project:, user:|
ContainerExpirationPolicyService.new(project, user)
.execute(container_expiration_policy)
+ rescue ContainerExpirationPolicyService::InvalidPolicyError => e
+ Gitlab::ErrorTracking.log_exception(e, container_expiration_policy_id: container_expiration_policy.id)
end
end
end
diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb
index a88d2bf7d15..f81baf20d19 100644
--- a/app/workers/create_commit_signature_worker.rb
+++ b/app/workers/create_commit_signature_worker.rb
@@ -5,8 +5,8 @@ class CreateCommitSignatureWorker
feature_category :source_code_management
weight 2
-
idempotent!
+ loggable_arguments 0
# rubocop: disable CodeReuse/ActiveRecord
def perform(commit_shas, project_id)
@@ -37,7 +37,7 @@ class CreateCommitSignatureWorker
commits.each do |commit|
commit&.signature
rescue => e
- Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}")
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/create_evidence_worker.rb b/app/workers/create_evidence_worker.rb
index 135e2ac38b4..b18028e4114 100644
--- a/app/workers/create_evidence_worker.rb
+++ b/app/workers/create_evidence_worker.rb
@@ -6,10 +6,15 @@ class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :release_evidence
weight 2
- def perform(release_id)
+ # pipeline_id is optional for backward compatibility with existing jobs
+ # caller should always try to provide the pipeline and pass nil only
+ # if pipeline is absent
+ def perform(release_id, pipeline_id = nil)
release = Release.find_by_id(release_id)
return unless release
- Releases::Evidence.create!(release: release)
+ pipeline = Ci::Pipeline.find_by_id(pipeline_id)
+
+ ::Releases::CreateEvidenceService.new(release, pipeline: pipeline).execute
end
end
diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb
index 54698518e4f..68fe44d01ce 100644
--- a/app/workers/create_pipeline_worker.rb
+++ b/app/workers/create_pipeline_worker.rb
@@ -8,6 +8,7 @@ class CreatePipelineWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :continuous_integration
urgency :high
worker_resource_boundary :cpu
+ loggable_arguments 2, 3, 4
def perform(project_id, user_id, ref, source, params = {})
project = Project.find(project_id)
diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb
index 463f26fdd5a..9cf5631b7d8 100644
--- a/app/workers/delete_stored_files_worker.rb
+++ b/app/workers/delete_stored_files_worker.rb
@@ -4,6 +4,7 @@ class DeleteStoredFilesWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
feature_category_not_owned!
+ loggable_arguments 0
def perform(class_name, keys)
klass = begin
diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb
index d3b87c133d3..ed2e00f1241 100644
--- a/app/workers/delete_user_worker.rb
+++ b/app/workers/delete_user_worker.rb
@@ -4,6 +4,7 @@ class DeleteUserWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
feature_category :authentication_and_authorization
+ loggable_arguments 2
def perform(current_user_id, delete_user_id, options = {})
delete_user = User.find(delete_user_id)
@@ -11,6 +12,6 @@ class DeleteUserWorker # rubocop:disable Scalability/IdempotentWorker
Users::DestroyService.new(current_user).execute(delete_user, options.symbolize_keys)
rescue Gitlab::Access::AccessDeniedError => e
- Rails.logger.warn("User could not be destroyed: #{e}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.warn("User could not be destroyed: #{e}")
end
end
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index fcb88982c0b..9ceab9bb878 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -20,7 +20,7 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker
private
def handle_failure(raw, error)
- Rails.logger.warn("Email can not be processed: #{error}\n\n#{raw}") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.warn("Email can not be processed: #{error}\n\n#{raw}")
return unless raw.present?
diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb
index 48fd086f88f..e6cd60a3e47 100644
--- a/app/workers/expire_build_instance_artifacts_worker.rb
+++ b/app/workers/expire_build_instance_artifacts_worker.rb
@@ -14,7 +14,7 @@ class ExpireBuildInstanceArtifactsWorker # rubocop:disable Scalability/Idempoten
return unless build&.project && !build.project.pending_delete
- Rails.logger.info "Removing artifacts for build #{build.id}..." # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.info("Removing artifacts for build #{build.id}...")
build.erase_erasable_artifacts!
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/export_csv_worker.rb b/app/workers/export_csv_worker.rb
index 9e2b3ad9bb4..e7baaf40a41 100644
--- a/app/workers/export_csv_worker.rb
+++ b/app/workers/export_csv_worker.rb
@@ -5,6 +5,7 @@ class ExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :issue_tracking
worker_resource_boundary :cpu
+ loggable_arguments 2
def perform(current_user_id, project_id, params)
@current_user = User.find(current_user_id)
diff --git a/app/workers/file_hook_worker.rb b/app/workers/file_hook_worker.rb
index f8cdea54a17..b1422cd8795 100644
--- a/app/workers/file_hook_worker.rb
+++ b/app/workers/file_hook_worker.rb
@@ -5,6 +5,7 @@ class FileHookWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: false
feature_category :integrations
+ loggable_arguments 0
def perform(file_name, data)
success, message = Gitlab::FileHook.execute(file_name, data)
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index 37ca3af517f..f2222c7be5e 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -5,6 +5,7 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: false
feature_category :gitaly
+ loggable_arguments 1, 2, 3
# Timeout set to 24h
LEASE_TIMEOUT = 86400
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index 21e478f935b..834c2f7791c 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -12,6 +12,7 @@ module Gitlab
sidekiq_options dead: false
feature_category :importers
+ loggable_arguments 1, 2
# The known importer stages and their corresponding Sidekiq workers.
STAGES = {
diff --git a/app/workers/gitlab/import/stuck_import_job.rb b/app/workers/gitlab/import/stuck_import_job.rb
new file mode 100644
index 00000000000..16be7a77ab1
--- /dev/null
+++ b/app/workers/gitlab/import/stuck_import_job.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Import
+ module StuckImportJob
+ extend ActiveSupport::Concern
+
+ IMPORT_JOBS_EXPIRATION = 15.hours.seconds.to_i
+
+ included do
+ include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker updates several import states inline and does not schedule
+ # other jobs. So no context needed
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ feature_category :importers
+ worker_resource_boundary :cpu
+ end
+
+ def perform
+ stuck_imports_without_jid_count = mark_imports_without_jid_as_failed!
+ stuck_imports_with_jid_count = mark_imports_with_jid_as_failed!
+
+ track_metrics(stuck_imports_with_jid_count, stuck_imports_without_jid_count)
+ end
+
+ private
+
+ def track_metrics(with_jid_count, without_jid_count)
+ raise NotImplementedError
+ end
+
+ def mark_imports_without_jid_as_failed!
+ enqueued_import_states_without_jid.each do |import_state|
+ import_state.mark_as_failed(error_message)
+ end.size
+ end
+
+ def mark_imports_with_jid_as_failed!
+ jids_and_ids = enqueued_import_states_with_jid.pluck(:jid, :id).to_h # rubocop: disable CodeReuse/ActiveRecord
+
+ # Find the jobs that aren't currently running or that exceeded the threshold.
+ completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys)
+ return 0 unless completed_jids.any?
+
+ completed_import_state_ids = jids_and_ids.values_at(*completed_jids)
+
+ # We select the import states again, because they may have transitioned from
+ # scheduled/started to finished/failed while we were looking up their Sidekiq status.
+ completed_import_states = enqueued_import_states_with_jid.id_in(completed_import_state_ids)
+ completed_import_state_jids = completed_import_states.map { |import_state| import_state.jid }.join(', ')
+
+ Gitlab::Import::Logger.info(
+ message: 'Marked stuck import jobs as failed',
+ job_ids: completed_import_state_jids
+ )
+
+ completed_import_states.each do |import_state|
+ import_state.mark_as_failed(error_message)
+ end.size
+ end
+
+ def enqueued_import_states
+ raise NotImplementedError
+ end
+
+ def enqueued_import_states_with_jid
+ enqueued_import_states.with_jid
+ end
+
+ def enqueued_import_states_without_jid
+ enqueued_import_states.without_jid
+ end
+
+ def error_message
+ _("Import timed out. Import took longer than %{import_jobs_expiration} seconds") % { import_jobs_expiration: IMPORT_JOBS_EXPIRATION }
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/import/stuck_project_import_jobs_worker.rb b/app/workers/gitlab/import/stuck_project_import_jobs_worker.rb
new file mode 100644
index 00000000000..01979b2029f
--- /dev/null
+++ b/app/workers/gitlab/import/stuck_project_import_jobs_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+module Gitlab
+ module Import
+ class StuckProjectImportJobsWorker # rubocop:disable Scalability/IdempotentWorker
+ include Gitlab::Import::StuckImportJob
+
+ private
+
+ def track_metrics(with_jid_count, without_jid_count)
+ Gitlab::Metrics.add_event(
+ :stuck_import_jobs,
+ projects_without_jid_count: without_jid_count,
+ projects_with_jid_count: with_jid_count
+ )
+ end
+
+ def enqueued_import_states
+ ProjectImportState.with_status([:scheduled, :started])
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb
index 78de5cf1307..7709d2ec31b 100644
--- a/app/workers/gitlab/jira_import/import_issue_worker.rb
+++ b/app/workers/gitlab/jira_import/import_issue_worker.rb
@@ -8,6 +8,8 @@ module Gitlab
include Gitlab::JiraImport::QueueOptions
include Gitlab::Import::DatabaseHelpers
+ loggable_arguments 3
+
def perform(project_id, jira_issue_id, issue_attributes, waiter_key)
issue_id = create_issue(issue_attributes, project_id)
JiraImport.cache_issue_mapping(issue_id, jira_issue_id, project_id)
@@ -48,7 +50,7 @@ module Gitlab
label_link_attrs << build_label_attrs(issue_id, import_label_id.to_i)
- Gitlab::Database.bulk_insert(LabelLink.table_name, label_link_attrs)
+ Gitlab::Database.bulk_insert(LabelLink.table_name, label_link_attrs) # rubocop:disable Gitlab/BulkInsert
end
def assign_issue(project_id, issue_id, assignee_ids)
@@ -56,7 +58,7 @@ module Gitlab
assignee_attrs = assignee_ids.map { |user_id| { issue_id: issue_id, user_id: user_id } }
- Gitlab::Database.bulk_insert(IssueAssignee.table_name, assignee_attrs)
+ Gitlab::Database.bulk_insert(IssueAssignee.table_name, assignee_attrs) # rubocop:disable Gitlab/BulkInsert
end
def build_label_attrs(issue_id, label_id)
diff --git a/app/workers/gitlab/jira_import/stage/start_import_worker.rb b/app/workers/gitlab/jira_import/stage/start_import_worker.rb
index 5b36feadbd1..bfc02224ee4 100644
--- a/app/workers/gitlab/jira_import/stage/start_import_worker.rb
+++ b/app/workers/gitlab/jira_import/stage/start_import_worker.rb
@@ -25,7 +25,6 @@ module Gitlab
def start_import
return false unless project
- return false unless project.jira_issues_import_feature_flag_enabled?
return true if start(project.latest_jira_import)
Gitlab::Import::Logger.info(
diff --git a/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb b/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb
new file mode 100644
index 00000000000..5e675193a8c
--- /dev/null
+++ b/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module JiraImport
+ class StuckJiraImportJobsWorker # rubocop:disable Scalability/IdempotentWorker
+ include Gitlab::Import::StuckImportJob
+
+ private
+
+ def track_metrics(with_jid_count, without_jid_count)
+ Gitlab::Metrics.add_event(:stuck_jira_import_jobs,
+ jira_imports_without_jid_count: with_jid_count,
+ jira_imports_with_jid_count: without_jid_count)
+ end
+
+ def enqueued_import_states
+ JiraImportState.with_status([:scheduled, :started])
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/phabricator_import/base_worker.rb b/app/workers/gitlab/phabricator_import/base_worker.rb
index 82ef9e825f9..2dc4855f854 100644
--- a/app/workers/gitlab/phabricator_import/base_worker.rb
+++ b/app/workers/gitlab/phabricator_import/base_worker.rb
@@ -13,7 +13,7 @@
# - It keeps track of the jobs so we know how many jobs are running for the
# project
# - It refreshes the import jid, so it doesn't get cleaned up by the
-# `StuckImportJobsWorker`
+# `Gitlab::Import::StuckProjectImportJobsWorker`
# - It marks the import as failed if a job failed to many times
# - It marks the import as finished when all remaining jobs are done
module Gitlab
diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb
index b104d3c681e..b8e1e3d8fc4 100644
--- a/app/workers/gitlab_shell_worker.rb
+++ b/app/workers/gitlab_shell_worker.rb
@@ -7,6 +7,7 @@ class GitlabShellWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :source_code_management
urgency :high
weight 2
+ loggable_arguments 0
def perform(action, *arg)
# Gitlab::Shell is being removed but we need to continue to process jobs
diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb
index 3e0390429d6..6fd977e43d8 100644
--- a/app/workers/group_export_worker.rb
+++ b/app/workers/group_export_worker.rb
@@ -5,6 +5,7 @@ class GroupExportWorker # rubocop:disable Scalability/IdempotentWorker
include ExceptionBacktrace
feature_category :importers
+ loggable_arguments 2
def perform(current_user_id, group_id, params = {})
current_user = User.find(current_user_id)
diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb
index d8f236013bf..36d81468d55 100644
--- a/app/workers/group_import_worker.rb
+++ b/app/workers/group_import_worker.rb
@@ -9,15 +9,16 @@ class GroupImportWorker # rubocop:disable Scalability/IdempotentWorker
def perform(user_id, group_id)
current_user = User.find(user_id)
group = Group.find(group_id)
- group_import = group.build_import_state(jid: self.jid)
+ group_import_state = group.import_state || group.build_import_state
- group_import.start!
+ group_import_state.jid = self.jid
+ group_import_state.start!
::Groups::ImportExport::ImportService.new(group: group, user: current_user).execute
- group_import.finish!
+ group_import_state.finish!
rescue StandardError => e
- group_import&.fail_op(e.message)
+ group_import_state&.fail_op(e.message)
raise e
end
diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb
index 3ce60ce7eb6..03e53058dbb 100644
--- a/app/workers/hashed_storage/project_migrate_worker.rb
+++ b/app/workers/hashed_storage/project_migrate_worker.rb
@@ -5,6 +5,7 @@ module HashedStorage
include ApplicationWorker
queue_namespace :hashed_storage
+ loggable_arguments 1
attr_reader :project_id
diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb
index 17b3cca83e1..d4a5e474323 100644
--- a/app/workers/hashed_storage/project_rollback_worker.rb
+++ b/app/workers/hashed_storage/project_rollback_worker.rb
@@ -5,6 +5,7 @@ module HashedStorage
include ApplicationWorker
queue_namespace :hashed_storage
+ loggable_arguments 1
attr_reader :project_id
diff --git a/app/workers/incident_management/process_prometheus_alert_worker.rb b/app/workers/incident_management/process_prometheus_alert_worker.rb
index 768e049c60e..e405bc2c2d2 100644
--- a/app/workers/incident_management/process_prometheus_alert_worker.rb
+++ b/app/workers/incident_management/process_prometheus_alert_worker.rb
@@ -41,23 +41,11 @@ module IncidentManagement
end
def find_gitlab_managed_event(alert)
- payload_key = payload_key_for_alert(alert)
-
- PrometheusAlertEvent.find_by_payload_key(payload_key)
+ PrometheusAlertEvent.find_by_payload_key(alert.gitlab_fingerprint)
end
def find_self_managed_event(alert)
- payload_key = payload_key_for_alert(alert)
-
- SelfManagedPrometheusAlertEvent.find_by_payload_key(payload_key)
- end
-
- def payload_key_for_alert(alert)
- if alert.gitlab_managed?
- PrometheusAlertEvent.payload_key_for(alert.metric_id, alert.starts_at_raw)
- else
- SelfManagedPrometheusAlertEvent.payload_key_for(alert.starts_at_raw, alert.title, alert.full_query)
- end
+ SelfManagedPrometheusAlertEvent.find_by_payload_key(alert.gitlab_fingerprint)
end
def create_issue(project, alert)
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index 09f53681e7f..687fb1cd02a 100644
--- a/app/workers/irker_worker.rb
+++ b/app/workers/irker_worker.rb
@@ -8,26 +8,30 @@ class IrkerWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :integrations
- def perform(project_id, chans, colors, push_data, settings)
- project = Project.find(project_id)
+ def perform(project_id, channels, colors, push_data, settings)
+ # Establish connection to irker server
+ return false unless start_connection(settings['server_host'],
+ settings['server_port'])
- # Get config parameters
- return false unless init_perform settings, chans, colors
+ @project = Project.find(project_id)
+ @colors = colors
+ @channels = channels
- repo_name = push_data['repository']['name']
- committer = push_data['user_name']
- branch = push_data['ref'].gsub(%r'refs/[^/]*/', '')
+ @repo_path = @project.full_path
+ @repo_name = push_data['repository']['name']
+ @committer = push_data['user_name']
+ @branch = push_data['ref'].gsub(%r'refs/[^/]*/', '')
if @colors
- repo_name = "\x0304#{repo_name}\x0f"
- branch = "\x0305#{branch}\x0f"
+ @repo_name = "\x0304#{@repo_name}\x0f"
+ @branch = "\x0305#{@branch}\x0f"
end
# First messages are for branch creation/deletion
- send_branch_updates push_data, project, repo_name, committer, branch
+ send_branch_updates(push_data)
# Next messages are for commits
- send_commits push_data, project, repo_name, committer, branch
+ send_commits(push_data)
close_connection
true
@@ -35,12 +39,6 @@ class IrkerWorker # rubocop:disable Scalability/IdempotentWorker
private
- def init_perform(set, chans, colors)
- @colors = colors
- @channels = chans
- start_connection set['server_host'], set['server_port']
- end
-
def start_connection(irker_server, irker_port)
begin
@socket = TCPSocket.new irker_server, irker_port
@@ -48,11 +46,13 @@ class IrkerWorker # rubocop:disable Scalability/IdempotentWorker
logger.fatal "Can't connect to Irker daemon: #{e}"
return false
end
+
true
end
- def sendtoirker(privmsg)
+ def send_to_irker(privmsg)
to_send = { to: @channels, privmsg: privmsg }
+
@socket.puts Gitlab::Json.dump(to_send)
end
@@ -60,51 +60,51 @@ class IrkerWorker # rubocop:disable Scalability/IdempotentWorker
@socket.close
end
- def send_branch_updates(push_data, project, repo_name, committer, branch)
- if Gitlab::Git.blank_ref?(push_data['before'])
- send_new_branch project, repo_name, committer, branch
- elsif Gitlab::Git.blank_ref?(push_data['after'])
- send_del_branch repo_name, committer, branch
- end
+ def send_branch_updates(push_data)
+ message =
+ if Gitlab::Git.blank_ref?(push_data['before'])
+ new_branch_message
+ elsif Gitlab::Git.blank_ref?(push_data['after'])
+ delete_branch_message
+ end
+
+ send_to_irker(message)
end
- def send_new_branch(project, repo_name, committer, branch)
- repo_path = project.full_path
- newbranch = "#{Gitlab.config.gitlab.url}/#{repo_path}/-/branches"
+ def new_branch_message
+ newbranch = "#{Gitlab.config.gitlab.url}/#{@repo_path}/-/branches"
newbranch = "\x0302\x1f#{newbranch}\x0f" if @colors
- privmsg = "[#{repo_name}] #{committer} has created a new branch " \
- "#{branch}: #{newbranch}"
- sendtoirker privmsg
+ "[#{@repo_name}] #{@committer} has created a new branch #{@branch}: #{newbranch}"
end
- def send_del_branch(repo_name, committer, branch)
- privmsg = "[#{repo_name}] #{committer} has deleted the branch #{branch}"
- sendtoirker privmsg
+ def delete_branch_message
+ "[#{@repo_name}] #{@committer} has deleted the branch #{@branch}"
end
- def send_commits(push_data, project, repo_name, committer, branch)
+ def send_commits(push_data)
return if push_data['total_commits_count'] == 0
# Next message is for number of commit pushed, if any
if Gitlab::Git.blank_ref?(push_data['before'])
# Tweak on push_data["before"] in order to have a nice compare URL
- push_data['before'] = before_on_new_branch push_data, project
+ push_data['before'] = before_on_new_branch(push_data)
end
- send_commits_count(push_data, project, repo_name, committer, branch)
+ send_commits_count(push_data)
# One message per commit, limited by 3 messages (same limit as the
# github irc hook)
commits = push_data['commits'].first(3)
- commits.each do |hook_attrs|
- send_one_commit project, hook_attrs, repo_name, branch
+ commits.each do |commit_attrs|
+ send_one_commit(commit_attrs)
end
end
- def before_on_new_branch(push_data, project)
- commit = commit_from_id project, push_data['commits'][0]['id']
+ def before_on_new_branch(push_data)
+ commit = commit_from_id(push_data['commits'][0]['id'])
parents = commit.parents
+
# Return old value if there's no new one
return push_data['before'] if parents.empty?
@@ -112,35 +112,36 @@ class IrkerWorker # rubocop:disable Scalability/IdempotentWorker
parents[0].id
end
- def send_commits_count(data, project, repo, committer, branch)
- url = compare_url data, project.full_path
- commits = colorize_commits data['total_commits_count']
+ def send_commits_count(push_data)
+ url = compare_url(push_data['before'], push_data['after'])
+ commits = colorize_commits(push_data['total_commits_count'])
+ new_commits = 'new commit'.pluralize(push_data['total_commits_count'])
- new_commits = 'new commit'.pluralize(data['total_commits_count'])
- sendtoirker "[#{repo}] #{committer} pushed #{commits} #{new_commits} " \
- "to #{branch}: #{url}"
+ send_to_irker("[#{@repo_name}] #{@committer} pushed #{commits} #{new_commits} " \
+ "to #{@branch}: #{url}")
end
- def compare_url(data, repo_path)
- sha1 = Commit.truncate_sha(data['before'])
- sha2 = Commit.truncate_sha(data['after'])
- compare_url = "#{Gitlab.config.gitlab.url}/#{repo_path}/-/compare" \
+ def compare_url(sha_before, sha_after)
+ sha1 = Commit.truncate_sha(sha_before)
+ sha2 = Commit.truncate_sha(sha_after)
+ compare_url = "#{Gitlab.config.gitlab.url}/#{@repo_path}/-/compare" \
"/#{sha1}...#{sha2}"
- colorize_url compare_url
+
+ colorize_url(compare_url)
end
- def send_one_commit(project, hook_attrs, repo_name, branch)
- commit = commit_from_id project, hook_attrs['id']
- sha = colorize_sha Commit.truncate_sha(hook_attrs['id'])
- author = hook_attrs['author']['name']
+ def send_one_commit(commit_attrs)
+ commit = commit_from_id(commit_attrs['id'])
+ sha = colorize_sha(Commit.truncate_sha(commit_attrs['id']))
+ author = commit_attrs['author']['name']
files = colorize_nb_files(files_count(commit))
title = commit.title
- sendtoirker "#{repo_name}/#{branch} #{sha} #{author} (#{files}): #{title}"
+ send_to_irker("#{@repo_name}/#{@branch} #{sha} #{author} (#{files}): #{title}")
end
- def commit_from_id(project, id)
- project.commit(id)
+ def commit_from_id(id)
+ @project.commit(id)
end
def files_count(commit)
diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb
index 691af2a724d..309f23c8708 100644
--- a/app/workers/mail_scheduler/notification_service_worker.rb
+++ b/app/workers/mail_scheduler/notification_service_worker.rb
@@ -9,6 +9,7 @@ module MailScheduler
feature_category :issue_tracking
worker_resource_boundary :cpu
+ loggable_arguments 0
def perform(meth, *args)
check_arguments!(args)
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index cc5fe884aec..270bd831f96 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -6,6 +6,7 @@ class MergeWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :source_code_management
urgency :high
weight 5
+ loggable_arguments 2
def perform(merge_request_id, current_user_id, params)
params = params.with_indifferent_access
diff --git a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb
new file mode 100644
index 00000000000..2a9ce3bb8e6
--- /dev/null
+++ b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Metrics
+ module Dashboard
+ class PruneOldAnnotationsWorker
+ include ApplicationWorker
+
+ DELETE_LIMIT = 10_000
+ DEFAULT_CUT_OFF_PERIOD = 2.weeks
+
+ feature_category :metrics
+
+ idempotent! # in the scope of 24 hours
+
+ def perform
+ stale_annotations = ::Metrics::Dashboard::Annotation.ending_before(DEFAULT_CUT_OFF_PERIOD.ago.beginning_of_day)
+ stale_annotations.delete_with_limit(DELETE_LIMIT)
+
+ self.class.perform_async if stale_annotations.exists?
+ end
+ end
+ end
+end
diff --git a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb
new file mode 100644
index 00000000000..cbdd69c6e8c
--- /dev/null
+++ b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Metrics
+ module Dashboard
+ class ScheduleAnnotationsPruneWorker
+ include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ feature_category :metrics
+
+ idempotent! # PruneOldAnnotationsWorker worker is idempotent in the scope of 24 hours
+
+ def perform
+ # Process is split into two jobs to avoid long running jobs, which are more prone to be disrupted
+ # mid work, which may cause some data not be delete, especially because cronjobs has retry option disabled
+ PruneOldAnnotationsWorker.perform_async
+ end
+ end
+ end
+end
diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb
index 8ead87a9230..b31311b0e44 100644
--- a/app/workers/new_note_worker.rb
+++ b/app/workers/new_note_worker.rb
@@ -16,17 +16,14 @@ class NewNoteWorker # rubocop:disable Scalability/IdempotentWorker
NotificationService.new.new_note(note) unless skip_notification?(note)
Notes::PostProcessService.new(note).execute
else
- Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job")
end
end
+ # rubocop: enable CodeReuse/ActiveRecord
private
- # EE-only method
def skip_notification?(note)
- false
+ note.review.present?
end
- # rubocop: enable CodeReuse/ActiveRecord
end
-
-NewNoteWorker.prepend_if_ee('EE::NewNoteWorker')
diff --git a/app/workers/object_storage/background_move_worker.rb b/app/workers/object_storage/background_move_worker.rb
index 7b0a7c7ec58..fba91e49e43 100644
--- a/app/workers/object_storage/background_move_worker.rb
+++ b/app/workers/object_storage/background_move_worker.rb
@@ -7,6 +7,7 @@ module ObjectStorage
sidekiq_options retry: 5
feature_category_not_owned!
+ loggable_arguments 0, 1, 2, 3
def perform(uploader_class_name, subject_class_name, file_field, subject_id)
uploader_class = uploader_class_name.constantize
diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb
index d9d21f2cb7e..33a0e0f5f88 100644
--- a/app/workers/object_storage/migrate_uploads_worker.rb
+++ b/app/workers/object_storage/migrate_uploads_worker.rb
@@ -7,6 +7,7 @@ module ObjectStorage
include ObjectStorageQueue
feature_category_not_owned!
+ loggable_arguments 0, 1, 2, 3
SanityCheckError = Class.new(StandardError)
diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb
index 875f17282f9..d699e32c1a0 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -5,6 +5,7 @@ class PagesWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
feature_category :pages
+ loggable_arguments 0, 1
def perform(action, *arg)
send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb
index 86240f827fc..f9940d9d014 100644
--- a/app/workers/personal_access_tokens/expiring_worker.rb
+++ b/app/workers/personal_access_tokens/expiring_worker.rb
@@ -15,9 +15,9 @@ module PersonalAccessTokens
with_context(user: user) do
notification_service.access_token_about_to_expire(user)
- Rails.logger.info "#{self.class}: Notifying User #{user.id} about expiring tokens" # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring tokens"
- user.personal_access_tokens.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true)
+ user.personal_access_tokens.without_impersonation.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true)
end
end
end
diff --git a/app/workers/pipeline_notification_worker.rb b/app/workers/pipeline_notification_worker.rb
index 3336383adf7..f4b43106bf2 100644
--- a/app/workers/pipeline_notification_worker.rb
+++ b/app/workers/pipeline_notification_worker.rb
@@ -7,10 +7,10 @@ class PipelineNotificationWorker # rubocop:disable Scalability/IdempotentWorker
urgency :high
worker_resource_boundary :cpu
- # rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id, args = {})
case args
when Hash
+ args = args.with_indifferent_access
ref_status = args[:ref_status]
recipients = args[:recipients]
else # TODO: backward compatible interface, can be removed in 12.10
@@ -18,10 +18,9 @@ class PipelineNotificationWorker # rubocop:disable Scalability/IdempotentWorker
ref_status = nil
end
- pipeline = Ci::Pipeline.find_by(id: pipeline_id)
+ pipeline = Ci::Pipeline.find_by_id(pipeline_id)
return unless pipeline
NotificationService.new.pipeline_finished(pipeline, ref_status: ref_status, recipients: recipients)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb
index 66a661dde71..cd7c82d3117 100644
--- a/app/workers/pipeline_process_worker.rb
+++ b/app/workers/pipeline_process_worker.rb
@@ -7,6 +7,7 @@ class PipelineProcessWorker # rubocop:disable Scalability/IdempotentWorker
queue_namespace :pipeline_processing
feature_category :continuous_integration
urgency :high
+ loggable_arguments 1
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id, build_ids = nil)
diff --git a/app/workers/pipeline_update_ci_ref_status_worker.rb b/app/workers/pipeline_update_ci_ref_status_worker.rb
index 96e14e126de..9b1a5d8e7cf 100644
--- a/app/workers/pipeline_update_ci_ref_status_worker.rb
+++ b/app/workers/pipeline_update_ci_ref_status_worker.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# NOTE: This class is unused and to be removed in 13.1~
class PipelineUpdateCiRefStatusWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include PipelineQueue
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index ddf112e648c..62d76294bc0 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -7,6 +7,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker
urgency :high
worker_resource_boundary :cpu
weight 5
+ loggable_arguments 0, 1, 2, 3
def perform(gl_repository, identifier, changes, push_options = {})
container, project, repo_type = Gitlab::GlRepository.parse(gl_repository)
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index bdfabea8938..5756ebb8358 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -13,8 +13,8 @@ class ProcessCommitWorker
feature_category :source_code_management
urgency :high
weight 3
-
idempotent!
+ loggable_arguments 2, 3
# project_id - The ID of the project this commit belongs to.
# user_id - The ID of the user that pushed the commit.
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index 573f903f4e0..b114c67de47 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -4,11 +4,11 @@
class ProjectCacheWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- urgency :high
-
LEASE_TIMEOUT = 15.minutes.to_i
feature_category :source_code_management
+ urgency :high
+ loggable_arguments 1, 2, 3
# project_id - The ID of the project for which to flush the cache.
# files - An Array containing extra types of files to refresh such as
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index a287c511a65..d29348e85bc 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -8,6 +8,7 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :importers
worker_resource_boundary :memory
urgency :throttled
+ loggable_arguments 2, 3
def perform(current_user_id, project_id, after_export_strategy = {}, params = {})
current_user = User.find(current_user_id)
diff --git a/app/workers/propagate_integration_worker.rb b/app/workers/propagate_integration_worker.rb
new file mode 100644
index 00000000000..15c0e761a0a
--- /dev/null
+++ b/app/workers/propagate_integration_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class PropagateIntegrationWorker
+ include ApplicationWorker
+
+ feature_category :integrations
+ idempotent!
+ loggable_arguments 1
+
+ def perform(integration_id, overwrite)
+ Admin::PropagateIntegrationService.propagate(
+ integration: Service.find(integration_id),
+ overwrite: overwrite
+ )
+ end
+end
diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb
index 2e13af5e0aa..ee9ae827bb6 100644
--- a/app/workers/rebase_worker.rb
+++ b/app/workers/rebase_worker.rb
@@ -7,6 +7,7 @@ class RebaseWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :source_code_management
weight 2
+ loggable_arguments 2
def perform(merge_request_id, current_user_id, skip_ci = false)
current_user = User.find(current_user_id)
diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb
index 8226f22837c..3f1a484f384 100644
--- a/app/workers/remove_expired_group_links_worker.rb
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -7,10 +7,12 @@ class RemoveExpiredGroupLinksWorker # rubocop:disable Scalability/IdempotentWork
feature_category :authentication_and_authorization
def perform
- ProjectGroupLink.expired.destroy_all # rubocop: disable DestroyAll
+ ProjectGroupLink.expired.find_each do |link|
+ Projects::GroupLinks::DestroyService.new(link.project, nil).execute(link)
+ end
GroupGroupLink.expired.find_in_batches do |link_batch|
- Groups::GroupLinks::DestroyService.new(nil, nil).execute(link_batch)
+ Groups::GroupLinks::DestroyService.new(nil, nil).execute(link_batch, skip_authorization: true)
end
end
end
diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb
index 9e762860ec6..1e2cb912598 100644
--- a/app/workers/repository_check/batch_worker.rb
+++ b/app/workers/repository_check/batch_worker.rb
@@ -12,6 +12,8 @@ module RepositoryCheck
attr_reader :shard_name
+ loggable_arguments 0
+
def perform(shard_name)
@shard_name = shard_name
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 9f17ef467e3..30570a2227e 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -43,7 +43,12 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker
def start_import
return true if start(project.import_state)
- Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.") # rubocop:disable Gitlab/RailsLogger
+ Gitlab::Import::Logger.info(
+ message: 'Project was in inconsistent state while importing',
+ project_full_path: project.full_path,
+ project_import_status: project.import_status
+ )
+
false
end
diff --git a/app/workers/repository_remove_remote_worker.rb b/app/workers/repository_remove_remote_worker.rb
index 23a9ec1e202..5e632b1b1ca 100644
--- a/app/workers/repository_remove_remote_worker.rb
+++ b/app/workers/repository_remove_remote_worker.rb
@@ -5,6 +5,7 @@ class RepositoryRemoveRemoteWorker # rubocop:disable Scalability/IdempotentWorke
include ExclusiveLeaseGuard
feature_category :source_code_management
+ loggable_arguments 1
LEASE_TIMEOUT = 1.hour
diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb
index cfff2382f04..21b5916f459 100644
--- a/app/workers/repository_update_remote_mirror_worker.rb
+++ b/app/workers/repository_update_remote_mirror_worker.rb
@@ -10,6 +10,7 @@ class RepositoryUpdateRemoteMirrorWorker # rubocop:disable Scalability/Idempoten
sidekiq_options retry: 3, dead: false
feature_category :source_code_management
+ loggable_arguments 1
LOCK_WAIT_TIME = 30.seconds
MAX_TRIES = 3
diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
index 6a48b78b22c..ce8d5bf0219 100644
--- a/app/workers/stuck_import_jobs_worker.rb
+++ b/app/workers/stuck_import_jobs_worker.rb
@@ -1,75 +1,19 @@
# frozen_string_literal: true
class StuckImportJobsWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
- # rubocop:disable Scalability/CronWorkerContext
- # This worker updates several import states inline and does not schedule
- # other jobs. So no context needed
- include CronjobQueue
- # rubocop:enable Scalability/CronWorkerContext
-
- feature_category :importers
- worker_resource_boundary :cpu
-
- IMPORT_JOBS_EXPIRATION = 15.hours.to_i
-
- def perform
- import_state_without_jid_count = mark_import_states_without_jid_as_failed!
- import_state_with_jid_count = mark_import_states_with_jid_as_failed!
-
- Gitlab::Metrics.add_event(:stuck_import_jobs,
- projects_without_jid_count: import_state_without_jid_count,
- projects_with_jid_count: import_state_with_jid_count)
- end
+ include Gitlab::Import::StuckImportJob
private
- def mark_import_states_without_jid_as_failed!
- enqueued_import_states_without_jid.each do |import_state|
- import_state.mark_as_failed(error_message)
- end.count
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def mark_import_states_with_jid_as_failed!
- jids_and_ids = enqueued_import_states_with_jid.pluck(:jid, :id).to_h
-
- # Find the jobs that aren't currently running or that exceeded the threshold.
- completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys)
- return unless completed_jids.any?
-
- completed_import_state_ids = jids_and_ids.values_at(*completed_jids)
-
- # We select the import states again, because they may have transitioned from
- # scheduled/started to finished/failed while we were looking up their Sidekiq status.
- completed_import_states = enqueued_import_states_with_jid.where(id: completed_import_state_ids)
-
- completed_import_state_jids = completed_import_states.map { |import_state| import_state.jid }.join(', ')
- Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_import_state_jids}") # rubocop:disable Gitlab/RailsLogger
-
- completed_import_states.each do |import_state|
- import_state.mark_as_failed(error_message)
- end.count
+ def track_metrics(with_jid_count, without_jid_count)
+ Gitlab::Metrics.add_event(
+ :stuck_import_jobs,
+ projects_without_jid_count: without_jid_count,
+ projects_with_jid_count: with_jid_count
+ )
end
- # rubocop: enable CodeReuse/ActiveRecord
def enqueued_import_states
ProjectImportState.with_status([:scheduled, :started])
end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def enqueued_import_states_with_jid
- enqueued_import_states.where.not(jid: nil)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def enqueued_import_states_without_jid
- enqueued_import_states.where(jid: nil)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def error_message
- _("Import timed out. Import took longer than %{import_jobs_expiration} seconds") % { import_jobs_expiration: IMPORT_JOBS_EXPIRATION }
- end
end
diff --git a/app/workers/todos_destroyer/entity_leave_worker.rb b/app/workers/todos_destroyer/entity_leave_worker.rb
index 558cc32d158..4996456dc91 100644
--- a/app/workers/todos_destroyer/entity_leave_worker.rb
+++ b/app/workers/todos_destroyer/entity_leave_worker.rb
@@ -5,6 +5,8 @@ module TodosDestroyer
include ApplicationWorker
include TodosDestroyerQueue
+ loggable_arguments 2
+
def perform(user_id, entity_id, entity_type)
::Todos::Destroy::EntityLeaveService.new(user_id, entity_id, entity_type).execute
end
diff --git a/app/workers/update_external_pull_requests_worker.rb b/app/workers/update_external_pull_requests_worker.rb
index 0d48877e1b0..e916331ae82 100644
--- a/app/workers/update_external_pull_requests_worker.rb
+++ b/app/workers/update_external_pull_requests_worker.rb
@@ -5,6 +5,7 @@ class UpdateExternalPullRequestsWorker # rubocop:disable Scalability/IdempotentW
feature_category :source_code_management
weight 3
+ loggable_arguments 2
def perform(project_id, user_id, ref)
project = Project.find_by_id(project_id)
diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb
index 63bb6171b9c..98534b258a7 100644
--- a/app/workers/update_merge_requests_worker.rb
+++ b/app/workers/update_merge_requests_worker.rb
@@ -7,6 +7,7 @@ class UpdateMergeRequestsWorker # rubocop:disable Scalability/IdempotentWorker
urgency :high
worker_resource_boundary :cpu
weight 3
+ loggable_arguments 2, 3, 4
LOG_TIME_THRESHOLD = 90 # seconds
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index 6e1e7e7d62e..5230f3bfa1f 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -5,6 +5,7 @@ class WebHookWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :integrations
worker_has_external_dependencies!
+ loggable_arguments 2
sidekiq_options retry: 4, dead: false