summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/cluster_app_logos/fluentd.pngbin0 -> 2480 bytes
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue14
-rw-r--r--app/assets/javascripts/access_tokens/index.js12
-rw-r--r--app/assets/javascripts/actioncable_consumer.js3
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue236
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue303
-rw-r--r--app/assets/javascripts/alert_management/constants.js46
-rw-r--r--app/assets/javascripts/alert_management/details.js47
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql11
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql9
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql9
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/details.query.graphql11
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql11
-rw-r--r--app/assets/javascripts/alert_management/list.js55
-rw-r--r--app/assets/javascripts/alert_management/services/index.js7
-rw-r--r--app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue4
-rw-r--r--app/assets/javascripts/api.js57
-rw-r--r--app/assets/javascripts/autosave.js13
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_html.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/paste_markdown_table.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js12
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_metrics.js27
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js2
-rw-r--r--app/assets/javascripts/blob/blob_fork_suggestion.js2
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue17
-rw-r--r--app/assets/javascripts/blob/components/blob_content_error.vue71
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_header.vue4
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue2
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue14
-rw-r--r--app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue2
-rw-r--r--app/assets/javascripts/blob/components/constants.js56
-rw-r--r--app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue3
-rw-r--r--app/assets/javascripts/blob/utils.js19
-rw-r--r--app/assets/javascripts/boards/components/board.js3
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js5
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue4
-rw-r--r--app/assets/javascripts/boards/constants.js8
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js2
-rw-r--r--app/assets/javascripts/boards/icons/fullscreen_collapse.svg1
-rw-r--r--app/assets/javascripts/boards/icons/fullscreen_expand.svg1
-rw-r--r--app/assets/javascripts/boards/index.js116
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js5
-rw-r--r--app/assets/javascripts/boards/models/assignee.js2
-rw-r--r--app/assets/javascripts/boards/models/issue.js28
-rw-r--r--app/assets/javascripts/boards/models/list.js87
-rw-r--r--app/assets/javascripts/boards/queries/board_list.fragment.graphql5
-rw-r--r--app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql15
-rw-r--r--app/assets/javascripts/boards/queries/group_board.query.graphql13
-rw-r--r--app/assets/javascripts/boards/queries/project_board.query.graphql13
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js114
-rw-r--r--app/assets/javascripts/boards/stores/state.js4
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js46
-rw-r--r--app/assets/javascripts/broadcast_notification.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/ajax_variable_list.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue8
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js2
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/store/actions.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutations.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/store/state.js1
-rw-r--r--app/assets/javascripts/close_reopen_report_toggle.js2
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js13
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue73
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue241
-rw-r--r--app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue4
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue4
-rw-r--r--app/assets/javascripts/clusters/constants.js2
-rw-r--r--app/assets/javascripts/clusters/event_hub.js4
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js1
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js19
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue162
-rw-r--r--app/assets/javascripts/clusters_list/constants.js3
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js33
-rw-r--r--app/assets/javascripts/clusters_list/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/clusters_list/store/mutations.js10
-rw-r--r--app/assets/javascripts/clusters_list/store/state.js6
-rw-r--r--app/assets/javascripts/code_navigation/components/app.vue8
-rw-r--r--app/assets/javascripts/code_navigation/components/popover.vue33
-rw-r--r--app/assets/javascripts/code_navigation/store/actions.js9
-rw-r--r--app/assets/javascripts/code_navigation/store/mutations.js3
-rw-r--r--app/assets/javascripts/code_navigation/store/state.js1
-rw-r--r--app/assets/javascripts/comment_type_toggle.js2
-rw-r--r--app/assets/javascripts/commit/image_file.js5
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/compare_autocomplete.js5
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue6
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue6
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue4
-rw-r--r--app/assets/javascripts/create_item_dropdown.js8
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js2
-rw-r--r--app/assets/javascripts/deploy_keys/eventhub.js4
-rw-r--r--app/assets/javascripts/design_management/components/app.vue3
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue64
-rw-r--r--app/assets/javascripts/design_management/components/design_destroyer.vue66
-rw-r--r--app/assets/javascripts/design_management/components/design_note_pin.vue61
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue169
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue148
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue137
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue279
-rw-r--r--app/assets/javascripts/design_management/components/design_presentation.vue314
-rw-r--r--app/assets/javascripts/design_management/components/design_scaler.vue65
-rw-r--r--app/assets/javascripts/design_management/components/image.vue110
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue174
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue126
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/pagination.vue83
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/pagination_button.vue48
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue58
-rw-r--r--app/assets/javascripts/design_management/components/upload/design_dropzone.vue134
-rw-r--r--app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue76
-rw-r--r--app/assets/javascripts/design_management/constants.js14
-rw-r--r--app/assets/javascripts/design_management/graphql.js45
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql22
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql8
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql28
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql5
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql3
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql4
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql21
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql3
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql21
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql6
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/appData.query.graphql4
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql31
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql26
-rw-r--r--app/assets/javascripts/design_management/graphql/typedefs.graphql12
-rw-r--r--app/assets/javascripts/design_management/index.js58
-rw-r--r--app/assets/javascripts/design_management/mixins/all_designs.js49
-rw-r--r--app/assets/javascripts/design_management/mixins/all_versions.js62
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue400
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue323
-rw-r--r--app/assets/javascripts/design_management/router/constants.js3
-rw-r--r--app/assets/javascripts/design_management/router/index.js22
-rw-r--r--app/assets/javascripts/design_management/router/routes.js44
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js272
-rw-r--r--app/assets/javascripts/design_management/utils/design_management_utils.js125
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js95
-rw-r--r--app/assets/javascripts/design_management/utils/tracking.js28
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js21
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js11
-rw-r--r--app/assets/javascripts/diffs/components/app.vue23
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue75
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue6
-rw-r--r--app/assets/javascripts/diffs/components/edit_button.vue36
-rw-r--r--app/assets/javascripts/diffs/components/no_changes.vue6
-rw-r--r--app/assets/javascripts/diffs/store/actions.js80
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js5
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js5
-rw-r--r--app/assets/javascripts/diffs/store/utils.js53
-rw-r--r--app/assets/javascripts/dropzone_input.js6
-rw-r--r--app/assets/javascripts/editor/editor_lite.js4
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue10
-rw-r--r--app/assets/javascripts/environments/components/container.vue34
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue13
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue33
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue33
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue2
-rw-r--r--app/assets/javascripts/environments/event_hub.js4
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js2
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue28
-rw-r--r--app/assets/javascripts/environments/index.js2
-rw-r--r--app/assets/javascripts/environments/mixins/canary_callout_mixin.js23
-rw-r--r--app/assets/javascripts/environments/mixins/container_mixin.js34
-rw-r--r--app/assets/javascripts/environments/mixins/environment_item_mixin.js13
-rw-r--r--app/assets/javascripts/environments/mixins/environments_app_mixin.js32
-rw-r--r--app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js29
-rw-r--r--app/assets/javascripts/environments/mixins/environments_table_mixin.js10
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js20
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue6
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue4
-rw-r--r--app/assets/javascripts/error_tracking/store/actions.js2
-rw-r--r--app/assets/javascripts/filtered_search/constants.js5
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js10
-rw-r--r--app/assets/javascripts/filtered_search/event_hub.js4
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js41
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js17
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js14
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js4
-rw-r--r--app/assets/javascripts/flash.js6
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue2
-rw-r--r--app/assets/javascripts/frequent_items/event_hub.js4
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js18
-rw-r--r--app/assets/javascripts/gl_dropdown.js15
-rw-r--r--app/assets/javascripts/gl_form.js8
-rw-r--r--app/assets/javascripts/groups/components/app.vue7
-rw-r--r--app/assets/javascripts/groups/event_hub.js4
-rw-r--r--app/assets/javascripts/groups/new_group_child.js2
-rw-r--r--app/assets/javascripts/header.js23
-rw-r--r--app/assets/javascripts/helpers/avatar_helper.js5
-rw-r--r--app/assets/javascripts/helpers/event_hub_factory.js20
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue6
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue37
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue18
-rw-r--r--app/assets/javascripts/ide/components/ide.vue24
-rw-r--r--app/assets/javascripts/ide/components/ide_review.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue18
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue4
-rw-r--r--app/assets/javascripts/ide/components/nav_form.vue4
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue7
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue112
-rw-r--r--app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue3
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue11
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue29
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue32
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue31
-rw-r--r--app/assets/javascripts/ide/constants.js1
-rw-r--r--app/assets/javascripts/ide/eventhub.js4
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js13
-rw-r--r--app/assets/javascripts/ide/lib/editor.js3
-rw-r--r--app/assets/javascripts/ide/lib/languages/index.js5
-rw-r--r--app/assets/javascripts/ide/lib/languages/vue.js306
-rw-r--r--app/assets/javascripts/ide/lib/themes/index.js20
-rw-r--r--app/assets/javascripts/ide/lib/themes/monokai.js169
-rw-r--r--app/assets/javascripts/ide/lib/themes/none.js17
-rw-r--r--app/assets/javascripts/ide/lib/themes/solarized_dark.js1110
-rw-r--r--app/assets/javascripts/ide/lib/themes/solarized_light.js1101
-rw-r--r--app/assets/javascripts/ide/services/index.js8
-rw-r--r--app/assets/javascripts/ide/stores/actions.js20
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js6
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js36
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js19
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js5
-rw-r--r--app/assets/javascripts/ide/stores/state.js2
-rw-r--r--app/assets/javascripts/ide/stores/utils.js43
-rw-r--r--app/assets/javascripts/ide/utils.js16
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js4
-rw-r--r--app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js4
-rw-r--r--app/assets/javascripts/image_diff/helpers/dom_helper.js11
-rw-r--r--app/assets/javascripts/image_diff/image_diff.js6
-rw-r--r--app/assets/javascripts/import_projects/event_hub.js4
-rw-r--r--app/assets/javascripts/importer_status.js6
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_toggle.vue11
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue50
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue99
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue73
-rw-r--r--app/assets/javascripts/integrations/edit/event_hub.js4
-rw-r--r--app/assets/javascripts/integrations/edit/index.js40
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js8
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js2
-rw-r--r--app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql15
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue96
-rw-r--r--app/assets/javascripts/issuables_list/eventhub.js6
-rw-r--r--app/assets/javascripts/issuables_list/index.js55
-rw-r--r--app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql22
-rw-r--r--app/assets/javascripts/issue.js58
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue13
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue32
-rw-r--r--app/assets/javascripts/issue_show/event_hub.js4
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_app.vue70
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue23
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_progress.vue5
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_setup.vue6
-rw-r--r--app/assets/javascripts/jira_import/index.js1
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql2
-rw-r--r--app/assets/javascripts/jira_import/utils.js49
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue8
-rw-r--r--app/assets/javascripts/jobs/store/actions.js4
-rw-r--r--app/assets/javascripts/jobs/store/state.js2
-rw-r--r--app/assets/javascripts/labels_select.js50
-rw-r--r--app/assets/javascripts/lib/graphql.js1
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js13
-rw-r--r--app/assets/javascripts/lib/utils/downloader.js20
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js3
-rw-r--r--app/assets/javascripts/lib/utils/keys.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js7
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js66
-rw-r--r--app/assets/javascripts/line_highlighter.js9
-rw-r--r--app/assets/javascripts/locale/sprintf.js2
-rw-r--r--app/assets/javascripts/main.js5
-rw-r--r--app/assets/javascripts/member_expiration_date.js3
-rw-r--r--app/assets/javascripts/merge_request_tabs.js4
-rw-r--r--app/assets/javascripts/milestone_select.js41
-rw-r--r--app/assets/javascripts/milestones/project_milestone_combobox.vue228
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js4
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget.vue286
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue307
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue13
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue277
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue (renamed from app/assets/javascripts/monitoring/components/panel_type.vue)187
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue55
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/metric_embed.vue8
-rw-r--r--app/assets/javascripts/monitoring/components/variables/custom_variable.vue50
-rw-r--r--app/assets/javascripts/monitoring/components/variables/text_variable.vue39
-rw-r--r--app/assets/javascripts/monitoring/components/variables_section.vue56
-rw-r--r--app/assets/javascripts/monitoring/constants.js84
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js2
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js13
-rw-r--r--app/assets/javascripts/monitoring/services/alerts_service.js32
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js84
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js28
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js10
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js36
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js17
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js167
-rw-r--r--app/assets/javascripts/monitoring/utils.js201
-rw-r--r--app/assets/javascripts/monitoring/validators.js44
-rw-r--r--app/assets/javascripts/namespace_select.js8
-rw-r--r--app/assets/javascripts/new_branch_form.js5
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue34
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue3
-rw-r--r--app/assets/javascripts/notes.js19
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue83
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue19
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue93
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue10
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue5
-rw-r--r--app/assets/javascripts/notes/index.js4
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js17
-rw-r--r--app/assets/javascripts/notes/stores/getters.js2
-rw-r--r--app/assets/javascripts/notes/stores/index.js6
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/impersonation_tokens/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue8
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue8
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue2
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue2
-rw-r--r--app/assets/javascripts/pages/milestones/shared/event_hub.js4
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/alert_management/details/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/alert_management/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/new/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js10
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue4
-rw-r--r--app/assets/javascripts/pages/projects/labels/event_hub.js4
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue139
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js4
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/dag/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/index/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/settings/access_tokens/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue49
-rw-r--r--app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue5
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js13
-rw-r--r--app/assets/javascripts/pages/search/init_filtered_search.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue4
-rw-r--r--app/assets/javascripts/persistent_user_callout.js5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue44
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue91
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue18
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue70
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue114
-rw-r--r--app/assets/javascripts/pipelines/constants.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js65
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js14
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js2
-rw-r--r--app/assets/javascripts/profile/account/components/delete_account_modal.vue2
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue10
-rw-r--r--app/assets/javascripts/project_select.js23
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue1
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js6
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue3
-rw-r--r--app/assets/javascripts/registry/explorer/components/image_list.vue124
-rw-r--r--app/assets/javascripts/registry/explorer/constants.js44
-rw-r--r--app/assets/javascripts/registry/explorer/index.js2
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue168
-rw-r--r--app/assets/javascripts/registry/explorer/pages/index.vue39
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue172
-rw-r--r--app/assets/javascripts/registry/explorer/router.js12
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js11
-rw-r--r--app/assets/javascripts/registry/explorer/stores/index.js1
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js2
-rw-r--r--app/assets/javascripts/registry/settings/store/getters.js1
-rw-r--r--app/assets/javascripts/registry/settings/store/mutations.js2
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue116
-rw-r--r--app/assets/javascripts/registry/shared/constants.js32
-rw-r--r--app/assets/javascripts/releases/components/app_edit.vue74
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue11
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue2
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue8
-rw-r--r--app/assets/javascripts/releases/components/release_block_footer.vue7
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue13
-rw-r--r--app/assets/javascripts/releases/components/release_block_metadata.vue15
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestone_info.vue12
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/actions.js11
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutation_types.js1
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutations.js4
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/state.js4
-rw-r--r--app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue30
-rw-r--r--app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue64
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/actions.js79
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/getters.js48
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/index.js (renamed from app/assets/javascripts/static_site_editor/store/index.js)17
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/mutations.js20
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/state.js28
-rw-r--r--app/assets/javascripts/reports/components/grouped_issues_list.vue93
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue15
-rw-r--r--app/assets/javascripts/reports/components/issue_status_icon.vue3
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue2
-rw-r--r--app/assets/javascripts/reports/constants.js3
-rw-r--r--app/assets/javascripts/reports/store/mutations.js3
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue2
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue5
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue5
-rw-r--r--app/assets/javascripts/repository/queries/commit.fragment.graphql1
-rw-r--r--app/assets/javascripts/repository/queries/pathLastCommit.query.graphql1
-rw-r--r--app/assets/javascripts/repository/router.js21
-rw-r--r--app/assets/javascripts/repository/utils/commit.js1
-rw-r--r--app/assets/javascripts/right_sidebar.js3
-rw-r--r--app/assets/javascripts/search_autocomplete.js4
-rw-r--r--app/assets/javascripts/serverless/components/area.vue4
-rw-r--r--app/assets/javascripts/serverless/event_hub.js4
-rw-r--r--app/assets/javascripts/set_status_modal/event_hub.js4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue75
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue24
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue23
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue18
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js4
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js21
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js4
-rw-r--r--app/assets/javascripts/snippet/snippet_bundle.js24
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue18
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue36
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue4
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_view.vue21
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue40
-rw-r--r--app/assets/javascripts/snippets/components/snippet_title.vue10
-rw-r--r--app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/components/app.vue3
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue51
-rw-r--r--app/assets/javascripts/static_site_editor/components/publish_toolbar.vue15
-rw-r--r--app/assets/javascripts/static_site_editor/components/saved_changes_message.vue7
-rw-r--r--app/assets/javascripts/static_site_editor/components/skeleton_loader.vue19
-rw-r--r--app/assets/javascripts/static_site_editor/components/static_site_editor.vue95
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js7
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js39
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql7
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql9
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql3
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql9
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/file.js11
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js24
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/typedefs.graphql43
-rw-r--r--app/assets/javascripts/static_site_editor/index.js33
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue120
-rw-r--r--app/assets/javascripts/static_site_editor/pages/success.vue35
-rw-r--r--app/assets/javascripts/static_site_editor/router/constants.js2
-rw-r--r--app/assets/javascripts/static_site_editor/router/index.js15
-rw-r--r--app/assets/javascripts/static_site_editor/router/routes.js21
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js15
-rw-r--r--app/assets/javascripts/static_site_editor/store/actions.js37
-rw-r--r--app/assets/javascripts/static_site_editor/store/getters.js2
-rw-r--r--app/assets/javascripts/static_site_editor/store/mutation_types.js8
-rw-r--r--app/assets/javascripts/static_site_editor/store/mutations.js36
-rw-r--r--app/assets/javascripts/static_site_editor/store/state.js23
-rw-r--r--app/assets/javascripts/syntax_highlight.js13
-rw-r--r--app/assets/javascripts/terminal/terminal.js13
-rw-r--r--app/assets/javascripts/tracking.js47
-rw-r--r--app/assets/javascripts/tree.js5
-rw-r--r--app/assets/javascripts/users_select/constants.js18
-rw-r--r--app/assets/javascripts/users_select/index.js (renamed from app/assets/javascripts/users_select.js)70
-rw-r--r--app/assets/javascripts/users_select/utils.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue2
-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_pipeline.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/event_hub.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/form/title.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_warning.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field_view.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js37
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue20
-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/base.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue73
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue38
-rw-r--r--app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js29
-rw-r--r--app/assets/stylesheets/application.scss8
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss2
-rw-r--r--app/assets/stylesheets/components/dashboard_skeleton.scss2
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss140
-rw-r--r--app/assets/stylesheets/components/design_management/design_list_item.scss19
-rw-r--r--app/assets/stylesheets/components/design_management/design_version_dropdown.scss3
-rw-r--r--app/assets/stylesheets/components/milestone_combobox.scss13
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss123
-rw-r--r--app/assets/stylesheets/components/rich_content_editor.scss11
-rw-r--r--app/assets/stylesheets/framework/animations.scss2
-rw-r--r--app/assets/stylesheets/framework/buttons.scss4
-rw-r--r--app/assets/stylesheets/framework/common.scss59
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss21
-rw-r--r--app/assets/stylesheets/framework/header.scss1
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss10
-rw-r--r--app/assets/stylesheets/framework/tables.scss23
-rw-r--r--app/assets/stylesheets/framework/typography.scss13
-rw-r--r--app/assets/stylesheets/framework/variables.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_mixins.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss23
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss308
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss191
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/README.md53
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_dark.scss50
-rw-r--r--app/assets/stylesheets/pages/alert_management/details.scss42
-rw-r--r--app/assets/stylesheets/pages/alert_management/list.scss83
-rw-r--r--app/assets/stylesheets/pages/alert_management/severity-icons.scss26
-rw-r--r--app/assets/stylesheets/pages/boards.scss24
-rw-r--r--app/assets/stylesheets/pages/commits.scss12
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss1
-rw-r--r--app/assets/stylesheets/pages/error_list.scss2
-rw-r--r--app/assets/stylesheets/pages/issuable.scss13
-rw-r--r--app/assets/stylesheets/pages/issues.scss10
-rw-r--r--app/assets/stylesheets/pages/labels.scss5
-rw-r--r--app/assets/stylesheets/pages/milestone.scss4
-rw-r--r--app/assets/stylesheets/pages/notes.scss24
-rw-r--r--app/assets/stylesheets/pages/pipeline_schedules.scss5
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss14
-rw-r--r--app/assets/stylesheets/pages/settings.scss10
-rw-r--r--app/assets/stylesheets/snippets.scss6
-rw-r--r--app/assets/stylesheets/utilities.scss34
-rw-r--r--app/channels/application_cable/channel.rb6
-rw-r--r--app/channels/application_cable/connection.rb22
-rw-r--r--app/channels/issues_channel.rb13
-rw-r--r--app/controllers/admin/appearances_controller.rb1
-rw-r--r--app/controllers/admin/application_settings_controller.rb4
-rw-r--r--app/controllers/admin/ci/variables_controller.rb48
-rw-r--r--app/controllers/admin/dashboard_controller.rb1
-rw-r--r--app/controllers/admin/logs_controller.rb24
-rw-r--r--app/controllers/admin/sessions_controller.rb1
-rw-r--r--app/controllers/admin/users_controller.rb2
-rw-r--r--app/controllers/application_controller.rb67
-rw-r--r--app/controllers/boards/issues_controller.rb3
-rw-r--r--app/controllers/clusters/applications_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb40
-rw-r--r--app/controllers/concerns/boards_actions.rb3
-rw-r--r--app/controllers/concerns/impersonation.rb43
-rw-r--r--app/controllers/concerns/issuable_actions.rb3
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb4
-rw-r--r--app/controllers/concerns/known_sign_in.rb31
-rw-r--r--app/controllers/concerns/members_presentation.rb1
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb29
-rw-r--r--app/controllers/concerns/notes_actions.rb2
-rw-r--r--app/controllers/concerns/preview_markdown.rb2
-rw-r--r--app/controllers/concerns/record_user_last_activity.rb1
-rw-r--r--app/controllers/concerns/renders_ldap_servers.rb19
-rw-r--r--app/controllers/concerns/service_params.rb1
-rw-r--r--app/controllers/concerns/snippets_actions.rb15
-rw-r--r--app/controllers/concerns/spammable_actions.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb6
-rw-r--r--app/controllers/dashboard_controller.rb1
-rw-r--r--app/controllers/google_api/authorizations_controller.rb3
-rw-r--r--app/controllers/graphql_controller.rb14
-rw-r--r--app/controllers/groups/group_links_controller.rb5
-rw-r--r--app/controllers/groups/group_members_controller.rb47
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb4
-rw-r--r--app/controllers/groups/settings/repository_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb6
-rw-r--r--app/controllers/help_controller.rb4
-rw-r--r--app/controllers/ide_controller.rb4
-rw-r--r--app/controllers/import/github_controller.rb2
-rw-r--r--app/controllers/import/google_code_controller.rb4
-rw-r--r--app/controllers/jwt_controller.rb11
-rw-r--r--app/controllers/ldap/omniauth_callbacks_controller.rb4
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb11
-rw-r--r--app/controllers/projects/alert_management_controller.rb16
-rw-r--r--app/controllers/projects/artifacts_controller.rb15
-rw-r--r--app/controllers/projects/branches_controller.rb1
-rw-r--r--app/controllers/projects/ci/daily_build_group_report_results_controller.rb77
-rw-r--r--app/controllers/projects/design_management/designs/raw_images_controller.rb30
-rw-r--r--app/controllers/projects/design_management/designs/resized_image_controller.rb46
-rw-r--r--app/controllers/projects/design_management/designs_controller.rb21
-rw-r--r--app/controllers/projects/environments_controller.rb15
-rw-r--r--app/controllers/projects/graphs_controller.rb22
-rw-r--r--app/controllers/projects/import/jira_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb23
-rw-r--r--app/controllers/projects/mattermosts_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb8
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb23
-rw-r--r--app/controllers/projects/pipelines_controller.rb47
-rw-r--r--app/controllers/projects/project_members_controller.rb14
-rw-r--r--app/controllers/projects/refs_controller.rb39
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb3
-rw-r--r--app/controllers/projects/settings/access_tokens_controller.rb71
-rw-r--r--app/controllers/projects/settings/repository_controller.rb2
-rw-r--r--app/controllers/projects/snippets_controller.rb11
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects/usage_ping_controller.rb6
-rw-r--r--app/controllers/projects/wikis_controller.rb27
-rw-r--r--app/controllers/projects_controller.rb12
-rw-r--r--app/controllers/registrations_controller.rb3
-rw-r--r--app/controllers/repositories/git_http_controller.rb8
-rw-r--r--app/controllers/search_controller.rb11
-rw-r--r--app/controllers/sessions_controller.rb14
-rw-r--r--app/controllers/snippets_controller.rb25
-rw-r--r--app/controllers/user_callouts_controller.rb2
-rw-r--r--app/finders/alert_management/alerts_finder.rb55
-rw-r--r--app/finders/artifacts_finder.rb24
-rw-r--r--app/finders/ci/daily_build_group_report_results_finder.rb37
-rw-r--r--app/finders/ci/job_artifacts_finder.rb26
-rw-r--r--app/finders/clusters/knative_services_finder.rb1
-rw-r--r--app/finders/container_repositories_finder.rb13
-rw-r--r--app/finders/design_management/designs_finder.rb57
-rw-r--r--app/finders/design_management/versions_finder.rb58
-rw-r--r--app/finders/freeze_periods_finder.rb14
-rw-r--r--app/finders/group_members_finder.rb2
-rw-r--r--app/finders/issuable_finder.rb133
-rw-r--r--app/finders/issuable_finder/params.rb2
-rw-r--r--app/finders/issues_finder/params.rb2
-rw-r--r--app/finders/members_finder.rb16
-rw-r--r--app/finders/metrics/users_starred_dashboards_finder.rb35
-rw-r--r--app/finders/projects/serverless/functions_finder.rb1
-rw-r--r--app/finders/projects_finder.rb4
-rw-r--r--app/finders/releases_finder.rb20
-rw-r--r--app/finders/todos_finder.rb2
-rw-r--r--app/graphql/mutations/alert_management/base.rb40
-rw-r--r--app/graphql/mutations/alert_management/create_alert_issue.rb30
-rw-r--r--app/graphql/mutations/alert_management/update_alert_status.rb35
-rw-r--r--app/graphql/mutations/base_mutation.rb2
-rw-r--r--app/graphql/mutations/branches/create.rb51
-rw-r--r--app/graphql/mutations/design_management/base.rb23
-rw-r--r--app/graphql/mutations/design_management/delete.rb66
-rw-r--r--app/graphql/mutations/design_management/upload.rb38
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/create.rb106
-rw-r--r--app/graphql/mutations/snippets/base.rb2
-rw-r--r--app/graphql/mutations/snippets/create.rb9
-rw-r--r--app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb13
-rw-r--r--app/graphql/resolvers/alert_management_alert_resolver.rb31
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb38
-rw-r--r--app/graphql/resolvers/branch_commit_resolver.rb17
-rw-r--r--app/graphql/resolvers/design_management/design_at_version_resolver.rb46
-rw-r--r--app/graphql/resolvers/design_management/design_resolver.rb57
-rw-r--r--app/graphql/resolvers/design_management/designs_resolver.rb50
-rw-r--r--app/graphql/resolvers/design_management/version/design_at_version_resolver.rb95
-rw-r--r--app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb60
-rw-r--r--app/graphql/resolvers/design_management/version_in_collection_resolver.rb45
-rw-r--r--app/graphql/resolvers/design_management/version_resolver.rb25
-rw-r--r--app/graphql/resolvers/design_management/versions_resolver.rb76
-rw-r--r--app/graphql/resolvers/issues_resolver.rb18
-rw-r--r--app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb1
-rw-r--r--app/graphql/resolvers/milestone_resolver.rb31
-rw-r--r--app/graphql/resolvers/namespace_projects_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects_resolver.rb31
-rw-r--r--app/graphql/resolvers/release_resolver.rb25
-rw-r--r--app/graphql/resolvers/releases_resolver.rb21
-rw-r--r--app/graphql/types/alert_management/alert_sort_enum.rb25
-rw-r--r--app/graphql/types/alert_management/alert_status_counts_type.rb30
-rw-r--r--app/graphql/types/alert_management/alert_type.rb88
-rw-r--r--app/graphql/types/alert_management/severity_enum.rb14
-rw-r--r--app/graphql/types/alert_management/status_enum.rb14
-rw-r--r--app/graphql/types/board_list_type.rb26
-rw-r--r--app/graphql/types/board_type.rb7
-rw-r--r--app/graphql/types/branch_type.rb18
-rw-r--r--app/graphql/types/commit_type.rb1
-rw-r--r--app/graphql/types/design_management/design_at_version_type.rb37
-rw-r--r--app/graphql/types/design_management/design_collection_type.rb44
-rw-r--r--app/graphql/types/design_management/design_fields.rb78
-rw-r--r--app/graphql/types/design_management/design_type.rb44
-rw-r--r--app/graphql/types/design_management/design_version_event_enum.rb18
-rw-r--r--app/graphql/types/design_management/version_type.rb37
-rw-r--r--app/graphql/types/design_management_type.rb18
-rw-r--r--app/graphql/types/grafana_integration_type.rb2
-rw-r--r--app/graphql/types/issuable_sort_enum.rb7
-rw-r--r--app/graphql/types/issue_sort_enum.rb6
-rw-r--r--app/graphql/types/issue_type.rb8
-rw-r--r--app/graphql/types/jira_import_type.rb5
-rw-r--r--app/graphql/types/metrics/dashboard_type.rb3
-rw-r--r--app/graphql/types/metrics/dashboards/annotation_type.rb4
-rw-r--r--app/graphql/types/mutation_type.rb6
-rw-r--r--app/graphql/types/notes/noteable_type.rb4
-rw-r--r--app/graphql/types/permission_types/issue.rb8
-rw-r--r--app/graphql/types/permission_types/project.rb4
-rw-r--r--app/graphql/types/project_type.rb32
-rw-r--r--app/graphql/types/query_type.rb16
-rw-r--r--app/graphql/types/release_type.rb47
-rw-r--r--app/graphql/types/snippet_type.rb2
-rw-r--r--app/graphql/types/snippets/blob_type.rb10
-rw-r--r--app/graphql/types/snippets/blob_viewer_type.rb6
-rw-r--r--app/graphql/types/todo_target_enum.rb1
-rw-r--r--app/graphql/types/todo_type.rb2
-rw-r--r--app/graphql/types/user_type.rb4
-rw-r--r--app/helpers/access_tokens_helper.rb7
-rw-r--r--app/helpers/appearances_helper.rb4
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/application_settings_helper.rb13
-rw-r--r--app/helpers/auth_helper.rb6
-rw-r--r--app/helpers/blob_helper.rb21
-rw-r--r--app/helpers/boards_helper.rb3
-rw-r--r--app/helpers/button_helper.rb4
-rw-r--r--app/helpers/clusters_helper.rb11
-rw-r--r--app/helpers/commits_helper.rb2
-rw-r--r--app/helpers/environment_helper.rb2
-rw-r--r--app/helpers/environments_helper.rb89
-rw-r--r--app/helpers/events_helper.rb14
-rw-r--r--app/helpers/export_helper.rb13
-rw-r--r--app/helpers/form_helper.rb4
-rw-r--r--app/helpers/groups_helper.rb4
-rw-r--r--app/helpers/icons_helper.rb2
-rw-r--r--app/helpers/issuables_helper.rb28
-rw-r--r--app/helpers/issues_helper.rb16
-rw-r--r--app/helpers/members_helper.rb13
-rw-r--r--app/helpers/milestones_helper.rb2
-rw-r--r--app/helpers/namespaces_helper.rb4
-rw-r--r--app/helpers/nav_helper.rb5
-rw-r--r--app/helpers/preferences_helper.rb2
-rw-r--r--app/helpers/projects/alert_management_helper.rb21
-rw-r--r--app/helpers/projects_helper.rb19
-rw-r--r--app/helpers/releases_helper.rb4
-rw-r--r--app/helpers/search_helper.rb6
-rw-r--r--app/helpers/services_helper.rb12
-rw-r--r--app/helpers/snippets_helper.rb76
-rw-r--r--app/helpers/sorting_helper.rb4
-rw-r--r--app/helpers/system_note_helper.rb8
-rw-r--r--app/helpers/todos_helper.rb19
-rw-r--r--app/helpers/workhorse_helper.rb4
-rw-r--r--app/helpers/x509_helper.rb4
-rw-r--r--app/mailers/emails/groups.rb19
-rw-r--r--app/mailers/emails/notes.rb12
-rw-r--r--app/mailers/emails/profile.rb10
-rw-r--r--app/mailers/notify.rb1
-rw-r--r--app/mailers/previews/notify_preview.rb4
-rw-r--r--app/models/active_session.rb4
-rw-r--r--app/models/alert_management/alert.rb146
-rw-r--r--app/models/appearance.rb3
-rw-r--r--app/models/application_setting.rb12
-rw-r--r--app/models/application_setting_implementation.rb5
-rw-r--r--app/models/blob.rb6
-rw-r--r--app/models/blob_viewer/dependency_manager.rb2
-rw-r--r--app/models/broadcast_message.rb5
-rw-r--r--app/models/ci/bridge.rb4
-rw-r--r--app/models/ci/build.rb67
-rw-r--r--app/models/ci/daily_build_group_report_result.rb20
-rw-r--r--app/models/ci/daily_report_result.rb22
-rw-r--r--app/models/ci/freeze_period.rb18
-rw-r--r--app/models/ci/freeze_period_status.rb47
-rw-r--r--app/models/ci/group.rb2
-rw-r--r--app/models/ci/instance_variable.rb76
-rw-r--r--app/models/ci/job_artifact.rb61
-rw-r--r--app/models/ci/legacy_stage.rb4
-rw-r--r--app/models/ci/persistent_ref.rb12
-rw-r--r--app/models/ci/pipeline.rb55
-rw-r--r--app/models/ci/pipeline_schedule.rb4
-rw-r--r--app/models/ci/processable.rb12
-rw-r--r--app/models/ci/stage.rb4
-rw-r--r--app/models/clusters/applications/elastic_stack.rb47
-rw-r--r--app/models/clusters/applications/fluentd.rb20
-rw-r--r--app/models/clusters/applications/ingress.rb7
-rw-r--r--app/models/clusters/applications/jupyter.rb2
-rw-r--r--app/models/clusters/applications/knative.rb4
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb92
-rw-r--r--app/models/clusters/concerns/application_status.rb9
-rw-r--r--app/models/commit_status.rb12
-rw-r--r--app/models/concerns/async_devise_email.rb14
-rw-r--r--app/models/concerns/awardable.rb43
-rw-r--r--app/models/concerns/cache_markdown_field.rb1
-rw-r--r--app/models/concerns/ci/contextable.rb8
-rw-r--r--app/models/concerns/diff_positionable_note.rb4
-rw-r--r--app/models/concerns/has_repository.rb1
-rw-r--r--app/models/concerns/has_user_type.rb45
-rw-r--r--app/models/concerns/has_wiki.rb44
-rw-r--r--app/models/concerns/issuable.rb29
-rw-r--r--app/models/concerns/issue_resource_event.rb13
-rw-r--r--app/models/concerns/limitable.rb27
-rw-r--r--app/models/concerns/merge_request_resource_event.rb11
-rw-r--r--app/models/concerns/milestoneable.rb2
-rw-r--r--app/models/concerns/noteable.rb14
-rw-r--r--app/models/concerns/prometheus_adapter.rb1
-rw-r--r--app/models/concerns/protected_ref_access.rb4
-rw-r--r--app/models/concerns/reactive_caching.rb15
-rw-r--r--app/models/concerns/redis_cacheable.rb6
-rw-r--r--app/models/concerns/spammable.rb33
-rw-r--r--app/models/concerns/state_eventable.rb9
-rw-r--r--app/models/concerns/storage/legacy_project_wiki.rb11
-rw-r--r--app/models/concerns/timebox.rb204
-rw-r--r--app/models/concerns/update_project_statistics.rb14
-rw-r--r--app/models/container_repository.rb2
-rw-r--r--app/models/cycle_analytics/group_level.rb29
-rw-r--r--app/models/deploy_token.rb5
-rw-r--r--app/models/design_management.rb13
-rw-r--r--app/models/design_management/action.rb44
-rw-r--r--app/models/design_management/design.rb266
-rw-r--r--app/models/design_management/design_action.rb64
-rw-r--r--app/models/design_management/design_at_version.rb119
-rw-r--r--app/models/design_management/design_collection.rb30
-rw-r--r--app/models/design_management/repository.rb51
-rw-r--r--app/models/design_management/version.rb144
-rw-r--r--app/models/design_user_mention.rb6
-rw-r--r--app/models/diff_note.rb10
-rw-r--r--app/models/email.rb14
-rw-r--r--app/models/environment.rb9
-rw-r--r--app/models/epic.rb2
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb1
-rw-r--r--app/models/event.rb15
-rw-r--r--app/models/global_milestone.rb5
-rw-r--r--app/models/group.rb35
-rw-r--r--app/models/group_import_state.rb34
-rw-r--r--app/models/group_milestone.rb3
-rw-r--r--app/models/hooks/project_hook.rb3
-rw-r--r--app/models/internal_id_enums.rb13
-rw-r--r--app/models/issue.rb22
-rw-r--r--app/models/iteration.rb100
-rw-r--r--app/models/jira_import_state.rb7
-rw-r--r--app/models/list.rb14
-rw-r--r--app/models/member.rb1
-rw-r--r--app/models/members/project_member.rb5
-rw-r--r--app/models/members_preloader.rb4
-rw-r--r--app/models/merge_request.rb54
-rw-r--r--app/models/merge_request_diff.rb30
-rw-r--r--app/models/metrics/users_starred_dashboard.rb18
-rw-r--r--app/models/milestone.rb195
-rw-r--r--app/models/milestone_note.rb2
-rw-r--r--app/models/namespace.rb25
-rw-r--r--app/models/namespace/root_storage_size.rb31
-rw-r--r--app/models/note.rb14
-rw-r--r--app/models/pages_domain.rb11
-rw-r--r--app/models/performance_monitoring/prometheus_dashboard.rb2
-rw-r--r--app/models/personal_access_token.rb21
-rw-r--r--app/models/personal_snippet.rb4
-rw-r--r--app/models/plan.rb42
-rw-r--r--app/models/plan_limits.rb23
-rw-r--r--app/models/project.rb127
-rw-r--r--app/models/project_authorization.rb3
-rw-r--r--app/models/project_ci_cd_setting.rb2
-rw-r--r--app/models/project_feature.rb2
-rw-r--r--app/models/project_repository_storage_move.rb58
-rw-r--r--app/models/project_services/chat_message/merge_message.rb4
-rw-r--r--app/models/project_services/chat_message/pipeline_message.rb48
-rw-r--r--app/models/project_services/jira_service.rb66
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb4
-rw-r--r--app/models/project_services/mock_monitoring_service.rb2
-rw-r--r--app/models/project_services/webex_teams_service.rb57
-rw-r--r--app/models/project_services/youtrack_service.rb4
-rw-r--r--app/models/project_statistics.rb3
-rw-r--r--app/models/project_wiki.rb220
-rw-r--r--app/models/release.rb14
-rw-r--r--app/models/remote_mirror.rb26
-rw-r--r--app/models/repository.rb11
-rw-r--r--app/models/resource_label_event.rb6
-rw-r--r--app/models/resource_milestone_event.rb11
-rw-r--r--app/models/resource_state_event.rb15
-rw-r--r--app/models/resource_weight_event.rb4
-rw-r--r--app/models/sent_notification.rb4
-rw-r--r--app/models/service.rb14
-rw-r--r--app/models/snippet.rb31
-rw-r--r--app/models/snippet_repository.rb30
-rw-r--r--app/models/ssh_host_key.rb1
-rw-r--r--app/models/state_note.rb19
-rw-r--r--app/models/storage/hashed.rb1
-rw-r--r--app/models/system_note_metadata.rb1
-rw-r--r--app/models/timelog.rb4
-rw-r--r--app/models/todo.rb6
-rw-r--r--app/models/user.rb105
-rw-r--r--app/models/user_type_enums.rb13
-rw-r--r--app/models/wiki.rb233
-rw-r--r--app/models/wiki_page.rb59
-rw-r--r--app/models/wiki_page/meta.rb108
-rw-r--r--app/models/x509_certificate.rb6
-rw-r--r--app/models/x509_commit_signature.rb4
-rw-r--r--app/policies/alert_management/alert_policy.rb7
-rw-r--r--app/policies/ci/build_policy.rb10
-rw-r--r--app/policies/ci/freeze_period_policy.rb7
-rw-r--r--app/policies/concerns/policy_actor.rb37
-rw-r--r--app/policies/design_management/design_at_version_policy.rb8
-rw-r--r--app/policies/design_management/design_collection_policy.rb7
-rw-r--r--app/policies/design_management/design_policy.rb8
-rw-r--r--app/policies/design_management/version_policy.rb8
-rw-r--r--app/policies/global_policy.rb10
-rw-r--r--app/policies/group_policy.rb35
-rw-r--r--app/policies/issue_policy.rb16
-rw-r--r--app/policies/project_policy.rb101
-rw-r--r--app/policies/wiki_page_policy.rb2
-rw-r--r--app/presenters/README.md16
-rw-r--r--app/presenters/ci/build_runner_presenter.rb35
-rw-r--r--app/presenters/clusterable_presenter.rb4
-rw-r--r--app/presenters/commit_status_presenter.rb8
-rw-r--r--app/presenters/instance_clusterable_presenter.rb4
-rw-r--r--app/presenters/pages_domain_presenter.rb2
-rw-r--r--app/presenters/projects/prometheus/alert_presenter.rb61
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb4
-rw-r--r--app/presenters/release_presenter.rb7
-rw-r--r--app/presenters/snippet_presenter.rb10
-rw-r--r--app/serializers/accessibility_error_entity.rb12
-rw-r--r--app/serializers/accessibility_reports_comparer_entity.rb15
-rw-r--r--app/serializers/accessibility_reports_comparer_serializer.rb5
-rw-r--r--app/serializers/analytics_summary_entity.rb4
-rw-r--r--app/serializers/ci/basic_variable_entity.rb13
-rw-r--r--app/serializers/ci/dag_job_entity.rb11
-rw-r--r--app/serializers/ci/dag_job_group_entity.rb9
-rw-r--r--app/serializers/ci/dag_pipeline_entity.rb20
-rw-r--r--app/serializers/ci/dag_pipeline_serializer.rb7
-rw-r--r--app/serializers/ci/dag_stage_entity.rb9
-rw-r--r--app/serializers/ci/instance_variable_serializer.rb7
-rw-r--r--app/serializers/cluster_application_entity.rb2
-rw-r--r--app/serializers/cluster_entity.rb9
-rw-r--r--app/serializers/cluster_serializer.rb15
-rw-r--r--app/serializers/diff_file_base_entity.rb23
-rw-r--r--app/serializers/diffs_entity.rb4
-rw-r--r--app/serializers/group_variable_entity.rb9
-rw-r--r--app/serializers/issuable_sidebar_basic_entity.rb2
-rw-r--r--app/serializers/issuable_sidebar_extras_entity.rb2
-rw-r--r--app/serializers/merge_request_assignee_entity.rb2
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb12
-rw-r--r--app/serializers/merge_request_serializer.rb2
-rw-r--r--app/serializers/note_user_entity.rb4
-rw-r--r--app/serializers/service_event_entity.rb44
-rw-r--r--app/serializers/service_event_serializer.rb5
-rw-r--r--app/serializers/test_suite_comparer_entity.rb2
-rw-r--r--app/serializers/test_suite_entity.rb3
-rw-r--r--app/serializers/variable_entity.rb9
-rw-r--r--app/services/alert_management/create_alert_issue_service.rb70
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb86
-rw-r--r--app/services/alert_management/update_alert_status_service.rb63
-rw-r--r--app/services/audit_event_service.rb5
-rw-r--r--app/services/auth/container_registry_authentication_service.rb29
-rw-r--r--app/services/authorized_project_update/project_create_service.rb34
-rw-r--r--app/services/base_container_service.rb12
-rw-r--r--app/services/base_service.rb73
-rw-r--r--app/services/boards/issues/list_service.rb9
-rw-r--r--app/services/boards/lists/list_service.rb6
-rw-r--r--app/services/branches/create_service.rb2
-rw-r--r--app/services/ci/compare_accessibility_reports_service.rb17
-rw-r--r--app/services/ci/create_job_artifacts_service.rb17
-rw-r--r--app/services/ci/create_pipeline_service.rb21
-rw-r--r--app/services/ci/daily_build_group_report_result_service.rb (renamed from app/services/ci/daily_report_result_service.rb)11
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb8
-rw-r--r--app/services/ci/generate_terraform_reports_service.rb29
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb2
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb19
-rw-r--r--app/services/ci/pipeline_schedule_service.rb14
-rw-r--r--app/services/ci/process_pipeline_service.rb13
-rw-r--r--app/services/ci/register_job_service.rb4
-rw-r--r--app/services/ci/retry_build_service.rb5
-rw-r--r--app/services/ci/retry_pipeline_service.rb2
-rw-r--r--app/services/ci/update_instance_variables_service.rb72
-rw-r--r--app/services/clusters/applications/base_service.rb20
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb2
-rw-r--r--app/services/clusters/applications/check_uninstall_progress_service.rb2
-rw-r--r--app/services/clusters/applications/check_upgrade_progress_service.rb2
-rw-r--r--app/services/clusters/applications/ingress_modsecurity_usage_service.rb69
-rw-r--r--app/services/clusters/applications/schedule_update_service.rb6
-rw-r--r--app/services/clusters/gcp/verify_provision_status_service.rb2
-rw-r--r--app/services/clusters/kubernetes/configure_istio_ingress_service.rb4
-rw-r--r--app/services/clusters/management/create_project_service.rb7
-rw-r--r--app/services/clusters/parse_cluster_applications_artifact_service.rb95
-rw-r--r--app/services/concerns/base_service_utility.rb72
-rw-r--r--app/services/concerns/git/logger.rb10
-rw-r--r--app/services/concerns/measurable.rb61
-rw-r--r--app/services/concerns/spam_check_methods.rb4
-rw-r--r--app/services/deployments/older_deployments_drop_service.rb4
-rw-r--r--app/services/design_management/delete_designs_service.rb66
-rw-r--r--app/services/design_management/design_service.rb31
-rw-r--r--app/services/design_management/design_user_notes_count_service.rb34
-rw-r--r--app/services/design_management/generate_image_versions_service.rb99
-rw-r--r--app/services/design_management/on_success_callbacks.rb23
-rw-r--r--app/services/design_management/runs_design_actions.rb35
-rw-r--r--app/services/design_management/save_designs_service.rb114
-rw-r--r--app/services/emails/base_service.rb2
-rw-r--r--app/services/event_create_service.rb28
-rw-r--r--app/services/git/branch_hooks_service.rb2
-rw-r--r--app/services/git/wiki_push_service.rb57
-rw-r--r--app/services/git/wiki_push_service/change.rb67
-rw-r--r--app/services/grafana/proxy_service.rb1
-rw-r--r--app/services/groups/create_service.rb4
-rw-r--r--app/services/groups/import_export/export_service.rb24
-rw-r--r--app/services/groups/import_export/import_service.rb34
-rw-r--r--app/services/groups/update_service.rb1
-rw-r--r--app/services/incident_management/create_issue_service.rb8
-rw-r--r--app/services/issuable/clone/attributes_rewriter.rb55
-rw-r--r--app/services/issuable/clone/base_service.rb2
-rw-r--r--app/services/issuable/common_system_notes_service.rb18
-rw-r--r--app/services/issuable_base_service.rb14
-rw-r--r--app/services/issues/build_service.rb10
-rw-r--r--app/services/issues/related_branches_service.rb20
-rw-r--r--app/services/issues/update_service.rb4
-rw-r--r--app/services/jira_import/start_import_service.rb2
-rw-r--r--app/services/lfs/file_transformer.rb3
-rw-r--r--app/services/members/request_access_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb31
-rw-r--r--app/services/merge_requests/rebase_service.rb8
-rw-r--r--app/services/merge_requests/refresh_service.rb4
-rw-r--r--app/services/merge_requests/squash_service.rb18
-rw-r--r--app/services/metrics/dashboard/base_service.rb2
-rw-r--r--app/services/metrics/dashboard/grafana_metric_embed_service.rb3
-rw-r--r--app/services/metrics/dashboard/transient_embed_service.rb8
-rw-r--r--app/services/metrics/users_starred_dashboards/create_service.rb74
-rw-r--r--app/services/metrics/users_starred_dashboards/delete_service.rb33
-rw-r--r--app/services/namespaces/check_storage_size_service.rb94
-rw-r--r--app/services/notes/post_process_service.rb8
-rw-r--r--app/services/notification_service.rb20
-rw-r--r--app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb2
-rw-r--r--app/services/pod_logs/base_service.rb3
-rw-r--r--app/services/pod_logs/elasticsearch_service.rb10
-rw-r--r--app/services/pod_logs/kubernetes_service.rb9
-rw-r--r--app/services/post_receive_service.rb17
-rw-r--r--app/services/projects/alerting/notify_service.rb17
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb2
-rw-r--r--app/services/projects/create_service.rb32
-rw-r--r--app/services/projects/gitlab_projects_import_service.rb8
-rw-r--r--app/services/projects/hashed_storage/base_attachment_service.rb2
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb28
-rw-r--r--app/services/projects/import_export/export_service.rb33
-rw-r--r--app/services/projects/import_service.rb25
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_link_list_service.rb2
-rw-r--r--app/services/projects/lsif_data_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb9
-rw-r--r--app/services/projects/propagate_service_template.rb54
-rw-r--r--app/services/projects/transfer_service.rb24
-rw-r--r--app/services/projects/update_remote_mirror_service.rb14
-rw-r--r--app/services/projects/update_repository_storage_service.rb69
-rw-r--r--app/services/prometheus/proxy_service.rb1
-rw-r--r--app/services/prometheus/proxy_variable_substitution_service.rb48
-rw-r--r--app/services/releases/create_service.rb6
-rw-r--r--app/services/resource_access_tokens/create_service.rb (renamed from app/services/resources/create_access_token_service.rb)18
-rw-r--r--app/services/resource_access_tokens/revoke_service.rb65
-rw-r--r--app/services/resource_events/base_synthetic_notes_builder_service.rb2
-rw-r--r--app/services/resource_events/change_milestone_service.rb7
-rw-r--r--app/services/search/snippet_service.rb2
-rw-r--r--app/services/search_service.rb19
-rw-r--r--app/services/snippets/base_service.rb41
-rw-r--r--app/services/snippets/create_service.rb40
-rw-r--r--app/services/snippets/update_service.rb47
-rw-r--r--app/services/spam/akismet_service.rb2
-rw-r--r--app/services/spam/spam_action_service.rb91
-rw-r--r--app/services/spam/spam_check_service.rb68
-rw-r--r--app/services/spam/spam_constants.rb9
-rw-r--r--app/services/spam/spam_verdict_service.rb26
-rw-r--r--app/services/system_note_service.rb28
-rw-r--r--app/services/system_notes/design_management_service.rb83
-rw-r--r--app/services/tags/destroy_service.rb14
-rw-r--r--app/services/template_engines/liquid_service.rb48
-rw-r--r--app/services/terraform/remote_state_handler.rb2
-rw-r--r--app/services/user_project_access_changed_service.rb13
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb6
-rw-r--r--app/services/verify_pages_domain_service.rb4
-rw-r--r--app/services/wiki_pages/base_service.rb13
-rw-r--r--app/services/wiki_pages/create_service.rb4
-rw-r--r--app/services/wiki_pages/event_create_service.rb30
-rw-r--r--app/services/wikis/create_attachment_service.rb11
-rw-r--r--app/uploaders/design_management/design_v432x230_uploader.rb45
-rw-r--r--app/validators/cron_freeze_period_timezone_validator.rb13
-rw-r--r--app/validators/cron_validator.rb15
-rw-r--r--app/views/admin/appearances/_form.html.haml35
-rw-r--r--app/views/admin/application_settings/_influx.html.haml60
-rw-r--r--app/views/admin/application_settings/_prometheus.html.haml6
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml4
-rw-r--r--app/views/admin/application_settings/_signup.html.haml27
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml1
-rw-r--r--app/views/admin/application_settings/general.html.haml4
-rw-r--r--app/views/admin/application_settings/integrations.html.haml2
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml11
-rw-r--r--app/views/admin/impersonation_tokens/index.html.haml28
-rw-r--r--app/views/admin/logs/show.html.haml24
-rw-r--r--app/views/admin/projects/show.html.haml28
-rw-r--r--app/views/admin/services/_deprecated_message.html.haml3
-rw-r--r--app/views/admin/services/edit.html.haml2
-rw-r--r--app/views/admin/sessions/_new_base.html.haml2
-rw-r--r--app/views/admin/sessions/_signin_box.html.haml19
-rw-r--r--app/views/admin/sessions/_tabs_normal.html.haml3
-rw-r--r--app/views/admin/sessions/new.html.haml25
-rw-r--r--app/views/admin/sessions/two_factor.html.haml2
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml4
-rw-r--r--app/views/ci/variables/_index.html.haml2
-rw-r--r--app/views/clusters/clusters/_cluster.html.haml3
-rw-r--r--app/views/clusters/clusters/index.html.haml2
-rw-r--r--app/views/clusters/clusters/show.html.haml1
-rw-r--r--app/views/dashboard/snippets/index.html.haml2
-rw-r--r--app/views/devise/registrations/new.html.haml2
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml8
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml4
-rw-r--r--app/views/devise/shared/_signin_box.html.haml2
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml11
-rw-r--r--app/views/devise/shared/_tabs_normal.html.haml7
-rw-r--r--app/views/groups/_flash_messages.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml46
-rw-r--r--app/views/groups/edit.html.haml4
-rw-r--r--app/views/groups/group_members/index.html.haml22
-rw-r--r--app/views/groups/settings/_advanced.html.haml2
-rw-r--r--app/views/groups/settings/_default_branch_protection.html.haml3
-rw-r--r--app/views/groups/settings/_export.html.haml28
-rw-r--r--app/views/groups/settings/_permissions.html.haml2
-rw-r--r--app/views/groups/settings/integrations/index.html.haml2
-rw-r--r--app/views/groups/settings/repository/show.html.haml2
-rw-r--r--app/views/groups/show.html.haml6
-rw-r--r--app/views/groups/sidebar/_packages.html.haml4
-rw-r--r--app/views/help/_shortcuts.html.haml162
-rw-r--r--app/views/import/google_code/new_user_map.html.haml2
-rw-r--r--app/views/kaminari/gitlab/_page.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml1
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/devise.html.haml4
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml1
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml1
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml11
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml4
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml16
-rw-r--r--app/views/layouts/nav/sidebar/_project_packages_link.html.haml4
-rw-r--r--app/views/notify/group_was_exported_email.html.haml9
-rw-r--r--app/views/notify/group_was_exported_email.text.erb6
-rw-r--r--app/views/notify/group_was_not_exported_email.html.haml10
-rw-r--r--app/views/notify/group_was_not_exported_email.text.erb7
-rw-r--r--app/views/notify/issues_csv_email.html.haml9
-rw-r--r--app/views/notify/issues_csv_email.text.erb4
-rw-r--r--app/views/notify/note_design_email.html.haml1
-rw-r--r--app/views/notify/note_design_email.text.erb1
-rw-r--r--app/views/notify/unknown_sign_in_email.html.haml14
-rw-r--r--app/views/notify/unknown_sign_in_email.text.haml10
-rw-r--r--app/views/profiles/keys/_form.html.haml2
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml24
-rw-r--r--app/views/profiles/show.html.haml5
-rw-r--r--app/views/projects/alert_management/details.html.haml4
-rw-r--r--app/views/projects/alert_management/index.html.haml3
-rw-r--r--app/views/projects/blob/_header.html.haml8
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml2
-rw-r--r--app/views/projects/buttons/_download.html.haml15
-rw-r--r--app/views/projects/commit/_signature.html.haml2
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml6
-rw-r--r--app/views/projects/commit/x509/_signature_badge_user.html.haml2
-rw-r--r--app/views/projects/commits/_commit.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml5
-rw-r--r--app/views/projects/graphs/charts.html.haml21
-rw-r--r--app/views/projects/graphs/show.html.haml2
-rw-r--r--app/views/projects/import/jira/show.html.haml1
-rw-r--r--app/views/projects/issues/_design_management.html.haml15
-rw-r--r--app/views/projects/issues/_issue.html.haml5
-rw-r--r--app/views/projects/issues/_related_branches.html.haml8
-rw-r--r--app/views/projects/issues/_tabs.html.haml14
-rw-r--r--app/views/projects/issues/export_csv/_modal.html.haml1
-rw-r--r--app/views/projects/issues/index.html.haml6
-rw-r--r--app/views/projects/issues/show.html.haml10
-rw-r--r--app/views/projects/labels/index.html.haml4
-rw-r--r--app/views/projects/merge_requests/_how_to_merge.html.haml2
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml2
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml1
-rw-r--r--app/views/projects/merge_requests/creations/update_branches.html.haml3
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos_push.html.haml12
-rw-r--r--app/views/projects/new.html.haml2
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml10
-rw-r--r--app/views/projects/pipelines/index.html.haml1
-rw-r--r--app/views/projects/services/_deprecated_message.html.haml3
-rw-r--r--app/views/projects/services/_form.html.haml8
-rw-r--r--app/views/projects/services/_index.html.haml30
-rw-r--r--app/views/projects/services/edit.html.haml3
-rw-r--r--app/views/projects/services/prometheus/_custom_metrics.html.haml4
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml2
-rw-r--r--app/views/projects/settings/_general.html.haml2
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml34
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/projects/settings/integrations/show.html.haml4
-rw-r--r--app/views/projects/settings/operations/_incidents.html.haml8
-rw-r--r--app/views/projects/settings/repository/show.html.haml2
-rw-r--r--app/views/projects/snippets/show.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml3
-rw-r--r--app/views/projects/tags/show.html.haml2
-rw-r--r--app/views/projects/wikis/_sidebar.html.haml2
-rw-r--r--app/views/projects/wikis/git_access.html.haml5
-rw-r--r--app/views/search/_category.html.haml2
-rw-r--r--app/views/search/results/_blob_data.html.haml2
-rw-r--r--app/views/search/results/_snippet_blob.html.haml50
-rw-r--r--app/views/search/results/_snippet_title.html.haml2
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml2
-rw-r--r--app/views/shared/_broadcast_message.html.haml2
-rw-r--r--app/views/shared/_clone_panel.html.haml2
-rw-r--r--app/views/shared/_commit_message_container.html.haml7
-rw-r--r--app/views/shared/_delete_label_modal.html.haml11
-rw-r--r--app/views/shared/_field.html.haml13
-rw-r--r--app/views/shared/_group_form.html.haml4
-rw-r--r--app/views/shared/_group_tips.html.haml8
-rw-r--r--app/views/shared/_label_row.html.haml2
-rw-r--r--app/views/shared/_milestone_expired.html.haml6
-rw-r--r--app/views/shared/_milestones_filter.html.haml6
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml2
-rw-r--r--app/views/shared/_no_ssh.html.haml2
-rw-r--r--app/views/shared/_personal_access_tokens_created_container.html.haml15
-rw-r--r--app/views/shared/_personal_access_tokens_form.html.haml31
-rw-r--r--app/views/shared/_project_limit.html.haml6
-rw-r--r--app/views/shared/_recaptcha_form.html.haml2
-rw-r--r--app/views/shared/_ref_dropdown.html.haml4
-rw-r--r--app/views/shared/_service_settings.html.haml35
-rw-r--r--app/views/shared/access_tokens/_created_container.html.haml12
-rw-r--r--app/views/shared/access_tokens/_form.html.haml34
-rw-r--r--app/views/shared/access_tokens/_table.html.haml (renamed from app/views/shared/_personal_access_tokens_table.html.haml)15
-rw-r--r--app/views/shared/boards/components/_board.html.haml3
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml3
-rw-r--r--app/views/shared/deploy_keys/_index.html.haml (renamed from app/views/projects/deploy_keys/_index.html.haml)7
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml (renamed from app/views/projects/deploy_keys/_form.html.haml)6
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml10
-rw-r--r--app/views/shared/file_hooks/_index.html.haml4
-rw-r--r--app/views/shared/hook_logs/_content.html.haml2
-rw-r--r--app/views/shared/integrations/_form.html.haml2
-rw-r--r--app/views/shared/integrations/_index.html.haml (renamed from app/views/shared/integrations/_integrations.html.haml)11
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml38
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml17
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml23
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml7
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml2
-rw-r--r--app/views/shared/issuable/_nav.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml2
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml2
-rw-r--r--app/views/shared/members/_badge.html.haml4
-rw-r--r--app/views/shared/members/_blocked_badge.html.haml3
-rw-r--r--app/views/shared/members/_its_you_badge.html.haml3
-rw-r--r--app/views/shared/members/_member.html.haml29
-rw-r--r--app/views/shared/members/_two_factor_auth_badge.html.haml3
-rw-r--r--app/views/shared/milestones/_issuable.html.haml2
-rw-r--r--app/views/shared/milestones/_issues_tab.html.haml6
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml4
-rw-r--r--app/views/shared/milestones/_merge_requests_tab.haml8
-rw-r--r--app/views/shared/milestones/_milestone.html.haml16
-rw-r--r--app/views/shared/milestones/_top.html.haml12
-rw-r--r--app/views/shared/notes/_form.html.haml4
-rw-r--r--app/views/shared/notes/_note.html.haml7
-rw-r--r--app/views/shared/snippets/_form.html.haml10
-rw-r--r--app/views/shared/snippets/_header.html.haml4
-rw-r--r--app/views/shared/snippets/_snippet.html.haml5
-rw-r--r--app/views/shared/tokens/_scopes_form.html.haml2
-rw-r--r--app/views/snippets/show.html.haml2
-rw-r--r--app/views/users/_deletion_guidance.html.haml7
-rw-r--r--app/views/users/calendar_activities.html.haml4
-rw-r--r--app/workers/all_queues.yml73
-rw-r--r--app/workers/authorized_project_update/project_create_worker.rb19
-rw-r--r--app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb11
-rw-r--r--app/workers/ci/daily_build_group_report_results_worker.rb (renamed from app/workers/ci/daily_report_results_worker.rb)4
-rw-r--r--app/workers/cluster_configure_worker.rb10
-rw-r--r--app/workers/cluster_project_configure_worker.rb12
-rw-r--r--app/workers/concerns/application_worker.rb17
-rw-r--r--app/workers/concerns/chaos_queue.rb2
-rw-r--r--app/workers/concerns/reactive_cacheable_worker.rb33
-rw-r--r--app/workers/create_commit_signature_worker.rb4
-rw-r--r--app/workers/design_management/new_version_worker.rb31
-rw-r--r--app/workers/external_service_reactive_caching_worker.rb7
-rw-r--r--app/workers/gitlab/jira_import/import_issue_worker.rb28
-rw-r--r--app/workers/group_import_worker.rb11
-rw-r--r--app/workers/incident_management/process_alert_worker.rb25
-rw-r--r--app/workers/irker_worker.rb2
-rw-r--r--app/workers/merge_request_mergeability_check_worker.rb3
-rw-r--r--app/workers/new_release_worker.rb2
-rw-r--r--app/workers/pages_domain_ssl_renewal_cron_worker.rb5
-rw-r--r--app/workers/process_commit_worker.rb4
-rw-r--r--app/workers/project_update_repository_storage_worker.rb16
-rw-r--r--app/workers/reactive_caching_worker.rb32
-rw-r--r--app/workers/stage_update_worker.rb4
-rw-r--r--app/workers/update_head_pipeline_for_merge_request_worker.rb4
-rw-r--r--app/workers/x509_issuer_crl_check_worker.rb76
1331 files changed, 27593 insertions, 6165 deletions
diff --git a/app/assets/images/cluster_app_logos/fluentd.png b/app/assets/images/cluster_app_logos/fluentd.png
new file mode 100644
index 00000000000..6d42578f2ce
--- /dev/null
+++ b/app/assets/images/cluster_app_logos/fluentd.png
Binary files differ
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
new file mode 100644
index 00000000000..d0932ad80e1
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -0,0 +1,14 @@
+<script>
+import { GlDatepicker } from '@gitlab/ui';
+
+export default {
+ name: 'ExpiresAtField',
+ components: { GlDatepicker },
+};
+</script>
+
+<template>
+ <gl-datepicker :target="null" :min-date="new Date()">
+ <slot></slot>
+ </gl-datepicker>
+</template>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
new file mode 100644
index 00000000000..9bdb2940956
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import ExpiresAtField from './components/expires_at_field.vue';
+
+const initExpiresAtField = () => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: document.querySelector('.js-access-tokens-expires-at'),
+ components: { ExpiresAtField },
+ });
+};
+
+export default initExpiresAtField;
diff --git a/app/assets/javascripts/actioncable_consumer.js b/app/assets/javascripts/actioncable_consumer.js
new file mode 100644
index 00000000000..5658ffc1a38
--- /dev/null
+++ b/app/assets/javascripts/actioncable_consumer.js
@@ -0,0 +1,3 @@
+import { createConsumer } from '@rails/actioncable';
+
+export default createConsumer();
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
new file mode 100644
index 00000000000..89db7db77d5
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -0,0 +1,236 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import {
+ GlAlert,
+ 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';
+
+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.',
+ ),
+ fullAlertDetailsTitle: s__('AlertManagement|Alert details'),
+ overviewTitle: s__('AlertManagement|Overview'),
+ reportedAt: s__('AlertManagement|Reported %{when}'),
+ reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
+ },
+ severityLabels: ALERTS_SEVERITY_LABELS,
+ components: {
+ GlAlert,
+ GlIcon,
+ GlLoadingIcon,
+ GlSprintf,
+ GlDropdown,
+ GlDropdownItem,
+ GlTab,
+ GlTabs,
+ GlButton,
+ GlTable,
+ TimeAgoTooltip,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ alertId: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ newIssuePath: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ alert: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ alertId: this.alertId,
+ };
+ },
+ update(data) {
+ return data?.project?.alertManagementAlerts?.nodes?.[0] ?? null;
+ },
+ error(error) {
+ this.errored = true;
+ Sentry.captureException(error);
+ },
+ },
+ },
+ data() {
+ return { alert: null, errored: false, isErrorDismissed: false };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.alert.loading;
+ },
+ reportedAtMessage() {
+ return this.alert?.monitoringTool
+ ? this.$options.i18n.reportedAtWithTool
+ : this.$options.i18n.reportedAt;
+ },
+ showErrorMsg() {
+ return this.errored && !this.isErrorDismissed;
+ },
+ },
+ methods: {
+ dismissError() {
+ this.isErrorDismissed = true;
+ },
+ updateAlertStatus(status) {
+ this.$apollo
+ .mutate({
+ mutation: updateAlertStatus,
+ variables: {
+ iid: this.alertId,
+ status: status.toUpperCase(),
+ projectPath: this.projectPath,
+ },
+ })
+ .catch(() => {
+ createFlash(
+ s__(
+ 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
+ ),
+ );
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
+ {{ $options.i18n.errorMsg }}
+ </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
+ 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"
+ >
+ <div
+ data-testid="alert-header"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center"
+ >
+ <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>
+ </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>
+ </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"
+ data-testid="createIssueBtn"
+ :href="newIssuePath"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('AlertManagement|Create issue') }}
+ </gl-button>
+ </div>
+ <div
+ v-if="alert"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center"
+ >
+ <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>
+ <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>
+ </gl-tab>
+ <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle">
+ <gl-table
+ class="alert-management-details-table"
+ :items="[{ key: 'Value', ...alert }]"
+ :show-empty="true"
+ :busy="loading"
+ stacked
+ >
+ <template #empty>
+ {{ s__('AlertManagement|No alert data to display.') }}
+ </template>
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+ </gl-table>
+ </gl-tab>
+ </gl-tabs>
+ </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
new file mode 100644
index 00000000000..74fc19ff3d4
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue
@@ -0,0 +1,303 @@
+<script>
+import {
+ GlEmptyState,
+ GlDeprecatedButton,
+ GlLoadingIcon,
+ GlTable,
+ GlAlert,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlTabs,
+ GlTab,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
+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 updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+const tdClass = 'table-col d-flex d-md-table-cell align-items-center';
+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';
+
+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.",
+ ),
+ errorMsg: s__(
+ "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
+ ),
+ },
+ fields: [
+ {
+ key: 'severity',
+ label: s__('AlertManagement|Severity'),
+ tdClass: `${tdClass} rounded-top text-capitalize`,
+ },
+ {
+ key: 'startedAt',
+ label: s__('AlertManagement|Start time'),
+ tdClass,
+ },
+ {
+ key: 'endedAt',
+ label: s__('AlertManagement|End time'),
+ tdClass,
+ },
+ {
+ key: 'title',
+ label: s__('AlertManagement|Alert'),
+ thClass: 'w-30p',
+ tdClass,
+ },
+ {
+ key: 'eventCount',
+ label: s__('AlertManagement|Events'),
+ thClass: 'text-right event-count',
+ tdClass: `${tdClass} text-md-right event-count`,
+ },
+ {
+ key: 'status',
+ thClass: 'w-15p',
+ label: s__('AlertManagement|Status'),
+ tdClass: `${tdClass} rounded-bottom`,
+ },
+ ],
+ statuses: {
+ [ALERTS_STATUS.TRIGGERED]: s__('AlertManagement|Triggered'),
+ [ALERTS_STATUS.ACKNOWLEDGED]: s__('AlertManagement|Acknowledged'),
+ [ALERTS_STATUS.RESOLVED]: s__('AlertManagement|Resolved'),
+ },
+ severityLabels: ALERTS_SEVERITY_LABELS,
+ statusTabs: ALERTS_STATUS_TABS,
+ components: {
+ GlEmptyState,
+ GlLoadingIcon,
+ GlTable,
+ GlAlert,
+ GlDeprecatedButton,
+ TimeAgo,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlTabs,
+ GlTab,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alertManagementEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ enableAlertManagementPath: {
+ type: String,
+ required: true,
+ },
+ userCanEnableAlertManagement: {
+ type: Boolean,
+ required: true,
+ },
+ emptyAlertSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ alerts: {
+ query: getAlerts,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ statuses: this.statusFilter,
+ };
+ },
+ update(data) {
+ return data.project.alertManagementAlerts.nodes;
+ },
+ error() {
+ this.errored = true;
+ },
+ },
+ },
+ data() {
+ return {
+ alerts: null,
+ errored: false,
+ isAlertDismissed: false,
+ isErrorAlertDismissed: false,
+ statusFilter: this.$options.statusTabs[4].filters,
+ };
+ },
+ computed: {
+ showNoAlertsMsg() {
+ return !this.errored && !this.loading && !this.alerts?.length && !this.isAlertDismissed;
+ },
+ showErrorMsg() {
+ return this.errored && !this.isErrorAlertDismissed;
+ },
+ loading() {
+ return this.$apollo.queries.alerts.loading;
+ },
+ },
+ methods: {
+ filterAlertsByStatus(tabIndex) {
+ this.statusFilter = this.$options.statusTabs[tabIndex].filters;
+ },
+ capitalizeFirstCharacter,
+ updateAlertStatus(status, iid) {
+ this.$apollo
+ .mutate({
+ mutation: updateAlertStatus,
+ variables: {
+ iid,
+ status: status.toUpperCase(),
+ projectPath: this.projectPath,
+ },
+ })
+ .then(() => {
+ this.$apollo.queries.alerts.refetch();
+ })
+ .catch(() => {
+ createFlash(
+ s__(
+ 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
+ ),
+ );
+ });
+ },
+ navigateToAlertDetails({ iid }) {
+ return visitUrl(joinPaths(window.location.pathname, iid, 'details'));
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div v-if="alertManagementEnabled" class="alert-management-list">
+ <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true">
+ {{ $options.i18n.noAlertsMsg }}
+ </gl-alert>
+ <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true">
+ {{ $options.i18n.errorMsg }}
+ </gl-alert>
+
+ <gl-tabs v-if="glFeatures.alertListStatusFilteringEnabled" @input="filterAlertsByStatus">
+ <gl-tab v-for="tab in $options.statusTabs" :key="tab.status">
+ <template slot="title">
+ <span>{{ tab.title }}</span>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+
+ <h4 class="d-block d-md-none my-3">
+ {{ s__('AlertManagement|Alerts') }}
+ </h4>
+ <gl-table
+ class="alert-management-table mt-3"
+ :items="alerts"
+ :fields="$options.fields"
+ :show-empty="true"
+ :busy="loading"
+ stacked="md"
+ :tbody-tr-class="$options.bodyTrClass"
+ @row-clicked="navigateToAlertDetails"
+ >
+ <template #cell(severity)="{ item }">
+ <div
+ class="d-inline-flex align-items-center justify-content-between"
+ data-testid="severityField"
+ >
+ <gl-icon
+ class="mr-2"
+ :size="12"
+ :name="`severity-${item.severity.toLowerCase()}`"
+ :class="`icon-${item.severity.toLowerCase()}`"
+ />
+ {{ $options.severityLabels[item.severity] }}
+ </div>
+ </template>
+
+ <template #cell(startedAt)="{ item }">
+ <time-ago v-if="item.startedAt" :time="item.startedAt" />
+ </template>
+
+ <template #cell(endedAt)="{ item }">
+ <time-ago v-if="item.endedAt" :time="item.endedAt" />
+ </template>
+
+ <template #cell(title)="{ item }">
+ <div class="gl-max-w-full text-truncate">{{ item.title }}</div>
+ </template>
+
+ <template #cell(status)="{ item }">
+ <gl-dropdown
+ :text="capitalizeFirstCharacter(item.status.toLowerCase())"
+ class="w-100"
+ right
+ >
+ <gl-dropdown-item
+ v-for="(label, field) in $options.statuses"
+ :key="field"
+ @click="updateAlertStatus(label, item.iid)"
+ >
+ <span class="d-flex">
+ <gl-icon
+ class="flex-shrink-0 append-right-4"
+ :class="{ invisible: label.toUpperCase() !== item.status }"
+ name="mobile-issue-close"
+ />
+ {{ label }}
+ </span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
+
+ <template #empty>
+ {{ s__('AlertManagement|No alerts to display.') }}
+ </template>
+
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+ </gl-table>
+ </div>
+ <gl-empty-state
+ v-else
+ :title="s__('AlertManagement|Surface alerts in GitLab')"
+ :svg-path="emptyAlertSvgPath"
+ >
+ <template #description>
+ <div class="d-block">
+ <span>{{
+ s__(
+ 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.',
+ )
+ }}</span>
+ <a href="/help/user/project/operations/alert_management.html" target="_blank">
+ {{ s__('AlertManagement|More information') }}
+ </a>
+ </div>
+ <div v-if="userCanEnableAlertManagement" class="d-block center pt-4">
+ <gl-deprecated-button
+ category="primary"
+ variant="success"
+ :href="enableAlertManagementPath"
+ >
+ {{ s__('AlertManagement|Authorize external service') }}
+ </gl-deprecated-button>
+ </div>
+ </template>
+ </gl-empty-state>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js
new file mode 100644
index 00000000000..9df01d9d0b5
--- /dev/null
+++ b/app/assets/javascripts/alert_management/constants.js
@@ -0,0 +1,46 @@
+import { s__ } from '~/locale';
+
+export const ALERTS_SEVERITY_LABELS = {
+ CRITICAL: s__('AlertManagement|Critical'),
+ HIGH: s__('AlertManagement|High'),
+ MEDIUM: s__('AlertManagement|Medium'),
+ LOW: s__('AlertManagement|Low'),
+ INFO: s__('AlertManagement|Info'),
+ 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],
+ },
+ {
+ title: s__('AlertManagement|Triggered'),
+ status: ALERTS_STATUS.TRIGGERED,
+ filters: [ALERTS_STATUS.TRIGGERED],
+ },
+ {
+ title: s__('AlertManagement|Acknowledged'),
+ status: ALERTS_STATUS.ACKNOWLEDGED,
+ filters: [ALERTS_STATUS.ACKNOWLEDGED],
+ },
+ {
+ title: s__('AlertManagement|Resolved'),
+ status: ALERTS_STATUS.RESOLVED,
+ filters: [ALERTS_STATUS.RESOLVED],
+ },
+ {
+ title: s__('AlertManagement|All alerts'),
+ status: ALERTS_STATUS.ALL,
+ filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED, ALERTS_STATUS.RESOLVED],
+ },
+];
diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js
new file mode 100644
index 00000000000..d3523e0a29d
--- /dev/null
+++ b/app/assets/javascripts/alert_management/details.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import AlertDetails from './components/alert_details.vue';
+
+Vue.use(VueApollo);
+
+export default selector => {
+ const domEl = document.querySelector(selector);
+ const { alertId, projectPath, newIssuePath } = domEl.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (object.__typename === 'AlertManagementAlert') {
+ return object.iid;
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ },
+ ),
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: selector,
+ apolloProvider,
+ components: {
+ AlertDetails,
+ },
+ render(createElement) {
+ return createElement('alert-details', {
+ props: {
+ alertId,
+ projectPath,
+ newIssuePath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql
new file mode 100644
index 00000000000..df802616e97
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql
@@ -0,0 +1,11 @@
+#import "./listItem.fragment.graphql"
+
+fragment AlertDetailItem on AlertManagementAlert {
+ ...AlertListItem
+ createdAt
+ monitoringTool
+ service
+ description
+ updatedAt
+ details
+}
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql
new file mode 100644
index 00000000000..fffe07b0cfd
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql
@@ -0,0 +1,9 @@
+fragment AlertListItem on AlertManagementAlert {
+ iid
+ title
+ severity
+ status
+ startedAt
+ endedAt
+ eventCount
+}
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
new file mode 100644
index 00000000000..009ae0b2930
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
@@ -0,0 +1,9 @@
+mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) {
+ updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) {
+ errors
+ alert {
+ iid,
+ status,
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
new file mode 100644
index 00000000000..7c77715fad2
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
@@ -0,0 +1,11 @@
+#import "../fragments/detailItem.fragment.graphql"
+
+query alertDetails($fullPath: ID!, $alertId: String) {
+ project(fullPath: $fullPath) {
+ alertManagementAlerts(iid: $alertId) {
+ nodes {
+ ...AlertDetailItem
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql
new file mode 100644
index 00000000000..54b66389d5b
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql
@@ -0,0 +1,11 @@
+#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/list.js b/app/assets/javascripts/alert_management/list.js
new file mode 100644
index 00000000000..cae6a536b56
--- /dev/null
+++ b/app/assets/javascripts/alert_management/list.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import AlertManagementList from './components/alert_management_list.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const selector = '#js-alert_management';
+
+ const domEl = document.querySelector(selector);
+ const { projectPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset;
+ let { alertManagementEnabled, userCanEnableAlertManagement } = domEl.dataset;
+
+ alertManagementEnabled = parseBoolean(alertManagementEnabled);
+ userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (object.__typename === 'AlertManagementAlert') {
+ return object.iid;
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ },
+ ),
+ });
+
+ return new Vue({
+ el: selector,
+ apolloProvider,
+ components: {
+ AlertManagementList,
+ },
+ render(createElement) {
+ return createElement('alert-management-list', {
+ props: {
+ projectPath,
+ enableAlertManagementPath,
+ emptyAlertSvgPath,
+ alertManagementEnabled,
+ userCanEnableAlertManagement,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/alert_management/services/index.js b/app/assets/javascripts/alert_management/services/index.js
new file mode 100644
index 00000000000..787603d3e7a
--- /dev/null
+++ b/app/assets/javascripts/alert_management/services/index.js
@@ -0,0 +1,7 @@
+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 785598142fe..410c5c00e8a 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
@@ -6,7 +6,7 @@ import {
GlModal,
GlModalDirective,
} from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+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';
@@ -65,7 +65,7 @@ export default {
'AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts.',
),
{
- linkStart: `<a href="${esc(
+ linkStart: `<a href="${escape(
this.learnMoreUrl,
)}" target="_blank" rel="noopener noreferrer">`,
linkEnd: '</a>',
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 6301f6a3910..e527659a939 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -23,6 +23,8 @@ const Api = {
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
+ projectSearchPath: '/api/:version/projects/:id/search',
+ projectMilestonesPath: '/api/:version/projects/:id/milestones',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
@@ -46,6 +48,7 @@ const Api = {
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
+ pipelinesPath: '/api/:version/projects/:id/pipelines/',
environmentsPath: '/api/:version/projects/:id/environments',
rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw',
@@ -74,13 +77,11 @@ const Api = {
const url = Api.buildUrl(Api.groupsPath);
return axios
.get(url, {
- params: Object.assign(
- {
- search: query,
- per_page: DEFAULT_PER_PAGE,
- },
- options,
- ),
+ params: {
+ search: query,
+ per_page: DEFAULT_PER_PAGE,
+ ...options,
+ },
})
.then(({ data }) => {
callback(data);
@@ -247,6 +248,23 @@ const Api = {
.then(({ data }) => data);
},
+ projectSearch(id, options = {}) {
+ const url = Api.buildUrl(Api.projectSearchPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url, {
+ params: {
+ search: options.search,
+ scope: options.scope,
+ },
+ });
+ },
+
+ projectMilestones(id) {
+ const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url);
+ },
+
mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath);
@@ -281,7 +299,7 @@ const Api = {
};
return axios
.get(url, {
- params: Object.assign({}, defaults, options),
+ params: { ...defaults, ...options },
})
.then(({ data }) => callback(data))
.catch(() => flash(__('Something went wrong while fetching projects')));
@@ -364,13 +382,11 @@ const Api = {
users(query, options) {
const url = Api.buildUrl(this.usersPath);
return axios.get(url, {
- params: Object.assign(
- {
- search: query,
- per_page: DEFAULT_PER_PAGE,
- },
- options,
- ),
+ params: {
+ search: query,
+ per_page: DEFAULT_PER_PAGE,
+ ...options,
+ },
});
},
@@ -401,7 +417,7 @@ const Api = {
};
return axios
.get(url, {
- params: Object.assign({}, defaults, options),
+ params: { ...defaults, ...options },
})
.then(({ data }) => callback(data))
.catch(() => flash(__('Something went wrong while fetching projects')));
@@ -502,6 +518,15 @@ const Api = {
return axios.get(url);
},
+ // Return all pipelines for a project or filter by query params
+ pipelines(id, options = {}) {
+ const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url, {
+ params: options,
+ });
+ },
+
environments(id) {
const url = Api.buildUrl(this.environmentsPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 07d79ea1c70..5f50fcc112e 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -3,7 +3,7 @@
import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
- constructor(field, key, fallbackKey) {
+ constructor(field, key, fallbackKey, lockVersion) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
@@ -12,6 +12,8 @@ export default class Autosave {
}
this.key = `autosave/${key}`;
this.fallbackKey = fallbackKey;
+ this.lockVersionKey = `${this.key}/lockVersion`;
+ this.lockVersion = lockVersion;
this.field.data('autosave', this);
this.restore();
this.field.on('input', () => this.save());
@@ -40,6 +42,11 @@ export default class Autosave {
}
}
+ getSavedLockVersion() {
+ if (!this.isLocalStorageAvailable) return;
+ return window.localStorage.getItem(this.lockVersionKey);
+ }
+
save() {
if (!this.field.length) return;
@@ -49,6 +56,9 @@ export default class Autosave {
if (this.fallbackKey) {
window.localStorage.setItem(this.fallbackKey, text);
}
+ if (this.lockVersion !== undefined) {
+ window.localStorage.setItem(this.lockVersionKey, this.lockVersion);
+ }
return window.localStorage.setItem(this.key, text);
}
@@ -58,6 +68,7 @@ export default class Autosave {
reset() {
if (!this.isLocalStorageAvailable) return;
+ window.localStorage.removeItem(this.lockVersionKey);
window.localStorage.removeItem(this.fallbackKey);
return window.localStorage.removeItem(this.key);
}
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 67164997bd8..8381b050900 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this, @gitlab/require-i18n-strings */
import $ from 'jquery';
-import _ from 'underscore';
+import { uniq } from 'lodash';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
import { __ } from './locale';
@@ -513,7 +513,7 @@ export class AwardsHandler {
addEmojiToFrequentlyUsedList(emoji) {
if (this.emoji.isEmojiNameValid(emoji)) {
- this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
+ this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji));
Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
}
}
@@ -522,9 +522,7 @@ export class AwardsHandler {
return (
this.frequentlyUsedEmojis ||
(() => {
- const frequentlyUsedEmojis = _.uniq(
- (Cookies.get('frequently_used_emojis') || '').split(','),
- );
+ const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName =>
this.emoji.isEmojiNameValid(inputName),
);
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index c3541e62568..48bcba7bcca 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -17,10 +17,11 @@ function showTooltip(target, title) {
}
function genericSuccess(e) {
- showTooltip(e.trigger, __('Copied'));
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
$(e.trigger).blur();
+
+ showTooltip(e.trigger, __('Copied'));
}
/**
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
index 7e020139fe7..f8465111959 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class InlineHTML extends Mark {
@@ -35,7 +35,7 @@ export default class InlineHTML extends Mark {
mixable: true,
open(state, mark) {
return `<${mark.attrs.tag}${
- mark.attrs.title ? ` title="${state.esc(esc(mark.attrs.title))}"` : ''
+ mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : ''
}>`;
},
close(state, mark) {
diff --git a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
index 665a7216424..278dd857ab8 100644
--- a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
+++ b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
@@ -47,7 +47,8 @@ export default class PasteMarkdownTable {
const htmlData = this.data.getData('text/html');
this.doc = new DOMParser().parseFromString(htmlData, 'text/html');
- const tables = this.doc.querySelectorAll('table');
+ // Avoid formatting lines that were copied from a diff
+ const tables = this.doc.querySelectorAll('table:not(.diff-wrap-lines)');
// We're only looking for exactly one table. If there happens to be
// multiple tables, it's possible an application copied data into
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 137cc7b4669..01627b7206d 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -16,7 +16,7 @@ $.fn.renderGFM = function renderGFM() {
renderMath(this.find('.js-render-math'));
renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get());
- initUserPopovers(this.find('.gfm-project_member').get());
+ initUserPopovers(this.find('.js-user-link').get());
initMRPopovers(this.find('.gfm-merge_request').get());
renderMetrics(this.find('.js-render-metrics').get());
return this;
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index fe63ebd470d..057cdb6cc4c 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -24,13 +24,23 @@ let mermaidModule = {};
function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then(mermaid => {
+ let theme = 'neutral';
+
+ if (
+ window.gon?.user_color_scheme === 'dark' &&
+ // if on the Web IDE page
+ document.querySelector('.ide')
+ ) {
+ theme = 'dark';
+ }
+
mermaid.initialize({
// mermaid core options
mermaid: {
startOnLoad: false,
},
// mermaidAPI options
- theme: 'neutral',
+ theme,
flowchart: {
useMaxWidth: true,
htmlLabels: false,
diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js
index 9260a89bd52..37cbce46b6f 100644
--- a/app/assets/javascripts/behaviors/markdown/render_metrics.js
+++ b/app/assets/javascripts/behaviors/markdown/render_metrics.js
@@ -1,15 +1,12 @@
import Vue from 'vue';
-import EmbedGroup from '~/monitoring/components/embeds/embed_group.vue';
import { createStore } from '~/monitoring/stores/embed_group/';
// TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-foss/issues/64369.
export default function renderMetrics(elements) {
if (!elements.length) {
- return;
+ return Promise.resolve();
}
- const EmbedGroupComponent = Vue.extend(EmbedGroup);
-
const wrapperList = [];
elements.forEach(element => {
@@ -31,14 +28,20 @@ export default function renderMetrics(elements) {
element.parentNode.removeChild(element);
});
- wrapperList.forEach(wrapper => {
- // eslint-disable-next-line no-new
- new EmbedGroupComponent({
- el: wrapper,
- store: createStore(),
- propsData: {
- urls: wrapper.urls,
- },
+ return import(
+ /* webpackChunkName: 'gfm_metrics' */ '~/monitoring/components/embeds/embed_group.vue'
+ ).then(({ default: EmbedGroup }) => {
+ const EmbedGroupComponent = Vue.extend(EmbedGroup);
+
+ wrapperList.forEach(wrapper => {
+ // eslint-disable-next-line no-new
+ new EmbedGroupComponent({
+ el: wrapper,
+ store: createStore(),
+ propsData: {
+ urls: wrapper.urls,
+ },
+ });
});
});
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
index d5d8edd5ac0..c35a073b291 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
@@ -22,7 +22,7 @@ function eventHasModifierKeys(event) {
export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
- const options = Object.assign({}, defaults, opts);
+ const options = { ...defaults, ...opts };
super(options.skipResetBindings);
this.options = options;
diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js
index 476b9405a9e..44dfbfcfe1c 100644
--- a/app/assets/javascripts/blob/blob_fork_suggestion.js
+++ b/app/assets/javascripts/blob/blob_fork_suggestion.js
@@ -17,7 +17,7 @@ const defaults = {
class BlobForkSuggestion {
constructor(options) {
- this.elementMap = Object.assign({}, defaults, options);
+ this.elementMap = { ...defaults, ...options };
this.onOpenButtonClick = this.onOpenButtonClick.bind(this);
this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
}
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index 7d5d48cfc31..4f433bd8dfd 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -3,12 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import BlobContentError from './blob_content_error.vue';
+import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants';
+
export default {
components: {
GlLoadingIcon,
BlobContentError,
},
props: {
+ blob: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
content: {
type: String,
default: '',
@@ -37,6 +44,8 @@ export default {
return this.activeViewer.renderError;
},
},
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
};
</script>
<template>
@@ -44,7 +53,13 @@ export default {
<gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" />
<template v-else>
- <blob-content-error v-if="viewerError" :viewer-error="viewerError" />
+ <blob-content-error
+ v-if="viewerError"
+ :viewer-error="viewerError"
+ :blob="blob"
+ @[$options.BLOB_RENDER_EVENT_LOAD]="$emit($options.BLOB_RENDER_EVENT_LOAD)"
+ @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="$emit($options.BLOB_RENDER_EVENT_SHOW_SOURCE)"
+ />
<component
:is="viewer"
v-else
diff --git a/app/assets/javascripts/blob/components/blob_content_error.vue b/app/assets/javascripts/blob/components/blob_content_error.vue
index 0f1af0a962d..44dc4a6c727 100644
--- a/app/assets/javascripts/blob/components/blob_content_error.vue
+++ b/app/assets/javascripts/blob/components/blob_content_error.vue
@@ -1,15 +1,84 @@
<script>
+import { __ } from '~/locale';
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { BLOB_RENDER_ERRORS } from './constants';
+
export default {
+ components: {
+ GlSprintf,
+ GlLink,
+ },
props: {
viewerError: {
type: String,
required: true,
},
+ blob: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ notStoredExternally() {
+ return this.viewerError !== BLOB_RENDER_ERRORS.REASONS.EXTERNAL.id;
+ },
+ renderErrorReason() {
+ const defaultReasonPath = Object.keys(BLOB_RENDER_ERRORS.REASONS).find(
+ reason => BLOB_RENDER_ERRORS.REASONS[reason].id === this.viewerError,
+ );
+ const defaultReason = BLOB_RENDER_ERRORS.REASONS[defaultReasonPath].text;
+ return this.notStoredExternally
+ ? defaultReason
+ : defaultReason[this.blob.externalStorage || 'default'];
+ },
+ renderErrorOptions() {
+ const load = {
+ ...BLOB_RENDER_ERRORS.OPTIONS.LOAD,
+ condition: this.shouldShowLoadBtn,
+ };
+ const showSource = {
+ ...BLOB_RENDER_ERRORS.OPTIONS.SHOW_SOURCE,
+ condition: this.shouldShowSourceBtn,
+ };
+ const download = {
+ ...BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD,
+ href: this.blob.rawPath,
+ };
+ return [load, showSource, download];
+ },
+ shouldShowLoadBtn() {
+ return this.viewerError === BLOB_RENDER_ERRORS.REASONS.COLLAPSED.id;
+ },
+ shouldShowSourceBtn() {
+ return this.blob.richViewer && this.blob.renderedAsText && this.notStoredExternally;
+ },
},
+ errorMessage: __(
+ 'This content could not be displayed because %{reason}. You can %{options} instead.',
+ ),
};
</script>
<template>
<div class="file-content code">
- <div class="text-center py-4" v-html="viewerError"></div>
+ <div class="text-center py-4">
+ <gl-sprintf :message="$options.errorMessage">
+ <template #reason>{{ renderErrorReason }}</template>
+ <template #options>
+ <template v-for="option in renderErrorOptions">
+ <span v-if="option.condition" :key="option.text">
+ <gl-link
+ :href="option.href"
+ :target="option.target"
+ :data-test-id="`option-${option.id}`"
+ @click="option.event && $emit(option.event)"
+ >{{ option.text }}</gl-link
+ >
+ {{ option.conjunction }}
+ </span>
+ </template>
+ </template>
+ </gl-sprintf>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue
index e9b5ceda479..e1e1d76f721 100644
--- a/app/assets/javascripts/blob/components/blob_edit_header.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_header.vue
@@ -5,6 +5,7 @@ export default {
components: {
GlFormInput,
},
+ inheritAttrs: false,
props: {
value: {
type: String,
@@ -27,8 +28,9 @@ export default {
s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby')
"
name="snippet_file_name"
- class="form-control js-snippet-file-name qa-snippet-file-name"
+ class="form-control js-snippet-file-name"
type="text"
+ v-bind="$attrs"
@change="$emit('input', name)"
/>
</div>
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index b7d9600ec40..e5e01caa9a5 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -66,7 +66,7 @@ export default {
</template>
</blob-filepath>
- <div class="file-actions d-none d-sm-block">
+ <div class="file-actions d-none d-sm-flex">
<viewer-switcher v-if="showViewerSwitcher" v-model="viewer" />
<slot name="actions"></slot>
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index 6c6a22e2b36..e9be7fbcf9b 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -28,12 +28,14 @@ export default {
<div class="file-header-content d-flex align-items-center lh-100">
<slot name="filepathPrepend"></slot>
- <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" />
- <strong
- v-if="blob.name"
- class="file-title-name qa-file-title-name mr-1 js-blob-header-filepath"
- >{{ blob.name }}</strong
- >
+ <template v-if="blob.path">
+ <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"
+ >{{ blob.path }}</strong
+ >
+ </template>
<small class="mr-2">{{ blobSize }}</small>
diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
index 7155a1d35b1..ed03213d7cf 100644
--- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
+++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
@@ -45,7 +45,7 @@ export default {
};
</script>
<template>
- <gl-button-group class="js-blob-viewer-switcher ml-2">
+ <gl-button-group class="js-blob-viewer-switcher mx-2">
<gl-deprecated-button
v-gl-tooltip.hover
:aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE"
diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js
index d3fed9e51e9..93dceacabdd 100644
--- a/app/assets/javascripts/blob/components/constants.js
+++ b/app/assets/javascripts/blob/components/constants.js
@@ -1,4 +1,5 @@
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
export const BTN_COPY_CONTENTS_TITLE = __('Copy file contents');
export const BTN_RAW_TITLE = __('Open raw');
@@ -9,3 +10,56 @@ export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source');
export const RICH_BLOB_VIEWER = 'rich';
export const RICH_BLOB_VIEWER_TITLE = __('Display rendered file');
+
+export const BLOB_RENDER_EVENT_LOAD = 'force-content-fetch';
+export const BLOB_RENDER_EVENT_SHOW_SOURCE = 'force-switch-viewer';
+
+export const BLOB_RENDER_ERRORS = {
+ REASONS: {
+ COLLAPSED: {
+ id: 'collapsed',
+ text: sprintf(__('it is larger than %{limit}'), {
+ limit: numberToHumanSize(1048576), // 1MB in bytes
+ }),
+ },
+ TOO_LARGE: {
+ id: 'too_large',
+ text: sprintf(__('it is larger than %{limit}'), {
+ limit: numberToHumanSize(104857600), // 100MB in bytes
+ }),
+ },
+ EXTERNAL: {
+ id: 'server_side_but_stored_externally',
+ text: {
+ lfs: __('it is stored in LFS'),
+ build_artifact: __('it is stored as a job artifact'),
+ default: __('it is stored externally'),
+ },
+ },
+ },
+ OPTIONS: {
+ LOAD: {
+ id: 'load',
+ text: __('load it anyway'),
+ conjunction: __('or'),
+ href: '#',
+ target: '',
+ event: BLOB_RENDER_EVENT_LOAD,
+ },
+ SHOW_SOURCE: {
+ id: 'show_source',
+ text: __('view the source'),
+ conjunction: __('or'),
+ href: '#',
+ target: '',
+ event: BLOB_RENDER_EVENT_SHOW_SOURCE,
+ },
+ DOWNLOAD: {
+ id: 'download',
+ text: __('download it'),
+ conjunction: '',
+ target: '_blank',
+ condition: true,
+ },
+ },
+};
diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
index 5023496e2c3..1e9e36feecc 100644
--- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
+++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
@@ -72,9 +72,6 @@ export default {
dismissCookieName() {
return `${this.trackLabel}_${this.dismissKey}`;
},
- commitCookieName() {
- return `suggest_gitlab_ci_yml_commit_${this.dismissKey}`;
- },
},
mounted() {
if (
diff --git a/app/assets/javascripts/blob/utils.js b/app/assets/javascripts/blob/utils.js
index dc2ec642e59..840a3dbe450 100644
--- a/app/assets/javascripts/blob/utils.js
+++ b/app/assets/javascripts/blob/utils.js
@@ -1,22 +1,15 @@
-/* global ace */
import Editor from '~/editor/editor_lite';
export function initEditorLite({ el, blobPath, blobContent }) {
if (!el) {
throw new Error(`"el" parameter is required to initialize Editor`);
}
- let editor;
-
- if (window?.gon?.features?.monacoSnippets) {
- editor = new Editor();
- editor.createInstance({
- el,
- blobPath,
- blobContent,
- });
- } else {
- editor = ace.edit(el);
- }
+ const editor = new Editor();
+ editor.createInstance({
+ el,
+ blobPath,
+ blobContent,
+ });
return editor;
}
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index ac02c229a07..517a13ceb27 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -113,9 +113,6 @@ export default Vue.extend({
// eslint-disable-next-line @gitlab/require-i18n-strings
return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
},
- helpLink() {
- return boardsStore.scopedLabels.helpLink;
- },
},
watch: {
filter: {
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 10c855675db..fb854616a04 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -113,9 +113,6 @@ export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
},
- helpLink() {
- return boardsStore.scopedLabels.helpLink;
- },
},
watch: {
filter: {
@@ -286,7 +283,6 @@ export default {
:background-color="list.label.color"
:description="list.label.description"
:scoped="showScopedLabels(list.label)"
- :scoped-labels-documentation-link="helpLink"
:size="!list.isExpanded ? 'sm' : ''"
:title="list.label.title"
tooltip-placement="bottom"
@@ -353,7 +349,7 @@ export default {
v-if="isSettingsShown"
ref="settingsBtn"
:aria-label="__(`List settings`)"
- class="no-drag rounded-right"
+ class="no-drag rounded-right js-board-settings-button"
title="List settings"
type="button"
@click="openSidebarSettings"
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index c0df8b72095..fbe221041c1 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -58,11 +58,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
data() {
return {
@@ -182,7 +177,7 @@ export default {
@cancel="cancel"
@submit="submit"
>
- <template slot="body">
+ <template #body>
<p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p>
<form v-else class="js-board-config-modal" @submit.prevent>
<div v-if="!readonly" class="append-bottom-20">
@@ -208,7 +203,6 @@ export default {
:can-admin-board="canAdminBoard"
:milestone-path="milestonePath"
:labels-path="labelsPath"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
:project-id="projectId"
:group-id="groupId"
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 66a5e134205..c8953158811 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -51,7 +51,7 @@ export default Vue.extend({
return Object.keys(this.issue).length;
},
milestoneTitle() {
- return this.issue.milestone ? this.issue.milestone.title : __('No Milestone');
+ return this.issue.milestone ? this.issue.milestone.title : __('No milestone');
},
canRemove() {
return !this.list.preset;
@@ -70,9 +70,6 @@ export default Vue.extend({
selectedLabels() {
return this.hasLabels ? this.issue.labels.map(l => l.title).join(',') : '';
},
- helpLink() {
- return boardsStore.scopedLabels.helpLink;
- },
},
watch: {
detail: {
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index f2c976be7ae..80db9930259 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -86,11 +86,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
data() {
return {
@@ -348,7 +343,6 @@ export default {
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights"
:enable-scoped-labels="enabledScopedLabels"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
/>
</span>
</div>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index daaa12c096b..a589fb325b2 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -102,9 +102,6 @@ export default {
orderedLabels() {
return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
},
- helpLink() {
- return boardsStore.scopedLabels.helpLink;
- },
},
methods: {
isIndexLessThanlimit(index) {
@@ -181,7 +178,6 @@ export default {
:description="label.description"
size="sm"
:scoped="showScopedLabel(label)"
- :scoped-labels-documentation-link="helpLink"
@click="filterByLabel(label)"
/>
</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index dcecfe5e1bb..f577a168e75 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,3 +1,8 @@
+export const BoardType = {
+ project: 'project',
+ group: 'group',
+};
+
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
@@ -8,6 +13,9 @@ export const ListType = {
blank: 'blank',
};
+export const inactiveListId = 0;
+
export default {
+ BoardType,
ListType,
};
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index b1b4b1c5508..ca85e54eb89 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -1,6 +1,6 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchContainer from '../filtered_search/container';
-import FilteredSearchManager from '../filtered_search/filtered_search_manager';
+import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
import boardsStore from './stores/boards_store';
export default class FilteredSearchBoards extends FilteredSearchManager {
diff --git a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg
new file mode 100644
index 00000000000..6bd773dc4c5
--- /dev/null
+++ b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg
@@ -0,0 +1 @@
+<svg width="17" height="17" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg"><path d="M.147 15.496l2.146-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.146-1.428-1.428zM14.996.646l1.428 1.43-2.146 2.145 1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125l1.286 1.286L14.996.647zm-13.42 0L3.72 2.794l1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286L.147 2.075 1.575.647zm14.848 14.85l-1.428 1.428-2.146-2.146-1.286 1.286c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616l-1.286 1.286 2.146 2.146z" fill-rule="evenodd"/></svg>
diff --git a/app/assets/javascripts/boards/icons/fullscreen_expand.svg b/app/assets/javascripts/boards/icons/fullscreen_expand.svg
new file mode 100644
index 00000000000..306073b8af2
--- /dev/null
+++ b/app/assets/javascripts/boards/icons/fullscreen_expand.svg
@@ -0,0 +1 @@
+<svg width="15" height="15" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><path d="M8.591 5.056l2.147-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.147-1.429-1.43zM5.018 8.553l1.429 1.43L4.3 12.127l1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125L2.872 10.7l2.146-2.147zm4.964 0l2.146 2.147 1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286-2.147-2.146 1.43-1.429zM6.447 5.018l-1.43 1.429L2.873 4.3 1.586 5.586c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616L4.3 2.872l2.147 2.146z" fill-rule="evenodd"/></svg>
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index a12db7a5f1a..9ff7575ae09 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -6,7 +6,6 @@ import 'ee_else_ce/boards/models/list';
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 toggleFocusMode from 'ee_else_ce/boards/toggle_focus';
import toggleLabels from 'ee_else_ce/boards/toggle_labels';
import {
setPromotionState,
@@ -16,11 +15,15 @@ import {
getBoardsModalData,
} from 'ee_else_ce/boards/ee_functions';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import Flash from '~/flash';
import { __ } from '~/locale';
import './models/label';
import './models/assignee';
+import { BoardType } from './constants';
+import toggleFocusMode from '~/boards/toggle_focus';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
import eventHub from '~/boards/eventhub';
import sidebarEventHub from '~/sidebar/event_hub';
@@ -37,7 +40,16 @@ import {
convertObjectPropsToCamelCase,
parseBoolean,
} from '~/lib/utils/common_utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
+import projectBoardQuery from './queries/project_board.query.graphql';
+import groupQuery from './queries/group_board.query.graphql';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
let issueBoardsApp;
@@ -79,18 +91,22 @@ export default () => {
import('ee_component/boards/components/board_settings_sidebar.vue'),
},
store,
- data: {
- state: boardsStore.state,
- loading: true,
- boardsEndpoint: $boardApp.dataset.boardsEndpoint,
- recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
- listsEndpoint: $boardApp.dataset.listsEndpoint,
- boardId: $boardApp.dataset.boardId,
- disabled: parseBoolean($boardApp.dataset.disabled),
- issueLinkBase: $boardApp.dataset.issueLinkBase,
- rootPath: $boardApp.dataset.rootPath,
- bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
- detailIssue: boardsStore.detail,
+ apolloProvider,
+ data() {
+ return {
+ state: boardsStore.state,
+ loading: 0,
+ boardsEndpoint: $boardApp.dataset.boardsEndpoint,
+ recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
+ listsEndpoint: $boardApp.dataset.listsEndpoint,
+ boardId: $boardApp.dataset.boardId,
+ disabled: parseBoolean($boardApp.dataset.disabled),
+ issueLinkBase: $boardApp.dataset.issueLinkBase,
+ rootPath: $boardApp.dataset.rootPath,
+ bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
+ detailIssue: boardsStore.detail,
+ parent: $boardApp.dataset.parent,
+ };
},
computed: {
detailIssueVisible() {
@@ -124,31 +140,56 @@ export default () => {
this.filterManager.setup();
boardsStore.disabled = this.disabled;
- boardsStore
- .all()
- .then(res => res.data)
- .then(lists => {
- lists.forEach(listObj => {
- let { position } = listObj;
- if (listObj.list_type === 'closed') {
- position = Infinity;
- } else if (listObj.list_type === 'backlog') {
- position = -1;
+
+ if (gon.features.graphqlBoardLists) {
+ this.$apollo.addSmartQuery('lists', {
+ query() {
+ return this.parent === BoardType.group ? groupQuery : projectBoardQuery;
+ },
+ variables() {
+ return {
+ fullPath: this.state.endpoints.fullPath,
+ boardId: `gid://gitlab/Board/${this.boardId}`,
+ };
+ },
+ update(data) {
+ return this.getNodes(data);
+ },
+ result({ data, error }) {
+ if (error) {
+ throw error;
}
- boardsStore.addList({
- ...listObj,
- position,
- });
- });
+ const lists = this.getNodes(data);
+
+ lists.forEach(list =>
+ boardsStore.addList({
+ ...list,
+ id: getIdFromGraphQLId(list.id),
+ }),
+ );
- boardsStore.addBlankState();
- setPromotionState(boardsStore);
- this.loading = false;
- })
- .catch(() => {
- Flash(__('An error occurred while fetching the board lists. Please try again.'));
+ boardsStore.addBlankState();
+ setPromotionState(boardsStore);
+ },
+ error() {
+ Flash(__('An error occurred while fetching the board lists. Please try again.'));
+ },
});
+ } else {
+ boardsStore
+ .all()
+ .then(res => res.data)
+ .then(lists => {
+ lists.forEach(list => boardsStore.addList(list));
+ boardsStore.addBlankState();
+ setPromotionState(boardsStore);
+ this.loading = false;
+ })
+ .catch(() => {
+ Flash(__('An error occurred while fetching the board lists. Please try again.'));
+ });
+ }
},
methods: {
updateTokens() {
@@ -233,6 +274,9 @@ export default () => {
});
}
},
+ getNodes(data) {
+ return data[this.parent]?.board?.lists.nodes;
+ },
},
});
@@ -261,7 +305,7 @@ export default () => {
return {
modal: ModalStore.store,
store: boardsStore.state,
- ...getBoardsModalData($boardApp),
+ ...getBoardsModalData(),
canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
};
},
@@ -325,7 +369,7 @@ export default () => {
});
}
- toggleFocusMode(ModalStore, boardsStore, $boardApp);
+ toggleFocusMode(ModalStore, boardsStore);
toggleLabels();
mountMultipleBoardsSwitcher();
};
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index 68ea28e68d9..fceb8c9d48e 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -19,14 +19,15 @@ export function getBoardSortableDefaultOptions(obj) {
const touchEnabled =
'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch);
- const defaultSortOptions = Object.assign({}, sortableConfig, {
+ const defaultSortOptions = {
+ ...sortableConfig,
filter: '.no-drag',
delay: touchEnabled ? 100 : 0,
scrollSensitivity: touchEnabled ? 60 : 100,
scrollSpeed: 20,
onStart: sortableStart,
onEnd: sortableEnd,
- });
+ };
Object.keys(obj).forEach(key => {
defaultSortOptions[key] = obj[key];
diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js
index 5f5758583bb..1e822d06bfd 100644
--- a/app/assets/javascripts/boards/models/assignee.js
+++ b/app/assets/javascripts/boards/models/assignee.js
@@ -3,7 +3,7 @@ export default class ListAssignee {
this.id = obj.id;
this.name = obj.name;
this.username = obj.username;
- this.avatar = obj.avatar_url || obj.avatar || gon.default_avatar_url;
+ this.avatar = obj.avatarUrl || obj.avatar_url || obj.avatar || gon.default_avatar_url;
this.path = obj.path;
this.state = obj.state;
this.webUrl = obj.web_url || obj.webUrl;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index d099c4b930c..878f49cc6be 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -15,7 +15,7 @@ class ListIssue {
this.labels = [];
this.assignees = [];
this.selected = false;
- this.position = obj.relative_position || Infinity;
+ this.position = obj.position || obj.relative_position || Infinity;
this.isFetching = {
subscriptions: true,
};
@@ -99,31 +99,7 @@ class ListIssue {
}
update() {
- const data = {
- issue: {
- milestone_id: this.milestone ? this.milestone.id : null,
- due_date: this.dueDate,
- assignee_ids: this.assignees.length > 0 ? this.assignees.map(u => u.id) : [0],
- label_ids: this.labels.map(label => label.id),
- },
- };
-
- if (!data.issue.label_ids.length) {
- data.issue.label_ids = [''];
- }
-
- const projectPath = this.project ? this.project.path : '';
- return axios.patch(`${this.path}.json`, data).then(({ data: body = {} } = {}) => {
- /**
- * Since post implementation of Scoped labels, server can reject
- * same key-ed labels. To keep the UI and server Model consistent,
- * we're just assigning labels that server echo's back to us when we
- * PATCH the said object.
- */
- if (body) {
- this.labels = convertObjectPropsToCamelCase(body.labels, { deep: true });
- }
- });
+ return boardsStore.updateIssue(this);
}
}
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 990b648190a..31c372b7a75 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -1,10 +1,9 @@
-/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow */
+/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return */
import ListIssue from 'ee_else_ce/boards/models/issue';
import { __ } from '~/locale';
import ListLabel from './label';
import ListAssignee from './assignee';
-import { urlParamsToObject } from '~/lib/utils/common_utils';
import flash from '~/flash';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
@@ -40,8 +39,8 @@ class List {
this.id = obj.id;
this._uid = this.guid();
this.position = obj.position;
- this.title = obj.list_type === 'backlog' ? __('Open') : obj.title;
- this.type = obj.list_type;
+ this.title = (obj.list_type || obj.listType) === 'backlog' ? __('Open') : obj.title;
+ this.type = obj.list_type || obj.listType;
const typeInfo = this.getTypeInfo(this.type);
this.preset = Boolean(typeInfo.isPreset);
@@ -52,14 +51,12 @@ class List {
this.loadingMore = false;
this.issues = obj.issues || [];
this.issuesSize = obj.issuesSize ? obj.issuesSize : 0;
- this.maxIssueCount = Object.hasOwnProperty.call(obj, 'max_issue_count')
- ? obj.max_issue_count
- : 0;
+ this.maxIssueCount = obj.maxIssueCount || obj.max_issue_count || 0;
if (obj.label) {
this.label = new ListLabel(obj.label);
- } else if (obj.user) {
- this.assignee = new ListAssignee(obj.user);
+ } else if (obj.user || obj.assignee) {
+ this.assignee = new ListAssignee(obj.user || obj.assignee);
this.title = this.assignee.name;
} else if (IS_EE && obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
@@ -113,34 +110,7 @@ class List {
}
getIssues(emptyIssues = true) {
- const data = {
- ...urlParamsToObject(boardsStore.filter.path),
- page: this.page,
- };
-
- if (this.label && data.label_name) {
- data.label_name = data.label_name.filter(label => label !== this.label.title);
- }
-
- if (emptyIssues) {
- this.loading = true;
- }
-
- return boardsStore
- .getIssuesForList(this.id, data)
- .then(res => res.data)
- .then(data => {
- this.loading = false;
- this.issuesSize = data.size;
-
- if (emptyIssues) {
- this.issues = [];
- }
-
- this.createIssues(data.issues);
-
- return data;
- });
+ return boardsStore.getListIssues(this, emptyIssues);
}
newIssue(issue) {
@@ -164,48 +134,7 @@ class List {
}
addIssue(issue, listFrom, newIndex) {
- let moveBeforeId = null;
- let moveAfterId = null;
-
- if (!this.findIssue(issue.id)) {
- if (newIndex !== undefined) {
- this.issues.splice(newIndex, 0, issue);
-
- if (this.issues[newIndex - 1]) {
- moveBeforeId = this.issues[newIndex - 1].id;
- }
-
- if (this.issues[newIndex + 1]) {
- moveAfterId = this.issues[newIndex + 1].id;
- }
- } else {
- this.issues.push(issue);
- }
-
- if (this.label) {
- issue.addLabel(this.label);
- }
-
- if (this.assignee) {
- if (listFrom && listFrom.type === 'assignee') {
- issue.removeAssignee(listFrom.assignee);
- }
- issue.addAssignee(this.assignee);
- }
-
- if (IS_EE && this.milestone) {
- if (listFrom && listFrom.type === 'milestone') {
- issue.removeMilestone(listFrom.milestone);
- }
- issue.addMilestone(this.milestone);
- }
-
- if (listFrom) {
- this.issuesSize += 1;
-
- this.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
- }
- }
+ boardsStore.addListIssue(this, issue, listFrom, newIndex);
}
moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
diff --git a/app/assets/javascripts/boards/queries/board_list.fragment.graphql b/app/assets/javascripts/boards/queries/board_list.fragment.graphql
new file mode 100644
index 00000000000..bbf3314377e
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board_list.fragment.graphql
@@ -0,0 +1,5 @@
+#import "./board_list_shared.fragment.graphql"
+
+fragment BoardListFragment on BoardList {
+ ...BoardListShared
+}
diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql
new file mode 100644
index 00000000000..6ba6c05d6d9
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql
@@ -0,0 +1,15 @@
+fragment BoardListShared on BoardList {
+ id,
+ title,
+ position,
+ listType,
+ collapsed,
+ label {
+ id,
+ title,
+ color,
+ textColor,
+ description,
+ descriptionHtml
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/group_board.query.graphql b/app/assets/javascripts/boards/queries/group_board.query.graphql
new file mode 100644
index 00000000000..cb42cb3f73d
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/group_board.query.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
+
+query GroupBoard($fullPath: ID!, $boardId: ID!) {
+ group(fullPath: $fullPath) {
+ board(id: $boardId) {
+ lists {
+ nodes {
+ ...BoardListFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/project_board.query.graphql b/app/assets/javascripts/boards/queries/project_board.query.graphql
new file mode 100644
index 00000000000..4620a7e0fd5
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/project_board.query.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
+
+query ProjectBoard($fullPath: ID!, $boardId: ID!) {
+ project(fullPath: $fullPath) {
+ board(id: $boardId) {
+ lists {
+ nodes {
+ ...BoardListFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index e5447080e37..fdbd7e89bfb 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -6,7 +6,12 @@ import { sortBy } from 'lodash';
import Vue from 'vue';
import Cookies from 'js-cookie';
import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
-import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils';
+import {
+ urlParamsToObject,
+ getUrlParamsArray,
+ parseBoolean,
+ convertObjectPropsToCamelCase,
+} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
@@ -23,7 +28,6 @@ const boardsStore = {
limitToHours: false,
},
scopedLabels: {
- helpLink: '',
enabled: false,
},
filter: {
@@ -75,7 +79,15 @@ const boardsStore = {
this.state.currentPage = page;
},
addList(listObj) {
- const list = new List(listObj);
+ const listType = listObj.listType || listObj.list_type;
+ let { position } = listObj;
+ if (listType === ListType.closed) {
+ position = Infinity;
+ } else if (listType === ListType.backlog) {
+ position = -1;
+ }
+
+ const list = new List({ ...listObj, position });
this.state.lists = sortBy([...this.state.lists, list], 'position');
return list;
},
@@ -121,6 +133,50 @@ const boardsStore = {
path: '',
});
},
+ addListIssue(list, issue, listFrom, newIndex) {
+ let moveBeforeId = null;
+ let moveAfterId = null;
+
+ if (!list.findIssue(issue.id)) {
+ if (newIndex !== undefined) {
+ list.issues.splice(newIndex, 0, issue);
+
+ if (list.issues[newIndex - 1]) {
+ moveBeforeId = list.issues[newIndex - 1].id;
+ }
+
+ if (list.issues[newIndex + 1]) {
+ moveAfterId = list.issues[newIndex + 1].id;
+ }
+ } else {
+ list.issues.push(issue);
+ }
+
+ if (list.label) {
+ issue.addLabel(list.label);
+ }
+
+ if (list.assignee) {
+ if (listFrom && listFrom.type === 'assignee') {
+ issue.removeAssignee(listFrom.assignee);
+ }
+ issue.addAssignee(list.assignee);
+ }
+
+ if (IS_EE && list.milestone) {
+ if (listFrom && listFrom.type === 'milestone') {
+ issue.removeMilestone(listFrom.milestone);
+ }
+ issue.addMilestone(list.milestone);
+ }
+
+ if (listFrom) {
+ list.issuesSize += 1;
+
+ list.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
+ }
+ }
+ },
welcomeIsHidden() {
return parseBoolean(Cookies.get('issue_board_welcome_hidden'));
},
@@ -487,6 +543,36 @@ const boardsStore = {
});
},
+ getListIssues(list, emptyIssues = true) {
+ const data = {
+ ...urlParamsToObject(this.filter.path),
+ page: list.page,
+ };
+
+ if (list.label && data.label_name) {
+ data.label_name = data.label_name.filter(label => label !== list.label.title);
+ }
+
+ if (emptyIssues) {
+ list.loading = true;
+ }
+
+ return this.getIssuesForList(list.id, data)
+ .then(res => res.data)
+ .then(data => {
+ list.loading = false;
+ list.issuesSize = data.size;
+
+ if (emptyIssues) {
+ list.issues = [];
+ }
+
+ list.createIssues(data.issues);
+
+ return data;
+ });
+ },
+
getIssuesForList(id, filter = {}) {
const data = { id };
Object.keys(filter).forEach(key => {
@@ -632,6 +718,28 @@ const boardsStore = {
issue.assignees = obj.assignees.map(a => new ListAssignee(a));
}
},
+ updateIssue(issue) {
+ const data = {
+ issue: {
+ milestone_id: issue.milestone ? issue.milestone.id : null,
+ due_date: issue.dueDate,
+ assignee_ids: issue.assignees.length > 0 ? issue.assignees.map(({ id }) => id) : [0],
+ label_ids: issue.labels.length > 0 ? issue.labels.map(({ id }) => id) : [''],
+ },
+ };
+
+ return axios.patch(`${issue.path}.json`, data).then(({ data: body = {} } = {}) => {
+ /**
+ * Since post implementation of Scoped labels, server can reject
+ * same key-ed labels. To keep the UI and server Model consistent,
+ * we're just assigning labels that server echo's back to us when we
+ * PATCH the said object.
+ */
+ if (body) {
+ issue.labels = convertObjectPropsToCamelCase(body.labels, { deep: true });
+ }
+ });
+ },
};
BoardsStoreEE.initEESpecific(boardsStore);
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 731aea996fb..10aac2f649e 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,4 +1,6 @@
+import { inactiveListId } from '~/boards/constants';
+
export default () => ({
isShowingLabels: true,
- activeListId: 0,
+ activeListId: inactiveListId,
});
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
index 2d1ec238274..a437a34c948 100644
--- a/app/assets/javascripts/boards/toggle_focus.js
+++ b/app/assets/javascripts/boards/toggle_focus.js
@@ -1 +1,45 @@
-export default () => {};
+import $ from 'jquery';
+import Vue from 'vue';
+import collapseIcon from './icons/fullscreen_collapse.svg';
+import expandIcon from './icons/fullscreen_expand.svg';
+
+export default (ModalStore, boardsStore) => {
+ const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board');
+
+ return new Vue({
+ el: document.getElementById('js-toggle-focus-btn'),
+ data: {
+ modal: ModalStore.store,
+ store: boardsStore.state,
+ isFullscreen: false,
+ },
+ methods: {
+ toggleFocusMode() {
+ $(this.$refs.toggleFocusModeButton).tooltip('hide');
+ issueBoardsContent.classList.toggle('is-focused');
+
+ this.isFullscreen = !this.isFullscreen;
+ },
+ },
+ template: `
+ <div class="board-extra-actions">
+ <a
+ href="#"
+ class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn"
+ data-qa-selector="focus_mode_button"
+ role="button"
+ aria-label="Toggle focus mode"
+ title="Toggle focus mode"
+ ref="toggleFocusModeButton"
+ @click="toggleFocusMode">
+ <span v-show="isFullscreen">
+ ${collapseIcon}
+ </span>
+ <span v-show="!isFullscreen">
+ ${expandIcon}
+ </span>
+ </a>
+ </div>
+ `,
+ });
+};
diff --git a/app/assets/javascripts/broadcast_notification.js b/app/assets/javascripts/broadcast_notification.js
index dc5401199dc..97da6fa34da 100644
--- a/app/assets/javascripts/broadcast_notification.js
+++ b/app/assets/javascripts/broadcast_notification.js
@@ -3,10 +3,10 @@ import Cookies from 'js-cookie';
const handleOnDismiss = ({ currentTarget }) => {
currentTarget.removeEventListener('click', handleOnDismiss);
const {
- dataset: { id },
+ dataset: { id, expireDate },
} = currentTarget;
- Cookies.set(`hide_broadcast_message_${id}`, true);
+ Cookies.set(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) });
const notification = document.querySelector(`.js-broadcast-notification-${id}`);
notification.parentNode.removeChild(notification);
diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
index da33e092086..470649e63fb 100644
--- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
@@ -1,4 +1,4 @@
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import axios from '../lib/utils/axios_utils';
import { s__ } from '../locale';
import Flash from '../flash';
@@ -10,7 +10,7 @@ function generateErrorBoxContent(errors) {
const errorList = [].concat(errors).map(
errorString => `
<li>
- ${esc(errorString)}
+ ${escape(errorString)}
</li>
`,
);
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 8f5acd4a0a0..f6ade0867cd 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
@@ -46,6 +46,7 @@ export default {
'isGroup',
'maskableRegex',
'selectedEnvironment',
+ 'isProtectedByDefault',
]),
canSubmit() {
return (
@@ -123,6 +124,7 @@ export default {
'addWildCardScope',
'resetSelectedEnvironment',
'setSelectedEnvironment',
+ 'setVariableProtected',
]),
deleteVarAndClose() {
this.deleteVariable(this.variableBeingEdited);
@@ -147,6 +149,11 @@ export default {
}
this.hideModal();
},
+ setVariableProtectedByDefault() {
+ if (this.isProtectedByDefault && !this.variableBeingEdited) {
+ this.setVariableProtected();
+ }
+ },
},
};
</script>
@@ -159,6 +166,7 @@ export default {
static
lazy
@hidden="resetModalHandler"
+ @shown="setVariableProtectedByDefault"
>
<form>
<ci-key-field
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index 5fe1e32e37e..a4db6481720 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -4,7 +4,7 @@ import { __ } from '~/locale';
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';
export const displayText = {
- variableText: __('Var'),
+ variableText: __('Variable'),
fileText: __('File'),
allEnvironmentsText: __('All (default)'),
};
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index 58501b216c1..2b4a56a4e6d 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -5,14 +5,16 @@ import { parseBoolean } from '~/lib/utils/common_utils';
export default () => {
const el = document.getElementById('js-ci-project-variables');
- const { endpoint, projectId, group, maskableRegex } = el.dataset;
+ const { endpoint, projectId, group, maskableRegex, protectedByDefault } = el.dataset;
const isGroup = parseBoolean(group);
+ const isProtectedByDefault = parseBoolean(protectedByDefault);
const store = createStore({
endpoint,
projectId,
isGroup,
maskableRegex,
+ isProtectedByDefault,
});
return new Vue({
diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js
index a22fa03e16d..d9129c919f8 100644
--- a/app/assets/javascripts/ci_variable_list/store/actions.js
+++ b/app/assets/javascripts/ci_variable_list/store/actions.js
@@ -20,6 +20,10 @@ export const resetEditing = ({ commit, dispatch }) => {
commit(types.RESET_EDITING);
};
+export const setVariableProtected = ({ commit }) => {
+ commit(types.SET_VARIABLE_PROTECTED);
+};
+
export const requestAddVariable = ({ commit }) => {
commit(types.REQUEST_ADD_VARIABLE);
};
diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js
index 0b41c20bce7..ccf8fbd3cb5 100644
--- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js
+++ b/app/assets/javascripts/ci_variable_list/store/mutation_types.js
@@ -2,6 +2,7 @@ export const TOGGLE_VALUES = 'TOGGLE_VALUES';
export const VARIABLE_BEING_EDITED = 'VARIABLE_BEING_EDITED';
export const RESET_EDITING = 'RESET_EDITING';
export const CLEAR_MODAL = 'CLEAR_MODAL';
+export const SET_VARIABLE_PROTECTED = 'SET_VARIABLE_PROTECTED';
export const REQUEST_VARIABLES = 'REQUEST_VARIABLES';
export const RECEIVE_VARIABLES_SUCCESS = 'RECEIVE_VARIABLES_SUCCESS';
diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js
index 7ee7d7bdc26..7d9cd0dd727 100644
--- a/app/assets/javascripts/ci_variable_list/store/mutations.js
+++ b/app/assets/javascripts/ci_variable_list/store/mutations.js
@@ -104,4 +104,8 @@ export default {
[types.SET_SELECTED_ENVIRONMENT](state, environment) {
state.selectedEnvironment = environment;
},
+
+ [types.SET_VARIABLE_PROTECTED](state) {
+ state.variable.protected = true;
+ },
};
diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js
index 8c0b9c6966f..2fffd115589 100644
--- a/app/assets/javascripts/ci_variable_list/store/state.js
+++ b/app/assets/javascripts/ci_variable_list/store/state.js
@@ -5,6 +5,7 @@ export default () => ({
projectId: null,
isGroup: null,
maskableRegex: null,
+ isProtectedByDefault: null,
isLoading: false,
isDeleting: false,
variable: {
diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js
index 882d20671cc..bcddce6e727 100644
--- a/app/assets/javascripts/close_reopen_report_toggle.js
+++ b/app/assets/javascripts/close_reopen_report_toggle.js
@@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
// Todo: Remove this when fixing issue in input_setter plugin
-const InputSetter = Object.assign({}, ISetter);
+const InputSetter = { ...ISetter };
class CloseReopenReportToggle {
constructor(opts = {}) {
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 1b11ec355bb..3699a3b8b2b 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -14,6 +14,7 @@ import {
INGRESS_DOMAIN_SUFFIX,
CROSSPLANE,
KNATIVE,
+ FLUENTD,
} from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
@@ -49,6 +50,7 @@ export default class Clusters {
installElasticStackPath,
installCrossplanePath,
installPrometheusPath,
+ installFluentdPath,
managePrometheusPath,
clusterEnvironmentsPath,
hasRbac,
@@ -102,6 +104,7 @@ export default class Clusters {
updateKnativeEndpoint: updateKnativePath,
installElasticStackEndpoint: installElasticStackPath,
clusterEnvironmentsEndpoint: clusterEnvironmentsPath,
+ installFluentdEndpoint: installFluentdPath,
});
this.installApplication = this.installApplication.bind(this);
@@ -265,6 +268,7 @@ export default class Clusters {
eventHub.$on('setIngressModSecurityEnabled', data => this.setIngressModSecurityEnabled(data));
eventHub.$on('setIngressModSecurityMode', data => this.setIngressModSecurityMode(data));
eventHub.$on('resetIngressModSecurityChanges', id => this.resetIngressModSecurityChanges(id));
+ eventHub.$on('setFluentdSettings', data => this.setFluentdSettings(data));
// Add event listener to all the banner close buttons
this.addBannerCloseHandler(this.unreachableContainer, 'unreachable');
this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure');
@@ -281,6 +285,7 @@ export default class Clusters {
eventHub.$off('setIngressModSecurityEnabled');
eventHub.$off('setIngressModSecurityMode');
eventHub.$off('resetIngressModSecurityChanges');
+ eventHub.$off('setFluentdSettings');
}
initPolling(method, successCallback, errorCallback) {
@@ -320,7 +325,7 @@ export default class Clusters {
handleClusterStatusSuccess(data) {
const prevStatus = this.store.state.status;
- const prevApplicationMap = Object.assign({}, this.store.state.applications);
+ const prevApplicationMap = { ...this.store.state.applications };
this.store.updateStateFromServer(data.data);
@@ -506,6 +511,12 @@ export default class Clusters {
});
}
+ setFluentdSettings(settings = {}) {
+ Object.entries(settings).forEach(([key, value]) => {
+ this.store.updateAppProperty(FLUENTD, key, value);
+ });
+ }
+
toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) {
if (externalIp !== newExternalIp) {
this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp);
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 723030c5b8b..f11502a7dde 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+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';
@@ -14,6 +14,7 @@ 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';
@@ -22,6 +23,7 @@ import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../con
import eventHub from '~/clusters/event_hub';
import CrossplaneProviderStack from './crossplane_provider_stack.vue';
import IngressModsecuritySettings from './ingress_modsecurity_settings.vue';
+import FluentdOutputSettings from './fluentd_output_settings.vue';
export default {
components: {
@@ -31,6 +33,7 @@ export default {
KnativeDomainEditor,
CrossplaneProviderStack,
IngressModsecuritySettings,
+ FluentdOutputSettings,
},
props: {
type: {
@@ -102,6 +105,7 @@ export default {
meltanoLogo,
prometheusLogo,
elasticStackLogo,
+ fluentdLogo,
}),
computed: {
isProjectCluster() {
@@ -134,7 +138,7 @@ export default {
},
ingressDescription() {
return sprintf(
- esc(
+ escape(
s__(
`ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}.`,
),
@@ -142,14 +146,14 @@ export default {
{
pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb"
target="_blank" rel="noopener noreferrer">
- ${esc(s__('ClusterIntegration|pricing'))}</a>`,
+ ${escape(s__('ClusterIntegration|pricing'))}</a>`,
},
false,
);
},
certManagerDescription() {
return sprintf(
- esc(
+ 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
@@ -159,14 +163,14 @@ export default {
{
letsEncrypt: `<a href="https://letsencrypt.org/"
target="_blank" rel="noopener noreferrer">
- ${esc(s__("ClusterIntegration|Let's Encrypt"))}</a>`,
+ ${escape(s__("ClusterIntegration|Let's Encrypt"))}</a>`,
},
false,
);
},
crossplaneDescription() {
return sprintf(
- esc(
+ 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.`,
@@ -175,7 +179,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
{
gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane"
target="_blank" rel="noopener noreferrer">
- ${esc(s__('ClusterIntegration|Gitlab Integration'))}</a>`,
+ ${escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`,
kubectl: `<code>kubectl</code>`,
},
false,
@@ -184,7 +188,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
prometheusDescription() {
return sprintf(
- esc(
+ escape(
s__(
`ClusterIntegration|Prometheus is an open-source monitoring system
with %{gitlabIntegrationLink} to monitor deployed applications.`,
@@ -193,7 +197,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
{
gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html"
target="_blank" rel="noopener noreferrer">
- ${esc(s__('ClusterIntegration|GitLab Integration'))}</a>`,
+ ${escape(s__('ClusterIntegration|GitLab Integration'))}</a>`,
},
false,
);
@@ -219,11 +223,11 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
installedVia() {
if (this.cloudRun) {
return sprintf(
- esc(s__(`ClusterIntegration|installed via %{installed_via}`)),
+ escape(s__(`ClusterIntegration|installed via %{installed_via}`)),
{
installed_via: `<a href="${
this.cloudRunHelpPath
- }" target="_blank" rel="noopener noreferrer">${esc(
+ }" target="_blank" rel="noopener noreferrer">${escape(
s__('ClusterIntegration|Cloud Run'),
)}</a>`,
},
@@ -658,7 +662,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:uninstall-successful="applications.elastic_stack.uninstallSuccessful"
:uninstall-failed="applications.elastic_stack.uninstallFailed"
:disabled="!helmInstalled"
- title-link="https://github.com/helm/charts/tree/master/stable/elastic-stack"
+ title-link="https://gitlab.com/gitlab-org/charts/elastic-stack"
>
<div slot="description">
<p>
@@ -670,6 +674,51 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
</p>
</div>
</application-row>
+
+ <application-row
+ id="fluentd"
+ :logo-url="fluentdLogo"
+ :title="applications.fluentd.title"
+ :status="applications.fluentd.status"
+ :status-reason="applications.fluentd.statusReason"
+ :request-status="applications.fluentd.requestStatus"
+ :request-reason="applications.fluentd.requestReason"
+ :installed="applications.fluentd.installed"
+ :install-failed="applications.fluentd.installFailed"
+ :install-application-request-params="{
+ host: applications.fluentd.host,
+ port: applications.fluentd.port,
+ protocol: applications.fluentd.protocol,
+ waf_log_enabled: applications.fluentd.wafLogEnabled,
+ cilium_log_enabled: applications.fluentd.ciliumLogEnabled,
+ }"
+ :uninstallable="applications.fluentd.uninstallable"
+ :uninstall-successful="applications.fluentd.uninstallSuccessful"
+ :uninstall-failed="applications.fluentd.uninstallFailed"
+ :disabled="!helmInstalled"
+ :updateable="false"
+ title-link="https://github.com/helm/charts/tree/master/stable/fluentd"
+ >
+ <div slot="description">
+ <p>
+ {{
+ s__(
+ `ClusterIntegration|Fluentd is an open source data collector, which lets you unify the data collection and consumption for a better use and understanding of data. It requires at least one of the following logs to be successfully installed.`,
+ )
+ }}
+ </p>
+
+ <fluentd-output-settings
+ :port="applications.fluentd.port"
+ :protocol="applications.fluentd.protocol"
+ :host="applications.fluentd.host"
+ :waf-log-enabled="applications.fluentd.wafLogEnabled"
+ :cilium-log-enabled="applications.fluentd.ciliumLogEnabled"
+ :status="applications.fluentd.status"
+ :update-failed="applications.fluentd.updateFailed"
+ />
+ </div>
+ </application-row>
</div>
</section>
</template>
diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
new file mode 100644
index 00000000000..1884b501a20
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
@@ -0,0 +1,241 @@
+<script>
+import { __ } from '~/locale';
+import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
+import {
+ GlAlert,
+ GlDeprecatedButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlFormCheckbox,
+} from '@gitlab/ui';
+import eventHub from '~/clusters/event_hub';
+import { mapValues } from 'lodash';
+
+const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS;
+
+export default {
+ components: {
+ GlAlert,
+ GlDeprecatedButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlFormCheckbox,
+ },
+ props: {
+ protocols: {
+ type: Array,
+ required: false,
+ default: () => ['TCP', 'UDP'],
+ },
+ status: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updateFailed: {
+ type: Boolean,
+ required: false,
+ },
+ protocol: {
+ type: String,
+ required: false,
+ default: () => __('Protocol'),
+ },
+ port: {
+ type: Number,
+ required: false,
+ default: 514,
+ },
+ host: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ wafLogEnabled: {
+ type: Boolean,
+ required: false,
+ },
+ ciliumLogEnabled: {
+ type: Boolean,
+ required: false,
+ },
+ },
+ data: () => ({
+ currentServerSideSettings: {
+ host: null,
+ port: null,
+ protocol: null,
+ wafLogEnabled: null,
+ ciliumLogEnabled: null,
+ },
+ }),
+ computed: {
+ isSaving() {
+ return [UPDATING].includes(this.status);
+ },
+ saveButtonDisabled() {
+ return [UNINSTALLING, UPDATING, INSTALLING].includes(this.status);
+ },
+ saveButtonLabel() {
+ return this.isSaving ? __('Saving') : __('Save changes');
+ },
+ /**
+ * Returns true either when:
+ * - The application is getting updated.
+ * - The user has changed some of the settings for an application which is
+ * neither getting installed nor updated.
+ */
+ showButtons() {
+ return this.isSaving || (this.changedByUser && [INSTALLED, UPDATED].includes(this.status));
+ },
+ protocolName() {
+ if (this.protocol) {
+ return this.protocol.toUpperCase();
+ }
+ return __('Protocol');
+ },
+ changedByUser() {
+ return Object.entries(this.currentServerSideSettings).some(([key, value]) => {
+ return value !== null && value !== this[key];
+ });
+ },
+ },
+ watch: {
+ status() {
+ this.resetCurrentServerSideSettings();
+ },
+ },
+ methods: {
+ updateApplication() {
+ eventHub.$emit('updateApplication', {
+ id: FLUENTD,
+ params: {
+ port: this.port,
+ protocol: this.protocol,
+ host: this.host,
+ waf_log_enabled: this.wafLogEnabled,
+ cilium_log_enabled: this.ciliumLogEnabled,
+ },
+ });
+ },
+ resetCurrentServerSideSettings() {
+ this.currentServerSideSettings = mapValues(this.currentServerSideSettings, () => {
+ return null;
+ });
+ },
+ resetStatus() {
+ const newSettings = mapValues(this.currentServerSideSettings, (value, key) => {
+ return value === null ? this[key] : value;
+ });
+ eventHub.$emit('setFluentdSettings', {
+ ...newSettings,
+ isEditingSettings: false,
+ });
+ },
+ updateCurrentServerSideSettings(settings) {
+ Object.keys(settings).forEach(key => {
+ if (this.currentServerSideSettings[key] === null) {
+ this.currentServerSideSettings[key] = this[key];
+ }
+ });
+ },
+ setFluentdSettings(settings) {
+ this.updateCurrentServerSideSettings(settings);
+ eventHub.$emit('setFluentdSettings', {
+ ...settings,
+ isEditingSettings: true,
+ });
+ },
+ selectProtocol(protocol) {
+ this.setFluentdSettings({ protocol });
+ },
+ hostChanged(host) {
+ this.setFluentdSettings({ host });
+ },
+ portChanged(port) {
+ this.setFluentdSettings({ port: Number(port) });
+ },
+ wafLogChanged(wafLogEnabled) {
+ this.setFluentdSettings({ wafLogEnabled });
+ },
+ ciliumLogChanged(ciliumLogEnabled) {
+ this.setFluentdSettings({ ciliumLogEnabled });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="updateFailed" class="mb-3" variant="danger" :dismissible="false">
+ {{
+ s__(
+ 'ClusterIntegration|Something went wrong while trying to save your settings. Please try again.',
+ )
+ }}
+ </gl-alert>
+ <div class="form-horizontal">
+ <div class="form-group">
+ <label for="fluentd-host">
+ <strong>{{ s__('ClusterIntegration|SIEM Hostname') }}</strong>
+ </label>
+ <input
+ id="fluentd-host"
+ :value="host"
+ type="text"
+ class="form-control"
+ @input="hostChanged($event.target.value)"
+ />
+ </div>
+ <div class="form-group">
+ <label for="fluentd-port">
+ <strong>{{ s__('ClusterIntegration|SIEM Port') }}</strong>
+ </label>
+ <input
+ id="fluentd-port"
+ :value="port"
+ type="number"
+ class="form-control"
+ @input="portChanged($event.target.value)"
+ />
+ </div>
+ <div class="form-group">
+ <label for="fluentd-protocol">
+ <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong>
+ </label>
+ <gl-dropdown :text="protocolName" class="w-100">
+ <gl-dropdown-item
+ v-for="(value, index) in protocols"
+ :key="index"
+ @click="selectProtocol(value.toLowerCase())"
+ >
+ {{ value }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ <div class="form-group flex flex-wrap">
+ <gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged">
+ <strong>{{ s__('ClusterIntegration|Send ModSecurity Logs') }}</strong>
+ </gl-form-checkbox>
+ <gl-form-checkbox :checked="ciliumLogEnabled" @input="ciliumLogChanged">
+ <strong>{{ s__('ClusterIntegration|Send Cilium Logs') }}</strong>
+ </gl-form-checkbox>
+ </div>
+ <div v-if="showButtons" class="mt-3">
+ <gl-deprecated-button
+ ref="saveBtn"
+ class="mr-1"
+ variant="success"
+ :loading="isSaving"
+ :disabled="saveButtonDisabled"
+ @click="updateApplication"
+ >
+ {{ saveButtonLabel }}
+ </gl-deprecated-button>
+ <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus">
+ {{ __('Cancel') }}
+ </gl-deprecated-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
index 95eb427a49c..c2f963f0b34 100644
--- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
+++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { s__, __ } from '../../locale';
import { APPLICATION_STATUS, INGRESS, LOGGING_MODE, BLOCKING_MODE } from '~/clusters/constants';
import {
@@ -87,7 +87,7 @@ export default {
);
},
ingressModSecurityDescription() {
- return esc(this.ingressModSecurityHelpPath);
+ return escape(this.ingressModSecurityHelpPath);
},
saving() {
return [UPDATING].includes(this.ingress.status);
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
index b35adae3352..271f9f74838 100644
--- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import SplitButton from '~/vue_shared/components/split_button.vue';
import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -82,7 +82,7 @@ export default {
)
: s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:'),
{
- clusterName: `<code>${esc(this.clusterName)}</code>`,
+ clusterName: `<code>${escape(this.clusterName)}</code>`,
},
false,
);
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 6c3046fc56b..60e179c54eb 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -53,6 +53,7 @@ export const CERT_MANAGER = 'cert_manager';
export const CROSSPLANE = 'crossplane';
export const PROMETHEUS = 'prometheus';
export const ELASTIC_STACK = 'elastic_stack';
+export const FLUENTD = 'fluentd';
export const APPLICATIONS = [
HELM,
@@ -63,6 +64,7 @@ export const APPLICATIONS = [
CERT_MANAGER,
PROMETHEUS,
ELASTIC_STACK,
+ FLUENTD,
];
export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
diff --git a/app/assets/javascripts/clusters/event_hub.js b/app/assets/javascripts/clusters/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/clusters/event_hub.js
+++ b/app/assets/javascripts/clusters/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 333fb293a15..2a6c6965dab 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -13,6 +13,7 @@ export default class ClusterService {
jupyter: this.options.installJupyterEndpoint,
knative: this.options.installKnativeEndpoint,
elastic_stack: this.options.installElasticStackEndpoint,
+ fluentd: this.options.installFluentdEndpoint,
};
this.appUpdateEndpointMap = {
knative: this.options.updateKnativeEndpoint,
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index b09fd6800b6..9d354e66661 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -13,6 +13,7 @@ import {
UPDATE_EVENT,
UNINSTALL_EVENT,
ELASTIC_STACK,
+ FLUENTD,
} from '../constants';
import transitionApplicationState from '../services/application_state_machine';
@@ -103,6 +104,16 @@ export default class ClusterStore {
...applicationInitialState,
title: s__('ClusterIntegration|Elastic Stack'),
},
+ fluentd: {
+ ...applicationInitialState,
+ title: s__('ClusterIntegration|Fluentd'),
+ host: null,
+ port: null,
+ protocol: null,
+ wafLogEnabled: null,
+ ciliumLogEnabled: null,
+ isEditingSettings: false,
+ },
},
environments: [],
fetchingEnvironments: false,
@@ -253,6 +264,14 @@ export default class ClusterStore {
} else if (appId === ELASTIC_STACK) {
this.state.applications.elastic_stack.version = version;
this.state.applications.elastic_stack.updateAvailable = updateAvailable;
+ } else if (appId === FLUENTD) {
+ if (!this.state.applications.fluentd.isEditingSettings) {
+ this.state.applications.fluentd.port = serverAppEntry.port;
+ this.state.applications.fluentd.host = serverAppEntry.host;
+ this.state.applications.fluentd.protocol = serverAppEntry.protocol;
+ this.state.applications.fluentd.wafLogEnabled = serverAppEntry.waf_log_enabled;
+ this.state.applications.fluentd.ciliumLogEnabled = serverAppEntry.cilium_log_enabled;
+ }
}
});
}
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 46dacf30f39..af3f1437c64 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -1,61 +1,78 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlTable, GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { GlBadge, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { CLUSTER_TYPES, STATUSES } from '../constants';
import { __, sprintf } from '~/locale';
export default {
components: {
- GlTable,
- GlLoadingIcon,
GlBadge,
+ GlLink,
+ GlLoadingIcon,
+ GlPagination,
+ GlTable,
},
directives: {
tooltip,
},
- fields: [
- {
- key: 'name',
- label: __('Kubernetes cluster'),
- },
- {
- key: 'environmentScope',
- label: __('Environment scope'),
- },
- {
- key: 'size',
- label: __('Size'),
- },
- {
- key: 'cpu',
- label: __('Total cores (vCPUs)'),
+ computed: {
+ ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'totalCulsters']),
+ currentPage: {
+ get() {
+ return this.page;
+ },
+ set(newVal) {
+ this.setPage(newVal);
+ this.fetchClusters();
+ },
},
- {
- key: 'memory',
- label: __('Total memory (GB)'),
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: __('Kubernetes cluster'),
+ },
+ {
+ 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: 'cluster_type',
+ label: __('Cluster level'),
+ formatter: value => CLUSTER_TYPES[value],
+ },
+ ];
},
- {
- key: 'clusterType',
- label: __('Cluster level'),
- formatter: value => CLUSTER_TYPES[value],
+ hasClusters() {
+ return this.clustersPerPage > 0;
},
- ],
- computed: {
- ...mapState(['clusters', 'loading']),
},
mounted() {
- // TODO - uncomment this once integrated with BE
- // this.fetchClusters();
+ this.fetchClusters();
},
methods: {
- ...mapActions(['fetchClusters']),
+ ...mapActions(['fetchClusters', 'setPage']),
statusClass(status) {
- return STATUSES[status].className;
+ const iconClass = STATUSES[status] || STATUSES.default;
+ return iconClass.className;
},
statusTitle(status) {
- const { title } = STATUSES[status];
- return sprintf(__('Status: %{title}'), { title }, false);
+ const iconTitle = STATUSES[status] || STATUSES.default;
+ return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false);
},
},
};
@@ -63,37 +80,46 @@ export default {
<template>
<gl-loading-icon v-if="loading" size="md" class="mt-3" />
- <gl-table
- v-else
- :items="clusters"
- :fields="$options.fields"
- stacked="md"
- variant="light"
- class="qa-clusters-table"
- >
- <template #cell(name)="{ item }">
- <div class="d-flex flex-row-reverse flex-md-row js-status">
- {{ item.name }}
- <gl-loading-icon
- v-if="item.status === 'deleting'"
- 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-8 gl-h-8 mr-2 ml-md-2"
- :class="statusClass(item.status)"
- :title="statusTitle(item.status)"
- ></div>
- </div>
- </template>
- <template #cell(clusterType)="{value}">
- <gl-badge variant="light">
- {{ value }}
- </gl-badge>
- </template>
- </gl-table>
+
+ <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">
+ {{ item.name }}
+ </gl-link>
+
+ <gl-loading-icon
+ v-if="item.status === 'deleting'"
+ 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(cluster_type)="{value}">
+ <gl-badge variant="light">
+ {{ value }}
+ </gl-badge>
+ </template>
+ </gl-table>
+
+ <gl-pagination
+ v-if="hasClusters"
+ v-model="currentPage"
+ :per-page="clustersPerPage"
+ :total-items="totalCulsters"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ align="center"
+ />
+ </section>
</template>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 9428f08176c..eebcaa086f9 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -7,8 +7,9 @@ export const CLUSTER_TYPES = {
};
export const STATUSES = {
+ default: { className: 'bg-white', title: __('Unknown') },
disabled: { className: 'disabled', title: __('Disabled') },
- connected: { className: 'bg-success', title: __('Connected') },
+ created: { className: 'bg-success', title: __('Connected') },
unreachable: { className: 'bg-danger', title: __('Unreachable') },
authentication_failure: { className: 'bg-warning', title: __('Authentication Failure') },
deleting: { title: __('Deleting') },
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 79bc9932438..919625f69b4 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -1,36 +1,35 @@
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
import axios from '~/lib/utils/axios_utils';
-import Visibility from 'visibilityjs';
import flash from '~/flash';
import { __ } from '~/locale';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
export const fetchClusters = ({ state, commit }) => {
const poll = new Poll({
resource: {
- fetchClusters: endpoint => axios.get(endpoint),
+ fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint),
},
- data: state.endpoint,
+ data: `${state.endpoint}?page=${state.page}`,
method: 'fetchClusters',
- successCallback: ({ data }) => {
- commit(types.SET_CLUSTERS_DATA, convertObjectPropsToCamelCase(data, { deep: true }));
- commit(types.SET_LOADING_STATE, false);
+ successCallback: ({ data, headers }) => {
+ if (data.clusters) {
+ const normalizedHeaders = normalizeHeaders(headers);
+ const paginationInformation = parseIntPagination(normalizedHeaders);
+
+ commit(types.SET_CLUSTERS_DATA, { data, paginationInformation });
+ commit(types.SET_LOADING_STATE, false);
+ poll.stop();
+ }
},
errorCallback: () => flash(__('An error occurred while loading clusters')),
});
- if (!Visibility.hidden()) {
- poll.makeRequest();
- }
+ poll.makeRequest();
+};
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- poll.restart();
- } else {
- poll.stop();
- }
- });
+export const setPage = ({ commit }, page) => {
+ commit(types.SET_PAGE, page);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/clusters_list/store/mutation_types.js b/app/assets/javascripts/clusters_list/store/mutation_types.js
index f056f3ab7d9..a5275f28c13 100644
--- a/app/assets/javascripts/clusters_list/store/mutation_types.js
+++ b/app/assets/javascripts/clusters_list/store/mutation_types.js
@@ -1,2 +1,3 @@
export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA';
export const SET_LOADING_STATE = 'SET_LOADING_STATE';
+export const SET_PAGE = 'SET_PAGE';
diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js
index ffd3c4601bf..2a9df9f38f0 100644
--- a/app/assets/javascripts/clusters_list/store/mutations.js
+++ b/app/assets/javascripts/clusters_list/store/mutations.js
@@ -4,9 +4,15 @@ export default {
[types.SET_LOADING_STATE](state, value) {
state.loading = value;
},
- [types.SET_CLUSTERS_DATA](state, clusters) {
+ [types.SET_CLUSTERS_DATA](state, { data, paginationInformation }) {
Object.assign(state, {
- clusters,
+ clusters: data.clusters,
+ clustersPerPage: paginationInformation.perPage,
+ hasAncestorClusters: data.has_ancestor_clusters,
+ totalCulsters: paginationInformation.total,
});
},
+ [types.SET_PAGE](state, value) {
+ state.page = Number(value) || 1;
+ },
};
diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js
index ed032ed8435..d590ea09e66 100644
--- a/app/assets/javascripts/clusters_list/store/state.js
+++ b/app/assets/javascripts/clusters_list/store/state.js
@@ -1,5 +1,9 @@
export default (initialState = {}) => ({
endpoint: initialState.endpoint,
- loading: false, // TODO - set this to true once integrated with BE
+ hasAncestorClusters: false,
+ loading: true,
clusters: [],
+ clustersPerPage: 0,
+ page: 1,
+ totalCulsters: 0,
});
diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue
index d738c914125..85ec0a60ec5 100644
--- a/app/assets/javascripts/code_navigation/components/app.vue
+++ b/app/assets/javascripts/code_navigation/components/app.vue
@@ -8,7 +8,12 @@ export default {
Popover,
},
computed: {
- ...mapState(['currentDefinition', 'currentDefinitionPosition', 'definitionPathPrefix']),
+ ...mapState([
+ 'currentDefinition',
+ 'currentDefinitionPosition',
+ 'currentBlobPath',
+ 'definitionPathPrefix',
+ ]),
},
mounted() {
this.body = document.body;
@@ -44,5 +49,6 @@ export default {
:position="currentDefinitionPosition"
:data="currentDefinition"
:definition-path-prefix="definitionPathPrefix"
+ :blob-path="currentBlobPath"
/>
</template>
diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue
index b4d9bc7b181..7147ce227e8 100644
--- a/app/assets/javascripts/code_navigation/components/popover.vue
+++ b/app/assets/javascripts/code_navigation/components/popover.vue
@@ -1,9 +1,9 @@
<script>
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
},
props: {
position: {
@@ -18,6 +18,10 @@ export default {
type: String,
required: true,
},
+ blobPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -32,9 +36,18 @@ export default {
};
},
definitionPath() {
- return (
- this.data.definition_path && `${this.definitionPathPrefix}/${this.data.definition_path}`
- );
+ if (!this.data.definition_path) {
+ return null;
+ }
+
+ if (this.isDefinitionCurrentBlob) {
+ return `#${this.data.definition_path.split('#').pop()}`;
+ }
+
+ return `${this.definitionPathPrefix}/${this.data.definition_path}`;
+ },
+ isDefinitionCurrentBlob() {
+ return this.data.definition_path.indexOf(this.blobPath) === 0;
},
},
watch: {
@@ -77,9 +90,15 @@ export default {
</p>
</div>
<div v-if="definitionPath" class="popover-body">
- <gl-deprecated-button :href="definitionPath" target="_blank" class="w-100" variant="default">
+ <gl-button
+ :href="definitionPath"
+ :target="isDefinitionCurrentBlob ? null : '_blank'"
+ class="w-100"
+ variant="default"
+ data-testid="go-to-definition-btn"
+ >
{{ __('Go to definition') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js
index 6ecede32944..7b2669691bd 100644
--- a/app/assets/javascripts/code_navigation/store/actions.js
+++ b/app/assets/javascripts/code_navigation/store/actions.js
@@ -30,7 +30,9 @@ export default {
});
},
showBlobInteractionZones({ state }, path) {
- Object.values(state.data[path]).forEach(d => addInteractionClass(path, d));
+ if (state.data && state.data[path]) {
+ Object.values(state.data[path]).forEach(d => addInteractionClass(path, d));
+ }
},
showDefinition({ commit, state }, { target: el }) {
let definition;
@@ -52,7 +54,8 @@ export default {
return;
}
- const data = state.data[blobEl.dataset.path];
+ const blobPath = blobEl.dataset.path;
+ const data = state.data[blobPath];
if (!data) return;
@@ -72,6 +75,6 @@ export default {
setCurrentHoverElement(el);
}
- commit(types.SET_CURRENT_DEFINITION, { definition, position });
+ commit(types.SET_CURRENT_DEFINITION, { definition, position, blobPath });
},
};
diff --git a/app/assets/javascripts/code_navigation/store/mutations.js b/app/assets/javascripts/code_navigation/store/mutations.js
index 84b1c264418..07b190c7476 100644
--- a/app/assets/javascripts/code_navigation/store/mutations.js
+++ b/app/assets/javascripts/code_navigation/store/mutations.js
@@ -15,8 +15,9 @@ export default {
[types.REQUEST_DATA_ERROR](state) {
state.loading = false;
},
- [types.SET_CURRENT_DEFINITION](state, { definition, position }) {
+ [types.SET_CURRENT_DEFINITION](state, { definition, position, blobPath }) {
state.currentDefinition = definition;
state.currentDefinitionPosition = position;
+ state.currentBlobPath = blobPath;
},
};
diff --git a/app/assets/javascripts/code_navigation/store/state.js b/app/assets/javascripts/code_navigation/store/state.js
index ffe44ec5381..569d2f7b319 100644
--- a/app/assets/javascripts/code_navigation/store/state.js
+++ b/app/assets/javascripts/code_navigation/store/state.js
@@ -4,4 +4,5 @@ export default () => ({
data: null,
currentDefinition: null,
currentDefinitionPosition: null,
+ currentBlobPath: null,
});
diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js
index a259667bb75..2fcd40a901d 100644
--- a/app/assets/javascripts/comment_type_toggle.js
+++ b/app/assets/javascripts/comment_type_toggle.js
@@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
// Todo: Remove this when fixing issue in input_setter plugin
-const InputSetter = Object.assign({}, ISetter);
+const InputSetter = { ...ISetter };
class CommentTypeToggle {
constructor(opts = {}) {
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index fb8b1c17407..ddb129f36f4 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-else-return, consistent-return, one-var, no-return-assign */
+/* eslint-disable func-names, consistent-return, one-var, no-return-assign */
import $ from 'jquery';
@@ -201,9 +201,8 @@ export default class ImageFile {
if (domImg) {
if (domImg.complete) {
return callback.call(this, domImg.naturalWidth, domImg.naturalHeight);
- } else {
- return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight));
}
+ return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight));
}
}
}
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index ad0f6cc1496..e0d012cef23 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -1,4 +1,3 @@
-import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js
index a23707209dc..4539b9a39ef 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-else-return */
+/* eslint-disable func-names */
import $ from 'jquery';
import { __ } from './locale';
@@ -52,9 +52,8 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
return $('<li />')
.addClass('dropdown-header')
.text(ref.header);
- } else {
- return $('<li />').append(link);
}
+ return $('<li />').append(link);
},
id(obj, $el) {
return $el.attr('data-ref');
diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
index 444640980af..92a5423d5ea 100644
--- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
@@ -39,7 +39,7 @@ export default {
<template>
<gl-dropdown toggle-class="d-flex align-items-center w-100" class="w-100">
- <template slot="button-content">
+ <template #button-content>
<span class="str-truncated-100 mr-2">
<icon name="lock" />
{{ dropdownText }}
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index 74b5a62f754..d6c402fcb5d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -1,6 +1,6 @@
<script>
import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
@@ -137,7 +137,7 @@ export default {
: s__('ClusterIntegration|Create Kubernetes cluster');
},
kubernetesIntegrationHelpText() {
- const escapedUrl = esc(this.kubernetesIntegrationHelpPath);
+ const escapedUrl = escape(this.kubernetesIntegrationHelpPath);
return sprintf(
s__(
@@ -256,7 +256,7 @@ export default {
);
},
gitlabManagedHelpText() {
- const escapedUrl = esc(this.gitlabManagedClusterHelpPath);
+ const escapedUrl = escape(this.gitlabManagedClusterHelpPath);
return sprintf(
s__(
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
index 47cc4e4ce67..e063f9edfd9 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
@@ -1,6 +1,6 @@
<script>
import { GlFormInput } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { sprintf, s__, __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -42,7 +42,7 @@ export default {
: s__('ClusterIntegration|Authenticate with AWS');
},
accountAndExternalIdsHelpText() {
- const escapedUrl = esc(this.accountAndExternalIdsHelpPath);
+ const escapedUrl = escape(this.accountAndExternalIdsHelpPath);
return sprintf(
s__(
@@ -59,7 +59,7 @@ export default {
);
},
provisionRoleArnHelpText() {
- const escapedUrl = esc(this.createRoleArnHelpPath);
+ const escapedUrl = escape(this.createRoleArnHelpPath);
return sprintf(
s__(
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
index 6d8e6bbac11..b0bec10f64d 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import { s__, sprintf } from '~/locale';
@@ -65,7 +65,7 @@ export default {
s__(message),
{
docsLinkEnd: '&nbsp;<i class="fa fa-external-link" aria-hidden="true"></i></a>',
- docsLinkStart: `<a href="${esc(
+ docsLinkStart: `<a href="${escape(
this.docsUrl,
)}" target="_blank" rel="noopener noreferrer">`,
},
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
index 523e5592fd0..ec09dafebcb 100644
--- a/app/assets/javascripts/create_item_dropdown.js
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -1,4 +1,4 @@
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import '~/gl_dropdown';
export default class CreateItemDropdown {
@@ -37,14 +37,14 @@ export default class CreateItemDropdown {
},
selectable: true,
toggleLabel(selected) {
- return selected && 'id' in selected ? esc(selected.title) : this.defaultToggleLabel;
+ return selected && 'id' in selected ? escape(selected.title) : this.defaultToggleLabel;
},
fieldName: this.fieldName,
text(item) {
- return esc(item.text);
+ return escape(item.text);
},
id(item) {
- return esc(item.id);
+ return escape(item.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
clicked: options => {
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index ba585444ba5..801566d2f2f 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -13,7 +13,7 @@ import {
import confidentialMergeRequestState from './confidential_merge_request/state';
// Todo: Remove this when fixing issue in input_setter plugin
-const InputSetter = Object.assign({}, ISetter);
+const InputSetter = { ...ISetter };
const CREATE_MERGE_REQUEST = 'create-mr';
const CREATE_BRANCH = 'create-branch';
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 304a0726597..4f9069f61a5 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -84,7 +84,7 @@ export default {
events.forEach(item => {
if (!item) return;
- const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item);
+ const eventItem = { ...DEFAULT_EVENT_OBJECTS[stage.slug], ...item };
eventItem.totalTime = eventItem.total_time;
diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/deploy_keys/eventhub.js
+++ b/app/assets/javascripts/deploy_keys/eventhub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/design_management/components/app.vue b/app/assets/javascripts/design_management/components/app.vue
new file mode 100644
index 00000000000..98240aef810
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/app.vue
@@ -0,0 +1,3 @@
+<template>
+ <router-view />
+</template>
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
new file mode 100644
index 00000000000..1fd902c9ed7
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+export default {
+ name: 'DeleteButton',
+ components: {
+ GlDeprecatedButton,
+ GlModal,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ props: {
+ isDeleting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ buttonClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ buttonVariant: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasSelectedDesigns: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ modalId: uniqueId('design-deletion-confirmation-'),
+ };
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-modal
+ :modal-id="modalId"
+ :title="s__('DesignManagement|Delete designs confirmation')"
+ :ok-title="s__('DesignManagement|Delete')"
+ ok-variant="danger"
+ @ok="$emit('deleteSelectedDesigns')"
+ >
+ <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p>
+ </gl-modal>
+ <gl-deprecated-button
+ v-gl-modal-directive="modalId"
+ :variant="buttonVariant"
+ :disabled="isDeleting || !hasSelectedDesigns"
+ :class="buttonClass"
+ >
+ <slot></slot>
+ </gl-deprecated-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue
new file mode 100644
index 00000000000..ad3f2736c4a
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_destroyer.vue
@@ -0,0 +1,66 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import destroyDesignMutation from '../graphql/mutations/destroyDesign.mutation.graphql';
+import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
+
+export default {
+ components: {
+ ApolloMutation,
+ },
+ props: {
+ filenames: {
+ type: Array,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ iid: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ projectQueryBody() {
+ return {
+ query: getDesignListQuery,
+ variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null },
+ };
+ },
+ },
+ methods: {
+ updateStoreAfterDelete(
+ store,
+ {
+ data: { designManagementDelete },
+ },
+ ) {
+ updateStoreAfterDesignsDelete(
+ store,
+ designManagementDelete,
+ this.projectQueryBody,
+ this.filenames,
+ );
+ },
+ },
+ destroyDesignMutation,
+};
+</script>
+
+<template>
+ <apollo-mutation
+ #default="{ mutate, loading, error }"
+ :mutation="$options.destroyDesignMutation"
+ :variables="{
+ filenames,
+ projectPath,
+ iid,
+ }"
+ :update="updateStoreAfterDelete"
+ v-on="$listeners"
+ >
+ <slot v-bind="{ mutate, loading, error }"></slot>
+ </apollo-mutation>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/design_management/components/design_note_pin.vue
new file mode 100644
index 00000000000..50ea69d52ce
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_note_pin.vue
@@ -0,0 +1,61 @@
+<script>
+import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'DesignNotePin',
+ components: {
+ Icon,
+ },
+ props: {
+ position: {
+ type: Object,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ repositioning: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isNewNote() {
+ return this.label === null;
+ },
+ pinStyle() {
+ return this.repositioning ? { ...this.position, cursor: 'move' } : this.position;
+ },
+ pinLabel() {
+ return this.isNewNote
+ ? __('Comment form position')
+ : sprintf(__("Comment '%{label}' position"), { label: this.label });
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ :style="pinStyle"
+ :aria-label="pinLabel"
+ :class="{
+ 'btn-transparent comment-indicator': isNewNote,
+ 'js-image-badge badge badge-pill': !isNewNote,
+ }"
+ class="position-absolute"
+ type="button"
+ @mousedown="$emit('mousedown', $event)"
+ @mouseup="$emit('mouseup', $event)"
+ @click="$emit('click', $event)"
+ >
+ <icon v-if="isNewNote" name="image-comment-dark" />
+ <template v-else>
+ {{ label }}
+ </template>
+ </button>
+</template>
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
new file mode 100644
index 00000000000..c6c5ee88a93
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -0,0 +1,169 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import allVersionsMixin from '../../mixins/all_versions';
+import createNoteMutation from '../../graphql/mutations/createNote.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';
+
+export default {
+ components: {
+ ApolloMutation,
+ DesignNote,
+ ReplyPlaceholder,
+ DesignReplyForm,
+ },
+ mixins: [allVersionsMixin],
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ noteableId: {
+ type: String,
+ required: true,
+ },
+ designId: {
+ type: String,
+ required: true,
+ },
+ discussionIndex: {
+ type: Number,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ apollo: {
+ activeDiscussion: {
+ query: activeDiscussionQuery,
+ result({ data }) {
+ const discussionId = data.activeDiscussion.id;
+ // 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 (
+ discussionId &&
+ data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin &&
+ discussionId === this.discussion.notes[0].id
+ ) {
+ this.$el.scrollIntoView({
+ behavior: 'smooth',
+ inline: 'start',
+ });
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ discussionComment: '',
+ isFormRendered: false,
+ activeDiscussion: {},
+ };
+ },
+ computed: {
+ mutationPayload() {
+ return {
+ noteableId: this.noteableId,
+ body: this.discussionComment,
+ discussionId: this.discussion.id,
+ };
+ },
+ designVariables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ filenames: [this.$route.params.id],
+ atVersion: this.designsVersion,
+ };
+ },
+ isDiscussionHighlighted() {
+ return this.discussion.notes[0].id === this.activeDiscussion.id;
+ },
+ },
+ methods: {
+ addDiscussionComment(
+ store,
+ {
+ data: { createNote },
+ },
+ ) {
+ updateStoreAfterAddDiscussionComment(
+ store,
+ createNote,
+ getDesignQuery,
+ this.designVariables,
+ this.discussion.id,
+ );
+ },
+ onDone() {
+ this.discussionComment = '';
+ this.hideForm();
+ },
+ onError(err) {
+ this.$emit('error', err);
+ },
+ hideForm() {
+ this.isFormRendered = false;
+ this.discussionComment = '';
+ },
+ showForm() {
+ this.isFormRendered = true;
+ },
+ },
+ createNoteMutation,
+};
+</script>
+
+<template>
+ <div class="design-discussion-wrapper">
+ <div class="badge badge-pill" type="button">{{ discussionIndex }}</div>
+ <div
+ class="design-discussion bordered-box position-relative"
+ data-qa-selector="design_discussion_content"
+ >
+ <design-note
+ v-for="note in discussion.notes"
+ :key="note.id"
+ :note="note"
+ :markdown-preview-path="markdownPreviewPath"
+ :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
+ @error="$emit('updateNoteError', $event)"
+ />
+ <div class="reply-wrapper">
+ <reply-placeholder
+ v-if="!isFormRendered"
+ class="qa-discussion-reply"
+ :button-text="__('Reply...')"
+ @onClick="showForm"
+ />
+ <apollo-mutation
+ v-else
+ #default="{ mutate, loading }"
+ :mutation="$options.createNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ :update="addDiscussionComment"
+ @done="onDone"
+ @error="onError"
+ >
+ <design-reply-form
+ v-model="discussionComment"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ @submitForm="mutate"
+ @cancelForm="hideForm"
+ />
+ </apollo-mutation>
+ </div>
+ </div>
+ </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
new file mode 100644
index 00000000000..c1c19c0a597
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -0,0 +1,148 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DesignReplyForm from './design_reply_form.vue';
+import { findNoteId } from '../../utils/design_management_utils';
+import { hasErrors } from '../../utils/cache_update';
+
+export default {
+ components: {
+ UserAvatarLink,
+ TimelineEntryItem,
+ TimeAgoTooltip,
+ DesignReplyForm,
+ ApolloMutation,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ noteText: this.note.body,
+ isEditing: false,
+ };
+ },
+ computed: {
+ author() {
+ return this.note.author;
+ },
+ noteAnchorId() {
+ return findNoteId(this.note.id);
+ },
+ isNoteLinked() {
+ return this.$route.hash === `#note_${this.noteAnchorId}`;
+ },
+ mutationPayload() {
+ return {
+ id: this.note.id,
+ body: this.noteText,
+ };
+ },
+ },
+ mounted() {
+ if (this.isNoteLinked) {
+ this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
+ }
+ },
+ methods: {
+ hideForm() {
+ this.isEditing = false;
+ this.noteText = this.note.body;
+ },
+ onDone({ data }) {
+ this.hideForm();
+ if (hasErrors(data.updateNote)) {
+ this.$emit('error', data.errors[0]);
+ }
+ },
+ },
+ updateNoteMutation,
+};
+</script>
+
+<template>
+ <timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form">
+ <user-avatar-link
+ :link-href="author.webUrl"
+ :img-src="author.avatarUrl"
+ :img-alt="author.username"
+ :img-size="40"
+ />
+ <div class="d-flex justify-content-between">
+ <div>
+ <a
+ v-once
+ :href="author.webUrl"
+ class="js-user-link"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ >
+ <span class="note-header-author-name bold">{{ author.name }}</span>
+ <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
+ <span class="note-headline-light">@{{ author.username }}</span>
+ </a>
+ <span class="note-headline-light note-headline-meta">
+ <span class="system-note-message"> <slot></slot> </span>
+ <template v-if="note.createdAt">
+ <span class="system-note-separator"></span>
+ <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
+ <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
+ </a>
+ </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>
+ <div
+ v-if="!isEditing"
+ class="note-text js-note-text md"
+ data-qa-selector="note_content"
+ v-html="note.bodyHtml"
+ ></div>
+ <apollo-mutation
+ v-else
+ #default="{ mutate, loading }"
+ :mutation="$options.updateNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ @error="$emit('error', $event)"
+ @done="onDone"
+ >
+ <design-reply-form
+ v-model="noteText"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ :is-new-comment="false"
+ class="mt-5"
+ @submitForm="mutate"
+ @cancelForm="hideForm"
+ />
+ </apollo-mutation>
+ </timeline-entry-item>
+</template>
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
new file mode 100644
index 00000000000..40be9867fee
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -0,0 +1,137 @@
+<script>
+import { GlDeprecatedButton, GlModal } from '@gitlab/ui';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'DesignReplyForm',
+ components: {
+ MarkdownField,
+ GlDeprecatedButton,
+ GlModal,
+ },
+ props: {
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ isSaving: {
+ type: Boolean,
+ required: true,
+ },
+ isNewComment: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ formText: this.value,
+ };
+ },
+ computed: {
+ hasValue() {
+ return this.value.trim().length > 0;
+ },
+ modalSettings() {
+ if (this.isNewComment) {
+ return {
+ title: s__('DesignManagement|Cancel comment confirmation'),
+ okTitle: s__('DesignManagement|Discard comment'),
+ cancelTitle: s__('DesignManagement|Keep comment'),
+ content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'),
+ };
+ }
+ return {
+ title: s__('DesignManagement|Cancel comment update confirmation'),
+ okTitle: s__('DesignManagement|Cancel changes'),
+ cancelTitle: s__('DesignManagement|Keep changes'),
+ content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'),
+ };
+ },
+ buttonText() {
+ return this.isNewComment
+ ? s__('DesignManagement|Comment')
+ : s__('DesignManagement|Save comment');
+ },
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ methods: {
+ submitForm() {
+ if (this.hasValue) this.$emit('submitForm');
+ },
+ cancelComment() {
+ if (this.hasValue && this.formText !== this.value) {
+ this.$refs.cancelCommentModal.show();
+ } else {
+ this.$emit('cancelForm');
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <form class="new-note common-note-form" @submit.prevent>
+ <markdown-field
+ :markdown-preview-path="markdownPreviewPath"
+ :can-attach-file="false"
+ :enable-autocomplete="true"
+ :textarea-value="value"
+ markdown-docs-path="/help/user/markdown"
+ class="bordered-box"
+ >
+ <template #textarea>
+ <textarea
+ ref="textarea"
+ :value="value"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ data-qa-selector="note_textarea"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment…')"
+ @input="$emit('input', $event.target.value)"
+ @keydown.meta.enter="submitForm"
+ @keydown.ctrl.enter="submitForm"
+ @keyup.esc.stop="cancelComment"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ <div class="note-form-actions d-flex justify-content-between">
+ <gl-deprecated-button
+ ref="submitButton"
+ :disabled="!hasValue || isSaving"
+ variant="success"
+ type="submit"
+ data-track-event="click_button"
+ data-qa-selector="save_comment_button"
+ @click="$emit('submitForm')"
+ >
+ {{ buttonText }}
+ </gl-deprecated-button>
+ <gl-deprecated-button ref="cancelButton" @click="cancelComment">{{
+ __('Cancel')
+ }}</gl-deprecated-button>
+ </div>
+ <gl-modal
+ ref="cancelCommentModal"
+ ok-variant="danger"
+ :title="modalSettings.title"
+ :ok-title="modalSettings.okTitle"
+ :cancel-title="modalSettings.cancelTitle"
+ modal-id="cancel-comment-modal"
+ @ok="$emit('cancelForm')"
+ >{{ modalSettings.content }}
+ </gl-modal>
+ </form>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
new file mode 100644
index 00000000000..beb51647821
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -0,0 +1,279 @@
+<script>
+import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql';
+import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
+import DesignNotePin from './design_note_pin.vue';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
+
+export default {
+ name: 'DesignOverlay',
+ components: {
+ DesignNotePin,
+ },
+ props: {
+ dimensions: {
+ type: Object,
+ required: true,
+ },
+ position: {
+ type: Object,
+ required: true,
+ },
+ notes: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ currentCommentForm: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ disableCommenting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ apollo: {
+ activeDiscussion: {
+ query: activeDiscussionQuery,
+ },
+ },
+ data() {
+ return {
+ movingNoteNewPosition: null,
+ movingNoteStartPosition: null,
+ activeDiscussion: {},
+ };
+ },
+ computed: {
+ overlayStyle() {
+ const cursor = this.disableCommenting ? 'unset' : undefined;
+
+ return {
+ cursor,
+ width: `${this.dimensions.width}px`,
+ height: `${this.dimensions.height}px`,
+ ...this.position,
+ };
+ },
+ isMovingCurrentComment() {
+ return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId);
+ },
+ currentCommentPositionStyle() {
+ return this.isMovingCurrentComment && this.movingNoteNewPosition
+ ? this.getNotePositionStyle(this.movingNoteNewPosition)
+ : this.getNotePositionStyle(this.currentCommentForm);
+ },
+ },
+ methods: {
+ setNewNoteCoordinates({ x, y }) {
+ this.$emit('openCommentForm', { x, y });
+ },
+ getNoteRelativePosition(position) {
+ const { x, y, width, height } = position;
+ const widthRatio = this.dimensions.width / width;
+ const heightRatio = this.dimensions.height / height;
+ return {
+ left: Math.round(x * widthRatio),
+ top: Math.round(y * heightRatio),
+ };
+ },
+ getNotePositionStyle(position) {
+ const { left, top } = this.getNoteRelativePosition(position);
+ return {
+ left: `${left}px`,
+ top: `${top}px`,
+ };
+ },
+ getMovingNotePositionDelta(e) {
+ let deltaX = 0;
+ let deltaY = 0;
+
+ if (this.movingNoteStartPosition) {
+ const { clientX, clientY } = this.movingNoteStartPosition;
+ deltaX = e.clientX - clientX;
+ deltaY = e.clientY - clientY;
+ }
+
+ return {
+ deltaX,
+ deltaY,
+ };
+ },
+ isMovingNote(noteId) {
+ const movingNoteId = this.movingNoteStartPosition?.noteId;
+ return Boolean(movingNoteId && movingNoteId === noteId);
+ },
+ canMoveNote(note) {
+ const { userPermissions } = note;
+ const { adminNote } = userPermissions || {};
+
+ return Boolean(adminNote);
+ },
+ isPositionInOverlay(position) {
+ const { top, left } = this.getNoteRelativePosition(position);
+ const { height, width } = this.dimensions;
+
+ return top >= 0 && top <= height && left >= 0 && left <= width;
+ },
+ onNewNoteMove(e) {
+ if (!this.isMovingCurrentComment) return;
+
+ const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
+ const x = this.currentCommentForm.x + deltaX;
+ const y = this.currentCommentForm.y + deltaY;
+
+ const movingNoteNewPosition = {
+ x,
+ y,
+ width: this.dimensions.width,
+ height: this.dimensions.height,
+ };
+
+ if (!this.isPositionInOverlay(movingNoteNewPosition)) {
+ this.onNewNoteMouseup();
+ return;
+ }
+
+ this.movingNoteNewPosition = movingNoteNewPosition;
+ },
+ onExistingNoteMove(e) {
+ const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId);
+ if (!note) return;
+
+ const { position } = note;
+ const { width, height } = position;
+ const widthRatio = this.dimensions.width / width;
+ const heightRatio = this.dimensions.height / height;
+
+ const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
+ const x = position.x * widthRatio + deltaX;
+ const y = position.y * heightRatio + deltaY;
+
+ const movingNoteNewPosition = {
+ x,
+ y,
+ width: this.dimensions.width,
+ height: this.dimensions.height,
+ };
+
+ if (!this.isPositionInOverlay(movingNoteNewPosition)) {
+ this.onExistingNoteMouseup();
+ return;
+ }
+
+ this.movingNoteNewPosition = movingNoteNewPosition;
+ },
+ onNewNoteMouseup() {
+ if (!this.movingNoteNewPosition) return;
+
+ const { x, y } = this.movingNoteNewPosition;
+ this.setNewNoteCoordinates({ x, y });
+ },
+ onExistingNoteMouseup(note) {
+ if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) {
+ this.updateActiveDiscussion(note.id);
+ this.$emit('closeCommentForm');
+ return;
+ }
+
+ const { x, y } = this.movingNoteNewPosition;
+ this.$emit('moveNote', {
+ noteId: this.movingNoteStartPosition.noteId,
+ discussionId: this.movingNoteStartPosition.discussionId,
+ coordinates: { x, y },
+ });
+ },
+ onNoteMousedown({ clientX, clientY }, note) {
+ if (note && !this.canMoveNote(note)) return;
+
+ this.movingNoteStartPosition = {
+ noteId: note?.id,
+ discussionId: note?.discussion.id,
+ clientX,
+ clientY,
+ };
+ },
+ onOverlayMousemove(e) {
+ if (!this.movingNoteStartPosition) return;
+
+ if (this.isMovingCurrentComment) {
+ this.onNewNoteMove(e);
+ } else {
+ this.onExistingNoteMove(e);
+ }
+ },
+ onNoteMouseup(note) {
+ if (!this.movingNoteStartPosition) return;
+
+ if (this.isMovingCurrentComment) {
+ this.onNewNoteMouseup();
+ } else {
+ this.onExistingNoteMouseup(note);
+ }
+
+ this.movingNoteStartPosition = null;
+ this.movingNoteNewPosition = null;
+ },
+ onAddCommentMouseup({ offsetX, offsetY }) {
+ if (this.disableCommenting) return;
+ if (this.activeDiscussion.id) {
+ this.updateActiveDiscussion();
+ }
+
+ this.setNewNoteCoordinates({ x: offsetX, y: offsetY });
+ },
+ updateActiveDiscussion(id) {
+ this.$apollo.mutate({
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
+ },
+ });
+ },
+ isNoteInactive(note) {
+ return this.activeDiscussion.id && this.activeDiscussion.id !== note.id;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="position-absolute image-diff-overlay frame"
+ :style="overlayStyle"
+ @mousemove="onOverlayMousemove"
+ @mouseleave="onNoteMouseup"
+ >
+ <button
+ v-show="!disableCommenting"
+ type="button"
+ class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
+ 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)"
+ />
+ <design-note-pin
+ v-if="currentCommentForm"
+ :position="currentCommentPositionStyle"
+ :repositioning="isMovingCurrentComment"
+ @mousedown.stop="onNoteMousedown"
+ @mouseup.stop="onNoteMouseup"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue
new file mode 100644
index 00000000000..5c113b3dbed
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_presentation.vue
@@ -0,0 +1,314 @@
+<script>
+import { throttle } from 'lodash';
+import DesignImage from './image.vue';
+import DesignOverlay from './design_overlay.vue';
+
+const CLICK_DRAG_BUFFER_PX = 2;
+
+export default {
+ components: {
+ DesignImage,
+ DesignOverlay,
+ },
+ props: {
+ image: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ discussions: {
+ type: Array,
+ required: true,
+ },
+ isAnnotating: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scale: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ },
+ data() {
+ return {
+ overlayDimensions: null,
+ overlayPosition: null,
+ currentAnnotationPosition: null,
+ zoomFocalPoint: {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ },
+ initialLoad: true,
+ lastDragPosition: null,
+ isDraggingDesign: false,
+ };
+ },
+ computed: {
+ discussionStartingNotes() {
+ return this.discussions.map(discussion => discussion.notes[0]);
+ },
+ currentCommentForm() {
+ return (this.isAnnotating && this.currentAnnotationPosition) || null;
+ },
+ presentationStyle() {
+ return {
+ cursor: this.isDraggingDesign ? 'grabbing' : undefined,
+ };
+ },
+ },
+ beforeDestroy() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ presentationViewport.removeEventListener('scroll', this.scrollThrottled, false);
+ },
+ mounted() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ this.scrollThrottled = throttle(() => {
+ this.shiftZoomFocalPoint();
+ }, 400);
+
+ presentationViewport.addEventListener('scroll', this.scrollThrottled, false);
+ },
+ methods: {
+ syncCurrentAnnotationPosition() {
+ if (!this.currentAnnotationPosition) return;
+
+ const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width;
+ const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height;
+ const x = this.currentAnnotationPosition.x * widthRatio;
+ const y = this.currentAnnotationPosition.y * heightRatio;
+
+ this.currentAnnotationPosition = this.getAnnotationPositon({ x, y });
+ },
+ setOverlayDimensions(overlayDimensions) {
+ this.overlayDimensions = overlayDimensions;
+
+ // every time we set overlay dimensions, we need to
+ // update the current annotation as well
+ this.syncCurrentAnnotationPosition();
+ },
+ setOverlayPosition() {
+ if (!this.overlayDimensions) {
+ this.overlayPosition = {};
+ }
+
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ // default to center
+ this.overlayPosition = {
+ left: `calc(50% - ${this.overlayDimensions.width / 2}px)`,
+ top: `calc(50% - ${this.overlayDimensions.height / 2}px)`,
+ };
+
+ // if the overlay overflows, then don't center
+ if (this.overlayDimensions.width > presentationViewport.offsetWidth) {
+ this.overlayPosition.left = '0';
+ }
+ if (this.overlayDimensions.height > presentationViewport.offsetHeight) {
+ this.overlayPosition.top = '0';
+ }
+ },
+ /**
+ * Return a point that represents the center of an
+ * overflowing child element w.r.t it's parent
+ */
+ getViewportCenter() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return {};
+
+ // get height of scroll bars (i.e. the max values for scrollTop, scrollLeft)
+ const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth;
+ const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight;
+
+ // determine how many child pixels have been scrolled
+ const xScrollRatio =
+ presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0;
+ const yScrollRatio =
+ presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0;
+ const xScrollOffset =
+ (presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio;
+ const yScrollOffset =
+ (presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio;
+
+ const viewportCenterX = presentationViewport.offsetWidth / 2;
+ const viewportCenterY = presentationViewport.offsetHeight / 2;
+ const focalPointX = viewportCenterX + xScrollOffset;
+ const focalPointY = viewportCenterY + yScrollOffset;
+
+ return {
+ x: focalPointX,
+ y: focalPointY,
+ };
+ },
+ /**
+ * Scroll the viewport such that the focal point is positioned centrally
+ */
+ scrollToFocalPoint() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2;
+ const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2;
+
+ presentationViewport.scrollTo(scrollX, scrollY);
+ },
+ scaleZoomFocalPoint() {
+ const { x, y, width, height } = this.zoomFocalPoint;
+ const widthRatio = this.overlayDimensions.width / width;
+ const heightRatio = this.overlayDimensions.height / height;
+
+ this.zoomFocalPoint = {
+ x: Math.round(x * widthRatio * 100) / 100,
+ y: Math.round(y * heightRatio * 100) / 100,
+ ...this.overlayDimensions,
+ };
+ },
+ shiftZoomFocalPoint() {
+ this.zoomFocalPoint = {
+ ...this.getViewportCenter(),
+ ...this.overlayDimensions,
+ };
+ },
+ onImageResize(imageDimensions) {
+ this.setOverlayDimensions(imageDimensions);
+ this.setOverlayPosition();
+
+ this.$nextTick(() => {
+ if (this.initialLoad) {
+ // set focal point on initial load
+ this.shiftZoomFocalPoint();
+ this.initialLoad = false;
+ } else {
+ this.scaleZoomFocalPoint();
+ this.scrollToFocalPoint();
+ }
+ });
+ },
+ getAnnotationPositon(coordinates) {
+ const { x, y } = coordinates;
+ const { width, height } = this.overlayDimensions;
+ return {
+ x: Math.round(x),
+ y: Math.round(y),
+ width: Math.round(width),
+ height: Math.round(height),
+ };
+ },
+ openCommentForm(coordinates) {
+ this.currentAnnotationPosition = this.getAnnotationPositon(coordinates);
+ this.$emit('openCommentForm', this.currentAnnotationPosition);
+ },
+ closeCommentForm() {
+ this.currentAnnotationPosition = null;
+ this.$emit('closeCommentForm');
+ },
+ moveNote({ noteId, discussionId, coordinates }) {
+ const position = this.getAnnotationPositon(coordinates);
+ this.$emit('moveNote', { noteId, discussionId, position });
+ },
+ onPresentationMousedown({ clientX, clientY }) {
+ if (!this.isDesignOverflowing()) return;
+
+ this.lastDragPosition = {
+ x: clientX,
+ y: clientY,
+ };
+ },
+ getDragDelta(clientX, clientY) {
+ return {
+ deltaX: this.lastDragPosition.x - clientX,
+ deltaY: this.lastDragPosition.y - clientY,
+ };
+ },
+ exceedsDragThreshold(clientX, clientY) {
+ const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
+
+ return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX;
+ },
+ shouldDragDesign(clientX, clientY) {
+ return (
+ this.lastDragPosition &&
+ (this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY))
+ );
+ },
+ onPresentationMousemove({ clientX, clientY }) {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return;
+
+ this.isDraggingDesign = true;
+
+ const { scrollLeft, scrollTop } = presentationViewport;
+ const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
+ presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY);
+
+ this.lastDragPosition = {
+ x: clientX,
+ y: clientY,
+ };
+ },
+ onPresentationMouseup() {
+ this.lastDragPosition = null;
+ this.isDraggingDesign = false;
+ },
+ isDesignOverflowing() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return false;
+
+ return (
+ presentationViewport.scrollWidth > presentationViewport.offsetWidth ||
+ presentationViewport.scrollHeight > presentationViewport.offsetHeight
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="presentationViewport"
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+ :style="presentationStyle"
+ @mousedown="onPresentationMousedown"
+ @mousemove="onPresentationMousemove"
+ @mouseup="onPresentationMouseup"
+ @mouseleave="onPresentationMouseup"
+ @touchstart="onPresentationMousedown"
+ @touchmove="onPresentationMousemove"
+ @touchend="onPresentationMouseup"
+ @touchcancel="onPresentationMouseup"
+ >
+ <div class="h-100 w-100 d-flex align-items-center position-relative">
+ <design-image
+ v-if="image"
+ :image="image"
+ :name="imageName"
+ :scale="scale"
+ @resize="onImageResize"
+ />
+ <design-overlay
+ v-if="overlayDimensions && overlayPosition"
+ :dimensions="overlayDimensions"
+ :position="overlayPosition"
+ :notes="discussionStartingNotes"
+ :current-comment-form="currentCommentForm"
+ :disable-commenting="isDraggingDesign"
+ @openCommentForm="openCommentForm"
+ @closeCommentForm="closeCommentForm"
+ @moveNote="moveNote"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue
new file mode 100644
index 00000000000..55dee74bef5
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_scaler.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+const SCALE_STEP_SIZE = 0.2;
+const DEFAULT_SCALE = 1;
+const MIN_SCALE = 1;
+const MAX_SCALE = 2;
+
+export default {
+ components: {
+ GlIcon,
+ },
+ data() {
+ return {
+ scale: DEFAULT_SCALE,
+ };
+ },
+ computed: {
+ disableReset() {
+ return this.scale <= MIN_SCALE;
+ },
+ disableDecrease() {
+ return this.scale === DEFAULT_SCALE;
+ },
+ disableIncrease() {
+ return this.scale >= MAX_SCALE;
+ },
+ },
+ methods: {
+ setScale(scale) {
+ if (scale < MIN_SCALE) {
+ return;
+ }
+
+ this.scale = Math.round(scale * 100) / 100;
+ this.$emit('scale', this.scale);
+ },
+ incrementScale() {
+ this.setScale(this.scale + SCALE_STEP_SIZE);
+ },
+ decrementScale() {
+ this.setScale(this.scale - SCALE_STEP_SIZE);
+ },
+ resetScale() {
+ this.setScale(DEFAULT_SCALE);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="design-scaler btn-group" role="group">
+ <button class="btn" :disabled="disableDecrease" @click="decrementScale">
+ <span class="d-flex-center gl-icon s16">
+ –
+ </span>
+ </button>
+ <button class="btn" :disabled="disableReset" @click="resetScale">
+ <gl-icon name="redo" />
+ </button>
+ <button class="btn" :disabled="disableIncrease" @click="incrementScale">
+ <gl-icon name="plus" />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue
new file mode 100644
index 00000000000..91b7b576e0c
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/image.vue
@@ -0,0 +1,110 @@
+<script>
+import { throttle } from 'lodash';
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ image: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ scale: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ },
+ data() {
+ return {
+ baseImageSize: null,
+ imageStyle: null,
+ imageError: false,
+ };
+ },
+ watch: {
+ scale(val) {
+ this.zoom(val);
+ },
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.resizeThrottled, false);
+ },
+ mounted() {
+ this.onImgLoad();
+
+ this.resizeThrottled = throttle(() => {
+ // NOTE: if imageStyle is set, then baseImageSize
+ // won't change due to resize. We must still emit a
+ // `resize` event so that the parent can handle
+ // resizes appropriately (e.g. for design_overlay)
+ this.setBaseImageSize();
+ }, 400);
+ window.addEventListener('resize', this.resizeThrottled, false);
+ },
+ methods: {
+ onImgLoad() {
+ requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
+ },
+ onImgError() {
+ this.imageError = true;
+ },
+ setBaseImageSize() {
+ const { contentImg } = this.$refs;
+ if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return;
+
+ this.baseImageSize = {
+ height: contentImg.offsetHeight,
+ width: contentImg.offsetWidth,
+ };
+ this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
+ },
+ onResize({ width, height }) {
+ this.$emit('resize', { width, height });
+ },
+ zoom(amount) {
+ if (amount === 1) {
+ this.imageStyle = null;
+ this.$nextTick(() => {
+ this.setBaseImageSize();
+ });
+ return;
+ }
+ const width = this.baseImageSize.width * amount;
+ const height = this.baseImageSize.height * amount;
+
+ this.imageStyle = {
+ width: `${width}px`,
+ height: `${height}px`,
+ };
+
+ this.onResize({ width, height });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="m-auto js-design-image">
+ <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" />
+ <img
+ v-show="!imageError"
+ ref="contentImg"
+ class="mh-100"
+ :src="image"
+ :alt="name"
+ :style="imageStyle"
+ :class="{ 'img-fluid': !imageStyle }"
+ @error="onImgError"
+ @load="onImgLoad"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
new file mode 100644
index 00000000000..eaa641d85d6
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -0,0 +1,174 @@
+<script>
+import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+import { n__, __ } from '~/locale';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlIntersectionObserver,
+ GlIcon,
+ Icon,
+ Timeago,
+ },
+ props: {
+ id: {
+ type: [Number, String],
+ required: true,
+ },
+ event: {
+ type: String,
+ required: true,
+ },
+ notesCount: {
+ type: Number,
+ required: true,
+ },
+ image: {
+ type: String,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isUploading: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ imageV432x230: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ imageLoading: true,
+ imageError: false,
+ wasInView: false,
+ };
+ },
+ computed: {
+ icon() {
+ const normalizedEvent = this.event.toLowerCase();
+ const icons = {
+ creation: {
+ name: 'file-addition-solid',
+ classes: 'text-success-500',
+ tooltip: __('Added in this version'),
+ },
+ modification: {
+ name: 'file-modified-solid',
+ classes: 'text-primary-500',
+ tooltip: __('Modified in this version'),
+ },
+ deletion: {
+ name: 'file-deletion-solid',
+ classes: 'text-danger-500',
+ tooltip: __('Deleted in this version'),
+ },
+ };
+
+ return icons[normalizedEvent] ? icons[normalizedEvent] : {};
+ },
+ notesLabel() {
+ return n__('%d comment', '%d comments', this.notesCount);
+ },
+ imageLink() {
+ return this.wasInView ? this.imageV432x230 || this.image : '';
+ },
+ showLoadingSpinner() {
+ return this.imageLoading || this.isUploading;
+ },
+ showImageErrorIcon() {
+ return this.wasInView && this.imageError;
+ },
+ showImage() {
+ return !this.showLoadingSpinner && !this.showImageErrorIcon;
+ },
+ },
+ methods: {
+ onImageLoad() {
+ this.imageLoading = false;
+ this.imageError = false;
+ },
+ onImageError() {
+ this.imageLoading = false;
+ this.imageError = true;
+ },
+ onAppear() {
+ // do nothing if image has previously
+ // been in view
+ if (this.wasInView) {
+ return;
+ }
+
+ this.wasInView = true;
+ this.imageLoading = true;
+ },
+ },
+ DESIGN_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <router-link
+ :to="{
+ name: $options.DESIGN_ROUTE_NAME,
+ params: { id: filename },
+ query: $route.query,
+ }"
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ >
+ <div class="card-body p-0 d-flex-center overflow-hidden position-relative">
+ <div v-if="icon.name" class="design-event position-absolute">
+ <span :title="icon.tooltip" :aria-label="icon.tooltip">
+ <icon :name="icon.name" :size="18" :class="icon.classes" />
+ </span>
+ </div>
+ <gl-intersection-observer @appear="onAppear">
+ <gl-loading-icon v-if="showLoadingSpinner" size="md" />
+ <gl-icon
+ v-else-if="showImageErrorIcon"
+ name="media-broken"
+ class="text-secondary"
+ :size="32"
+ />
+ <img
+ v-show="showImage"
+ :src="imageLink"
+ :alt="filename"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ @load="onImageLoad"
+ @error="onImageError"
+ />
+ </gl-intersection-observer>
+ </div>
+ <div class="card-footer d-flex w-100">
+ <div class="d-flex flex-column str-truncated-100">
+ <span class="bold str-truncated-100" data-qa-selector="design_file_name">{{
+ filename
+ }}</span>
+ <span v-if="updatedAt" class="str-truncated-100">
+ {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
+ </span>
+ </div>
+ <div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary">
+ <icon name="comments" class="ml-1" />
+ <span :aria-label="notesLabel" class="ml-1">
+ {{ notesCount }}
+ </span>
+ </div>
+ </div>
+ </router-link>
+</template>
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
new file mode 100644
index 00000000000..ea9f7300981
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -0,0 +1,126 @@
+<script>
+import { GlDeprecatedButton } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import Pagination from './pagination.vue';
+import DeleteButton from '../delete_button.vue';
+import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
+import appDataQuery from '../../graphql/queries/appData.query.graphql';
+import { DESIGNS_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ Icon,
+ Pagination,
+ DeleteButton,
+ GlDeprecatedButton,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ isDeleting: {
+ type: Boolean,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ updatedBy: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isLatestVersion: {
+ type: Boolean,
+ required: true,
+ },
+ image: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ permissions: {
+ createDesign: false,
+ },
+ projectPath: '',
+ issueIid: null,
+ };
+ },
+ apollo: {
+ appData: {
+ query: appDataQuery,
+ manual: true,
+ result({ data: { projectPath, issueIid } }) {
+ this.projectPath = projectPath;
+ this.issueIid = issueIid;
+ },
+ },
+ permissions: {
+ query: permissionsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ };
+ },
+ update: data => data.project.issue.userPermissions,
+ },
+ },
+ computed: {
+ updatedText() {
+ return sprintf(__('Updated %{updated_at} by %{updated_by}'), {
+ updated_at: this.timeFormatted(this.updatedAt),
+ updated_by: this.updatedBy.name,
+ });
+ },
+ canDeleteDesign() {
+ return this.permissions.createDesign;
+ },
+ },
+ DESIGNS_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <header class="d-flex p-2 bg-white align-items-center js-design-header">
+ <router-link
+ :to="{
+ name: $options.DESIGNS_ROUTE_NAME,
+ query: $route.query,
+ }"
+ :aria-label="s__('DesignManagement|Go back to designs')"
+ class="mr-3 text-plain d-flex justify-content-center align-items-center"
+ >
+ <icon :size="18" name="close" />
+ </router-link>
+ <div class="overflow-hidden d-flex align-items-center">
+ <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
+ <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
+ </div>
+ <pagination :id="id" class="ml-auto flex-shrink-0" />
+ <gl-deprecated-button :href="image" class="mr-2">
+ <icon :size="18" name="download" />
+ </gl-deprecated-button>
+ <delete-button
+ v-if="isLatestVersion && canDeleteDesign"
+ :is-deleting="isDeleting"
+ button-variant="danger"
+ @deleteSelectedDesigns="$emit('delete')"
+ >
+ <icon :size="18" name="remove" />
+ </delete-button>
+ </header>
+</template>
diff --git a/app/assets/javascripts/design_management/components/toolbar/pagination.vue b/app/assets/javascripts/design_management/components/toolbar/pagination.vue
new file mode 100644
index 00000000000..bf62a8f66a6
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/toolbar/pagination.vue
@@ -0,0 +1,83 @@
+<script>
+/* global Mousetrap */
+import 'mousetrap';
+import { s__, sprintf } from '~/locale';
+import PaginationButton from './pagination_button.vue';
+import allDesignsMixin from '../../mixins/all_designs';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ PaginationButton,
+ },
+ mixins: [allDesignsMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ designsCount() {
+ return this.designs.length;
+ },
+ currentIndex() {
+ return this.designs.findIndex(design => design.filename === this.id);
+ },
+ paginationText() {
+ return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), {
+ current_design: this.currentIndex + 1,
+ designs_count: this.designsCount,
+ });
+ },
+ previousDesign() {
+ if (!this.designsCount) return null;
+
+ return this.designs[this.currentIndex - 1];
+ },
+ nextDesign() {
+ if (!this.designsCount) return null;
+
+ return this.designs[this.currentIndex + 1];
+ },
+ },
+ mounted() {
+ Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign));
+ Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign));
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(['left', 'right'], this.navigateToDesign);
+ },
+ methods: {
+ navigateToDesign(design) {
+ if (design) {
+ this.$router.push({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: design.filename },
+ query: this.$route.query,
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="designsCount" class="d-flex align-items-center">
+ {{ paginationText }}
+ <div class="btn-group ml-3 mr-3">
+ <pagination-button
+ :design="previousDesign"
+ :title="s__('DesignManagement|Go to previous design')"
+ icon-name="angle-left"
+ class="js-previous-design"
+ />
+ <pagination-button
+ :design="nextDesign"
+ :title="s__('DesignManagement|Go to next design')"
+ icon-name="angle-right"
+ class="js-next-design"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue
new file mode 100644
index 00000000000..f00ecefca01
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue
@@ -0,0 +1,48 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ design: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ iconName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ designLink() {
+ if (!this.design) return {};
+
+ return {
+ name: DESIGN_ROUTE_NAME,
+ params: { id: this.design.filename },
+ query: this.$route.query,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <router-link
+ :to="designLink"
+ :disabled="!design"
+ :class="{ disabled: !design }"
+ :aria-label="title"
+ class="btn btn-default"
+ >
+ <icon :name="iconName" />
+ </router-link>
+</template>
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
new file mode 100644
index 00000000000..e3c5e369170
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
+
+export default {
+ components: {
+ GlDeprecatedButton,
+ GlLoadingIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ isSaving: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ openFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ onFileUploadChange(e) {
+ this.$emit('upload', e.target.files);
+ },
+ },
+ VALID_DESIGN_FILE_MIMETYPE,
+};
+</script>
+
+<template>
+ <div>
+ <gl-deprecated-button
+ v-gl-tooltip.hover
+ :title="
+ s__(
+ 'DesignManagement|Adding a design with the same filename replaces the file in a new version.',
+ )
+ "
+ :disabled="isSaving"
+ variant="success"
+ @click="openFileUpload"
+ >
+ {{ s__('DesignManagement|Add designs') }}
+ <gl-loading-icon v-if="isSaving" inline class="ml-1" />
+ </gl-deprecated-button>
+
+ <input
+ ref="fileUpload"
+ type="file"
+ name="design_file"
+ :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
+ class="hide"
+ multiple
+ @change="onFileUploadChange"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
new file mode 100644
index 00000000000..e2e1fc8bfad
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import uploadDesignMutation from '../../graphql/mutations/uploadDesign.mutation.graphql';
+import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
+import { isValidDesignFile } from '../../utils/design_management_utils';
+import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ data() {
+ return {
+ dragCounter: 0,
+ isDragDataValid: false,
+ };
+ },
+ computed: {
+ dragging() {
+ return this.dragCounter !== 0;
+ },
+ },
+ methods: {
+ isValidUpload(files) {
+ return files.every(isValidDesignFile);
+ },
+ isValidDragDataType({ dataTransfer }) {
+ return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
+ },
+ ondrop({ dataTransfer = {} }) {
+ this.dragCounter = 0;
+ // User already had feedback when dropzone was active, so bail here
+ if (!this.isDragDataValid) {
+ return;
+ }
+
+ const { files } = dataTransfer;
+ if (!this.isValidUpload(Array.from(files))) {
+ createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR);
+ return;
+ }
+
+ this.$emit('change', files);
+ },
+ ondragenter(e) {
+ this.dragCounter += 1;
+ this.isDragDataValid = this.isValidDragDataType(e);
+ },
+ ondragleave() {
+ this.dragCounter -= 1;
+ },
+ openFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ onDesignInputChange(e) {
+ this.$emit('change', e.target.files);
+ },
+ },
+ uploadDesignMutation,
+ VALID_DESIGN_FILE_MIMETYPE,
+};
+</script>
+
+<template>
+ <div
+ class="w-100 position-relative"
+ @dragstart.prevent.stop
+ @dragend.prevent.stop
+ @dragover.prevent.stop
+ @dragenter.prevent.stop="ondragenter"
+ @dragleave.prevent.stop="ondragleave"
+ @drop.prevent.stop="ondrop"
+ >
+ <slot>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ @click="openFileUpload"
+ >
+ <div class="d-flex-center flex-column text-center">
+ <gl-icon name="doc-new" :size="48" class="mb-4" />
+ <p>
+ <gl-sprintf
+ :message="
+ __(
+ '%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.',
+ )
+ "
+ >
+ <template #lineOne="{ content }"
+ ><span class="d-block">{{ content }}</span>
+ </template>
+
+ <template #link="{ content }">
+ <gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </button>
+
+ <input
+ ref="fileUpload"
+ type="file"
+ name="design_file"
+ :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
+ class="hide"
+ multiple
+ @change="onDesignInputChange"
+ />
+ </slot>
+ <transition name="design-dropzone-fade">
+ <div
+ v-show="dragging"
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ >
+ <div v-show="!isDragDataValid" class="mw-50 text-center">
+ <h3>{{ __('Oh no!') }}</h3>
+ <span>{{
+ __(
+ 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
+ )
+ }}</span>
+ </div>
+ <div v-show="isDragDataValid" class="mw-50 text-center">
+ <h3>{{ __('Incoming!') }}</h3>
+ <span>{{ __('Drop your designs to start your upload.') }}</span>
+ </div>
+ </div>
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
new file mode 100644
index 00000000000..993eac6f37f
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import allVersionsMixin from '../../mixins/all_versions';
+import { findVersionId } from '../../utils/design_management_utils';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ mixins: [allVersionsMixin],
+ computed: {
+ queryVersion() {
+ return this.$route.query.version;
+ },
+ currentVersionIdx() {
+ if (!this.queryVersion) return 0;
+
+ const idx = this.allVersions.findIndex(
+ version => this.findVersionId(version.node.id) === this.queryVersion,
+ );
+
+ // if the currentVersionId isn't a valid version (i.e. not in allVersions)
+ // then return the latest version (index 0)
+ return idx !== -1 ? idx : 0;
+ },
+ currentVersionId() {
+ if (this.queryVersion) return this.queryVersion;
+
+ const currentVersion = this.allVersions[this.currentVersionIdx];
+ return this.findVersionId(currentVersion.node.id);
+ },
+ dropdownText() {
+ if (this.isLatestVersion) {
+ return __('Showing Latest Version');
+ }
+ // allVersions is sorted in reverse chronological order (latest first)
+ const currentVersionNumber = this.allVersions.length - this.currentVersionIdx;
+
+ return sprintf(__('Showing Version #%{versionNumber}'), {
+ versionNumber: currentVersionNumber,
+ });
+ },
+ },
+ methods: {
+ findVersionId,
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown :text="dropdownText" variant="link" class="design-version-dropdown">
+ <gl-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id">
+ <router-link
+ class="d-flex js-version-link"
+ :to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }"
+ >
+ <div class="flex-grow-1 ml-2">
+ <div>
+ <strong
+ >{{ __('Version') }} {{ allVersions.length - index }}
+ <span v-if="findVersionId(version.node.id) === latestVersionId"
+ >({{ __('latest') }})</span
+ >
+ </strong>
+ </div>
+ </div>
+ <i
+ v-if="findVersionId(version.node.id) === currentVersionId"
+ class="fa fa-check pull-right"
+ ></i>
+ </router-link>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
new file mode 100644
index 00000000000..59d34669ad7
--- /dev/null
+++ b/app/assets/javascripts/design_management/constants.js
@@ -0,0 +1,14 @@
+// WARNING: replace this with something
+// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611
+export const VALID_DESIGN_FILE_MIMETYPE = {
+ mimetype: 'image/*',
+ regex: /image\/.+/,
+};
+
+// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
+export const VALID_DATA_TRANSFER_TYPE = 'Files';
+
+export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
+ pin: 'pin',
+ discussion: 'discussion',
+};
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
new file mode 100644
index 00000000000..fae337aa75b
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { uniqueId } from 'lodash';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import createDefaultClient from '~/lib/graphql';
+import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
+import typeDefs from './graphql/typedefs.graphql';
+
+Vue.use(VueApollo);
+
+const resolvers = {
+ Mutation: {
+ updateActiveDiscussion: (_, { id = null, source }, { cache }) => {
+ const data = cache.readQuery({ query: activeDiscussionQuery });
+ data.activeDiscussion = {
+ __typename: 'ActiveDiscussion',
+ id,
+ source,
+ };
+ cache.writeQuery({ query: activeDiscussionQuery, data });
+ },
+ },
+};
+
+const defaultClient = createDefaultClient(
+ resolvers,
+ // This config is added temporarily to resolve an issue with duplicate design IDs.
+ // Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved
+ {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings
+ if (object.__typename === 'Design') {
+ return object.id && object.image ? `${object.id}-${object.image}` : uniqueId();
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ typeDefs,
+ },
+);
+
+export default new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
new file mode 100644
index 00000000000..ca5b5a52c71
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
@@ -0,0 +1,22 @@
+#import "./designNote.fragment.graphql"
+#import "./designList.fragment.graphql"
+#import "./diffRefs.fragment.graphql"
+
+fragment DesignItem on Design {
+ ...DesignListItem
+ fullPath
+ diffRefs {
+ ...DesignDiffRefs
+ }
+ discussions {
+ nodes {
+ id
+ replyId
+ notes {
+ nodes {
+ ...DesignNote
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql
new file mode 100644
index 00000000000..bc3132f9b42
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql
@@ -0,0 +1,8 @@
+fragment DesignListItem on Design {
+ id
+ event
+ filename
+ notesCount
+ image
+ imageV432x230
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql
new file mode 100644
index 00000000000..2ad84f9cb17
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql
@@ -0,0 +1,28 @@
+#import "./diffRefs.fragment.graphql"
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+#import "./note_permissions.fragment.graphql"
+
+fragment DesignNote on Note {
+ id
+ author {
+ ...Author
+ }
+ body
+ bodyHtml
+ createdAt
+ position {
+ diffRefs {
+ ...DesignDiffRefs
+ }
+ x
+ y
+ height
+ width
+ }
+ userPermissions {
+ ...DesignNotePermissions
+ }
+ discussion {
+ id
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql
new file mode 100644
index 00000000000..984a55814b0
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql
@@ -0,0 +1,5 @@
+fragment DesignDiffRefs on DiffRefs {
+ baseSha
+ startSha
+ headSha
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
new file mode 100644
index 00000000000..c243e39f3d3
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
@@ -0,0 +1,3 @@
+fragment DesignNotePermissions on NotePermissions {
+ adminNote
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
new file mode 100644
index 00000000000..7eb40b12f51
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
@@ -0,0 +1,4 @@
+fragment VersionListItem on DesignVersion {
+ id
+ sha
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql
new file mode 100644
index 00000000000..9e2931b23f2
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql
@@ -0,0 +1,21 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
+ createImageDiffNote(input: $input) {
+ note {
+ ...DesignNote
+ discussion {
+ id
+ replyId
+ notes {
+ edges {
+ node {
+ ...DesignNote
+ }
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql
new file mode 100644
index 00000000000..3ae478d658e
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation createNote($input: CreateNoteInput!) {
+ createNote(input: $input) {
+ note {
+ ...DesignNote
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql
new file mode 100644
index 00000000000..0b3cf636cdb
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/version.fragment.graphql"
+
+mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) {
+ designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) {
+ version {
+ ...VersionListItem
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql
new file mode 100644
index 00000000000..cdb2264d233
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
+ updateImageDiffNote(input: $input) {
+ errors
+ note {
+ ...DesignNote
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql
new file mode 100644
index 00000000000..343de4e3025
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateActiveDiscussion($id: String, $source: String) {
+ updateActiveDiscussion (id: $id, source: $source ) @client
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
new file mode 100644
index 00000000000..d96b2f3934a
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation updateNote($input: UpdateNoteInput!) {
+ updateNote(input: $input) {
+ note {
+ ...DesignNote
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql
new file mode 100644
index 00000000000..904acef599b
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql
@@ -0,0 +1,21 @@
+#import "../fragments/design.fragment.graphql"
+
+mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
+ designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
+ designs {
+ ...DesignItem
+ versions {
+ edges {
+ node {
+ id
+ sha
+ }
+ }
+ },
+ }
+ skippedDesigns {
+ filename
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql b/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql
new file mode 100644
index 00000000000..111023cea68
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql
@@ -0,0 +1,6 @@
+query activeDiscussion {
+ activeDiscussion @client {
+ id
+ source
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql b/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql
new file mode 100644
index 00000000000..e1269761206
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql
@@ -0,0 +1,4 @@
+query projectFullPath {
+ projectPath @client
+ issueIid @client
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql b/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql
new file mode 100644
index 00000000000..a87b256dc95
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql
@@ -0,0 +1,10 @@
+query permissions($fullPath: ID!, $iid: String!) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ userPermissions {
+ createDesign
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql b/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql
new file mode 100644
index 00000000000..07a9af55787
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql
@@ -0,0 +1,31 @@
+#import "../fragments/design.fragment.graphql"
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+
+query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ designCollection {
+ designs(atVersion: $atVersion, filenames: $filenames) {
+ edges {
+ node {
+ ...DesignItem
+ issue {
+ title
+ webPath
+ webUrl
+ participants {
+ edges {
+ node {
+ ...Author
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
new file mode 100644
index 00000000000..857f205ab07
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
@@ -0,0 +1,26 @@
+#import "../fragments/designList.fragment.graphql"
+#import "../fragments/version.fragment.graphql"
+
+query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ designCollection {
+ designs(atVersion: $atVersion) {
+ edges {
+ node {
+ ...DesignListItem
+ }
+ }
+ }
+ versions {
+ edges {
+ node {
+ ...VersionListItem
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/typedefs.graphql b/app/assets/javascripts/design_management/graphql/typedefs.graphql
new file mode 100644
index 00000000000..fdbad4a90e0
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/typedefs.graphql
@@ -0,0 +1,12 @@
+type ActiveDiscussion {
+ id: ID
+ source: String
+}
+
+extend type Query {
+ activeDiscussion: ActiveDiscussion
+}
+
+extend type Mutation {
+ updateActiveDiscussion(id: ID!, source: String!): Boolean
+}
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
new file mode 100644
index 00000000000..eb00e1742ea
--- /dev/null
+++ b/app/assets/javascripts/design_management/index.js
@@ -0,0 +1,58 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import createRouter from './router';
+import App from './components/app.vue';
+import apolloProvider from './graphql';
+import getDesignListQuery from './graphql/queries/get_design_list.query.graphql';
+import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants';
+
+export default () => {
+ const el = document.querySelector('.js-design-management');
+ const badge = document.querySelector('.js-designs-count');
+ const { issueIid, projectPath, issuePath } = el.dataset;
+ const router = createRouter(issuePath);
+
+ $('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => {
+ if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) {
+ router.push({ name: DESIGNS_ROUTE_NAME });
+ } else if (id === 'discussion') {
+ router.push({ name: ROOT_ROUTE_NAME });
+ }
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeData({
+ data: {
+ projectPath,
+ issueIid,
+ activeDiscussion: {
+ __typename: 'ActiveDiscussion',
+ id: null,
+ source: null,
+ },
+ },
+ });
+
+ apolloProvider.clients.defaultClient
+ .watchQuery({
+ query: getDesignListQuery,
+ variables: {
+ fullPath: projectPath,
+ iid: issueIid,
+ atVersion: null,
+ },
+ })
+ .subscribe(({ data }) => {
+ if (badge) {
+ badge.textContent = data.project.issue.designCollection.designs.edges.length;
+ }
+ });
+
+ return new Vue({
+ el,
+ router,
+ apolloProvider,
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};
diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js
new file mode 100644
index 00000000000..f7d6551c46c
--- /dev/null
+++ b/app/assets/javascripts/design_management/mixins/all_designs.js
@@ -0,0 +1,49 @@
+import { propertyOf } from 'lodash';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import { extractNodes } from '../utils/design_management_utils';
+import allVersionsMixin from './all_versions';
+import { DESIGNS_ROUTE_NAME } from '../router/constants';
+
+export default {
+ mixins: [allVersionsMixin],
+ apollo: {
+ designs: {
+ query: getDesignListQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ atVersion: this.designsVersion,
+ };
+ },
+ update: data => {
+ const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']);
+ if (designEdges) {
+ return extractNodes(designEdges);
+ }
+ return [];
+ },
+ error() {
+ this.error = true;
+ },
+ result() {
+ if (this.$route.query.version && !this.hasValidVersion) {
+ createFlash(
+ s__(
+ 'DesignManagement|Requested design version does not exist. Showing latest version instead',
+ ),
+ );
+ this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ designs: [],
+ error: false,
+ };
+ },
+};
diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js
new file mode 100644
index 00000000000..41c93064c26
--- /dev/null
+++ b/app/assets/javascripts/design_management/mixins/all_versions.js
@@ -0,0 +1,62 @@
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import appDataQuery from '../graphql/queries/appData.query.graphql';
+import { findVersionId } from '../utils/design_management_utils';
+
+export default {
+ apollo: {
+ appData: {
+ query: appDataQuery,
+ manual: true,
+ result({ data: { projectPath, issueIid } }) {
+ this.projectPath = projectPath;
+ this.issueIid = issueIid;
+ },
+ },
+ allVersions: {
+ query: getDesignListQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ atVersion: null,
+ };
+ },
+ update: data => data.project.issue.designCollection.versions.edges,
+ },
+ },
+ computed: {
+ hasValidVersion() {
+ return (
+ this.$route.query.version &&
+ this.allVersions &&
+ this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version))
+ );
+ },
+ designsVersion() {
+ return this.hasValidVersion
+ ? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}`
+ : null;
+ },
+ latestVersionId() {
+ const latestVersion = this.allVersions[0];
+ return latestVersion && findVersionId(latestVersion.node.id);
+ },
+ isLatestVersion() {
+ if (this.allVersions.length > 0) {
+ return (
+ !this.$route.query.version ||
+ !this.latestVersionId ||
+ this.$route.query.version === this.latestVersionId
+ );
+ }
+ return true;
+ },
+ },
+ data() {
+ return {
+ allVersions: [],
+ projectPath: '',
+ issueIid: null,
+ };
+ },
+};
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
new file mode 100644
index 00000000000..7ff3271394d
--- /dev/null
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -0,0 +1,400 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import Mousetrap from 'mousetrap';
+import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+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 getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
+import appDataQuery from '../../graphql/queries/appData.query.graphql';
+import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql';
+import updateImageDiffNoteMutation from '../../graphql/mutations/updateImageDiffNote.mutation.graphql';
+import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql';
+import {
+ extractDiscussions,
+ extractDesign,
+ extractParticipants,
+ updateImageDiffNoteOptimisticResponse,
+} from '../../utils/design_management_utils';
+import {
+ updateStoreAfterAddImageDiffNote,
+ updateStoreAfterUpdateImageDiffNote,
+} from '../../utils/cache_update';
+import {
+ ADD_DISCUSSION_COMMENT_ERROR,
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ DESIGN_NOT_FOUND_ERROR,
+ DESIGN_VERSION_NOT_EXIST_ERROR,
+ UPDATE_NOTE_ERROR,
+ designDeletionError,
+} from '../../utils/error_messages';
+import { trackDesignDetailView } from '../../utils/tracking';
+import { DESIGNS_ROUTE_NAME } from '../../router/constants';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+
+export default {
+ components: {
+ ApolloMutation,
+ DesignPresentation,
+ DesignDiscussion,
+ DesignScaler,
+ DesignDestroyer,
+ Toolbar,
+ DesignReplyForm,
+ GlLoadingIcon,
+ GlAlert,
+ Participants,
+ },
+ mixins: [allVersionsMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ design: {},
+ comment: '',
+ annotationCoordinates: null,
+ projectPath: '',
+ errorMessage: '',
+ issueIid: '',
+ scale: 1,
+ };
+ },
+ apollo: {
+ appData: {
+ query: appDataQuery,
+ manual: true,
+ result({ data: { projectPath, issueIid } }) {
+ this.projectPath = projectPath;
+ this.issueIid = issueIid;
+ },
+ },
+ design: {
+ query: getDesignQuery,
+ // We want to see cached design version if we have one, and fetch newer version on the background to update discussions
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ variables() {
+ return this.designVariables;
+ },
+ update: data => extractDesign(data),
+ result(res) {
+ this.onDesignQueryResult(res);
+ },
+ error() {
+ this.onQueryError(DESIGN_NOT_FOUND_ERROR);
+ },
+ },
+ },
+ computed: {
+ isFirstLoading() {
+ // We only want to show spinner on initial design load (when opened from a deep link to design)
+ // If we already have cached a design, loading shouldn't be indicated to user
+ return this.$apollo.queries.design.loading && !this.design.filename;
+ },
+ discussions() {
+ 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,
+ iid: this.issueIid,
+ filenames: [this.$route.params.id],
+ atVersion: this.designsVersion,
+ };
+ },
+ mutationPayload() {
+ const { x, y, width, height } = this.annotationCoordinates;
+ return {
+ noteableId: this.design.id,
+ body: this.comment,
+ position: {
+ headSha: this.design.diffRefs.headSha,
+ baseSha: this.design.diffRefs.baseSha,
+ startSha: this.design.diffRefs.startSha,
+ x,
+ y,
+ width,
+ height,
+ paths: {
+ newPath: this.design.fullPath,
+ },
+ },
+ };
+ },
+ issue() {
+ return {
+ ...this.design.issue,
+ webPath: this.design.issue.webPath.substr(1),
+ };
+ },
+ isAnnotating() {
+ return Boolean(this.annotationCoordinates);
+ },
+ },
+ mounted() {
+ Mousetrap.bind('esc', this.closeDesign);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind('esc', this.closeDesign);
+ },
+ methods: {
+ addImageDiffNoteToStore(
+ store,
+ {
+ data: { createImageDiffNote },
+ },
+ ) {
+ updateStoreAfterAddImageDiffNote(
+ store,
+ createImageDiffNote,
+ getDesignQuery,
+ this.designVariables,
+ );
+ },
+ updateImageDiffNoteInStore(
+ store,
+ {
+ data: { updateImageDiffNote },
+ },
+ ) {
+ return updateStoreAfterUpdateImageDiffNote(
+ store,
+ updateImageDiffNote,
+ getDesignQuery,
+ this.designVariables,
+ );
+ },
+ onMoveNote({ noteId, discussionId, position }) {
+ const discussion = this.discussions.find(({ id }) => id === discussionId);
+ const note = discussion.notes.find(
+ ({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId,
+ );
+
+ const mutationPayload = {
+ optimisticResponse: updateImageDiffNoteOptimisticResponse(note, {
+ position,
+ }),
+ variables: {
+ input: {
+ id: noteId,
+ position,
+ },
+ },
+ mutation: updateImageDiffNoteMutation,
+ update: this.updateImageDiffNoteInStore,
+ };
+
+ return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e));
+ },
+ onDesignQueryResult({ data, loading }) {
+ // On the initial load with cache-and-network policy data is undefined while loading is true
+ // To prevent throwing an error, we don't perform any logic until loading is false
+ if (loading) {
+ return;
+ }
+
+ if (!data || !extractDesign(data)) {
+ this.onQueryError(DESIGN_NOT_FOUND_ERROR);
+ } else if (this.$route.query.version && !this.hasValidVersion) {
+ this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR);
+ }
+ },
+ onQueryError(message) {
+ // because we redirect user to /designs (the issue page),
+ // we want to create these flashes on the issue page
+ createFlash(message);
+ this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
+ },
+ onError(message, e) {
+ this.errorMessage = message;
+ throw e;
+ },
+ onCreateImageDiffNoteError(e) {
+ this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
+ },
+ onUpdateNoteError(e) {
+ this.onError(UPDATE_NOTE_ERROR, e);
+ },
+ onDesignDiscussionError(e) {
+ this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
+ },
+ onUpdateImageDiffNoteError(e) {
+ this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
+ },
+ onDesignDeleteError(e) {
+ this.onError(designDeletionError({ singular: true }), e);
+ },
+ openCommentForm(annotationCoordinates) {
+ this.annotationCoordinates = annotationCoordinates;
+ },
+ closeCommentForm() {
+ this.comment = '';
+ this.annotationCoordinates = null;
+ },
+ closeDesign() {
+ this.$router.push({
+ name: this.$options.DESIGNS_ROUTE_NAME,
+ query: this.$route.query,
+ });
+ },
+ trackEvent() {
+ // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue
+ trackDesignDetailView(
+ 'issue-design-collection',
+ 'issue',
+ this.$route.query.version || this.latestVersionId,
+ this.isLatestVersion,
+ );
+ },
+ updateActiveDiscussion(id) {
+ this.$apollo.mutate({
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
+ },
+ });
+ },
+ },
+ 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();
+ },
+ createImageDiffNoteMutation,
+ DESIGNS_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <div
+ class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
+ >
+ <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" />
+ <template v-else>
+ <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative">
+ <design-destroyer
+ :filenames="[design.filename]"
+ :project-path="projectPath"
+ :iid="issueIid"
+ @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })"
+ @error="onDesignDeleteError"
+ >
+ <template #default="{ mutate, loading }">
+ <toolbar
+ :id="id"
+ :is-deleting="loading"
+ :is-latest-version="isLatestVersion"
+ v-bind="design"
+ @delete="mutate"
+ />
+ </template>
+ </design-destroyer>
+
+ <div v-if="errorMessage" class="p-3">
+ <gl-alert variant="danger" @dismiss="errorMessage = null">
+ {{ errorMessage }}
+ </gl-alert>
+ </div>
+ <design-presentation
+ :image="design.image"
+ :image-name="design.filename"
+ :discussions="discussions"
+ :is-annotating="isAnnotating"
+ :scale="scale"
+ @openCommentForm="openCommentForm"
+ @closeCommentForm="closeCommentForm"
+ @moveNote="onMoveNote"
+ />
+
+ <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center">
+ <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)"
+ />
+ <apollo-mutation
+ v-if="annotationCoordinates"
+ #default="{ mutate, loading }"
+ :mutation="$options.createImageDiffNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ :update="addImageDiffNoteToStore"
+ @done="closeCommentForm"
+ @error="onCreateImageDiffNoteError"
+ >
+ <design-reply-form
+ v-model="comment"
+ :is-saving="loading"
+ :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>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
new file mode 100644
index 00000000000..7d419bc3ded
--- /dev/null
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -0,0 +1,323 @@
+<script>
+import { GlLoadingIcon, GlDeprecatedButton, GlAlert } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__, sprintf } from '~/locale';
+import UploadButton from '../components/upload/button.vue';
+import DeleteButton from '../components/delete_button.vue';
+import Design from '../components/list/item.vue';
+import DesignDestroyer from '../components/design_destroyer.vue';
+import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
+import DesignDropzone from '../components/upload/design_dropzone.vue';
+import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql';
+import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import allDesignsMixin from '../mixins/all_designs';
+import {
+ UPLOAD_DESIGN_ERROR,
+ EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
+ EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
+ designUploadSkippedWarning,
+ designDeletionError,
+} from '../utils/error_messages';
+import { updateStoreAfterUploadDesign } from '../utils/cache_update';
+import {
+ designUploadOptimisticResponse,
+ isValidDesignFile,
+} from '../utils/design_management_utils';
+import { getFilename } from '~/lib/utils/file_upload';
+import { DESIGNS_ROUTE_NAME } from '../router/constants';
+
+const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlAlert,
+ GlDeprecatedButton,
+ UploadButton,
+ Design,
+ DesignDestroyer,
+ DesignVersionDropdown,
+ DeleteButton,
+ DesignDropzone,
+ },
+ mixins: [allDesignsMixin],
+ apollo: {
+ permissions: {
+ query: permissionsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ };
+ },
+ update: data => data.project.issue.userPermissions,
+ },
+ },
+ data() {
+ return {
+ permissions: {
+ createDesign: false,
+ },
+ filesToBeSaved: [],
+ selectedDesigns: [],
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading;
+ },
+ isSaving() {
+ return this.filesToBeSaved.length > 0;
+ },
+ canCreateDesign() {
+ return this.permissions.createDesign;
+ },
+ showToolbar() {
+ return this.canCreateDesign && this.allVersions.length > 0;
+ },
+ hasDesigns() {
+ return this.designs.length > 0;
+ },
+ hasSelectedDesigns() {
+ return this.selectedDesigns.length > 0;
+ },
+ canDeleteDesigns() {
+ return this.isLatestVersion && this.hasSelectedDesigns;
+ },
+ projectQueryBody() {
+ return {
+ query: getDesignListQuery,
+ variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
+ };
+ },
+ selectAllButtonText() {
+ return this.hasSelectedDesigns
+ ? s__('DesignManagement|Deselect all')
+ : s__('DesignManagement|Select all');
+ },
+ },
+ mounted() {
+ this.toggleOnPasteListener(this.$route.name);
+ },
+ methods: {
+ resetFilesToBeSaved() {
+ this.filesToBeSaved = [];
+ },
+ /**
+ * Determine if a design upload is valid, given [files]
+ * @param {Array<File>} files
+ */
+ isValidDesignUpload(files) {
+ if (!this.canCreateDesign) return false;
+
+ if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
+ createFlash(
+ sprintf(
+ s__(
+ 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
+ ),
+ {
+ upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
+ },
+ ),
+ );
+
+ return false;
+ }
+ return true;
+ },
+ onUploadDesign(files) {
+ // convert to Array so that we have Array methods (.map, .some, etc.)
+ this.filesToBeSaved = Array.from(files);
+ if (!this.isValidDesignUpload(this.filesToBeSaved)) return null;
+
+ const mutationPayload = {
+ optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved),
+ variables: {
+ files: this.filesToBeSaved,
+ projectPath: this.projectPath,
+ iid: this.issueIid,
+ },
+ context: {
+ hasUpload: true,
+ },
+ mutation: uploadDesignMutation,
+ update: this.afterUploadDesign,
+ };
+
+ return this.$apollo
+ .mutate(mutationPayload)
+ .then(res => this.onUploadDesignDone(res))
+ .catch(() => this.onUploadDesignError());
+ },
+ afterUploadDesign(
+ store,
+ {
+ data: { designManagementUpload },
+ },
+ ) {
+ updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody);
+ },
+ onUploadDesignDone(res) {
+ const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
+ const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
+ if (skippedWarningMessage) {
+ createFlash(skippedWarningMessage, 'warning');
+ }
+
+ // if this upload resulted in a new version being created, redirect user to the latest version
+ if (!this.isLatestVersion) {
+ this.$router.push({ name: DESIGNS_ROUTE_NAME });
+ }
+ this.resetFilesToBeSaved();
+ },
+ onUploadDesignError() {
+ this.resetFilesToBeSaved();
+ createFlash(UPLOAD_DESIGN_ERROR);
+ },
+ changeSelectedDesigns(filename) {
+ if (this.isDesignSelected(filename)) {
+ this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename);
+ } else {
+ this.selectedDesigns.push(filename);
+ }
+ },
+ toggleDesignsSelection() {
+ if (this.hasSelectedDesigns) {
+ this.selectedDesigns = [];
+ } else {
+ this.selectedDesigns = this.designs.map(design => design.filename);
+ }
+ },
+ isDesignSelected(filename) {
+ return this.selectedDesigns.includes(filename);
+ },
+ isDesignToBeSaved(filename) {
+ return this.filesToBeSaved.some(file => file.name === filename);
+ },
+ canSelectDesign(filename) {
+ return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename);
+ },
+ onDesignDelete() {
+ this.selectedDesigns = [];
+ if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME });
+ },
+ onDesignDeleteError() {
+ const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
+ createFlash(errorMessage);
+ },
+ onExistingDesignDropzoneChange(files, existingDesignFilename) {
+ const filesArr = Array.from(files);
+
+ if (filesArr.length > 1) {
+ createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE);
+ return;
+ }
+
+ if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
+ createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE);
+ return;
+ }
+
+ this.onUploadDesign(files);
+ },
+ onDesignPaste(event) {
+ const { clipboardData } = event;
+ const files = Array.from(clipboardData.files);
+ if (clipboardData && files.length > 0) {
+ if (!files.some(isValidDesignFile)) {
+ return;
+ }
+ event.preventDefault();
+ let filename = getFilename(event);
+ if (!filename || filename === 'image.png') {
+ filename = `design_${Date.now()}.png`;
+ }
+ const newFile = new File([files[0]], filename);
+ this.onUploadDesign([newFile]);
+ }
+ },
+ toggleOnPasteListener(route) {
+ if (route === DESIGNS_ROUTE_NAME) {
+ document.addEventListener('paste', this.onDesignPaste);
+ } else {
+ document.removeEventListener('paste', this.onDesignPaste);
+ }
+ },
+ },
+ beforeRouteUpdate(to, from, next) {
+ this.toggleOnPasteListener(to.name);
+ this.selectedDesigns = [];
+ next();
+ },
+ beforeRouteLeave(to, from, next) {
+ this.toggleOnPasteListener(to.name);
+ next();
+ },
+};
+</script>
+
+<template>
+ <div>
+ <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
+ <div class="d-flex justify-content-between align-items-center w-100">
+ <design-version-dropdown />
+ <div :class="['qa-selector-toolbar', { 'd-flex': hasDesigns, 'd-none': !hasDesigns }]">
+ <gl-deprecated-button
+ v-if="isLatestVersion"
+ variant="link"
+ class="mr-2 js-select-all"
+ @click="toggleDesignsSelection"
+ >{{ selectAllButtonText }}</gl-deprecated-button
+ >
+ <design-destroyer
+ #default="{ mutate, loading }"
+ :filenames="selectedDesigns"
+ :project-path="projectPath"
+ :iid="issueIid"
+ @done="onDesignDelete"
+ @error="onDesignDeleteError"
+ >
+ <delete-button
+ v-if="isLatestVersion"
+ :is-deleting="loading"
+ button-class="btn-danger btn-inverted mr-2"
+ :has-selected-designs="hasSelectedDesigns"
+ @deleteSelectedDesigns="mutate()"
+ >
+ {{ s__('DesignManagement|Delete selected') }}
+ <gl-loading-icon v-if="loading" inline class="ml-1" />
+ </delete-button>
+ </design-destroyer>
+ <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" />
+ </div>
+ </div>
+ </header>
+ <div class="mt-4">
+ <gl-loading-icon v-if="isLoading" size="md" />
+ <gl-alert v-else-if="error" variant="danger" :dismissible="false">
+ {{ __('An error occurred while loading designs. Please try again.') }}
+ </gl-alert>
+ <ol v-else class="list-unstyled row">
+ <li class="col-md-6 col-lg-4 mb-3">
+ <design-dropzone class="design-list-item" @change="onUploadDesign" />
+ </li>
+ <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3">
+ <design-dropzone @change="onExistingDesignDropzoneChange($event, design.filename)"
+ ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)"
+ /></design-dropzone>
+
+ <input
+ v-if="canSelectDesign(design.filename)"
+ :checked="isDesignSelected(design.filename)"
+ type="checkbox"
+ class="design-checkbox"
+ @change="changeSelectedDesigns(design.filename)"
+ />
+ </li>
+ </ol>
+ </div>
+ <router-view />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/router/constants.js b/app/assets/javascripts/design_management/router/constants.js
new file mode 100644
index 00000000000..abeef520e33
--- /dev/null
+++ b/app/assets/javascripts/design_management/router/constants.js
@@ -0,0 +1,3 @@
+export const ROOT_ROUTE_NAME = 'root';
+export const DESIGNS_ROUTE_NAME = 'designs';
+export const DESIGN_ROUTE_NAME = 'design';
diff --git a/app/assets/javascripts/design_management/router/index.js b/app/assets/javascripts/design_management/router/index.js
new file mode 100644
index 00000000000..7dc92f55d47
--- /dev/null
+++ b/app/assets/javascripts/design_management/router/index.js
@@ -0,0 +1,22 @@
+import $ from 'jquery';
+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,
+ });
+
+ router.beforeEach(({ meta: { el } }, from, next) => {
+ $(`#${el}`).tab('show');
+
+ next();
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/design_management/router/routes.js b/app/assets/javascripts/design_management/router/routes.js
new file mode 100644
index 00000000000..788910e5514
--- /dev/null
+++ b/app/assets/javascripts/design_management/router/routes.js
@@ -0,0 +1,44 @@
+import Home from '../pages/index.vue';
+import DesignDetail from '../pages/design/index.vue';
+import { ROOT_ROUTE_NAME, DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants';
+
+export default [
+ {
+ name: ROOT_ROUTE_NAME,
+ path: '/',
+ component: Home,
+ meta: {
+ el: 'discussion',
+ },
+ },
+ {
+ name: DESIGNS_ROUTE_NAME,
+ path: '/designs',
+ component: Home,
+ meta: {
+ el: 'designs',
+ },
+ children: [
+ {
+ name: DESIGN_ROUTE_NAME,
+ path: ':id',
+ component: DesignDetail,
+ meta: {
+ el: 'designs',
+ },
+ beforeEnter(
+ {
+ params: { id },
+ },
+ from,
+ next,
+ ) {
+ if (typeof id === 'string') {
+ next();
+ }
+ },
+ props: ({ params: { id } }) => ({ id }),
+ },
+ ],
+ },
+];
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
new file mode 100644
index 00000000000..01c073bddc2
--- /dev/null
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -0,0 +1,272 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+import createFlash from '~/flash';
+import { extractCurrentDiscussion, extractDesign } from './design_management_utils';
+import {
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ ADD_DISCUSSION_COMMENT_ERROR,
+ designDeletionError,
+} from './error_messages';
+
+const deleteDesignsFromStore = (store, query, selectedDesigns) => {
+ const data = store.readQuery(query);
+
+ const changedDesigns = data.project.issue.designCollection.designs.edges.filter(
+ ({ node }) => !selectedDesigns.includes(node.filename),
+ );
+ data.project.issue.designCollection.designs.edges = [...changedDesigns];
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+/**
+ * Adds a new version of designs to store
+ *
+ * @param {Object} store
+ * @param {Object} query
+ * @param {Object} version
+ */
+const addNewVersionToStore = (store, query, version) => {
+ if (!version) return;
+
+ const data = store.readQuery(query);
+ const newEdge = { node: version, __typename: 'DesignVersionEdge' };
+
+ data.project.issue.designCollection.versions.edges = [
+ newEdge,
+ ...data.project.issue.designCollection.versions.edges,
+ ];
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => {
+ const data = store.readQuery({
+ query,
+ variables: queryVariables,
+ });
+
+ const design = extractDesign(data);
+ const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId);
+ currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note];
+
+ design.notesCount += 1;
+ if (
+ !design.issue.participants.edges.some(
+ participant => participant.node.username === createNote.note.author.username,
+ )
+ ) {
+ design.issue.participants.edges = [
+ ...design.issue.participants.edges,
+ {
+ __typename: 'UserEdge',
+ node: {
+ __typename: 'User',
+ ...createNote.note.author,
+ },
+ },
+ ];
+ }
+ store.writeQuery({
+ query,
+ variables: queryVariables,
+ data: {
+ ...data,
+ design: {
+ ...design,
+ },
+ },
+ });
+};
+
+const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => {
+ const data = store.readQuery({
+ query,
+ variables,
+ });
+ const newDiscussion = {
+ __typename: 'Discussion',
+ id: createImageDiffNote.note.discussion.id,
+ replyId: createImageDiffNote.note.discussion.replyId,
+ notes: {
+ __typename: 'NoteConnection',
+ nodes: [createImageDiffNote.note],
+ },
+ };
+ const design = extractDesign(data);
+ const notesCount = design.notesCount + 1;
+ design.discussions.nodes = [...design.discussions.nodes, newDiscussion];
+ if (
+ !design.issue.participants.edges.some(
+ participant => participant.node.username === createImageDiffNote.note.author.username,
+ )
+ ) {
+ design.issue.participants.edges = [
+ ...design.issue.participants.edges,
+ {
+ __typename: 'UserEdge',
+ node: {
+ __typename: 'User',
+ ...createImageDiffNote.note.author,
+ },
+ },
+ ];
+ }
+ store.writeQuery({
+ query,
+ variables,
+ data: {
+ ...data,
+ design: {
+ ...design,
+ notesCount,
+ },
+ },
+ });
+};
+
+const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => {
+ const data = store.readQuery({
+ query,
+ variables,
+ });
+
+ const design = extractDesign(data);
+ const discussion = extractCurrentDiscussion(
+ design.discussions,
+ updateImageDiffNote.note.discussion.id,
+ );
+
+ discussion.notes = {
+ ...discussion.notes,
+ nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
+ };
+
+ store.writeQuery({
+ query,
+ variables,
+ data: {
+ ...data,
+ design,
+ },
+ });
+};
+
+const addNewDesignToStore = (store, designManagementUpload, query) => {
+ const data = store.readQuery(query);
+
+ const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => {
+ if (!acc.find(d => d.filename === design.node.filename)) {
+ acc.push(design.node);
+ }
+
+ return acc;
+ }, designManagementUpload.designs);
+
+ let newVersionNode;
+ const findNewVersions = designManagementUpload.designs.find(design => design.versions);
+
+ if (findNewVersions) {
+ const findNewVersionsEdges = findNewVersions.versions.edges;
+
+ if (findNewVersionsEdges && findNewVersionsEdges.length) {
+ newVersionNode = [findNewVersionsEdges[0]];
+ }
+ }
+
+ const newVersions = [
+ ...(newVersionNode || []),
+ ...data.project.issue.designCollection.versions.edges,
+ ];
+
+ const updatedDesigns = {
+ __typename: 'DesignCollection',
+ designs: {
+ __typename: 'DesignConnection',
+ edges: newDesigns.map(design => ({
+ __typename: 'DesignEdge',
+ node: design,
+ })),
+ },
+ versions: {
+ __typename: 'DesignVersionConnection',
+ edges: newVersions,
+ },
+ };
+
+ data.project.issue.designCollection = updatedDesigns;
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+const onError = (data, message) => {
+ createFlash(message);
+ throw new Error(data.errors);
+};
+
+export const hasErrors = ({ errors = [] }) => errors?.length;
+
+/**
+ * Updates a store after design deletion
+ *
+ * @param {Object} store
+ * @param {Object} data
+ * @param {Object} query
+ * @param {Array} designs
+ */
+export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
+ if (hasErrors(data)) {
+ onError(data, designDeletionError({ singular: designs.length === 1 }));
+ } else {
+ deleteDesignsFromStore(store, query, designs);
+ addNewVersionToStore(store, query, data.version);
+ }
+};
+
+export const updateStoreAfterAddDiscussionComment = (
+ store,
+ data,
+ query,
+ queryVariables,
+ discussionId,
+) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_DISCUSSION_COMMENT_ERROR);
+ } else {
+ addDiscussionCommentToStore(store, data, query, queryVariables, discussionId);
+ }
+};
+
+export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_IMAGE_DIFF_NOTE_ERROR);
+ } else {
+ addImageDiffNoteToStore(store, data, query, queryVariables);
+ }
+};
+
+export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => {
+ if (hasErrors(data)) {
+ onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR);
+ } else {
+ updateImageDiffNoteInStore(store, data, query, queryVariables);
+ }
+};
+
+export const updateStoreAfterUploadDesign = (store, data, query) => {
+ if (hasErrors(data)) {
+ onError(data, data.errors[0]);
+ } else {
+ addNewDesignToStore(store, data, query);
+ }
+};
diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js
new file mode 100644
index 00000000000..e6d8796ffa4
--- /dev/null
+++ b/app/assets/javascripts/design_management/utils/design_management_utils.js
@@ -0,0 +1,125 @@
+import { uniqueId } from 'lodash';
+import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
+
+export const isValidDesignFile = ({ type }) =>
+ (type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0;
+
+/**
+ * Returns formatted array that doesn't contain
+ * `edges`->`node` nesting
+ *
+ * @param {Array} elements
+ */
+
+export const extractNodes = elements => elements.edges.map(({ node }) => node);
+
+/**
+ * Returns formatted array of discussions that doesn't contain
+ * `edges`->`node` nesting for child notes
+ *
+ * @param {Array} discussions
+ */
+
+export const extractDiscussions = discussions =>
+ discussions.nodes.map(discussion => ({
+ ...discussion,
+ notes: discussion.notes.nodes,
+ }));
+
+/**
+ * Returns a discussion with the given id from discussions array
+ *
+ * @param {Array} discussions
+ */
+
+export const extractCurrentDiscussion = (discussions, id) =>
+ discussions.nodes.find(discussion => discussion.id === id);
+
+export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1];
+
+export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1];
+
+export const extractDesigns = data => data.project.issue.designCollection.designs.edges;
+
+export const extractDesign = data => (extractDesigns(data) || [])[0]?.node;
+
+/**
+ * Generates optimistic response for a design upload mutation
+ * @param {Array<File>} files
+ */
+export const designUploadOptimisticResponse = files => {
+ const designs = files.map(file => ({
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Design',
+ id: -uniqueId(),
+ image: '',
+ imageV432x230: '',
+ filename: file.name,
+ fullPath: '',
+ notesCount: 0,
+ event: 'NONE',
+ diffRefs: {
+ __typename: 'DiffRefs',
+ baseSha: '',
+ startSha: '',
+ headSha: '',
+ },
+ discussions: {
+ __typename: 'DesignDiscussion',
+ nodes: [],
+ },
+ versions: {
+ __typename: 'DesignVersionConnection',
+ edges: {
+ __typename: 'DesignVersionEdge',
+ node: {
+ __typename: 'DesignVersion',
+ id: -uniqueId(),
+ sha: -uniqueId(),
+ },
+ },
+ },
+ }));
+
+ return {
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ designManagementUpload: {
+ __typename: 'DesignManagementUploadPayload',
+ designs,
+ skippedDesigns: [],
+ errors: [],
+ },
+ };
+};
+
+/**
+ * Generates optimistic response for a design upload mutation
+ * @param {Array<File>} files
+ */
+export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ updateImageDiffNote: {
+ __typename: 'UpdateImageDiffNotePayload',
+ note: {
+ ...note,
+ position: {
+ ...note.position,
+ ...position,
+ },
+ },
+ errors: [],
+ },
+});
+
+const normalizeAuthor = author => ({
+ ...author,
+ web_url: author.webUrl,
+ avatar_url: author.avatarUrl,
+});
+
+export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node));
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
new file mode 100644
index 00000000000..7666c726c2f
--- /dev/null
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -0,0 +1,95 @@
+import { __, s__, n__, sprintf } from '~/locale';
+
+export const ADD_DISCUSSION_COMMENT_ERROR = s__(
+ 'DesignManagement|Could not add a new comment. Please try again.',
+);
+
+export const ADD_IMAGE_DIFF_NOTE_ERROR = s__(
+ 'DesignManagement|Could not create new discussion. Please try again.',
+);
+
+export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
+ 'DesignManagement|Could not update discussion. Please try again.',
+);
+
+export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
+
+export const UPLOAD_DESIGN_ERROR = s__(
+ 'DesignManagement|Error uploading a new design. Please try again.',
+);
+
+export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __(
+ 'Could not upload your designs as one or more files uploaded are not supported.',
+);
+
+export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
+
+export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
+
+const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.');
+
+const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
+ 'The designs you tried uploading did not change.',
+)}`;
+
+export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
+ 'You can only upload one design when dropping onto an existing design.',
+);
+
+export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
+ 'You must upload a file with the same file name when dropping onto an existing design.',
+);
+
+const MAX_SKIPPED_FILES_LISTINGS = 5;
+
+const oneDesignSkippedMessage = filename =>
+ `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), {
+ filename,
+ })}`;
+
+/**
+ * Return warning message indicating that some (but not all) uploaded
+ * files were skipped.
+ * @param {Array<{ filename }>} skippedFiles
+ */
+const someDesignsSkippedMessage = skippedFiles => {
+ const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
+ 'Some of the designs you tried uploading did not change:',
+ )}`;
+
+ const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), {
+ moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
+ });
+
+ return `${designsSkippedMessage} ${skippedFiles
+ .slice(0, MAX_SKIPPED_FILES_LISTINGS)
+ .map(({ filename }) => filename)
+ .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`;
+};
+
+export const designDeletionError = ({ singular = true } = {}) => {
+ const design = singular ? __('a design') : __('designs');
+ return sprintf(s__('Could not delete %{design}. Please try again.'), {
+ design,
+ });
+};
+
+/**
+ * Return warning message, if applicable, that one, some or all uploaded
+ * files were skipped.
+ * @param {Array<{ filename }>} uploadedDesigns
+ * @param {Array<{ filename }>} skippedFiles
+ */
+export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
+ if (skippedFiles.length === 0) {
+ return null;
+ }
+
+ if (skippedFiles.length === uploadedDesigns.length) {
+ const { filename } = skippedFiles[0];
+
+ return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length);
+ }
+
+ return someDesignsSkippedMessage(skippedFiles);
+};
diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js
new file mode 100644
index 00000000000..39c20376271
--- /dev/null
+++ b/app/assets/javascripts/design_management/utils/tracking.js
@@ -0,0 +1,28 @@
+import Tracking from '~/tracking';
+
+function assembleDesignPayload(payloadArr) {
+ return {
+ value: {
+ 'internal-object-refrerer': payloadArr[0],
+ 'design-collection-owner': payloadArr[1],
+ 'design-version-number': payloadArr[2],
+ 'design-is-current-version': payloadArr[3],
+ },
+ };
+}
+
+// Tracking Constants
+const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
+
+// eslint-disable-next-line import/prefer-default-export
+export function trackDesignDetailView(
+ referer = '',
+ owner = '',
+ designVersion = 1,
+ latestVersion = false,
+) {
+ Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', {
+ label: 'design_viewed',
+ ...assembleDesignPayload([referer, owner, designVersion, latestVersion]),
+ });
+}
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
index 84e07598fed..dd60e2c7684 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -1,4 +1,3 @@
-/* eslint-disable no-else-return, no-lonely-if */
/* global CommentsStore */
import $ from 'jquery';
@@ -22,27 +21,19 @@ const CommentAndResolveBtn = Vue.extend({
showButton() {
if (this.discussion) {
return this.discussion.isResolvable();
- } else {
- return false;
}
+ return false;
},
isDiscussionResolved() {
return this.discussion.isResolved();
},
buttonText() {
- if (this.isDiscussionResolved) {
- if (this.textareaIsEmpty) {
- return __('Unresolve thread');
- } else {
- return __('Comment & unresolve thread');
- }
- } else {
- if (this.textareaIsEmpty) {
- return __('Resolve thread');
- } else {
- return __('Comment & resolve thread');
- }
+ if (this.textareaIsEmpty) {
+ return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread');
}
+ return this.isDiscussionResolved
+ ? __('Comment & unresolve thread')
+ : __('Comment & resolve thread');
},
},
created() {
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 fa5f8ea4005..0c521fa29bd 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, no-else-return, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue */
+/* eslint-disable func-names, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue */
/* global CommentsStore */
import $ from 'jquery';
@@ -25,9 +25,8 @@ const JumpToDiscussion = Vue.extend({
buttonText() {
if (this.discussionId) {
return __('Jump to next unresolved thread');
- } else {
- return __('Jump to first unresolved thread');
}
+ return __('Jump to first unresolved thread');
},
allResolved() {
return this.unresolvedDiscussionCount === 0;
@@ -36,12 +35,10 @@ const JumpToDiscussion = Vue.extend({
if (this.discussionId) {
if (this.unresolvedDiscussionCount > 1) {
return true;
- } else {
- return this.discussionId !== this.lastResolvedId;
}
- } else {
- return this.unresolvedDiscussionCount >= 1;
+ return this.discussionId !== this.lastResolvedId;
}
+ return this.unresolvedDiscussionCount >= 1;
},
lastResolvedId() {
let lastId;
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 072bcaaad97..941365d9d1d 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -7,6 +7,7 @@ import createFlash from '~/flash';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
+import { updateHistory } from '~/lib/utils/url_utility';
import eventHub from '../../notes/event_hub';
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
@@ -140,6 +141,20 @@ export default {
},
},
watch: {
+ commit(newCommit, oldCommit) {
+ const commitChangedAfterRender = newCommit && !this.isLoading;
+ const commitIsDifferent = oldCommit && newCommit.id !== oldCommit.id;
+ const url = window?.location ? String(window.location) : '';
+
+ if (commitChangedAfterRender && commitIsDifferent) {
+ updateHistory({
+ title: document.title,
+ url: url.replace(oldCommit.id, newCommit.id),
+ });
+ this.refetchDiffData();
+ this.adjustView();
+ }
+ },
diffViewType() {
if (this.needsReload() || this.needsFirstLoad()) {
this.refetchDiffData();
@@ -209,6 +224,7 @@ export default {
methods: {
...mapActions(['startTaskList']),
...mapActions('diffs', [
+ 'moveToNeighboringCommit',
'setBaseConfig',
'fetchDiffFiles',
'fetchDiffFilesMeta',
@@ -329,9 +345,16 @@ export default {
break;
}
});
+
+ if (this.commit && this.glFeatures.mrCommitNeighborNav) {
+ Mousetrap.bind('c', () => this.moveToNeighboringCommit({ direction: 'next' }));
+ Mousetrap.bind('x', () => this.moveToNeighboringCommit({ direction: 'previous' }));
+ }
},
removeEventListeners() {
Mousetrap.unbind(['[', 'k', ']', 'j']);
+ Mousetrap.unbind('c');
+ Mousetrap.unbind('x');
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 9d4edd84f25..ee93ca020e8 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,10 +1,18 @@
<script>
+import { mapActions } from 'vuex';
+import { GlButtonGroup, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import Icon from '~/vue_shared/components/icon.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+
import initUserPopovers from '../../user_popovers';
+import { setUrlParams } from '../../lib/utils/url_utility';
/**
* CommitItem
@@ -18,7 +26,16 @@ import initUserPopovers from '../../user_popovers';
* coexist, but there is an issue to remove the duplication.
* https://gitlab.com/gitlab-org/gitlab-foss/issues/51613
*
+ * EXCEPTION WARNING
+ * 1. The commit navigation buttons (next neighbor, previous neighbor)
+ * are not duplicated because:
+ * - We don't have the same data available on the Rails side (yet,
+ * without backend work)
+ * - This Vue component should always be what's used when in the
+ * context of an MR diff, so the HAML should never have any idea
+ * about navigating among commits.
*/
+
export default {
components: {
UserAvatarLink,
@@ -26,7 +43,14 @@ export default {
ClipboardButton,
TimeAgoTooltip,
CommitPipelineStatus,
+ GlButtonGroup,
+ GlButton,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
commit: {
type: Object,
@@ -54,12 +78,28 @@ export default {
authorAvatar() {
return this.author.avatar_url || this.commit.author_gravatar_url;
},
+ nextCommitUrl() {
+ return this.commit.next_commit_id
+ ? setUrlParams({ commit_id: this.commit.next_commit_id })
+ : '';
+ },
+ previousCommitUrl() {
+ return this.commit.prev_commit_id
+ ? setUrlParams({ commit_id: this.commit.prev_commit_id })
+ : '';
+ },
+ hasNeighborCommits() {
+ return this.commit.next_commit_id || this.commit.prev_commit_id;
+ },
},
created() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
+ methods: {
+ ...mapActions('diffs', ['moveToNeighboringCommit']),
+ },
};
</script>
@@ -123,6 +163,41 @@ export default {
class="btn btn-default"
/>
</div>
+ <div
+ v-if="hasNeighborCommits && glFeatures.mrCommitNeighborNav"
+ class="commit-nav-buttons ml-3"
+ >
+ <gl-button-group>
+ <gl-button
+ :href="previousCommitUrl"
+ :disabled="!commit.prev_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'previous' })"
+ >
+ <span
+ v-if="!commit.prev_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute"
+ :title="__('You\'re at the first commit')"
+ ></span>
+ <gl-icon name="chevron-left" />
+ {{ __('Prev') }}
+ </gl-button>
+ <gl-button
+ :href="nextCommitUrl"
+ :disabled="!commit.next_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'next' })"
+ >
+ <span
+ v-if="!commit.next_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute"
+ :title="__('You\'re at the last commit')"
+ ></span>
+ {{ __('Next') }}
+ <gl-icon name="chevron-right" />
+ </gl-button>
+ </gl-button-group>
+ </div>
</div>
</div>
</li>
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index b0460bacff2..b6a0724c201 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -85,9 +85,11 @@ export default {
:help-page-path="helpPagePath"
@noteDeleted="deleteNoteHandler"
>
- <span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill">
- {{ index + 1 }}
- </span>
+ <template v-if="renderAvatarBadge" #avatar-badge>
+ <span class="badge badge-pill">
+ {{ index + 1 }}
+ </span>
+ </template>
</noteable-discussion>
</ul>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 82ca3749ac1..54852b113ae 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
@@ -46,7 +46,7 @@ export default {
return sprintf(
__('You can %{linkStart}view the blob%{linkEnd} instead.'),
{
- linkStart: `<a href="${esc(this.file.view_path)}">`,
+ linkStart: `<a href="${escape(this.file.view_path)}">`,
linkEnd: '</a>',
},
false,
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index d601c3769a3..61bbf13aa53 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import { GlDeprecatedButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import { polyfillSticky } from '~/lib/utils/sticky';
@@ -91,7 +91,7 @@ export default {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
viewFileButtonText() {
- const truncatedContentSha = esc(truncateSha(this.diffFile.content_sha));
+ const truncatedContentSha = escape(truncateSha(this.diffFile.content_sha));
return sprintf(
s__('MergeRequests|View file @ %{commitId}'),
{ commitId: truncatedContentSha },
@@ -99,7 +99,7 @@ export default {
);
},
viewReplacedFileButtonText() {
- const truncatedBaseSha = esc(truncateSha(this.diffFile.diff_refs.base_sha));
+ const truncatedBaseSha = escape(truncateSha(this.diffFile.diff_refs.base_sha));
return sprintf(
s__('MergeRequests|View replaced file @ %{commitId}'),
{
diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue
index 91e296f8572..21fdb19287d 100644
--- a/app/assets/javascripts/diffs/components/edit_button.vue
+++ b/app/assets/javascripts/diffs/components/edit_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui';
+import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
@@ -13,7 +14,8 @@ export default {
props: {
editPath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
canCurrentUserFork: {
type: Boolean,
@@ -25,6 +27,18 @@ export default {
default: false,
},
},
+ computed: {
+ tooltipTitle() {
+ if (this.isDisabled) {
+ return __("Can't edit as source branch was deleted");
+ }
+
+ return __('Edit file');
+ },
+ isDisabled() {
+ return !this.editPath;
+ },
+ },
methods: {
handleEditClick(evt) {
if (this.canCurrentUserFork && !this.canModifyBlob) {
@@ -37,13 +51,15 @@ export default {
</script>
<template>
- <gl-deprecated-button
- v-gl-tooltip.top
- :href="editPath"
- :title="__('Edit file')"
- class="js-edit-blob"
- @click.native="handleEditClick"
- >
- <icon name="pencil" />
- </gl-deprecated-button>
+ <span v-gl-tooltip.top :title="tooltipTitle">
+ <gl-deprecated-button
+ :href="editPath"
+ :disabled="isDisabled"
+ :class="{ 'cursor-not-allowed': isDisabled }"
+ class="rounded-0 js-edit-blob"
+ @click.native="handleEditClick"
+ >
+ <icon name="pencil" />
+ </gl-deprecated-button>
+ </span>
</template>
diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue
index 5fd68471094..94c2695a945 100644
--- a/app/assets/javascripts/diffs/components/no_changes.vue
+++ b/app/assets/javascripts/diffs/components/no_changes.vue
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlDeprecatedButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
@@ -24,8 +24,8 @@ export default {
{
ref_start: '<span class="ref-name">',
ref_end: '</span>',
- source_branch: esc(this.getNoteableData.source_branch),
- target_branch: esc(this.getNoteableData.target_branch),
+ source_branch: escape(this.getNoteableData.source_branch),
+ target_branch: escape(this.getNoteableData.target_branch),
},
false,
);
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 93c242e32ac..1975d6996a5 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -16,6 +16,7 @@ import {
idleCallback,
allDiscussionWrappersExpanded,
prepareDiffData,
+ prepareLineForRenamedFile,
} from './utils';
import * as types from './mutation_types';
import {
@@ -627,6 +628,42 @@ export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => {
}
};
+export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { diffFile }) {
+ return axios
+ .get(diffFile.context_lines_path, {
+ params: {
+ full: true,
+ from_merge_request: true,
+ },
+ })
+ .then(({ data }) => {
+ const lines = data.map((line, index) =>
+ prepareLineForRenamedFile({
+ diffViewType: state.diffViewType,
+ line,
+ diffFile,
+ index,
+ }),
+ );
+
+ commit(types.SET_DIFF_FILE_VIEWER, {
+ filePath: diffFile.file_path,
+ viewer: {
+ ...diffFile.alternate_viewer,
+ collapsed: false,
+ },
+ });
+ commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines });
+
+ dispatch('startRenderDiffsQueue');
+ })
+ .catch(error => {
+ dispatch('receiveFullDiffError', diffFile.file_path);
+
+ throw error;
+ });
+}
+
export const setFileCollapsed = ({ commit }, { filePath, collapsed }) =>
commit(types.SET_FILE_COLLAPSED, { filePath, collapsed });
@@ -642,5 +679,48 @@ export const setSuggestPopoverDismissed = ({ commit, state }) =>
createFlash(s__('MergeRequest|Error dismissing suggestion popover. Please try again.'));
});
+export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ if (!commitId) {
+ return Promise.reject(new Error('`commitId` is a required argument'));
+ } else if (!state.commit) {
+ return Promise.reject(new Error('`state` must already contain a valid `commit`'));
+ }
+ /* eslint-enable @gitlab/require-i18n-strings */
+
+ // this is less than ideal, see: https://gitlab.com/gitlab-org/gitlab/-/issues/215421
+ const commitRE = new RegExp(state.commit.id, 'g');
+
+ commit(types.SET_DIFF_FILES, []);
+ commit(types.SET_BASE_CONFIG, {
+ ...state,
+ endpoint: state.endpoint.replace(commitRE, commitId),
+ endpointBatch: state.endpointBatch.replace(commitRE, commitId),
+ endpointMetadata: state.endpointMetadata.replace(commitRE, commitId),
+ });
+
+ return dispatch('fetchDiffFilesMeta');
+}
+
+export function moveToNeighboringCommit({ dispatch, state }, { direction }) {
+ const previousCommitId = state.commit?.prev_commit_id;
+ const nextCommitId = state.commit?.next_commit_id;
+ const canMove = {
+ next: !state.isLoading && nextCommitId,
+ previous: !state.isLoading && previousCommitId,
+ };
+ let commitId;
+
+ if (direction === 'next' && canMove.next) {
+ commitId = nextCommitId;
+ } else if (direction === 'previous' && canMove.previous) {
+ commitId = previousCommitId;
+ }
+
+ if (commitId) {
+ dispatch('changeCurrentCommit', { commitId });
+ }
+}
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
index acc8874dad8..1e8e736c028 100644
--- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
+++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
@@ -40,10 +40,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => {
};
};
- if (gon.features?.diffCompareWithHead) {
- return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion];
- }
- return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion];
+ return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion];
};
export const diffCompareDropdownSourceVersions = (state, getters) => {
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 699c61b3ddd..4b1dbc34902 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -41,6 +41,8 @@ export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINE
export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES';
export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
+export const SET_DIFF_FILE_VIEWER = 'SET_DIFF_FILE_VIEWER';
+
export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER';
export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 104686993a8..7e89d041c21 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -383,6 +383,11 @@ export default {
file.renderingLines = !file.renderingLines;
},
+ [types.SET_DIFF_FILE_VIEWER](state, { filePath, viewer }) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.viewer = viewer;
+ },
[types.SET_SHOW_SUGGEST_POPOVER](state) {
state.showSuggestPopover = false;
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index dd8dec49a37..2be71c77087 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -233,7 +233,7 @@ export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign
delete line.text;
- const parsedLine = Object.assign({}, line);
+ const parsedLine = { ...line };
if (line.rich_text) {
const firstChar = parsedLine.rich_text.charAt(0);
@@ -303,6 +303,42 @@ function prepareLine(line) {
}
}
+export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index = 0 }) {
+ /*
+ Renamed files are a little different than other diffs, which
+ is why this is distinct from `prepareDiffFileLines` below.
+
+ We don't get any of the diff file context when we get the diff
+ (so no "inline" vs. "parallel", no "line_code", etc.).
+
+ We can also assume that both the left and the right of each line
+ (for parallel diff view type) are identical, because the file
+ is renamed, not modified.
+
+ This should be cleaned up as part of the effort around flattening our data
+ ==> https://gitlab.com/groups/gitlab-org/-/epics/2852#note_304803402
+ */
+ const lineNumber = index + 1;
+ const cleanLine = {
+ ...line,
+ line_code: `${diffFile.file_hash}_${lineNumber}_${lineNumber}`,
+ new_line: lineNumber,
+ old_line: lineNumber,
+ };
+
+ prepareLine(cleanLine); // WARNING: In-Place Mutations!
+
+ if (diffViewType === PARALLEL_DIFF_VIEW_TYPE) {
+ return {
+ left: { ...cleanLine },
+ right: { ...cleanLine },
+ line_code: cleanLine.line_code,
+ };
+ }
+
+ return cleanLine;
+}
+
function prepareDiffFileLines(file) {
const inlineLines = file.highlighted_diff_lines;
const parallelLines = file.parallel_diff_lines;
@@ -437,7 +473,11 @@ export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
// This method will check whether the discussion is still applicable
// to the diff line in question regarding different versions of the MR
export function isDiscussionApplicableToLine({ discussion, diffPosition, latestDiff }) {
- const { line_code, ...diffPositionCopy } = diffPosition;
+ const { line_code, ...dp } = diffPosition;
+ // Removing `line_range` from diffPosition because the backend does not
+ // yet consistently return this property. This check can be removed,
+ // once this is addressed. see https://gitlab.com/gitlab-org/gitlab/-/issues/213010
+ const { line_range: dpNotUsed, ...diffPositionCopy } = dp;
if (discussion.original_position && discussion.position) {
const discussionPositions = [
@@ -446,7 +486,14 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD
...(discussion.positions || []),
];
- return discussionPositions.some(position => isEqual(position, diffPositionCopy));
+ const removeLineRange = position => {
+ const { line_range: pNotUsed, ...positionNoLineRange } = position;
+ return positionNoLineRange;
+ };
+
+ return discussionPositions
+ .map(removeLineRange)
+ .some(position => isEqual(position, diffPositionCopy));
}
// eslint-disable-next-line
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index f839e9acf04..9a0b85bd610 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import Dropzone from 'dropzone';
-import _ from 'underscore';
+import { escape } from 'lodash';
import './behaviors/preview_markdown';
import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table';
import csrf from './lib/utils/csrf';
@@ -16,7 +16,7 @@ Dropzone.autoDiscover = false;
* @param {String|Object} res
*/
function getErrorMessage(res) {
- if (!res || _.isString(res)) {
+ if (!res || typeof res === 'string') {
return res;
}
@@ -233,7 +233,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
};
addFileToForm = path => {
- $(form).append(`<input type="hidden" name="files[]" value="${_.escape(path)}">`);
+ $(form).append(`<input type="hidden" name="files[]" value="${escape(path)}">`);
};
const showSpinner = () => $uploadingProgressContainer.removeClass('hide');
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
index 663d14bcfcb..020ed6dc867 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -1,6 +1,8 @@
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
+import languages from '~/ide/lib/languages';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
+import { registerLanguages } from '~/ide/utils';
import { clearDomElement } from './utils';
export default class Editor {
@@ -17,6 +19,8 @@ export default class Editor {
};
Editor.setupMonacoTheme();
+
+ registerLanguages(...languages);
}
static setupMonacoTheme() {
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index 5c03c008faf..f0723e96ddf 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -3,7 +3,7 @@
* Render modal to confirm rollback/redeploy.
*/
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlModal } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -30,7 +30,7 @@ export default {
: s__('Environments|Rollback environment %{name}?');
return sprintf(title, {
- name: esc(this.environment.name),
+ name: escape(this.environment.name),
});
},
@@ -50,10 +50,10 @@ export default {
},
modalText() {
- const linkStart = `<a class="commit-sha mr-0" href="${esc(this.commitUrl)}">`;
- const commitId = esc(this.commitShortSha);
+ const linkStart = `<a class="commit-sha mr-0" href="${escape(this.commitUrl)}">`;
+ const commitId = escape(this.commitShortSha);
const linkEnd = '</a>';
- const name = esc(this.name);
+ const name = escape(this.name);
const body = this.environment.isLastDeployment
? s__(
'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?',
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index 0a978ab5869..899d7ec8521 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -1,8 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import containerMixin from 'ee_else_ce/environments/mixins/container_mixin';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
-import EnvironmentTable from '../components/environments_table.vue';
+import EnvironmentTable from './environments_table.vue';
export default {
components: {
@@ -10,8 +9,12 @@ export default {
TablePagination,
GlLoadingIcon,
},
- mixins: [containerMixin],
props: {
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: null,
+ },
isLoading: {
type: Boolean,
required: true,
@@ -28,6 +31,31 @@ export default {
type: Boolean,
required: true,
},
+ deployBoardsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
methods: {
onChangePage(page) {
diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index f731dc49a5b..29aab268fd3 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -52,7 +52,7 @@ export default {
footer-primary-button-variant="danger"
@submit="onSubmit"
>
- <template slot="header">
+ <template #header>
<h4 class="modal-title d-flex mw-100">
{{ __('Delete') }}
<span v-gl-tooltip :title="environment.name" class="text-truncate mx-1 flex-fill">
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 335c668474e..fa3d217f148 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -9,7 +9,6 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link
import CommitComponent from '~/vue_shared/components/commit.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
@@ -44,7 +43,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [environmentItemMixin, timeagoMixin],
+ mixins: [timeagoMixin],
props: {
canReadEnvironment: {
@@ -65,6 +64,9 @@ export default {
},
computed: {
+ deployIconName() {
+ return this.model.isDeployBoardVisible ? 'chevron-down' : 'chevron-right';
+ },
/**
* Verifies if `last_deployment` key exists in the current Environment.
* This key is required to render most of the html - this method works has
@@ -210,6 +212,10 @@ export default {
}));
},
+ shouldRenderDeployBoard() {
+ return this.model.hasDeployBoard;
+ },
+
/**
* Builds the string used in the user image alt attribute.
*
@@ -501,6 +507,9 @@ export default {
},
methods: {
+ toggleDeployBoard() {
+ eventHub.$emit('toggleDeployBoard', this.model);
+ },
onClickFolder() {
eventHub.$emit('toggleFolder', this.model);
},
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 0cc6f3df2d7..0a5538237f9 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,6 +1,5 @@
<script>
import { GlDeprecatedButton } from '@gitlab/ui';
-import envrionmentsAppMixin from 'ee_else_ce/environments/mixins/environments_app_mixin';
import Flash from '~/flash';
import { s__ } from '~/locale';
import emptyState from './empty_state.vue';
@@ -22,13 +21,18 @@ export default {
DeleteEnvironmentModal,
},
- mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin],
+ mixins: [CIPaginationMixin, environmentsMixin],
props: {
endpoint: {
type: String,
required: true,
},
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
canCreateEnvironment: {
type: Boolean,
required: true,
@@ -41,6 +45,11 @@ export default {
type: String,
required: true,
},
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
helpPagePath: {
type: String,
required: true,
@@ -50,17 +59,37 @@ export default {
required: false,
default: '',
},
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
created() {
eventHub.$on('toggleFolder', this.toggleFolder);
+ eventHub.$on('toggleDeployBoard', this.toggleDeployBoard);
},
beforeDestroy() {
eventHub.$off('toggleFolder');
+ eventHub.$off('toggleDeployBoard');
},
methods: {
+ toggleDeployBoard(model) {
+ this.store.toggleDeployBoard(model.id);
+ },
toggleFolder(folder) {
this.store.toggleFolder(folder);
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 89e40faa23e..380e16c7b71 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -4,7 +4,6 @@
*/
import { GlLoadingIcon } from '@gitlab/ui';
import { flow, reverse, sortBy } from 'lodash/fp';
-import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
import { s__ } from '~/locale';
import EnvironmentItem from './environment_item.vue';
@@ -16,7 +15,6 @@ export default {
CanaryDeploymentCallout: () =>
import('ee_component/environments/components/canary_deployment_callout.vue'),
},
- mixins: [environmentTableMixin],
props: {
environments: {
type: Array,
@@ -33,6 +31,31 @@ export default {
required: false,
default: false,
},
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
sortedEnvironments() {
@@ -79,9 +102,15 @@ export default {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
},
+ shouldRenderDeployBoard(model) {
+ return model.hasDeployBoard && model.isDeployBoardVisible;
+ },
shouldRenderFolderContent(env) {
return env.isFolder && env.isOpen && env.children && env.children.length > 0;
},
+ shouldShowCanaryCallout(env) {
+ return env.showCanaryCallout && this.showCanaryDeploymentCallout;
+ },
sortEnvironments(environments) {
/*
* The sorting algorithm should sort in the following priorities:
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index d3e8fb7ff08..7448fd584c6 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -60,7 +60,7 @@ export default {
footer-primary-button-variant="danger"
@submit="onSubmit"
>
- <template slot="header">
+ <template #header>
<h4 class="modal-title d-flex mw-100">
Stopping
<span v-gl-tooltip :title="environment.name" class="text-truncate ml-1 mr-1 flex-fill">
diff --git a/app/assets/javascripts/environments/event_hub.js b/app/assets/javascripts/environments/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/environments/event_hub.js
+++ b/app/assets/javascripts/environments/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index c1bfe8d05fe..56896ac4d43 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin';
+import canaryCalloutMixin from '../mixins/canary_callout_mixin';
import environmentsFolderApp from './environments_folder_view.vue';
import { parseBoolean } from '../../lib/utils/common_utils';
import Translate from '../../vue_shared/translate';
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 30b02585692..e1e356a977f 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,5 +1,4 @@
<script>
-import folderMixin from 'ee_else_ce/environments/mixins/environments_folder_view_mixin';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
@@ -11,7 +10,7 @@ export default {
DeleteEnvironmentModal,
},
- mixins: [environmentsMixin, CIPaginationMixin, folderMixin],
+ mixins: [environmentsMixin, CIPaginationMixin],
props: {
endpoint: {
@@ -30,6 +29,31 @@ export default {
type: Boolean,
required: true,
},
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
methods: {
successCallback(resp) {
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 9a68619d4f7..4848cb0f13d 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin';
+import canaryCalloutMixin from './mixins/canary_callout_mixin';
import environmentsComponent from './components/environments_app.vue';
import { parseBoolean } from '../lib/utils/common_utils';
import Translate from '../vue_shared/translate';
diff --git a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
index f6d3d67b777..398576a31cb 100644
--- a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
+++ b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
@@ -1,5 +1,26 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
export default {
+ data() {
+ const data = document.querySelector(this.$options.el).dataset;
+
+ return {
+ canaryDeploymentFeatureId: data.environmentsDataCanaryDeploymentFeatureId,
+ showCanaryDeploymentCallout: parseBoolean(data.environmentsDataShowCanaryDeploymentCallout),
+ userCalloutsPath: data.environmentsDataUserCalloutsPath,
+ lockPromotionSvgPath: data.environmentsDataLockPromotionSvgPath,
+ helpCanaryDeploymentsPath: data.environmentsDataHelpCanaryDeploymentsPath,
+ };
+ },
computed: {
- canaryCalloutProps() {},
+ canaryCalloutProps() {
+ return {
+ canaryDeploymentFeatureId: this.canaryDeploymentFeatureId,
+ showCanaryDeploymentCallout: this.showCanaryDeploymentCallout,
+ userCalloutsPath: this.userCalloutsPath,
+ lockPromotionSvgPath: this.lockPromotionSvgPath,
+ helpCanaryDeploymentsPath: this.helpCanaryDeploymentsPath,
+ };
+ },
},
};
diff --git a/app/assets/javascripts/environments/mixins/container_mixin.js b/app/assets/javascripts/environments/mixins/container_mixin.js
deleted file mode 100644
index abf7d33be91..00000000000
--- a/app/assets/javascripts/environments/mixins/container_mixin.js
+++ /dev/null
@@ -1,34 +0,0 @@
-export default {
- props: {
- canaryDeploymentFeatureId: {
- type: String,
- required: false,
- default: null,
- },
- showCanaryDeploymentCallout: {
- type: Boolean,
- required: false,
- default: false,
- },
- userCalloutsPath: {
- type: String,
- required: false,
- default: null,
- },
- lockPromotionSvgPath: {
- type: String,
- required: false,
- default: null,
- },
- helpCanaryDeploymentsPath: {
- type: String,
- required: false,
- default: null,
- },
- deployBoardsHelpPath: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
diff --git a/app/assets/javascripts/environments/mixins/environment_item_mixin.js b/app/assets/javascripts/environments/mixins/environment_item_mixin.js
deleted file mode 100644
index 2dfed36ec99..00000000000
--- a/app/assets/javascripts/environments/mixins/environment_item_mixin.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export default {
- computed: {
- deployIconName() {
- return '';
- },
- shouldRenderDeployBoard() {
- return false;
- },
- },
- methods: {
- toggleDeployBoard() {},
- },
-};
diff --git a/app/assets/javascripts/environments/mixins/environments_app_mixin.js b/app/assets/javascripts/environments/mixins/environments_app_mixin.js
deleted file mode 100644
index fc805b9235a..00000000000
--- a/app/assets/javascripts/environments/mixins/environments_app_mixin.js
+++ /dev/null
@@ -1,32 +0,0 @@
-export default {
- props: {
- canaryDeploymentFeatureId: {
- type: String,
- required: false,
- default: '',
- },
- showCanaryDeploymentCallout: {
- type: Boolean,
- required: false,
- default: false,
- },
- userCalloutsPath: {
- type: String,
- required: false,
- default: '',
- },
- lockPromotionSvgPath: {
- type: String,
- required: false,
- default: '',
- },
- helpCanaryDeploymentsPath: {
- type: String,
- required: false,
- default: '',
- },
- },
- metods: {
- toggleDeployBoard() {},
- },
-};
diff --git a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js
deleted file mode 100644
index e793a7cadf2..00000000000
--- a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export default {
- props: {
- canaryDeploymentFeatureId: {
- type: String,
- required: false,
- default: '',
- },
- showCanaryDeploymentCallout: {
- type: Boolean,
- required: false,
- default: false,
- },
- userCalloutsPath: {
- type: String,
- required: false,
- default: '',
- },
- lockPromotionSvgPath: {
- type: String,
- required: false,
- default: '',
- },
- helpCanaryDeploymentsPath: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
diff --git a/app/assets/javascripts/environments/mixins/environments_table_mixin.js b/app/assets/javascripts/environments/mixins/environments_table_mixin.js
deleted file mode 100644
index 208f1a7373d..00000000000
--- a/app/assets/javascripts/environments/mixins/environments_table_mixin.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default {
- methods: {
- shouldShowCanaryCallout() {
- return false;
- },
- shouldRenderDeployBoard() {
- return false;
- },
- },
-};
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 6b7c1ff627d..1992e753255 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -58,13 +58,14 @@ export default class EnvironmentsStore {
let filtered = {};
if (env.size > 1) {
- filtered = Object.assign({}, env, {
+ filtered = {
+ ...env,
isFolder: true,
isLoadingFolderContent: oldEnvironmentState.isLoading || false,
folderName: env.name,
isOpen: oldEnvironmentState.isOpen || false,
children: oldEnvironmentState.children || [],
- });
+ };
}
if (env.latest) {
@@ -133,6 +134,17 @@ export default class EnvironmentsStore {
}
/**
+ * Toggles deploy board visibility for the provided environment ID.
+ * Currently only works on EE.
+ *
+ * @param {Object} environment
+ * @return {Array}
+ */
+ toggleDeployBoard() {
+ return this.state.environments;
+ }
+
+ /**
* Toggles folder open property for the given folder.
*
* @param {Object} folder
@@ -155,7 +167,7 @@ export default class EnvironmentsStore {
let updated = env;
if (env.latest) {
- updated = Object.assign({}, env, env.latest);
+ updated = { ...env, ...env.latest };
delete updated.latest;
} else {
updated = env;
@@ -181,7 +193,7 @@ export default class EnvironmentsStore {
const { environments } = this.state;
const updatedEnvironments = environments.map(env => {
- const updateEnv = Object.assign({}, env);
+ const updateEnv = { ...env };
if (env.id === environment.id) {
updateEnv[prop] = newValue;
}
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 3d700f4d216..45432e8ebd8 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -393,9 +393,9 @@ export default {
<template #description>
<div>
<span>{{ __('Monitor your errors by integrating with Sentry.') }}</span>
- <a href="/help/user/project/operations/error_tracking.html">
- {{ __('More information') }}
- </a>
+ <gl-link target="_blank" href="/help/user/project/operations/error_tracking.html">{{
+ __('More information')
+ }}</gl-link>
</div>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index 8db0b1c5da0..f7f2c450be1 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -62,7 +62,7 @@ export default {
? sprintf(
__(`%{spanStart}in%{spanEnd} %{errorFn}`),
{
- errorFn: `<strong>${esc(this.errorFn)}</strong>`,
+ errorFn: `<strong>${escape(this.errorFn)}</strong>`,
spanStart: `<span class="text-tertiary">`,
spanEnd: `</span>`,
},
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js
index 8f6f404ef8a..05554b2b566 100644
--- a/app/assets/javascripts/error_tracking/store/actions.js
+++ b/app/assets/javascripts/error_tracking/store/actions.js
@@ -1,4 +1,4 @@
-import service from './../services';
+import service from '../services';
import * as types from './mutation_types';
import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index d7264e96b13..7e7a2588951 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -4,3 +4,8 @@ export const DROPDOWN_TYPE = {
hint: 'hint',
operator: 'operator',
};
+
+export const FILTER_TYPE = {
+ none: 'none',
+ any: 'any',
+};
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
index 0c8c8140ee9..1bbd33b6258 100644
--- a/app/assets/javascripts/filtered_search/dropdown_operator.js
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -47,13 +47,17 @@ export default class DropdownOperator extends FilteredSearchDropdown {
title: '=',
help: __('is'),
},
- {
+ ];
+
+ if (gon.features?.notIssuableQueries) {
+ dropdownData.push({
tag: 'not-equal',
type: 'string',
title: '!=',
help: __('is not'),
- },
- ];
+ });
+ }
+
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
super.renderContent(forceShowList);
diff --git a/app/assets/javascripts/filtered_search/event_hub.js b/app/assets/javascripts/filtered_search/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/filtered_search/event_hub.js
+++ b/app/assets/javascripts/filtered_search/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index 2b6e1f25dc6..f7ce2ea01e0 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,6 +1,7 @@
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import { FILTER_TYPE } from './constants';
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
@@ -74,6 +75,9 @@ export default class FilteredSearchDropdown {
renderContent(forceShowList = false) {
const currentHook = this.getCurrentHook();
+
+ FilteredSearchDropdown.hideDropdownItemsforNotOperator(currentHook);
+
if (forceShowList && currentHook && currentHook.list.hidden) {
currentHook.list.show();
}
@@ -138,4 +142,41 @@ export default class FilteredSearchDropdown {
hook.list.render(results);
}
}
+
+ /**
+ * Hide None & Any options from the current dropdown.
+ * Hiding happens only for NOT operator.
+ */
+ static hideDropdownItemsforNotOperator(currentHook) {
+ const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator();
+
+ if (lastOperator === '!=') {
+ const { list: dropdownEl } = currentHook.list;
+
+ let shouldHideDivider = true;
+
+ // Iterate over all the static dropdown values,
+ // then hide `None` and `Any` items.
+ Array.from(dropdownEl.querySelectorAll('li[data-value]')).forEach(itemEl => {
+ const {
+ dataset: { value },
+ } = itemEl;
+
+ if (value.toLowerCase() === FILTER_TYPE.none || value.toLowerCase() === FILTER_TYPE.any) {
+ itemEl.classList.add('hidden');
+ } else {
+ // If we encountered any element other than None/Any, then
+ // we shouldn't hide the divider
+ shouldHideDivider = false;
+ }
+ });
+
+ if (shouldHideDivider) {
+ const divider = dropdownEl.querySelector('li.divider');
+ if (divider) {
+ divider.classList.add('hidden');
+ }
+ }
+ }
+ }
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index d051b60814e..161a65c511d 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -120,7 +120,7 @@ export default class FilteredSearchDropdownManager {
filter: key,
};
const extraArguments = mappingKey.extraArguments || {};
- const glArguments = Object.assign({}, defaultArguments, extraArguments);
+ const glArguments = { ...defaultArguments, ...extraArguments };
// Passing glArguments to `new glClass(<arguments>)`
mappingKey.reference = new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 724f80f8866..55a0e91b0f3 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -31,6 +31,7 @@ export default class FilteredSearchManager {
isGroupDecendent = false,
filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters',
+ placeholder = __('Search or filter results...'),
}) {
this.isGroup = isGroup;
this.isGroupAncestor = isGroupAncestor;
@@ -45,6 +46,7 @@ export default class FilteredSearchManager {
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = filteredSearchTokenKeys;
this.stateFiltersSelector = stateFiltersSelector;
+ this.placeholder = placeholder;
const { multipleAssignees } = this.filteredSearchInput.dataset;
if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
@@ -395,11 +397,10 @@ export default class FilteredSearchManager {
handleInputPlaceholder() {
const query = DropdownUtils.getSearchQuery();
- const placeholder = __('Search or filter results...');
const currentPlaceholder = this.filteredSearchInput.placeholder;
- if (query.length === 0 && currentPlaceholder !== placeholder) {
- this.filteredSearchInput.placeholder = placeholder;
+ if (query.length === 0 && currentPlaceholder !== this.placeholder) {
+ this.filteredSearchInput.placeholder = this.placeholder;
} else if (query.length > 0 && currentPlaceholder !== '') {
this.filteredSearchInput.placeholder = '';
}
@@ -710,13 +711,17 @@ export default class FilteredSearchManager {
}
}
- search(state = null) {
- const paths = [];
+ getSearchTokens() {
const searchQuery = DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const tokenKeys = this.filteredSearchTokenKeys.getKeys();
- const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys);
+ return this.tokenizer.processTokens(searchQuery, tokenKeys);
+ }
+
+ search(state = null) {
+ const paths = [];
+ const { tokens, searchToken } = this.getSearchTokens();
const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
index b3eb0475d6f..cdbc9ec84bd 100644
--- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
+++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
@@ -2,14 +2,12 @@ import { uniq } from 'lodash';
class RecentSearchesStore {
constructor(initialState = {}, allowedKeys) {
- this.state = Object.assign(
- {
- isLocalStorageAvailable: true,
- recentSearches: [],
- allowedKeys,
- },
- initialState,
- );
+ this.state = {
+ isLocalStorageAvailable: true,
+ recentSearches: [],
+ allowedKeys,
+ ...initialState,
+ };
}
addRecentSearch(newSearch) {
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index b8f4cd8a1e1..02caf0851af 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -1,4 +1,4 @@
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { USER_TOKEN_TYPES } from 'ee_else_ce/filtered_search/constants';
import FilteredSearchContainer from '~/filtered_search/container';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
@@ -48,7 +48,7 @@ export default class VisualTokenValue {
tokenValueContainer.dataset.originalValue = tokenValue;
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="">
- ${esc(user.name)}
+ ${escape(user.name)}
`;
/* eslint-enable no-param-reassign */
})
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 4d62ec6e385..74c00d21535 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { escape } from 'lodash';
import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
@@ -39,14 +39,14 @@ const createAction = config => `
class="flash-action"
${config.href ? '' : 'role="button"'}
>
- ${_.escape(config.title)}
+ ${escape(config.title)}
</a>
`;
const createFlashEl = (message, type) => `
<div class="flash-${type}">
<div class="flash-text">
- ${_.escape(message)}
+ ${escape(message)}
<div class="close-icon-wrapper js-close-icon">
${spriteIcon('close', 'close-icon')}
</div>
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index 1f1776a5487..61080fb5487 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -3,7 +3,7 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import AccessorUtilities from '~/lib/utils/accessor';
import eventHub from '../event_hub';
-import store from '../store/';
+import store from '../store';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils';
import FrequentItemsSearchInput from './frequent_items_search_input.vue';
diff --git a/app/assets/javascripts/frequent_items/event_hub.js b/app/assets/javascripts/frequent_items/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/frequent_items/event_hub.js
+++ b/app/assets/javascripts/frequent_items/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b6deedfa5e4..f3ce30c942f 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import '@gitlab/at.js';
-import _ from 'underscore';
+import { escape, template } from 'lodash';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
@@ -11,7 +11,7 @@ function sanitize(str) {
}
export function membersBeforeSave(members) {
- return _.map(members, member => {
+ return members.map(member => {
const GROUP_TYPE = 'Group';
let title = '';
@@ -122,7 +122,7 @@ class GfmAutoComplete {
cssClasses.push('has-warning');
}
- return _.template(tpl)({
+ return template(tpl)({
...value,
className: cssClasses.join(' '),
});
@@ -137,7 +137,7 @@ class GfmAutoComplete {
tpl += '<%- referencePrefix %>';
}
}
- return _.template(tpl)({ referencePrefix });
+ return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
},
suffix: '',
callbacks: {
@@ -692,14 +692,14 @@ GfmAutoComplete.Emoji = {
// Team Members
GfmAutoComplete.Members = {
templateFunction({ avatarTag, username, title, icon }) {
- return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`;
+ return `<li>${avatarTag} ${username} <small>${escape(title)}</small> ${icon}</li>`;
},
};
GfmAutoComplete.Labels = {
templateFunction(color, title) {
- return `<li><span class="dropdown-label-box" style="background: ${_.escape(
+ return `<li><span class="dropdown-label-box" style="background: ${escape(
color,
- )}"></span> ${_.escape(title)}</li>`;
+ )}"></span> ${escape(title)}</li>`;
},
};
// Issues, MergeRequests and Snippets
@@ -709,13 +709,13 @@ GfmAutoComplete.Issues = {
return value.reference || '${atwho-at}${id}';
},
templateFunction({ id, title, reference }) {
- return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`;
+ return `<li><small>${reference || id}</small> ${escape(title)}</li>`;
},
};
// Milestones
GfmAutoComplete.Milestones = {
templateFunction(title) {
- return `<li>${_.escape(title)}</li>`;
+ return `<li>${escape(title)}</li>`;
},
};
GfmAutoComplete.Loading = {
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 918276ce329..be4b4b5f87d 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file, one-var, consistent-return */
import $ from 'jquery';
-import _ from 'underscore';
+import { escape } from 'lodash';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from './lib/utils/axios_utils';
import { visitUrl } from './lib/utils/url_utility';
@@ -145,7 +145,7 @@ class GitLabDropdownFilter {
// { prop: 'foo' },
// { prop: 'baz' }
// ]
- if (_.isArray(data)) {
+ if (Array.isArray(data)) {
results = fuzzaldrinPlus.filter(data, searchText, {
key: this.options.keys,
});
@@ -261,14 +261,14 @@ class GitLabDropdown {
// If no input is passed create a default one
self = this;
// If selector was passed
- if (_.isString(this.filterInput)) {
+ if (typeof this.filterInput === 'string') {
this.filterInput = this.getElement(this.filterInput);
}
const searchFields = this.options.search ? this.options.search.fields : [];
if (this.options.data) {
// If we provided data
// data could be an array of objects or a group of arrays
- if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) {
+ if (typeof this.options.data === 'object' && !(this.options.data instanceof Function)) {
this.fullData = this.options.data;
currentIndex = -1;
this.parseData(this.options.data);
@@ -595,13 +595,14 @@ class GitLabDropdown {
return renderItem({
instance: this,
- options: Object.assign({}, this.options, {
+ options: {
+ ...this.options,
icon: this.icon,
highlight: this.highlight,
highlightText: text => this.highlightTextMatches(text, this.filterInput.val()),
highlightTemplate: this.highlightTemplate.bind(this),
parent,
- }),
+ },
data,
group,
index,
@@ -610,7 +611,7 @@ class GitLabDropdown {
// eslint-disable-next-line class-methods-use-this
highlightTemplate(text, template) {
- return `"<b>${_.escape(text)}</b>" ${template}`;
+ return `"<b>${escape(text)}</b>" ${template}`;
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 1811a942beb..0b7735a7db9 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -8,7 +8,7 @@ export default class GLForm {
constructor(form, enableGFM = {}) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
- this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM);
+ this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM };
// Disable autocomplete for keywords which do not have dataSources available
const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
Object.keys(this.enableGFM).forEach(item => {
@@ -29,6 +29,10 @@ export default class GLForm {
if (this.autoComplete) {
this.autoComplete.destroy();
}
+ if (this.formDropzone) {
+ this.formDropzone.destroy();
+ }
+
this.form.data('glForm', null);
}
@@ -45,7 +49,7 @@ export default class GLForm {
);
this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM);
- dropzoneInput(this.form, { parallelUploads: 1 });
+ this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 });
autosize(this.textarea);
}
// form and textarea event listeners
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index ce6591e85cf..0b401f4d732 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -111,8 +111,8 @@ export default {
const filterGroupsBy = getParameterByName('filter') || null;
this.isLoading = true;
- // eslint-disable-next-line promise/catch-or-return
- this.fetchGroups({
+
+ return this.fetchGroups({
page,
filterGroupsBy,
sortBy,
@@ -126,8 +126,7 @@ export default {
fetchPage(page, filterGroupsBy, sortBy, archived) {
this.isLoading = true;
- // eslint-disable-next-line promise/catch-or-return
- this.fetchGroups({
+ return this.fetchGroups({
page,
filterGroupsBy,
sortBy,
diff --git a/app/assets/javascripts/groups/event_hub.js b/app/assets/javascripts/groups/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/groups/event_hub.js
+++ b/app/assets/javascripts/groups/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js
index 012177479c6..bb2aea3ea76 100644
--- a/app/assets/javascripts/groups/new_group_child.js
+++ b/app/assets/javascripts/groups/new_group_child.js
@@ -2,7 +2,7 @@ import { visitUrl } from '../lib/utils/url_utility';
import DropLab from '../droplab/drop_lab';
import ISetter from '../droplab/plugins/input_setter';
-const InputSetter = Object.assign({}, ISetter);
+const InputSetter = { ...ISetter };
const NEW_PROJECT = 'new-project';
const NEW_SUBGROUP = 'new-subgroup';
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 1678991b1ea..67b068f1c6b 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -74,20 +74,27 @@ function initStatusTriggers() {
}
}
+function trackShowUserDropdownLink(trackEvent, elToTrack, el) {
+ const { trackLabel, trackProperty } = elToTrack.dataset;
+
+ $(el).on('shown.bs.dropdown', () => {
+ Tracking.event(document.body.dataset.page, trackEvent, {
+ label: trackLabel,
+ property: trackProperty,
+ });
+ });
+}
export function initNavUserDropdownTracking() {
const el = document.querySelector('.js-nav-user-dropdown');
const buyEl = document.querySelector('.js-buy-ci-minutes-link');
+ const upgradeEl = document.querySelector('.js-upgrade-plan-link');
if (el && buyEl) {
- const { trackLabel, trackProperty } = buyEl.dataset;
- const trackEvent = 'show_buy_ci_minutes';
+ trackShowUserDropdownLink('show_buy_ci_minutes', buyEl, el);
+ }
- $(el).on('shown.bs.dropdown', () => {
- Tracking.event(undefined, trackEvent, {
- label: trackLabel,
- property: trackProperty,
- });
- });
+ if (el && upgradeEl) {
+ trackShowUserDropdownLink('show_upgrade_link', upgradeEl, el);
}
}
diff --git a/app/assets/javascripts/helpers/avatar_helper.js b/app/assets/javascripts/helpers/avatar_helper.js
index 7891b44dd27..4f04a1b8c16 100644
--- a/app/assets/javascripts/helpers/avatar_helper.js
+++ b/app/assets/javascripts/helpers/avatar_helper.js
@@ -1,11 +1,14 @@
import { escape } from 'lodash';
import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export const DEFAULT_SIZE_CLASS = 's40';
export const IDENTICON_BG_COUNT = 7;
export function getIdenticonBackgroundClass(entityId) {
- const type = (entityId % IDENTICON_BG_COUNT) + 1;
+ // If a GraphQL string id is passed in, convert it to the entity number
+ const id = typeof entityId === 'string' ? getIdFromGraphQLId(entityId) : entityId;
+ const type = (id % IDENTICON_BG_COUNT) + 1;
return `bg${type}`;
}
diff --git a/app/assets/javascripts/helpers/event_hub_factory.js b/app/assets/javascripts/helpers/event_hub_factory.js
new file mode 100644
index 00000000000..4d7f7550a94
--- /dev/null
+++ b/app/assets/javascripts/helpers/event_hub_factory.js
@@ -0,0 +1,20 @@
+import mitt from 'mitt';
+
+export default () => {
+ const emitter = mitt();
+
+ emitter.once = (event, handler) => {
+ const wrappedHandler = evt => {
+ handler(evt);
+ emitter.off(event, wrappedHandler);
+ };
+ emitter.on(event, wrappedHandler);
+ };
+
+ emitter.$on = emitter.on;
+ emitter.$once = emitter.once;
+ emitter.$off = emitter.off;
+ emitter.$emit = emitter.emit;
+
+ return emitter;
+};
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index 6a8ea506d1b..6c563776533 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { mapState, mapGetters, createNamespacedHelpers } from 'vuex';
import { sprintf, s__ } from '~/locale';
import consts from '../../stores/modules/commit/constants';
@@ -22,7 +22,7 @@ export default {
commitToCurrentBranchText() {
return sprintf(
s__('IDE|Commit to %{branchName} branch'),
- { branchName: `<strong class="monospace">${esc(this.currentBranchId)}</strong>` },
+ { branchName: `<strong class="monospace">${escape(this.currentBranchId)}</strong>` },
false,
);
},
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 e618fb3daae..24499fb9f6d 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -24,8 +24,8 @@ export default {
discardModalTitle() {
return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path });
},
- isStaged() {
- return !this.activeFile.changed && this.activeFile.staged;
+ canDiscard() {
+ return this.activeFile.changed || this.activeFile.staged;
},
},
methods: {
@@ -53,7 +53,7 @@ export default {
<changed-file-icon :file="activeFile" :is-centered="false" />
<div class="ml-auto">
<button
- v-if="!isStaged"
+ v-if="canDiscard"
ref="discardButton"
type="button"
class="btn btn-remove btn-inverted append-right-8"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index f6ca728defc..4cbd33e6ed6 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -1,11 +1,13 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { n__, __ } from '~/locale';
+import { GlModal } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
import SuccessMessage from './success_message.vue';
import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
+import consts from '../../stores/modules/commit/constants';
export default {
components: {
@@ -13,6 +15,7 @@ export default {
LoadingButton,
CommitMessageField,
SuccessMessage,
+ GlModal,
},
data() {
return {
@@ -54,7 +57,20 @@ export default {
},
methods: {
...mapActions(['updateActivityBarView']),
- ...mapActions('commit', ['updateCommitMessage', 'discardDraft', 'commitChanges']),
+ ...mapActions('commit', [
+ 'updateCommitMessage',
+ 'discardDraft',
+ 'commitChanges',
+ 'updateCommitAction',
+ ]),
+ commit() {
+ return this.commitChanges().catch(() => {
+ this.$refs.createBranchModal.show();
+ });
+ },
+ forceCreateNewBranch() {
+ return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit());
+ },
toggleIsCompact() {
if (this.currentViewIsCommitView) {
this.isCompact = !this.isCompact;
@@ -119,13 +135,13 @@ export default {
</button>
<p class="text-center bold">{{ overviewText }}</p>
</div>
- <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commitChanges">
+ <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commit">
<transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition>
<commit-message-field
:text="commitMessage"
:placeholder="preBuiltCommitMessage"
@input="updateCommitMessage"
- @submit="commitChanges"
+ @submit="commit"
/>
<div class="clearfix prepend-top-15">
<actions />
@@ -133,7 +149,7 @@ export default {
:loading="submitCommitLoading"
:label="commitButtonText"
container-class="btn btn-success btn-sm float-left qa-commit-button"
- @click="commitChanges"
+ @click="commit"
/>
<button
v-if="!discardDraftButtonDisabled"
@@ -152,6 +168,19 @@ export default {
{{ __('Collapse') }}
</button>
</div>
+ <gl-modal
+ ref="createBranchModal"
+ modal-id="ide-create-branch-modal"
+ :ok-title="__('Create new branch')"
+ :title="__('Branch has changed')"
+ ok-variant="success"
+ @ok="forceCreateNewBranch"
+ >
+ {{
+ __(`This branch has changed since you started editing.
+ Would you like to create a new branch?`)
+ }}
+ </gl-modal>
</form>
</transition>
</div>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index a15e22d4742..e6a1a1ba73c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -1,9 +1,8 @@
<script>
-import $ from 'jquery';
import { mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
+import { GlModal } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
@@ -11,7 +10,7 @@ export default {
components: {
Icon,
ListItem,
- GlModal: DeprecatedModal2,
+ GlModal,
},
directives: {
tooltip,
@@ -58,7 +57,7 @@ export default {
methods: {
...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']),
openDiscardModal() {
- $('#discard-all-changes').modal('show');
+ this.$refs.discardAllModal.show();
},
unstageAndDiscardAllChanges() {
this.unstageAllChanges();
@@ -114,11 +113,12 @@ export default {
</p>
<gl-modal
v-if="!stagedList"
- id="discard-all-changes"
- :footer-primary-button-text="__('Discard all changes')"
- :header-title-text="__('Discard all changes?')"
- footer-primary-button-variant="danger"
- @submit="unstageAndDiscardAllChanges"
+ ref="discardAllModal"
+ ok-variant="danger"
+ modal-id="discard-all-changes"
+ :ok-title="__('Discard all changes')"
+ :title="__('Discard all changes?')"
+ @ok="unstageAndDiscardAllChanges"
>
{{ $options.discardModalText }}
</gl-modal>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 7ebcacc530f..36c8b18e205 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -3,6 +3,7 @@ import Vue from 'vue';
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
+import { modalTypes } from '../constants';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
@@ -12,6 +13,7 @@ import RepoEditor from './repo_editor.vue';
import RightPane from './panes/right.vue';
import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -26,6 +28,7 @@ export default {
GlDeprecatedButton,
GlLoadingIcon,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
rightPaneComponent: {
type: Vue.Component,
@@ -52,13 +55,20 @@ export default {
'allBlobs',
'emptyRepo',
'currentTree',
+ 'editorTheme',
]),
+ themeName() {
+ return window.gon?.user_color_scheme;
+ },
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
+
+ if (this.themeName)
+ document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
},
methods: {
- ...mapActions(['toggleFileFinder', 'openNewEntryModal']),
+ ...mapActions(['toggleFileFinder']),
onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?');
@@ -72,12 +82,18 @@ export default {
openFile(file) {
this.$router.push(`/project${file.url}`);
},
+ createNewFile() {
+ this.$refs.newModal.open(modalTypes.blob);
+ },
},
};
</script>
<template>
- <article class="ide position-relative d-flex flex-column align-items-stretch">
+ <article
+ class="ide position-relative d-flex flex-column align-items-stretch"
+ :class="{ [`theme-${themeName}`]: themeName }"
+ >
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
<find-file
@@ -125,7 +141,7 @@ export default {
variant="success"
:title="__('New file')"
:aria-label="__('New file')"
- @click="openNewEntryModal({ type: 'blob' })"
+ @click="createNewFile()"
>
{{ __('New file') }}
</gl-deprecated-button>
@@ -147,6 +163,6 @@ export default {
<component :is="rightPaneComponent" v-if="currentProjectId" />
</div>
<ide-status-bar />
- <new-modal />
+ <new-modal ref="newModal" />
</article>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue
index 901b8892e80..62dbfea2088 100644
--- a/app/assets/javascripts/ide/components/ide_review.vue
+++ b/app/assets/javascripts/ide/components/ide_review.vue
@@ -43,7 +43,7 @@ export default {
<template>
<ide-tree-list :viewer-type="viewer" header-class="ide-review-header">
- <template slot="header">
+ <template #header>
<div class="ide-review-button-holder">
{{ __('Review') }}
<editor-mode-dropdown
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 40cd2178e09..7cb31df85ce 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -37,7 +37,7 @@ export default {
</script>
<template>
- <resizable-panel :collapsible="false" :initial-width="340" side="left" class="flex-column">
+ <resizable-panel :initial-width="340" side="left" class="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_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 598f3a1dac6..647f4d4be85 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -1,14 +1,17 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
+import { modalTypes } from '../constants';
import IdeTreeList from './ide_tree_list.vue';
import Upload from './new_dropdown/upload.vue';
import NewEntryButton from './new_dropdown/button.vue';
+import NewModal from './new_dropdown/modal.vue';
export default {
components: {
Upload,
IdeTreeList,
NewEntryButton,
+ NewModal,
},
computed: {
...mapState(['currentBranchId']),
@@ -26,14 +29,20 @@ export default {
}
},
methods: {
- ...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry', 'resetOpenFiles']),
+ ...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']),
+ createNewFile() {
+ this.$refs.newModal.open(modalTypes.blob);
+ },
+ createNewFolder() {
+ this.$refs.newModal.open(modalTypes.tree);
+ },
},
};
</script>
<template>
<ide-tree-list viewer-type="editor">
- <template slot="header">
+ <template #header>
{{ __('Edit') }}
<div class="ide-tree-actions ml-auto d-flex">
<new-entry-button
@@ -41,7 +50,7 @@ export default {
:show-label="false"
class="d-flex border-0 p-0 mr-3 qa-new-file"
icon="doc-new"
- @click="openNewEntryModal({ type: 'blob' })"
+ @click="createNewFile()"
/>
<upload
:show-label="false"
@@ -54,9 +63,10 @@ export default {
:show-label="false"
class="d-flex border-0 p-0"
icon="folder-new"
- @click="openNewEntryModal({ type: 'tree' })"
+ @click="createNewFolder()"
/>
</div>
+ <new-modal ref="newModal" />
</template>
</ide-tree-list>
</template>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index 504391ffdc7..975d54c7a4e 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -79,7 +79,7 @@ export default {
<icon name="chevron-left" /> {{ __('View jobs') }}
</button>
</header>
- <div class="top-bar d-flex border-left-0">
+ <div class="top-bar d-flex border-left-0 mr-3">
<job-description :job="detailJob" />
<div class="controllers ml-auto">
<a
@@ -97,7 +97,7 @@ export default {
<scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" />
</div>
</div>
- <pre ref="buildTrace" class="build-trace mb-0 h-100" @scroll="scrollBuildLog">
+ <pre ref="buildTrace" class="build-trace mb-0 h-100 mr-3" @scroll="scrollBuildLog">
<code
v-show="!detailJob.isLoading"
class="bash"
diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue
index 195504a6861..70a92b8d3ab 100644
--- a/app/assets/javascripts/ide/components/nav_form.vue
+++ b/app/assets/javascripts/ide/components/nav_form.vue
@@ -25,13 +25,13 @@ export default {
<div class="ide-nav-form p-0">
<tabs v-if="showMergeRequests" stop-propagation>
<tab active>
- <template slot="title">
+ <template #title>
{{ __('Branches') }}
</template>
<branches-search-list />
</tab>
<tab>
- <template slot="title">
+ <template #title>
{{ __('Merge Requests') }}
</template>
<merge-request-search-list />
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 9961c0df52e..2798ede5341 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -4,12 +4,14 @@ import icon from '~/vue_shared/components/icon.vue';
import upload from './upload.vue';
import ItemButton from './button.vue';
import { modalTypes } from '../../constants';
+import NewModal from './modal.vue';
export default {
components: {
icon,
upload,
ItemButton,
+ NewModal,
},
props: {
type: {
@@ -37,9 +39,9 @@ export default {
},
},
methods: {
- ...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']),
+ ...mapActions(['createTempEntry', 'deleteEntry']),
createNewItem(type) {
- this.openNewEntryModal({ type, path: this.path });
+ this.$refs.newModal.open(type, this.path);
this.$emit('toggle', false);
},
openDropdown() {
@@ -109,5 +111,6 @@ export default {
</li>
</ul>
</div>
+ <new-modal ref="newModal" />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index bf3d736ddf3..4766a2fe6ae 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,61 +1,49 @@
<script>
-import $ from 'jquery';
import { mapActions, mapState, mapGetters } from 'vuex';
import flash from '~/flash';
import { __, sprintf, s__ } from '~/locale';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
+import { GlModal } from '@gitlab/ui';
import { modalTypes } from '../../constants';
+import { trimPathComponents } from '../../utils';
export default {
components: {
- GlModal: DeprecatedModal2,
+ GlModal,
},
data() {
return {
- name: '',
+ entryName: '',
+ modalType: modalTypes.blob,
+ path: '',
};
},
computed: {
- ...mapState(['entries', 'entryModal']),
+ ...mapState(['entries']),
...mapGetters('fileTemplates', ['templateTypes']),
- entryName: {
- get() {
- const entryPath = this.entryModal.entry.path;
-
- if (this.entryModal.type === modalTypes.rename) {
- return this.name || entryPath;
- }
-
- return this.name || (entryPath ? `${entryPath}/` : '');
- },
- set(val) {
- this.name = val.trim();
- },
- },
modalTitle() {
- if (this.entryModal.type === modalTypes.tree) {
+ const entry = this.entries[this.path];
+
+ if (this.modalType === modalTypes.tree) {
return __('Create new directory');
- } else if (this.entryModal.type === modalTypes.rename) {
- return this.entryModal.entry.type === modalTypes.tree
- ? __('Rename folder')
- : __('Rename file');
+ } else if (this.modalType === modalTypes.rename) {
+ return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
}
return __('Create new file');
},
buttonLabel() {
- if (this.entryModal.type === modalTypes.tree) {
+ const entry = this.entries[this.path];
+
+ if (this.modalType === modalTypes.tree) {
return __('Create directory');
- } else if (this.entryModal.type === modalTypes.rename) {
- return this.entryModal.entry.type === modalTypes.tree
- ? __('Rename folder')
- : __('Rename file');
+ } else if (this.modalType === modalTypes.rename) {
+ return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
}
return __('Create file');
},
isCreatingNewFile() {
- return this.entryModal.type === 'blob';
+ return this.modalType === modalTypes.blob;
},
placeholder() {
return this.isCreatingNewFile ? 'dir/file_name' : 'dir/';
@@ -64,7 +52,9 @@ export default {
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
submitForm() {
- if (this.entryModal.type === modalTypes.rename) {
+ this.entryName = trimPathComponents(this.entryName);
+
+ if (this.modalType === modalTypes.rename) {
if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
flash(
sprintf(s__('The name "%{name}" is already taken in this directory.'), {
@@ -78,32 +68,32 @@ export default {
);
} else {
let parentPath = this.entryName.split('/');
- const entryName = parentPath.pop();
+ const name = parentPath.pop();
parentPath = parentPath.join('/');
this.renameEntry({
- path: this.entryModal.entry.path,
- name: entryName,
+ path: this.path,
+ name,
parentPath,
});
}
} else {
this.createTempEntry({
- name: this.name,
- type: this.entryModal.type,
+ name: this.entryName,
+ type: this.modalType,
});
}
},
createFromTemplate(template) {
this.createTempEntry({
name: template.name,
- type: this.entryModal.type,
+ type: this.modalType,
});
- $('#ide-new-entry').modal('toggle');
+ this.$refs.modal.toggle();
},
focusInput() {
- const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null;
+ const name = this.entries[this.entryName]?.name;
const inputValue = this.$refs.fieldName.value;
this.$refs.fieldName.focus();
@@ -112,8 +102,28 @@ export default {
this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length);
}
},
- closedModal() {
- this.name = '';
+ resetData() {
+ this.entryName = '';
+ this.path = '';
+ this.modalType = modalTypes.blob;
+ },
+ open(type = modalTypes.blob, path = '') {
+ this.modalType = type;
+ this.path = path;
+
+ if (this.modalType === modalTypes.rename) {
+ this.entryName = path;
+ } else {
+ this.entryName = path ? `${path}/` : '';
+ }
+
+ this.$refs.modal.show();
+
+ // wait for modal to show first
+ this.$nextTick(() => this.focusInput());
+ },
+ close() {
+ this.$refs.modal.hide();
},
},
};
@@ -121,22 +131,22 @@ export default {
<template>
<gl-modal
- id="ide-new-entry"
- class="qa-new-file-modal"
- :header-title-text="modalTitle"
- :footer-primary-button-text="buttonLabel"
- footer-primary-button-variant="success"
- modal-size="lg"
- @submit="submitForm"
- @open="focusInput"
- @closed="closedModal"
+ ref="modal"
+ modal-id="ide-new-entry"
+ modal-class="qa-new-file-modal"
+ :title="modalTitle"
+ :ok-title="buttonLabel"
+ ok-variant="success"
+ size="lg"
+ @ok="submitForm"
+ @hide="resetData"
>
<div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
<div class="col-sm-10">
<input
ref="fieldName"
- v-model="entryName"
+ v-model.trim="entryName"
type="text"
class="form-control qa-full-file-path"
:placeholder="placeholder"
diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
index 8adf0122fb4..91e80be7d18 100644
--- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
+++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
@@ -103,7 +103,6 @@ export default {
>
<resizable-panel
v-show="isOpen"
- :collapsible="false"
:initial-width="width"
:min-size="width"
:class="`ide-${side}-sidebar-${currentView}`"
@@ -116,7 +115,7 @@ export default {
v-for="tabView in aliveTabViews"
v-show="isActiveView(tabView.name)"
:key="tabView.name"
- class="flex-fill js-tab-view"
+ class="flex-fill gl-overflow-hidden js-tab-view"
>
<component :is="tabView.component" />
</div>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index d3e5add2e83..cf6d01b6351 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import { sprintf, __ } from '../../../locale';
import Icon from '../../../vue_shared/components/icon.vue';
@@ -10,6 +10,8 @@ import Tab from '../../../vue_shared/components/tabs/tab.vue';
import EmptyState from '../../../pipelines/components/empty_state.vue';
import JobsList from '../jobs/list.vue';
+import IDEServices from '~/ide/services';
+
export default {
components: {
Icon,
@@ -35,7 +37,7 @@ export default {
return sprintf(
__('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'),
{
- linkStart: `<a href="${esc(this.currentProject.web_url)}/-/ci/lint">`,
+ linkStart: `<a href="${escape(this.currentProject.web_url)}/-/ci/lint">`,
linkEnd: '</a>',
},
false,
@@ -47,6 +49,7 @@ export default {
},
created() {
this.fetchLatestPipeline();
+ IDEServices.pingUsage(this.currentProject.path_with_namespace);
},
methods: {
...mapActions('pipelines', ['fetchLatestPipeline']),
@@ -85,14 +88,14 @@ export default {
</div>
<tabs v-else class="ide-pipeline-list">
<tab :active="!pipelineFailed">
- <template slot="title">
+ <template #title>
{{ __('Jobs') }}
<span v-if="jobsCount" class="badge badge-pill"> {{ jobsCount }} </span>
</template>
<jobs-list :loading="isLoadingJobs" :stages="stages" />
</tab>
<tab :active="pipelineFailed">
- <template slot="title">
+ <template #title>
{{ __('Failed Jobs') }}
<span v-if="failedJobsCount" class="badge badge-pill"> {{ failedJobsCount }} </span>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 2e7e55a61c5..530fba49df2 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -1,15 +1,12 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
-import consts from '../stores/modules/commit/constants';
import { leftSidebarViews, stageKeys } from '../constants';
export default {
components: {
- DeprecatedModal,
CommitFilesList,
EmptyState,
},
@@ -17,13 +14,7 @@ export default {
tooltip,
},
computed: {
- ...mapState([
- 'changedFiles',
- 'stagedFiles',
- 'rightPanelCollapsed',
- 'lastCommitMsg',
- 'unusedSeal',
- ]),
+ ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg', 'unusedSeal']),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']),
...mapGetters('commit', ['discardDraftButtonDisabled']),
@@ -59,10 +50,6 @@ export default {
},
methods: {
...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']),
- ...mapActions('commit', ['commitChanges', 'updateCommitAction']),
- forceCreateNewBranch() {
- return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
- },
},
stageKeys,
};
@@ -70,20 +57,6 @@ export default {
<template>
<div class="multi-file-commit-panel-section">
- <deprecated-modal
- id="ide-create-branch-modal"
- :primary-button-label="__('Create new branch')"
- :title="__('Branch has changed')"
- kind="success"
- @submit="forceCreateNewBranch"
- >
- <template slot="body">
- {{
- __(`This branch has changed since you started editing.
- Would you like to create a new branch?`)
- }}
- </template>
- </deprecated-modal>
<template v-if="showStageUnstageArea">
<commit-files-list
:key-prefix="$options.stageKeys.staged"
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 08850679152..c72a8b2b0d0 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -13,6 +13,7 @@ import {
import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
+import { extractMarkdownImagesFromEntries } from '../stores/utils';
export default {
components: {
@@ -26,17 +27,23 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ content: '',
+ images: {},
+ };
+ },
computed: {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
}),
...mapState([
- 'rightPanelCollapsed',
'viewer',
'panelResizing',
'currentActivityView',
'renderWhitespaceInCode',
'editorTheme',
+ 'entries',
]),
...mapGetters([
'currentMergeRequest',
@@ -44,6 +51,7 @@ export default {
'isEditModeActive',
'isCommitModeActive',
'isReviewModeActive',
+ 'currentBranch',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
shouldHideEditor() {
@@ -87,6 +95,9 @@ export default {
theme: this.editorTheme,
};
},
+ currentBranchCommit() {
+ return this.currentBranch?.commit.id;
+ },
},
watch: {
file(newVal, oldVal) {
@@ -114,9 +125,6 @@ export default {
});
}
},
- rightPanelCollapsed() {
- this.refreshEditorDimensions();
- },
viewer() {
if (!this.file.pending) {
this.createEditorInstance();
@@ -136,6 +144,18 @@ export default {
this.$nextTick(() => this.refreshEditorDimensions());
}
},
+ showContentViewer(val) {
+ if (!val) return;
+
+ if (this.fileType === 'markdown') {
+ const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries);
+ this.content = content;
+ this.images = images;
+ } else {
+ this.content = this.file.content || this.file.raw;
+ this.images = {};
+ }
+ },
},
beforeDestroy() {
this.editor.dispose();
@@ -310,11 +330,13 @@ export default {
></div>
<content-viewer
v-if="showContentViewer"
- :content="file.content || file.raw"
+ :content="content"
+ :images="images"
:path="file.rawPath || file.path"
:file-path="file.path"
:file-size="file.size"
:project-path="file.projectId"
+ :commit-sha="currentBranchCommit"
:type="fileType"
/>
<diff-viewer
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
index 7277fcb7617..86a4622401c 100644
--- a/app/assets/javascripts/ide/components/resizable_panel.vue
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapState } from 'vuex';
+import { mapActions } from 'vuex';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
export default {
@@ -7,10 +7,6 @@ export default {
PanelResizer,
},
props: {
- collapsible: {
- type: Boolean,
- required: true,
- },
initialWidth: {
type: Number,
required: true,
@@ -31,11 +27,6 @@ export default {
};
},
computed: {
- ...mapState({
- collapsed(state) {
- return state[`${this.side}PanelCollapsed`];
- },
- }),
panelStyle() {
if (!this.collapsed) {
return {
@@ -47,33 +38,17 @@ export default {
},
},
methods: {
- ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']),
- toggleFullbarCollapsed() {
- if (this.collapsed && this.collapsible) {
- this.setPanelCollapsedStatus({
- side: this.side,
- collapsed: !this.collapsed,
- });
- }
- },
+ ...mapActions(['setResizingStatus']),
},
maxSize: window.innerWidth / 2,
};
</script>
<template>
- <div
- :class="{
- 'is-collapsed': collapsed && collapsible,
- }"
- :style="panelStyle"
- class="multi-file-commit-panel"
- @click="toggleFullbarCollapsed"
- >
+ <div :style="panelStyle" class="multi-file-commit-panel">
<slot></slot>
<panel-resizer
:size.sync="width"
- :enabled="!collapsed"
:start-size="initialWidth"
:min-size="minSize"
:max-size="$options.maxSize"
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index fa2672aaece..ae8550cba76 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -78,6 +78,7 @@ export const commitItemIconMap = {
export const modalTypes = {
rename: 'rename',
tree: 'tree',
+ blob: 'blob',
};
export const commitActionTypes = {
diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/ide/eventhub.js
+++ b/app/assets/javascripts/ide/eventhub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js
index 9b7ed68b893..29e29d7fcd3 100644
--- a/app/assets/javascripts/ide/lib/diff/diff.js
+++ b/app/assets/javascripts/ide/lib/diff/diff.js
@@ -14,13 +14,12 @@ export const computeDiff = (originalContent, newContent) => {
endLineNumber: lineNumber + change.count - 1,
});
} else if ('added' in change || 'removed' in change) {
- acc.push(
- Object.assign({}, change, {
- lineNumber,
- modified: undefined,
- endLineNumber: lineNumber + change.count - 1,
- }),
- );
+ acc.push({
+ ...change,
+ lineNumber,
+ modified: undefined,
+ endLineNumber: lineNumber + change.count - 1,
+ });
}
if (!change.removed) {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 3aff4d30d81..25224abd77c 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -7,8 +7,10 @@ import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
import { themes } from './themes';
+import languages from './languages';
import keymap from './keymap.json';
import { clearDomElement } from '~/editor/utils';
+import { registerLanguages } from '../utils';
function setupThemes() {
themes.forEach(theme => {
@@ -37,6 +39,7 @@ export default class Editor {
};
setupThemes();
+ registerLanguages(...languages);
this.debouncedUpdate = debounce(() => {
this.updateDimensions();
diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js
new file mode 100644
index 00000000000..0c85a1104fc
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/index.js
@@ -0,0 +1,5 @@
+import vue from './vue';
+
+const languages = [vue];
+
+export default languages;
diff --git a/app/assets/javascripts/ide/lib/languages/vue.js b/app/assets/javascripts/ide/lib/languages/vue.js
new file mode 100644
index 00000000000..b9ff5c5d776
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/vue.js
@@ -0,0 +1,306 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md
+ *--------------------------------------------------------------------------------------------*/
+
+// Based on handlebars template in https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts
+// Look for "vuejs template attributes" in this file for Vue specific syntax.
+
+import { languages } from 'monaco-editor';
+
+/* eslint-disable no-useless-escape */
+/* eslint-disable @gitlab/require-i18n-strings */
+
+const EMPTY_ELEMENTS = [
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'keygen',
+ 'link',
+ 'menuitem',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+];
+
+const conf = {
+ wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g,
+
+ comments: {
+ blockComment: ['{{!--', '--}}'],
+ },
+
+ brackets: [['<!--', '-->'], ['<', '>'], ['{{', '}}'], ['{', '}'], ['(', ')']],
+
+ autoClosingPairs: [
+ { open: '{', close: '}' },
+ { open: '[', close: ']' },
+ { open: '(', close: ')' },
+ { open: '"', close: '"' },
+ { open: "'", close: "'" },
+ ],
+
+ surroundingPairs: [
+ { open: '<', close: '>' },
+ { open: '"', close: '"' },
+ { open: "'", close: "'" },
+ ],
+
+ onEnterRules: [
+ {
+ beforeText: new RegExp(
+ `<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
+ 'i',
+ ),
+ afterText: /^<\/(\w[\w\d]*)\s*>$/i,
+ action: { indentAction: languages.IndentAction.IndentOutdent },
+ },
+ {
+ beforeText: new RegExp(
+ `<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
+ 'i',
+ ),
+ action: { indentAction: languages.IndentAction.Indent },
+ },
+ ],
+};
+
+const language = {
+ defaultToken: '',
+ tokenPostfix: '',
+ // ignoreCase: true,
+
+ // The main tokenizer for our languages
+ tokenizer: {
+ root: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.root' }],
+ [/<!DOCTYPE/, 'metatag.html', '@doctype'],
+ [/<!--/, 'comment.html', '@comment'],
+ [/(<)([\w]+)(\/>)/, ['delimiter.html', 'tag.html', 'delimiter.html']],
+ [/(<)(script)/, ['delimiter.html', { token: 'tag.html', next: '@script' }]],
+ [/(<)(style)/, ['delimiter.html', { token: 'tag.html', next: '@style' }]],
+ [/(<)([:\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
+ [/(<\/)([\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
+ [/</, 'delimiter.html'],
+ [/\{/, 'delimiter.html'],
+ [/[^<{]+/], // text
+ ],
+
+ doctype: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
+ [/[^>]+/, 'metatag.content.html'],
+ [/>/, 'metatag.html', '@pop'],
+ ],
+
+ comment: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
+ [/-->/, 'comment.html', '@pop'],
+ [/[^-]+/, 'comment.content.html'],
+ [/./, 'comment.content.html'],
+ ],
+
+ otherTag: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.otherTag' }],
+ [/\/?>/, 'delimiter.html', '@pop'],
+
+ // -- BEGIN vuejs template attributes
+ [/(v-|@|:)[\w\-\.\:\[\]]+="([^"]*)"/, 'variable'],
+ [/(v-|@|:)[\w\-\.\:\[\]]+='([^']*)'/, 'variable'],
+
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+
+ [/[\w\-\.\:\[\]]+/, 'attribute.name'],
+ // -- END vuejs template attributes
+
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ ],
+
+ // -- BEGIN <script> tags handling
+
+ // After <script
+ script: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.script' }],
+ [/type/, 'attribute.name', '@scriptAfterType'],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ],
+ [/[ \t\r\n]+/], // whitespace
+ [
+ /(<\/)(script\s*)(>)/,
+ ['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
+ ],
+ ],
+
+ // After <script ... type
+ scriptAfterType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterType' }],
+ [/=/, 'delimiter', '@scriptAfterTypeEquals'],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ], // cover invalid e.g. <script type>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <script ... type =
+ scriptAfterTypeEquals: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterTypeEquals' }],
+ [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
+ [/'([^']*)'/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ], // cover invalid e.g. <script type=>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <script ... type = $S2
+ scriptWithCustomType: [
+ [
+ /\{\{/,
+ { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptWithCustomType.$S2' },
+ ],
+ [/>/, { token: 'delimiter.html', next: '@scriptEmbedded.$S2', nextEmbedded: '$S2' }],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ scriptEmbedded: [
+ [
+ /\{\{/,
+ {
+ token: '@rematch',
+ switchTo: '@handlebarsInEmbeddedState.scriptEmbedded.$S2',
+ nextEmbedded: '@pop',
+ },
+ ],
+ [/<\/script/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
+ ],
+
+ // -- END <script> tags handling
+
+ // -- BEGIN <style> tags handling
+
+ // After <style
+ style: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.style' }],
+ [/type/, 'attribute.name', '@styleAfterType'],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }],
+ [/[ \t\r\n]+/], // whitespace
+ [
+ /(<\/)(style\s*)(>)/,
+ ['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
+ ],
+ ],
+
+ // After <style ... type
+ styleAfterType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterType' }],
+ [/=/, 'delimiter', '@styleAfterTypeEquals'],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <style ... type =
+ styleAfterTypeEquals: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterTypeEquals' }],
+ [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
+ [/'([^']*)'/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type=>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <style ... type = $S2
+ styleWithCustomType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleWithCustomType.$S2' }],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.$S2', nextEmbedded: '$S2' }],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ styleEmbedded: [
+ [
+ /\{\{/,
+ {
+ token: '@rematch',
+ switchTo: '@handlebarsInEmbeddedState.styleEmbedded.$S2',
+ nextEmbedded: '@pop',
+ },
+ ],
+ [/<\/style/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
+ ],
+
+ // -- END <style> tags handling
+
+ handlebarsInSimpleState: [
+ [/\{\{\{?/, 'delimiter.handlebars'],
+ [/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3' }],
+ { include: 'handlebarsRoot' },
+ ],
+
+ handlebarsInEmbeddedState: [
+ [/\{\{\{?/, 'delimiter.handlebars'],
+ [/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3', nextEmbedded: '$S3' }],
+ { include: 'handlebarsRoot' },
+ ],
+
+ handlebarsRoot: [
+ [/"[^"]*"/, 'string.handlebars'],
+ [/[#/][^\s}]+/, 'keyword.helper.handlebars'],
+ [/else\b/, 'keyword.helper.handlebars'],
+ [/[\s]+/],
+ [/[^}]/, 'variable.parameter.handlebars'],
+ ],
+ },
+};
+
+export default {
+ id: 'vue',
+ extensions: ['.vue'],
+ aliases: ['Vue', 'vue'],
+ mimetypes: ['text/x-vue-template'],
+ conf,
+ language,
+};
diff --git a/app/assets/javascripts/ide/lib/themes/index.js b/app/assets/javascripts/ide/lib/themes/index.js
index 6ed9f6679a4..bb5be50576c 100644
--- a/app/assets/javascripts/ide/lib/themes/index.js
+++ b/app/assets/javascripts/ide/lib/themes/index.js
@@ -1,5 +1,9 @@
import white from './white';
import dark from './dark';
+import monokai from './monokai';
+import solarizedLight from './solarized_light';
+import solarizedDark from './solarized_dark';
+import none from './none';
export const themes = [
{
@@ -10,6 +14,22 @@ export const themes = [
name: 'dark',
data: dark,
},
+ {
+ name: 'solarized-light',
+ data: solarizedLight,
+ },
+ {
+ name: 'solarized-dark',
+ data: solarizedDark,
+ },
+ {
+ name: 'monokai',
+ data: monokai,
+ },
+ {
+ name: 'none',
+ data: none,
+ },
];
export const DEFAULT_THEME = 'white';
diff --git a/app/assets/javascripts/ide/lib/themes/monokai.js b/app/assets/javascripts/ide/lib/themes/monokai.js
new file mode 100644
index 00000000000..d7636574754
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/monokai.js
@@ -0,0 +1,169 @@
+/*
+
+https://github.com/brijeshb42/monaco-themes/blob/master/themes/Tomorrow-Night.json
+
+The MIT License (MIT)
+
+Copyright (c) Brijesh Bittu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+
+export default {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [
+ {
+ foreground: '75715e',
+ token: 'comment',
+ },
+ {
+ foreground: 'e6db74',
+ token: 'string',
+ },
+ {
+ foreground: 'ae81ff',
+ token: 'constant.numeric',
+ },
+ {
+ foreground: 'ae81ff',
+ token: 'constant.language',
+ },
+ {
+ foreground: 'ae81ff',
+ token: 'constant.character',
+ },
+ {
+ foreground: 'ae81ff',
+ token: 'constant.other',
+ },
+ {
+ foreground: 'f92672',
+ token: 'keyword',
+ },
+ {
+ foreground: 'f92672',
+ token: 'storage',
+ },
+ {
+ foreground: '66d9ef',
+ fontStyle: 'italic',
+ token: 'storage.type',
+ },
+ {
+ foreground: 'a6e22e',
+ fontStyle: 'underline',
+ token: 'entity.name.class',
+ },
+ {
+ foreground: 'a6e22e',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ fontStyle: 'italic underline',
+ token: 'entity.other.inherited-class',
+ },
+ {
+ foreground: 'a6e22e',
+ token: 'entity.name.function',
+ },
+ {
+ foreground: 'fd971f',
+ fontStyle: 'italic',
+ token: 'variable.parameter',
+ },
+ {
+ foreground: 'f92672',
+ token: 'entity.name.tag',
+ },
+ {
+ foreground: 'a6e22e',
+ token: 'entity.other.attribute-name',
+ },
+ {
+ foreground: '66d9ef',
+ token: 'support.function',
+ },
+ {
+ foreground: '66d9ef',
+ token: 'support.constant',
+ },
+ {
+ foreground: '66d9ef',
+ fontStyle: 'italic',
+ token: 'support.type',
+ },
+ {
+ foreground: '66d9ef',
+ fontStyle: 'italic',
+ token: 'support.class',
+ },
+ {
+ foreground: 'f8f8f0',
+ background: 'f92672',
+ token: 'invalid',
+ },
+ {
+ foreground: 'f8f8f0',
+ background: 'ae81ff',
+ token: 'invalid.deprecated',
+ },
+ {
+ foreground: 'cfcfc2',
+ token: 'meta.structure.dictionary.json string.quoted.double.json',
+ },
+ {
+ foreground: '75715e',
+ token: 'meta.diff',
+ },
+ {
+ foreground: '75715e',
+ token: 'meta.diff.header',
+ },
+ {
+ foreground: 'f92672',
+ token: 'markup.deleted',
+ },
+ {
+ foreground: 'a6e22e',
+ token: 'markup.inserted',
+ },
+ {
+ foreground: 'e6db74',
+ token: 'markup.changed',
+ },
+ {
+ foreground: 'ae81ffa0',
+ token: 'constant.numeric.line-number.find-in-files - match',
+ },
+ {
+ foreground: 'e6db74',
+ token: 'entity.name.filename.find-in-files',
+ },
+ ],
+ colors: {
+ 'editor.foreground': '#F8F8F2',
+ 'editor.background': '#272822',
+ 'editor.selectionBackground': '#49483E',
+ 'editor.lineHighlightBackground': '#3E3D32',
+ 'editorCursor.foreground': '#F8F8F0',
+ 'editorWhitespace.foreground': '#3B3A32',
+ 'editorIndentGuide.activeBackground': '#9D550FB0',
+ 'editor.selectionHighlightBorder': '#222218',
+ },
+};
diff --git a/app/assets/javascripts/ide/lib/themes/none.js b/app/assets/javascripts/ide/lib/themes/none.js
new file mode 100644
index 00000000000..8e722c4ff88
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/none.js
@@ -0,0 +1,17 @@
+export default {
+ base: 'vs',
+ inherit: false,
+ rules: [],
+ colors: {
+ 'editor.foreground': '#2e2e2e',
+ 'editor.selectionBackground': '#aad6f8',
+ 'editor.lineHighlightBackground': '#fffeeb',
+ 'editorCursor.foreground': '#666666',
+ 'editorWhitespace.foreground': '#bbbbbb',
+
+ 'editorLineNumber.foreground': '#cccccc',
+ 'diffEditor.insertedTextBackground': '#a0f5b420',
+ 'diffEditor.removedTextBackground': '#f9d7dc20',
+ 'editorIndentGuide.activeBackground': '#cccccc',
+ },
+};
diff --git a/app/assets/javascripts/ide/lib/themes/solarized_dark.js b/app/assets/javascripts/ide/lib/themes/solarized_dark.js
new file mode 100644
index 00000000000..3c9414b9dc9
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/solarized_dark.js
@@ -0,0 +1,1110 @@
+/*
+
+https://github.com/brijeshb42/monaco-themes/blob/master/themes/Solarized-dark.json
+
+The MIT License (MIT)
+
+Copyright (c) Brijesh Bittu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+export default {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [
+ {
+ foreground: '586e75',
+ token: 'comment',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string',
+ },
+ {
+ foreground: '586e75',
+ token: 'string',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'string.regexp',
+ },
+ {
+ foreground: 'd33682',
+ token: 'constant.numeric',
+ },
+ {
+ foreground: '268bd2',
+ token: 'variable.language',
+ },
+ {
+ foreground: '268bd2',
+ token: 'variable.other',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword',
+ },
+ {
+ foreground: '859900',
+ token: 'storage',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.class',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.type.class',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.function',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.variable',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.end',
+ },
+ {
+ foreground: 'b58900',
+ token: 'constant.language',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.preprocessor',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'support.function.construct',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.other.new',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'constant.character',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'constant.other',
+ },
+ {
+ foreground: '268bd2',
+ fontStyle: 'bold',
+ token: 'entity.name.tag',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.tag.html',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.tag.begin',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.tag.end',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'entity.other.attribute-name',
+ },
+ {
+ foreground: '268bd2',
+ token: 'support.function',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.separator.continuation',
+ },
+ {
+ foreground: '859900',
+ token: 'support.type',
+ },
+ {
+ foreground: '859900',
+ token: 'support.class',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'support.type.exception',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.special-method',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string.quoted.double',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string.quoted.single',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.type.property-name.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.property-name.css',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'source.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.selector.css',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'punctuation.section.property-list.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.property-value.css constant.numeric.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.other.unit.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.color.rgb-value.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.property-value.css',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.other.important.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'support.constant.color',
+ },
+ {
+ foreground: '859900',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.separator.key-value.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.terminator.rule.css',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.other.attribute-name.class.css',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.other.attribute-name.pseudo-element.css',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.other.attribute-name.pseudo-class.css',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.other.attribute-name.id.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.function.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.function.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.function.dom.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'text.html.basic source.js.embedded.html',
+ },
+ {
+ foreground: '268bd2',
+ token: 'storage.type.function.js',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'meta.brace.square.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'storage.type.js',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'meta.brace.round',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.parameters.begin.js',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.parameters.end.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'meta.brace.curly.js',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'entity.name.tag.doctype.html',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'meta.tag.sgml.html',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'string.quoted.double.doctype.identifiers-and-DTDs.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'comment.block.html',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'entity.name.tag.script.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'source.css.embedded.html string.quoted.double.html',
+ },
+ {
+ foreground: 'cb4b16',
+ fontStyle: 'bold',
+ token: 'text.html.ruby',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.other.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.any.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.block.any',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.inline.any',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.structure.any.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic source.js.embedded.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'punctuation.separator.key-value.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic entity.other.attribute-name.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.basic meta.tag.structure.any.html punctuation.definition.string.begin.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.html',
+ },
+ {
+ foreground: '268bd2',
+ fontStyle: 'bold',
+ token: 'entity.name.tag.block.any.html',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'source.css.embedded.html entity.name.tag.style.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'source.css.embedded.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'comment.block.html',
+ },
+ {
+ foreground: '268bd2',
+ token: 'punctuation.definition.variable.ruby',
+ },
+ {
+ foreground: '657b83',
+ token: 'meta.function.method.with-arguments.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'variable.language.ruby',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.function.ruby',
+ },
+ {
+ foreground: '859900',
+ fontStyle: 'bold',
+ token: 'keyword.control.ruby',
+ },
+ {
+ foreground: '859900',
+ fontStyle: 'bold',
+ token: 'keyword.control.def.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.control.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'meta.class.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.type.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.control.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.other.special-method.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.language.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.constant.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.symbol.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.ruby',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.special-method.ruby',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.include.php',
+ },
+ {
+ foreground: '839496',
+ token: 'text.html.ruby meta.tag.inline.any.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.ruby punctuation.definition.string.begin',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.ruby punctuation.definition.string.end',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.definition.string.begin',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.definition.string.end',
+ },
+ {
+ foreground: '839496',
+ token: 'support.class.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.operator.index-start.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.operator.index-end.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.array.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.array.php support.function.construct.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.array.empty.php support.function.construct.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.function.construct.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.array.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.array.end',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.php',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.new.php',
+ },
+ {
+ foreground: '839496',
+ token: 'keyword.operator.class',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'variable.other.property.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.modifier.extends.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.type.class.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'keyword.operator.class.php',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.terminator.expression.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.other.inherited-class.php',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.php',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'entity.name.function.php',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.construct.php',
+ },
+ {
+ foreground: '839496',
+ token: 'entity.name.type.class.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.static.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.object.php',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'keyword.other.phpdoc',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'source.php.embedded.block.html',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'storage.type.function.php',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'meta.preprocessor.c.include',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'meta.preprocessor.macro.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.define.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.include.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.name.function.preprocessor.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include string.quoted.other.lt-gt.include.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include punctuation.definition.string.begin.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include punctuation.definition.string.end.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.function.C99.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.function.any-method.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'entity.name.function.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.c',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.type.c',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ fontStyle: 'italic',
+ token: 'meta.diff',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ fontStyle: 'italic',
+ token: 'meta.diff.header',
+ },
+ {
+ foreground: 'dc322f',
+ background: 'eee8d5',
+ token: 'markup.deleted',
+ },
+ {
+ foreground: 'cb4b16',
+ background: 'eee8d5',
+ token: 'markup.changed',
+ },
+ {
+ foreground: '219186',
+ background: 'eee8d5',
+ token: 'markup.inserted',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ token: 'text.html.markdown meta.dummy.line-break',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.markdown markup.raw.inline',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.restructuredtext markup.raw',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'other.package.exclude',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'other.remove',
+ },
+ {
+ foreground: '2aa198',
+ token: 'other.add',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.group.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.begin.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.end.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.latex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.group.braces.tex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'string.other.math.tex',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'variable.parameter.function.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.constant.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.tex.latex constant.other.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.general.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.general.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.character.math.tex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'string.other.math.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.control.label.latex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.tex.latex constant.other.general.math.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'variable.parameter.definition.label.latex',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.be.latex',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'support.function.section.latex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'support.function.general.tex',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'punctuation.definition.comment.tex',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'comment.line.percentage.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.control.ref.latex',
+ },
+ {
+ foreground: '586e75',
+ token: 'string.quoted.double.block.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.class.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.function.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.modifier.global.python',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.python',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.from.python',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.type.exception.python',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.builtin.shell',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'variable.other.normal.shell',
+ },
+ {
+ foreground: '268bd2',
+ token: 'source.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.for-in-loop.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'variable.other.loop.shell',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.string.end.shell',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.string.begin.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.case-block.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.case-body.shell',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.logical-expression.shell',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'comment.line.number-sign.shell',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.import.java',
+ },
+ {
+ foreground: '586e75',
+ token: 'storage.modifier.import.java',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.class.java storage.modifier.java',
+ },
+ {
+ foreground: '586e75',
+ token: 'source.java comment.block',
+ },
+ {
+ foreground: '586e75',
+ token:
+ 'comment.block meta.documentation.tag.param.javadoc keyword.other.documentation.param.javadoc',
+ },
+ {
+ foreground: 'b58900',
+ token: 'punctuation.definition.variable.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.readwrite.global.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.predefined.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'keyword.operator.comparison.perl',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.perl',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'comment.line.number-sign.perl',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.perl',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.perl',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'constant.character.escape.perl',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.1.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.2.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.3.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.4.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.5.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.6.markdown',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'bold',
+ token: 'markup.bold.markdown',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'markup.italic.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.bold.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.italic.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.raw.markdown',
+ },
+ {
+ foreground: 'b58900',
+ token: 'markup.list.unnumbered.markdown',
+ },
+ {
+ foreground: '859900',
+ token: 'markup.list.numbered.markdown',
+ },
+ {
+ foreground: '2aa198',
+ token: 'markup.raw.block.markdown',
+ },
+ {
+ foreground: '2aa198',
+ token: 'markup.raw.inline.markdown',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'markup.quote.markdown',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'punctuation.definition.blockquote.markdown',
+ },
+ {
+ foreground: 'd33682',
+ token: 'meta.separator.markdown',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'meta.image.inline.markdown',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'markup.underline.link.markdown',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'string.other.link.title.markdown',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'string.other.link.description.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.link.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.metadata.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.string.begin.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.string.end.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.constant.markdown',
+ },
+ {
+ foreground: 'eee8d5',
+ background: 'eee8d5',
+ token: 'sublimelinter.notes',
+ },
+ {
+ foreground: '93a1a1',
+ background: '93a1a1',
+ token: 'sublimelinter.outline.illegal',
+ },
+ {
+ background: 'dc322f',
+ token: 'sublimelinter.underline.illegal',
+ },
+ {
+ foreground: '839496',
+ background: '839496',
+ token: 'sublimelinter.outline.warning',
+ },
+ {
+ background: 'b58900',
+ token: 'sublimelinter.underline.warning',
+ },
+ {
+ foreground: '657b83',
+ background: '657b83',
+ token: 'sublimelinter.outline.violation',
+ },
+ {
+ background: 'cb4b16',
+ token: 'sublimelinter.underline.violation',
+ },
+ ],
+ colors: {
+ 'editor.foreground': '#839496',
+ 'editor.background': '#002B36',
+ 'editor.selectionBackground': '#073642',
+ 'editor.lineHighlightBackground': '#073642',
+ 'editorCursor.foreground': '#819090',
+ 'editorWhitespace.foreground': '#073642',
+ },
+};
diff --git a/app/assets/javascripts/ide/lib/themes/solarized_light.js b/app/assets/javascripts/ide/lib/themes/solarized_light.js
new file mode 100644
index 00000000000..b7bfcf33b0f
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/solarized_light.js
@@ -0,0 +1,1101 @@
+/*
+
+https://github.com/brijeshb42/monaco-themes/blob/master/themes/Solarized-dark.json
+
+The MIT License (MIT)
+
+Copyright (c) Brijesh Bittu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+export default {
+ base: 'vs',
+ inherit: true,
+ rules: [
+ {
+ foreground: '93a1a1',
+ token: 'comment',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string',
+ },
+ {
+ foreground: '586e75',
+ token: 'string',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'string.regexp',
+ },
+ {
+ foreground: 'd33682',
+ token: 'constant.numeric',
+ },
+ {
+ foreground: '268bd2',
+ token: 'variable.language',
+ },
+ {
+ foreground: '268bd2',
+ token: 'variable.other',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword',
+ },
+ {
+ foreground: '073642',
+ fontStyle: 'bold',
+ token: 'storage',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.class',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.type.class',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.function',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.variable',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.end',
+ },
+ {
+ foreground: 'b58900',
+ token: 'constant.language',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.preprocessor',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'support.function.construct',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.other.new',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'constant.character',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'constant.other',
+ },
+ {
+ foreground: '268bd2',
+ fontStyle: 'bold',
+ token: 'entity.name.tag',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.tag.html',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.tag.begin',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.tag.end',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'entity.other.attribute-name',
+ },
+ {
+ foreground: '268bd2',
+ token: 'support.function',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.separator.continuation',
+ },
+ {
+ foreground: '859900',
+ token: 'support.type',
+ },
+ {
+ foreground: '859900',
+ token: 'support.class',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'support.type.exception',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.special-method',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string.quoted.double',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string.quoted.single',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.type.property-name.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.property-name.css',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'source.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.selector.css',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'punctuation.section.property-list.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.property-value.css constant.numeric.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.other.unit.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.color.rgb-value.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.property-value.css',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.other.important.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'support.constant.color',
+ },
+ {
+ foreground: '859900',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.separator.key-value.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.terminator.rule.css',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.other.attribute-name.class.css',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.other.attribute-name.pseudo-element.css',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.other.attribute-name.pseudo-class.css',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.other.attribute-name.id.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.function.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.function.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.function.dom.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'text.html.basic source.js.embedded.html',
+ },
+ {
+ foreground: '268bd2',
+ token: 'storage.type.function.js',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'meta.brace.square.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'storage.type.js',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'meta.brace.round',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.parameters.begin.js',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.parameters.end.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'meta.brace.curly.js',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'entity.name.tag.doctype.html',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'meta.tag.sgml.html',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'string.quoted.double.doctype.identifiers-and-DTDs.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'comment.block.html',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'entity.name.tag.script.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'source.css.embedded.html string.quoted.double.html',
+ },
+ {
+ foreground: 'cb4b16',
+ fontStyle: 'bold',
+ token: 'text.html.ruby',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.other.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.any.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.block.any',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.inline.any',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.structure.any.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic source.js.embedded.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'punctuation.separator.key-value.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic entity.other.attribute-name.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.basic meta.tag.structure.any.html punctuation.definition.string.begin.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.html',
+ },
+ {
+ foreground: '268bd2',
+ fontStyle: 'bold',
+ token: 'entity.name.tag.block.any.html',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'source.css.embedded.html entity.name.tag.style.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'source.css.embedded.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'comment.block.html',
+ },
+ {
+ foreground: '268bd2',
+ token: 'punctuation.definition.variable.ruby',
+ },
+ {
+ foreground: '657b83',
+ token: 'meta.function.method.with-arguments.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'variable.language.ruby',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.function.ruby',
+ },
+ {
+ foreground: '859900',
+ fontStyle: 'bold',
+ token: 'keyword.control.ruby',
+ },
+ {
+ foreground: '859900',
+ fontStyle: 'bold',
+ token: 'keyword.control.def.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.control.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'meta.class.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.type.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.control.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.other.special-method.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.language.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.constant.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.symbol.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.ruby',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.special-method.ruby',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.include.php',
+ },
+ {
+ foreground: '839496',
+ token: 'text.html.ruby meta.tag.inline.any.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.ruby punctuation.definition.string.begin',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.ruby punctuation.definition.string.end',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.definition.string.begin',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.definition.string.end',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.operator.index-start.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.operator.index-end.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.array.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.array.php support.function.construct.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.array.empty.php support.function.construct.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.function.construct.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.array.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.array.end',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.php',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.new.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.class.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'keyword.operator.class',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'variable.other.property.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.modifier.extends.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.type.class.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'keyword.operator.class.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.other.inherited-class.php',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.php',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'entity.name.function.php',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.construct.php',
+ },
+ {
+ foreground: '839496',
+ token: 'entity.name.type.class.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.static.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.object.php',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'keyword.other.phpdoc',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'source.php.embedded.block.html',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'storage.type.function.php',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'meta.preprocessor.c.include',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'meta.preprocessor.macro.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.define.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.include.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.name.function.preprocessor.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include string.quoted.other.lt-gt.include.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include punctuation.definition.string.begin.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include punctuation.definition.string.end.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.function.C99.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.function.any-method.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'entity.name.function.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.c',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.type.c',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ fontStyle: 'italic',
+ token: 'meta.diff',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ fontStyle: 'italic',
+ token: 'meta.diff.header',
+ },
+ {
+ foreground: 'dc322f',
+ background: 'eee8d5',
+ token: 'markup.deleted',
+ },
+ {
+ foreground: 'cb4b16',
+ background: 'eee8d5',
+ token: 'markup.changed',
+ },
+ {
+ foreground: '219186',
+ background: 'eee8d5',
+ token: 'markup.inserted',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'a57706',
+ token: 'text.html.markdown meta.dummy.line-break',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.markdown markup.raw.inline',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.restructuredtext markup.raw',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'other.package.exclude',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'other.remove',
+ },
+ {
+ foreground: '2aa198',
+ token: 'other.add',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.group.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.begin.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.end.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.latex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.group.braces.tex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'string.other.math.tex',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'variable.parameter.function.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.constant.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.tex.latex constant.other.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.general.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.general.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.character.math.tex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'string.other.math.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.control.label.latex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.tex.latex constant.other.general.math.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'variable.parameter.definition.label.latex',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.be.latex',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'support.function.section.latex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'support.function.general.tex',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'punctuation.definition.comment.tex',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'comment.line.percentage.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.control.ref.latex',
+ },
+ {
+ foreground: '586e75',
+ token: 'string.quoted.double.block.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.class.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.function.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.modifier.global.python',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.python',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.from.python',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.type.exception.python',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.builtin.shell',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'variable.other.normal.shell',
+ },
+ {
+ foreground: '268bd2',
+ token: 'source.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.for-in-loop.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'variable.other.loop.shell',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.string.end.shell',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.string.begin.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.case-block.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.case-body.shell',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.logical-expression.shell',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'comment.line.number-sign.shell',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.import.java',
+ },
+ {
+ foreground: '586e75',
+ token: 'storage.modifier.import.java',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.class.java storage.modifier.java',
+ },
+ {
+ foreground: '586e75',
+ token: 'source.java comment.block',
+ },
+ {
+ foreground: '586e75',
+ token:
+ 'comment.block meta.documentation.tag.param.javadoc keyword.other.documentation.param.javadoc',
+ },
+ {
+ foreground: 'b58900',
+ token: 'punctuation.definition.variable.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.readwrite.global.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.predefined.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'keyword.operator.comparison.perl',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.perl',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'comment.line.number-sign.perl',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.perl',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.perl',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'constant.character.escape.perl',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.1.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.2.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.3.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.4.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.5.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.6.markdown',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'bold',
+ token: 'markup.bold.markdown',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'markup.italic.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.bold.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.italic.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.raw.markdown',
+ },
+ {
+ foreground: 'b58900',
+ token: 'markup.list.unnumbered.markdown',
+ },
+ {
+ foreground: '859900',
+ token: 'markup.list.numbered.markdown',
+ },
+ {
+ foreground: '2aa198',
+ token: 'markup.raw.block.markdown',
+ },
+ {
+ foreground: '2aa198',
+ token: 'markup.raw.inline.markdown',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'markup.quote.markdown',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'punctuation.definition.blockquote.markdown',
+ },
+ {
+ foreground: 'd33682',
+ token: 'meta.separator.markdown',
+ },
+ {
+ foreground: '839496',
+ token: 'markup.underline.link.markdown',
+ },
+ {
+ foreground: '839496',
+ token: 'markup.underline.link.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'meta.link.inet.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'meta.link.email.lt-gt.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.link.markdown',
+ },
+ {
+ foreground: '6a8187',
+ token: 'text.plain',
+ },
+ {
+ foreground: 'eee8d5',
+ background: 'eee8d5',
+ token: 'sublimelinter.notes',
+ },
+ {
+ foreground: '93a1a1',
+ background: '93a1a1',
+ token: 'sublimelinter.outline.illegal',
+ },
+ {
+ background: 'dc322f',
+ token: 'sublimelinter.underline.illegal',
+ },
+ {
+ foreground: '839496',
+ background: '839496',
+ token: 'sublimelinter.outline.warning',
+ },
+ {
+ background: 'b58900',
+ token: 'sublimelinter.underline.warning',
+ },
+ {
+ foreground: '657b83',
+ background: '657b83',
+ token: 'sublimelinter.outline.violation',
+ },
+ {
+ background: 'cb4b16',
+ token: 'sublimelinter.underline.violation',
+ },
+ ],
+ colors: {
+ 'editor.foreground': '#586E75',
+ 'editor.background': '#FDF6E3',
+ 'editor.selectionBackground': '#EEE8D5',
+ 'editor.lineHighlightBackground': '#EEE8D5',
+ 'editorCursor.foreground': '#000000',
+ 'editorWhitespace.foreground': '#EAE3C9',
+ },
+};
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 3adf0cf073f..1767d961259 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -88,12 +88,16 @@ export default {
commit(projectId, payload) {
return Api.commitMultiple(projectId, payload);
},
- getFiles(projectUrl, ref) {
- const url = `${projectUrl}/-/files/${ref}`;
+ getFiles(projectPath, ref) {
+ const url = `${gon.relative_url_root}/${projectPath}/-/files/${ref}`;
return axios.get(url, { params: { format: 'json' } });
},
lastCommitPipelines({ getters }) {
const commitSha = getters.lastCommit.id;
return Api.commitPipelines(getters.currentProject.path_with_namespace, commitSha);
},
+ pingUsage(projectPath) {
+ const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`;
+ return axios.post(url);
+ },
};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 04cf0ad53d5..e32b5ac7bdc 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,6 +1,5 @@
-import $ from 'jquery';
import Vue from 'vue';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
@@ -25,14 +24,6 @@ export const closeAllFiles = ({ state, dispatch }) => {
state.openFiles.forEach(file => dispatch('closeFile', file));
};
-export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
- if (side === 'left') {
- commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed);
- } else {
- commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed);
- }
-};
-
export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing);
};
@@ -176,13 +167,6 @@ export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export const setErrorMessage = ({ commit }, errorMessage) =>
commit(types.SET_ERROR_MESSAGE, errorMessage);
-export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
- commit(types.OPEN_NEW_ENTRY_MODAL, { type, path });
-
- // open the modal manually so we don't mess around with dropdown/rows
- $('#ide-new-entry').modal('show');
-};
-
export const deleteEntry = ({ commit, dispatch, state }, path) => {
const entry = state.entries[path];
const { prevPath, prevName, prevParentPath } = entry;
@@ -296,7 +280,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force =
sprintf(
__('Branch not loaded - %{branchId}'),
{
- branchId: `<strong>${esc(projectId)}/${esc(branchId)}</strong>`,
+ branchId: `<strong>${escape(projectId)}/${escape(branchId)}</strong>`,
},
false,
),
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index ae3829dc35e..6c8fb9f90aa 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -1,4 +1,4 @@
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import flash from '~/flash';
import { __, sprintf } from '~/locale';
import service from '../../services';
@@ -73,7 +73,7 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => {
text: sprintf(
__("Branch %{branchName} was not found in this project's repository."),
{
- branchName: `<strong>${esc(branchId)}</strong>`,
+ branchName: `<strong>${escape(branchId)}</strong>`,
},
false,
),
@@ -162,7 +162,7 @@ export const openBranch = ({ dispatch }, { projectId, branchId, basePath }) => {
sprintf(
__('An error occurred while getting files for - %{branchId}'),
{
- branchId: `<strong>${esc(projectId)}/${esc(branchId)}</strong>`,
+ branchId: `<strong>${escape(projectId)}/${escape(branchId)}</strong>`,
},
false,
),
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index 7d48f0adc4c..1ca608f1287 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -59,7 +59,7 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) =>
commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
service
- .getFiles(selectedProject.web_url, ref)
+ .getFiles(selectedProject.path_with_namespace, ref)
.then(({ data }) => {
const { entries, treeList } = decorateFiles({
data,
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 505daa8834d..592c7e15918 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -1,6 +1,6 @@
-import $ from 'jquery';
import { sprintf, __ } from '~/locale';
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';
@@ -215,25 +215,23 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
);
})
.catch(err => {
- if (err.response.status === 400) {
- $('#ide-create-branch-modal').modal('show');
- } else {
- dispatch(
- 'setErrorMessage',
- {
- text: __('An error occurred while committing your changes.'),
- action: () =>
- dispatch('commitChanges').then(() =>
- dispatch('setErrorMessage', null, { root: true }),
- ),
- actionText: __('Please try again'),
- },
- { root: true },
- );
- window.dispatchEvent(new Event('resize'));
- }
-
commit(types.UPDATE_LOADING, false);
+
+ // don't catch bad request errors, let the view handle them
+ if (err.response.status === httpStatusCodes.BAD_REQUEST) throw err;
+
+ dispatch(
+ 'setErrorMessage',
+ {
+ text: __('An error occurred while committing your changes.'),
+ action: () =>
+ dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true })),
+ actionText: __('Please try again'),
+ },
+ { root: true },
+ );
+
+ window.dispatchEvent(new Event('resize'));
});
};
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 78831bdf022..5c78bfefa04 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -2,8 +2,6 @@ export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
-export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
-export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS';
export const SET_LINKS = 'SET_LINKS';
@@ -73,7 +71,6 @@ export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
-export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';
export const DELETE_ENTRY = 'DELETE_ENTRY';
export const RENAME_ENTRY = 'RENAME_ENTRY';
export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 5d567d9b169..12ac10df206 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -29,16 +29,6 @@ export default {
});
}
},
- [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) {
- Object.assign(state, {
- leftPanelCollapsed: collapsed,
- });
- },
- [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) {
- Object.assign(state, {
- rightPanelCollapsed: collapsed,
- });
- },
[types.SET_RESIZING_STATUS](state, resizing) {
Object.assign(state, {
panelResizing: resizing,
@@ -192,15 +182,6 @@ export default {
[types.SET_ERROR_MESSAGE](state, errorMessage) {
Object.assign(state, { errorMessage });
},
- [types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) {
- Object.assign(state, {
- entryModal: {
- type,
- path,
- entry: { ...state.entries[path] },
- },
- });
- },
[types.DELETE_ENTRY](state, path) {
const entry = state.entries[path];
const { tempFile = false } = entry;
diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js
index 9230f3839c1..034fdad4305 100644
--- a/app/assets/javascripts/ide/stores/mutations/project.js
+++ b/app/assets/javascripts/ide/stores/mutations/project.js
@@ -16,9 +16,7 @@ export default {
});
Object.assign(state, {
- projects: Object.assign({}, state.projects, {
- [projectPath]: project,
- }),
+ projects: { ...state.projects, [projectPath]: project },
});
},
[types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) {
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
index 359943b4ab7..c8f14a680c2 100644
--- a/app/assets/javascripts/ide/stores/mutations/tree.js
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -14,12 +14,13 @@ export default {
},
[types.CREATE_TREE](state, { treePath }) {
Object.assign(state, {
- trees: Object.assign({}, state.trees, {
+ trees: {
+ ...state.trees,
[treePath]: {
tree: [],
loading: true,
},
- }),
+ },
});
},
[types.SET_DIRECTORY_DATA](state, { data, treePath }) {
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 0fd6a448283..0c95c22e8f8 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -15,8 +15,6 @@ export default () => ({
parentTreeUrl: '',
trees: {},
projects: {},
- leftPanelCollapsed: false,
- rightPanelCollapsed: false,
panelResizing: false,
entries: {},
viewer: viewerTypes.edit,
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 4e5b01596d8..56671142bd4 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,4 +1,5 @@
import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
+import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility';
export const dataStructure = () => ({
id: '',
@@ -274,3 +275,45 @@ export const pathsAreEqual = (a, b) => {
// 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:
+ *
+ * ![img alt goes here](/img.png)
+ * ![img alt](../img 1/img.png "my image title")
+ * ![img alt](https://gitlab.com/assets/logo.svg "title here")
+ *
+ */
+ const reMdImage = /!\[([^\]]*)\]\((.*?)(?:(?="|\))"([^"]*)")?\)/gi;
+ const prefix = 'gl_md_img_';
+ const images = {};
+
+ let content = mdFile.content || mdFile.raw;
+ let i = 0;
+
+ content = content.replace(reMdImage, (_, alt, path, title) => {
+ const imagePath = (isRootRelative(path) ? path : relativePathToAbsolute(path, mdFile.path))
+ .substr(1)
+ .trim();
+
+ const imageContent = entries[imagePath]?.content || entries[imagePath]?.raw;
+
+ if (!isAbsolute(path) && imageContent) {
+ const ext = path.includes('.')
+ ? path
+ .split('.')
+ .pop()
+ .trim()
+ : 'jpeg';
+ const src = `data:image/${ext};base64,${imageContent}`;
+ i += 1;
+ const key = `{{${prefix}${i}}}`;
+ images[key] = { alt, src, title };
+ return key;
+ }
+ return title ? `![${alt}](${path}"${title}")` : `![${alt}](${path})`;
+ });
+
+ return { content, images };
+}
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 64ac539a4ff..1ea2b199237 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -68,3 +68,19 @@ export const createPathWithExt = p => {
return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`;
};
+
+export const trimPathComponents = path =>
+ path
+ .split('/')
+ .map(s => s.trim())
+ .join('/');
+
+export function registerLanguages(def, ...defs) {
+ if (defs.length) defs.forEach(lang => registerLanguages(lang));
+
+ const languageId = def.id;
+
+ languages.register(def);
+ languages.setMonarchTokensProvider(languageId, def.language);
+ languages.setLanguageConfiguration(languageId, def.conf);
+}
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index 7921650e8a0..229e0a62c51 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -15,7 +15,7 @@ export function createImageBadge(noteId, { x, y }, classNames = []) {
export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']);
- buttonEl.innerText = badgeText;
+ buttonEl.textContent = badgeText;
containerEl.appendChild(buttonEl);
}
@@ -32,6 +32,6 @@ export function addAvatarBadge(el, event) {
// Add badge to new comment
const avatarBadgeEl = el.querySelector(`#${noteId} .badge`);
- avatarBadgeEl.innerText = badgeNumber;
+ avatarBadgeEl.textContent = badgeNumber;
avatarBadgeEl.classList.remove('hidden');
}
diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
index df3d90cff68..deaef686f59 100644
--- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
@@ -32,9 +32,7 @@ export function removeCommentIndicator(imageFrameEl) {
commentIndicatorEl.remove();
}
- return Object.assign({}, meta, {
- removed: willRemove,
- });
+ return { ...meta, removed: willRemove };
}
export function showCommentIndicator(imageFrameEl, coordinate) {
diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js
index a319bcccb8f..a61e5f01f9b 100644
--- a/app/assets/javascripts/image_diff/helpers/dom_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js
@@ -4,24 +4,19 @@ export function setPositionDataAttribute(el, options) {
const { x, y, width, height } = options;
const { position } = el.dataset;
- const positionObject = Object.assign({}, JSON.parse(position), {
- x,
- y,
- width,
- height,
- });
+ const positionObject = { ...JSON.parse(position), x, y, width, height };
el.setAttribute('data-position', JSON.stringify(positionObject));
}
export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) {
const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge');
- avatarBadgeEl.innerText = newBadgeNumber;
+ avatarBadgeEl.textContent = newBadgeNumber;
}
export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) {
const discussionBadgeEl = discussionEl.querySelector('.badge');
- discussionBadgeEl.innerText = newBadgeNumber;
+ discussionBadgeEl.textContent = newBadgeNumber;
}
export function toggleCollapsed(event) {
diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js
index 26c1b0ec7be..079f4a63f6e 100644
--- a/app/assets/javascripts/image_diff/image_diff.js
+++ b/app/assets/javascripts/image_diff/image_diff.js
@@ -75,9 +75,7 @@ export default class ImageDiff {
if (this.renderCommentBadge) {
imageDiffHelper.addImageCommentBadge(this.imageFrameEl, options);
} else {
- const numberBadgeOptions = Object.assign({}, options, {
- badgeText: index + 1,
- });
+ const numberBadgeOptions = { ...options, badgeText: index + 1 };
imageDiffHelper.addImageBadge(this.imageFrameEl, numberBadgeOptions);
}
@@ -130,7 +128,7 @@ export default class ImageDiff {
const updatedBadgeNumber = index;
const discussionEl = this.el.querySelector(`#discussion_${discussionId}`);
- imageBadgeEls[index].innerText = updatedBadgeNumber;
+ imageBadgeEls[index].textContent = updatedBadgeNumber;
imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, updatedBadgeNumber);
imageDiffHelper.updateDiscussionAvatarBadgeNumber(discussionEl, updatedBadgeNumber);
diff --git a/app/assets/javascripts/import_projects/event_hub.js b/app/assets/javascripts/import_projects/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/import_projects/event_hub.js
+++ b/app/assets/javascripts/import_projects/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 1ffd5c61282..d6b519f7eac 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import _ from 'underscore';
+import { escape } from 'lodash';
import { __, sprintf } from './locale';
import axios from './lib/utils/axios_utils';
import flash from './flash';
@@ -73,9 +73,9 @@ class ImporterStatus {
const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing');
job.find('.import-actions').html(
sprintf(
- _.escape(__('%{loadingIcon} Started')),
+ escape(__('%{loadingIcon} Started')),
{
- loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape(
+ loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${escape(
connectingVerb,
)}"></i>`,
},
diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue
index 2b0aa2586e4..8b95b04d93c 100644
--- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue
@@ -12,10 +12,6 @@ export default {
type: Boolean,
required: true,
},
- disabled: {
- type: Boolean,
- required: true,
- },
},
data() {
return {
@@ -41,12 +37,7 @@ export default {
<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">
- <gl-toggle
- v-model="activated"
- :disabled="disabled"
- name="service[active]"
- @change="onToggle"
- />
+ <gl-toggle v-model="activated" name="service[active]" @change="onToggle" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
new file mode 100644
index 00000000000..fbe58c30b13
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -0,0 +1,50 @@
+<script>
+import ActiveToggle from './active_toggle.vue';
+import JiraTriggerFields from './jira_trigger_fields.vue';
+import TriggerFields from './trigger_fields.vue';
+
+export default {
+ name: 'IntegrationForm',
+ components: {
+ ActiveToggle,
+ JiraTriggerFields,
+ TriggerFields,
+ },
+ props: {
+ activeToggleProps: {
+ type: Object,
+ required: true,
+ },
+ showActive: {
+ type: Boolean,
+ required: true,
+ },
+ triggerFieldsProps: {
+ type: Object,
+ required: true,
+ },
+ triggerEvents: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isJira() {
+ return this.type === 'jira';
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <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" />
+ </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
new file mode 100644
index 00000000000..70278e401ce
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlFormCheckbox, GlFormRadio } from '@gitlab/ui';
+
+export default {
+ name: 'JiraTriggerFields',
+ components: {
+ GlFormCheckbox,
+ GlFormRadio,
+ },
+ props: {
+ initialTriggerCommit: {
+ type: Boolean,
+ required: true,
+ },
+ initialTriggerMergeRequest: {
+ type: Boolean,
+ required: true,
+ },
+ initialEnableComments: {
+ type: Boolean,
+ required: true,
+ },
+ initialCommentDetail: {
+ type: String,
+ required: false,
+ default: 'standard',
+ },
+ },
+ data() {
+ return {
+ triggerCommit: this.initialTriggerCommit,
+ triggerMergeRequest: this.initialTriggerMergeRequest,
+ enableComments: this.initialEnableComments,
+ commentDetail: this.initialCommentDetail,
+ };
+ },
+};
+</script>
+
+<template>
+ <div 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">
+ {{
+ s__(
+ 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.',
+ )
+ }}
+ </label>
+
+ <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>
+
+ <div
+ v-show="triggerCommit || triggerMergeRequest"
+ class="mt-4"
+ data-testid="comment-settings"
+ >
+ <label>
+ {{ s__('Integrations|Comment settings:') }}
+ </label>
+ <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>
+
+ <div v-show="enableComments" class="mt-4" data-testid="comment-detail">
+ <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') }}
+ <template #help>
+ {{
+ s__(
+ 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs',
+ )
+ }}
+ </template>
+ </gl-form-radio>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
new file mode 100644
index 00000000000..531490ae40c
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -0,0 +1,73 @@
+<script>
+import { startCase } from 'lodash';
+import { __ } from '~/locale';
+import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
+
+const typeWithPlaceholder = {
+ SLACK: 'slack',
+ MATTERMOST: 'mattermost',
+};
+
+const placeholderForType = {
+ [typeWithPlaceholder.SLACK]: __('Slack channels (e.g. general, development)'),
+ [typeWithPlaceholder.MATTERMOST]: __('Channel handle (e.g. town-square)'),
+};
+
+export default {
+ name: 'TriggerFields',
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormInput,
+ },
+ props: {
+ events: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ placeholder() {
+ return placeholderForType[this.type];
+ },
+ },
+ methods: {
+ checkboxName(name) {
+ return `service[${name}]`;
+ },
+ fieldName(name) {
+ return `service[${name}]`;
+ },
+ startCase,
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ class="gl-pt-3"
+ :label="__('Trigger')"
+ label-for="trigger-fields"
+ data-testid="trigger-fields-group"
+ >
+ <div id="trigger-fields" class="gl-pt-3">
+ <gl-form-group v-for="event in events" :key="event.title" :description="event.description">
+ <input :name="checkboxName(event.name)" type="hidden" value="false" />
+ <gl-form-checkbox v-model="event.value" :name="checkboxName(event.name)">
+ {{ startCase(event.title) }}
+ </gl-form-checkbox>
+ <gl-form-input
+ v-if="event.field"
+ v-model="event.field.value"
+ :name="fieldName(event.field.name)"
+ :placeholder="placeholder"
+ />
+ </gl-form-group>
+ </div>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/event_hub.js b/app/assets/javascripts/integrations/edit/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/integrations/edit/event_hub.js
+++ b/app/assets/javascripts/integrations/edit/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index a2ba581d429..2ae1342a558 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -1,28 +1,46 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-import ActiveToggle from './components/active_toggle.vue';
+import IntegrationForm from './components/integration_form.vue';
export default el => {
if (!el) {
return null;
}
- const { showActive: showActiveStr, activated: activatedStr, disabled: disabledStr } = el.dataset;
- const showActive = parseBoolean(showActiveStr);
- const activated = parseBoolean(activatedStr);
- const disabled = parseBoolean(disabledStr);
-
- if (!showActive) {
- return null;
+ function parseBooleanInData(data) {
+ const result = {};
+ Object.entries(data).forEach(([key, value]) => {
+ result[key] = parseBoolean(value);
+ });
+ return result;
}
+ const { type, commentDetail, triggerEvents, ...booleanAttributes } = el.dataset;
+ const {
+ showActive,
+ activated,
+ commitEvents,
+ mergeRequestEvents,
+ enableComments,
+ } = parseBooleanInData(booleanAttributes);
+
return new Vue({
el,
render(createElement) {
- return createElement(ActiveToggle, {
+ return createElement(IntegrationForm, {
props: {
- initialActivated: activated,
- disabled,
+ activeToggleProps: {
+ initialActivated: activated,
+ },
+ showActive,
+ type,
+ triggerFieldsProps: {
+ initialTriggerCommit: commitEvents,
+ initialTriggerMergeRequest: mergeRequestEvents,
+ initialEnableComments: enableComments,
+ initialCommentDetail: commentDetail,
+ },
+ triggerEvents: JSON.parse(triggerEvents),
},
});
},
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index 45de287d44d..95e10cc75cc 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,7 +1,7 @@
/* eslint-disable consistent-return, func-names, array-callback-return */
import $ from 'jquery';
-import _ from 'underscore';
+import { intersection } from 'lodash';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
import { __ } from './locale';
@@ -111,7 +111,7 @@ export default {
this.getElement('.selected-issuable:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
- return _.intersection.apply(this, labelIds);
+ return intersection.apply(this, labelIds);
},
// From issuable's initial bulk selection
@@ -120,7 +120,7 @@ export default {
this.getElement('.selected-issuable:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
- return _.intersection.apply(this, labelIds);
+ return intersection.apply(this, labelIds);
},
// From issuable's initial bulk selection
@@ -144,7 +144,7 @@ export default {
// Add uniqueIds to add it as argument for _.intersection
labelIds.unshift(uniqueIds);
// Return IDs that are present but not in all selected issueables
- return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
+ return uniqueIds.filter(x => !intersection.apply(this, labelIds).includes(x));
},
getElement(selector) {
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index bd6e8433544..50562688c53 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this, no-new */
import $ from 'jquery';
-import { property } from 'underscore';
+import { property } from 'lodash';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import MilestoneSelect from './milestone_select';
import issueStatusSelect from './issue_status_select';
diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
new file mode 100644
index 00000000000..fe01d2c2e78
--- /dev/null
+++ b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
@@ -0,0 +1,15 @@
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+
+query getProjectIssue($iid: String!, $fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ issue(iid: $iid) {
+ assignees {
+ nodes {
+ ...Author
+ id
+ state
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000000..27a04da9541
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
@@ -0,0 +1,96 @@
+<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';
+
+export default {
+ name: 'IssuableListRoot',
+ components: {
+ GlAlert,
+ GlLabel,
+ },
+ props: {
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ isJiraConfigured: {
+ type: Boolean,
+ required: true,
+ },
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isFinishedAlertShowing: true,
+ isInProgressAlertShowing: true,
+ jiraImport: {},
+ };
+ },
+ apollo: {
+ jiraImport: {
+ query: getIssuesListDetailsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ };
+ },
+ update: ({ project }) => ({
+ isInProgress: isInProgress(project.jiraImportStatus),
+ isFinished: isFinished(project.jiraImportStatus),
+ label: calculateJiraImportLabel(
+ project.jiraImports.nodes,
+ project.issues.nodes.flatMap(({ labels }) => labels.nodes),
+ ),
+ }),
+ skip() {
+ return !this.isJiraConfigured || !this.canEdit;
+ },
+ },
+ },
+ computed: {
+ labelTarget() {
+ return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`;
+ },
+ shouldShowFinishedAlert() {
+ return this.isFinishedAlertShowing && this.jiraImport.isFinished;
+ },
+ shouldShowInProgressAlert() {
+ return this.isInProgressAlertShowing && this.jiraImport.isInProgress;
+ },
+ },
+ methods: {
+ hideFinishedAlert() {
+ this.isFinishedAlertShowing = false;
+ },
+ hideInProgressAlert() {
+ this.isInProgressAlertShowing = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-list-root">
+ <gl-alert v-if="shouldShowInProgressAlert" @dismiss="hideInProgressAlert">
+ {{ __('Import in progress. Refresh page to see newly added issues.') }}
+ </gl-alert>
+ <gl-alert v-if="shouldShowFinishedAlert" variant="success" @dismiss="hideFinishedAlert">
+ {{ __('Issues successfully imported with the label') }}
+ <gl-label
+ :background-color="jiraImport.label.color"
+ scoped
+ size="sm"
+ :target="labelTarget"
+ :title="jiraImport.label.title"
+ />
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js
index d1601a7d8f3..e31806ad199 100644
--- a/app/assets/javascripts/issuables_list/eventhub.js
+++ b/app/assets/javascripts/issuables_list/eventhub.js
@@ -1,5 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-const issueablesEventBus = new Vue();
-
-export default issueablesEventBus;
+export default createEventHub();
diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js
index 9fc7fa837ff..6bfb885a8af 100644
--- a/app/assets/javascripts/issuables_list/index.js
+++ b/app/assets/javascripts/issuables_list/index.js
@@ -1,24 +1,63 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import IssuableListRootApp from './components/issuable_list_root_app.vue';
import IssuablesListApp from './components/issuables_list_app.vue';
-export default function initIssuablesList() {
- if (!gon.features || !gon.features.vueIssuablesList) {
+function mountIssuableListRootApp() {
+ const el = document.querySelector('.js-projects-issues-root');
+
+ if (!el) {
+ return false;
+ }
+
+ Vue.use(VueApollo);
+
+ const defaultClient = createDefaultClient();
+ const apolloProvider = new VueApollo({
+ defaultClient,
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createComponent) {
+ return createComponent(IssuableListRootApp, {
+ props: {
+ canEdit: parseBoolean(el.dataset.canEdit),
+ isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
+ issuesPath: el.dataset.issuesPath,
+ projectPath: el.dataset.projectPath,
+ },
+ });
+ },
+ });
+}
+
+function mountIssuablesListApp() {
+ if (!gon.features?.vueIssuablesList) {
return;
}
document.querySelectorAll('.js-issuables-list').forEach(el => {
const { canBulkEdit, ...data } = el.dataset;
- const props = {
- ...data,
- canBulkEdit: Boolean(canBulkEdit),
- };
-
return new Vue({
el,
render(createElement) {
- return createElement(IssuablesListApp, { props });
+ return createElement(IssuablesListApp, {
+ props: {
+ ...data,
+ canBulkEdit: Boolean(canBulkEdit),
+ },
+ });
},
});
});
}
+
+export default function initIssuablesList() {
+ mountIssuableListRootApp();
+ mountIssuablesListApp();
+}
diff --git a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql
new file mode 100644
index 00000000000..b62b9b2af60
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql
@@ -0,0 +1,22 @@
+#import "~/jira_import/queries/jira_import.fragment.graphql"
+
+query($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ issues {
+ nodes {
+ labels {
+ nodes {
+ title
+ color
+ }
+ }
+ }
+ }
+ jiraImportStatus
+ jiraImports {
+ nodes {
+ ...JiraImport
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 9136a47d542..f0967e77faf 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -12,6 +12,8 @@ export default class Issue {
constructor() {
if ($('a.btn-close').length) this.initIssueBtnEventListeners();
+ if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener();
+
Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
@@ -89,7 +91,7 @@ export default class Issue {
return $(document).on(
'click',
- '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen',
+ '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen, a.btn-close-anyway',
e => {
e.preventDefault();
e.stopImmediatePropagation();
@@ -99,19 +101,30 @@ export default class Issue {
Issue.submitNoteForm($button.closest('form'));
}
- this.disableCloseReopenButton($button);
-
- const url = $button.attr('href');
- return axios
- .put(url)
- .then(({ data }) => {
- const isClosed = $button.hasClass('btn-close');
- this.updateTopState(isClosed, data);
- })
- .catch(() => flash(issueFailMessage))
- .then(() => {
- this.disableCloseReopenButton($button, false);
- });
+ const shouldDisplayBlockedWarning = $button.hasClass('btn-issue-blocked');
+ const warningBanner = $('.js-close-blocked-issue-warning');
+ if (shouldDisplayBlockedWarning) {
+ this.toggleWarningAndCloseButton();
+ } else {
+ this.disableCloseReopenButton($button);
+
+ const url = $button.attr('href');
+ return axios
+ .put(url)
+ .then(({ data }) => {
+ const isClosed = $button.is('.btn-close, .btn-close-anyway');
+ this.updateTopState(isClosed, data);
+ if ($button.hasClass('btn-close-anyway')) {
+ warningBanner.addClass('hidden');
+ if (this.closeReopenReportToggle)
+ $('.js-issuable-close-dropdown').removeClass('hidden');
+ }
+ })
+ .catch(() => flash(issueFailMessage))
+ .then(() => {
+ this.disableCloseReopenButton($button, false);
+ });
+ }
},
);
}
@@ -137,6 +150,23 @@ export default class Issue {
this.reopenButtons.toggleClass('hidden', !isClosed);
}
+ toggleWarningAndCloseButton() {
+ const warningBanner = $('.js-close-blocked-issue-warning');
+ warningBanner.toggleClass('hidden');
+ $('.btn-close').toggleClass('hidden');
+ if (this.closeReopenReportToggle) {
+ $('.js-issuable-close-dropdown').toggleClass('hidden');
+ }
+ }
+
+ initIssueWarningBtnEventListener() {
+ return $(document).on('click', '.js-close-blocked-issue-warning button.btn-secondary', e => {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ this.toggleWarningAndCloseButton();
+ });
+ }
+
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 b8b3a4f44fd..8cf2cda64a4 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -295,7 +295,7 @@ export default {
.then(res => res.data)
.then(data => this.checkForSpam(data))
.then(data => {
- if (window.location.pathname !== data.web_url) {
+ if (!window.location.pathname.includes(data.web_url)) {
visitUrl(data.web_url);
}
})
@@ -329,7 +329,7 @@ export default {
},
deleteIssuable(payload) {
- this.service
+ return this.service
.deleteIssuable(payload)
.then(res => res.data)
.then(data => {
@@ -340,7 +340,7 @@ export default {
})
.catch(() => {
createFlash(
- sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }),
+ sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }),
);
});
},
@@ -365,7 +365,12 @@ export default {
:issuable-type="issuableType"
/>
- <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptchaModal" />
+ <recaptcha-modal
+ v-show="showRecaptcha"
+ ref="recaptchaModal"
+ :html="recaptchaHTML"
+ @close="closeRecaptchaModal"
+ />
</div>
<div v-else>
<title-component
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 447d7bf21a5..35165c9b481 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -45,22 +45,24 @@ export default {
:markdown-docs-path="markdownDocsPath"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
+ :textarea-value="formState.description"
>
- <textarea
- id="issue-description"
- ref="textarea"
- slot="textarea"
- v-model="formState.description"
- class="note-textarea js-gfm-input js-autosize markdown-area
- qa-description-textarea"
- dir="auto"
- data-supports-quick-actions="false"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
- @keydown.meta.enter="updateIssuable"
- @keydown.ctrl.enter="updateIssuable"
- >
- </textarea>
+ <template #textarea>
+ <textarea
+ id="issue-description"
+ ref="textarea"
+ v-model="formState.description"
+ class="note-textarea js-gfm-input js-autosize markdown-area
+ qa-description-textarea"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ @keydown.meta.enter="updateIssuable"
+ @keydown.ctrl.enter="updateIssuable"
+ >
+ </textarea>
+ </template>
</markdown-field>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/issue_show/event_hub.js
+++ b/app/assets/javascripts/issue_show/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
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 b71c06e4217..d1570f52c8c 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_app.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue
@@ -1,5 +1,6 @@
<script>
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+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';
@@ -13,6 +14,7 @@ export default {
components: {
GlAlert,
GlLoadingIcon,
+ GlSprintf,
JiraImportForm,
JiraImportProgress,
JiraImportSetup,
@@ -30,6 +32,10 @@ export default {
type: String,
required: true,
},
+ jiraIntegrationPath: {
+ type: String,
+ required: true,
+ },
jiraProjects: {
type: Array,
required: true,
@@ -47,6 +53,7 @@ export default {
return {
errorMessage: '',
showAlert: false,
+ selectedProject: undefined,
};
},
apollo: {
@@ -59,7 +66,7 @@ export default {
},
update: ({ project }) => ({
status: project.jiraImportStatus,
- import: project.jiraImports.nodes[0],
+ imports: project.jiraImports.nodes,
}),
skip() {
return !this.isJiraConfigured;
@@ -73,6 +80,24 @@ export default {
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?.(
+ (acc, jiraProject) => (jiraProject.jiraProjectKey === this.selectedProject ? acc + 1 : acc),
+ 0,
+ );
+ },
+ importLabel() {
+ return this.selectedProject
+ ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImportsForProject + 1}`
+ : 'jira-import::KEY-1';
+ },
+ hasPreviousImports() {
+ return this.numberOfPreviousImportsForProject > 0;
+ },
},
methods: {
dismissAlert() {
@@ -93,6 +118,13 @@ export default {
return;
}
+ const cacheData = store.readQuery({
+ query: getJiraImportDetailsQuery,
+ variables: {
+ fullPath: this.projectPath,
+ },
+ });
+
store.writeQuery({
query: getJiraImportDetailsQuery,
variables: {
@@ -102,7 +134,10 @@ export default {
project: {
jiraImportStatus: IMPORT_STATE.SCHEDULED,
jiraImports: {
- nodes: [data.jiraImportStart.jiraImport],
+ nodes: [
+ ...cacheData.project.jiraImports.nodes,
+ data.jiraImportStart.jiraImport,
+ ],
__typename: 'JiraImportConnection',
},
// eslint-disable-next-line @gitlab/require-i18n-strings
@@ -115,6 +150,8 @@ export default {
.then(({ data }) => {
if (data.jiraImportStart.errors.length) {
this.setAlertMessage(data.jiraImportStart.errors.join('. '));
+ } else {
+ this.selectedProject = undefined;
}
})
.catch(() => this.setAlertMessage(__('There was an error importing the Jira project.')));
@@ -132,19 +169,38 @@ export default {
<gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert">
{{ 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>
+ </gl-alert>
- <jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" />
+ <jira-import-setup
+ v-if="!isJiraConfigured"
+ :illustration="setupIllustration"
+ :jira-integration-path="jiraIntegrationPath"
+ />
<gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" />
<jira-import-progress
v-else-if="isImportInProgress"
:illustration="inProgressIllustration"
- :import-initiator="jiraImportDetails.import.scheduledBy.name"
- :import-project="jiraImportDetails.import.jiraProjectKey"
- :import-time="jiraImportDetails.import.scheduledAt"
+ :import-initiator="mostRecentImport.scheduledBy.name"
+ :import-project="mostRecentImport.jiraProjectKey"
+ :import-time="mostRecentImport.scheduledAt"
:issues-path="issuesPath"
/>
<jira-import-form
v-else
+ v-model="selectedProject"
+ :import-label="importLabel"
:issues-path="issuesPath"
:jira-projects="jiraProjectsOptions"
@initiateJiraImport="initiateJiraImport"
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index 0146f564260..c2fe7b29c28 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -13,6 +13,10 @@ export default {
currentUserAvatarUrl: gon.current_user_avatar_url,
currentUsername: gon.current_username,
props: {
+ importLabel: {
+ type: String,
+ required: true,
+ },
issuesPath: {
type: String,
required: true,
@@ -21,21 +25,25 @@ export default {
type: Array,
required: true,
},
+ value: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
- selectedOption: null,
selectState: null,
};
},
methods: {
initiateJiraImport(event) {
event.preventDefault();
- if (!this.selectedOption) {
- this.showValidationError();
- } else {
+ if (this.value) {
this.hideValidationError();
- this.$emit('initiateJiraImport', this.selectedOption);
+ this.$emit('initiateJiraImport', this.value);
+ } else {
+ this.showValidationError();
}
},
hideValidationError() {
@@ -62,10 +70,11 @@ export default {
>
<gl-form-select
id="jira-project-select"
- v-model="selectedOption"
class="mb-2"
:options="jiraProjects"
:state="selectState"
+ :value="value"
+ @change="$emit('input', $event)"
/>
</gl-form-group>
@@ -79,7 +88,7 @@ export default {
id="jira-project-label"
class="mb-2"
background-color="#428BCA"
- title="jira-import::KEY-1"
+ :title="importLabel"
scoped
/>
</gl-form-group>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue
index 2d610224658..78f10decd31 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_progress.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue
@@ -46,6 +46,9 @@ export default {
importTime: formatDate(this.importTime),
});
},
+ issuesLink() {
+ return `${this.issuesPath}?search=${this.importProject}`;
+ },
},
};
</script>
@@ -55,7 +58,7 @@ export default {
:svg-path="illustration"
:title="__('Import in progress')"
:primary-button-text="__('View issues')"
- :primary-button-link="issuesPath"
+ :primary-button-link="issuesLink"
>
<template #description>
<p class="mb-0">{{ importInitiatorText }}</p>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue
index 44773a773d5..285c5c815ac 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue
@@ -11,6 +11,10 @@ export default {
type: String,
required: true,
},
+ jiraIntegrationPath: {
+ type: String,
+ required: true,
+ },
},
};
</script>
@@ -21,6 +25,6 @@ export default {
title=""
:description="__('You will first need to set up Jira Integration to use this feature.')"
:primary-button-text="__('Set up Jira Integration')"
- primary-button-link="../services/jira/edit"
+ :primary-button-link="jiraIntegrationPath"
/>
</template>
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 8bd70e4e277..b576668fe7c 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -27,6 +27,7 @@ export default function mountJiraImportApp() {
inProgressIllustration: el.dataset.inProgressIllustration,
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 0eaaad580fc..aa8d03c7f17 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
@@ -3,7 +3,7 @@
query($fullPath: ID!) {
project(fullPath: $fullPath) {
jiraImportStatus
- jiraImports(last: 1) {
+ jiraImports {
nodes {
...JiraImport
}
diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js
index 504cf19e44e..aa10dfc8099 100644
--- a/app/assets/javascripts/jira_import/utils.js
+++ b/app/assets/javascripts/jira_import/utils.js
@@ -1,3 +1,5 @@
+import { last } from 'lodash';
+
export const IMPORT_STATE = {
FAILED: 'failed',
FINISHED: 'finished',
@@ -8,3 +10,50 @@ export const IMPORT_STATE = {
export const isInProgress = state =>
state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED;
+
+export const isFinished = state => state === IMPORT_STATE.FINISHED;
+
+/**
+ * Calculates the label title for the most recent Jira import.
+ *
+ * @param {Object[]} jiraImports - List of Jira imports
+ * @param {string} jiraImports[].jiraProjectKey - Jira project key
+ * @returns {string} - A label title
+ */
+const calculateJiraImportLabelTitle = jiraImports => {
+ const mostRecentJiraProjectKey = last(jiraImports)?.jiraProjectKey;
+ const jiraProjectImportCount = jiraImports.filter(
+ jiraImport => jiraImport.jiraProjectKey === mostRecentJiraProjectKey,
+ ).length;
+ return `jira-import::${mostRecentJiraProjectKey}-${jiraProjectImportCount}`;
+};
+
+/**
+ * Finds the label color from a list of labels.
+ *
+ * @param {string} labelTitle - Label title
+ * @param {Object[]} labels - List of labels
+ * @param {string} labels[].title - Label title
+ * @param {string} labels[].color - Label color
+ * @returns {string} - The label color associated with the given labelTitle
+ */
+const calculateJiraImportLabelColor = (labelTitle, labels) =>
+ labels.find(label => label.title === labelTitle)?.color;
+
+/**
+ * Calculates the label for the most recent Jira import.
+ *
+ * @param {Object[]} jiraImports - List of Jira imports
+ * @param {string} jiraImports[].jiraProjectKey - Jira project key
+ * @param {Object[]} labels - List of labels
+ * @param {string} labels[].title - Label title
+ * @param {string} labels[].color - Label color
+ * @returns {{color: string, title: string}} - A label object containing a label color and title
+ */
+export const calculateJiraImportLabel = (jiraImports, labels) => {
+ const title = calculateJiraImportLabelTitle(jiraImports);
+ return {
+ color: calculateJiraImportLabelColor(title, labels),
+ title,
+ };
+};
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index d9168f57cc7..28cc03c88cb 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc, isEmpty } from 'lodash';
+import { escape, isEmpty } from 'lodash';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { sprintf, __ } from '../../locale';
@@ -43,7 +43,7 @@ export default {
'%{startLink}%{name}%{endLink}',
{
startLink: `<a href="${this.deploymentStatus.environment.environment_path}" class="js-environment-link">`,
- name: esc(this.deploymentStatus.environment.name),
+ name: escape(this.deploymentStatus.environment.name),
endLink: '</a>',
},
false,
@@ -74,8 +74,8 @@ export default {
}
const { name, path } = this.deploymentCluster;
- const escapedName = esc(name);
- const escapedPath = esc(path);
+ const escapedName = escape(name);
+ const escapedPath = escape(path);
if (!escapedPath) {
return escapedName;
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index f4030939f2c..0ce8dfe4442 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -220,7 +220,7 @@ export const fetchJobsForStage = ({ dispatch }, stage = {}) => {
},
})
.then(({ data }) => {
- const retriedJobs = data.retried.map(job => Object.assign({}, job, { retried: true }));
+ const retriedJobs = data.retried.map(job => ({ ...job, retried: true }));
const jobs = data.latest_statuses.concat(retriedJobs);
dispatch('receiveJobsForStageSuccess', jobs);
@@ -236,7 +236,7 @@ export const receiveJobsForStageError = ({ commit }) => {
export const triggerManualJob = ({ state }, variables) => {
const parsedVariables = variables.map(variable => {
- const copyVar = Object.assign({}, variable);
+ const copyVar = { ...variable };
delete copyVar.id;
return copyVar;
});
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 5a61828ec6d..d76828ad19b 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -1,4 +1,4 @@
-import { isNewJobLogActive } from '../store/utils';
+import { isNewJobLogActive } from './utils';
export default () => ({
jobEndpoint: null,
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 7107c970457..65d8866fcc3 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,9 +1,9 @@
-/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */
+/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, dot-notation, no-empty */
/* global Issuable */
/* global ListLabel */
import $ from 'jquery';
-import _ from 'underscore';
+import { isEqual, escape, sortBy, template } from 'lodash';
import { sprintf, s__, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -55,7 +55,6 @@ export default class LabelsSelect {
})
.get();
const scopedLabels = $dropdown.data('scopedLabels');
- const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink');
const { handleClick } = options;
$sidebarLabelTooltip.tooltip();
@@ -76,7 +75,7 @@ export default class LabelsSelect {
})
.get();
- if (_.isEqual(initialSelected, selected)) return;
+ if (isEqual(initialSelected, selected)) return;
initialSelected = selected;
const data = {};
@@ -101,10 +100,9 @@ export default class LabelsSelect {
let labelCount = 0;
if (data.labels.length && issueUpdateURL) {
template = LabelsSelect.getLabelTemplate({
- labels: _.sortBy(data.labels, 'title'),
+ labels: sortBy(data.labels, 'title'),
issueUpdateURL,
enableScopedLabels: scopedLabels,
- scopedLabelsDocumentationLink,
});
labelCount = data.labels.length;
@@ -188,13 +186,13 @@ export default class LabelsSelect {
if (showNo) {
extraData.unshift({
id: 0,
- title: __('No Label'),
+ title: __('No label'),
});
}
if (showAny) {
extraData.unshift({
isAny: true,
- title: __('Any Label'),
+ title: __('Any label'),
});
}
if (extraData.length) {
@@ -269,7 +267,7 @@ export default class LabelsSelect {
}
linkEl.className = selectedClass.join(' ');
- linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`;
+ linkEl.innerHTML = `${colorEl} ${escape(label.title)}`;
const listItemEl = document.createElement('li');
listItemEl.appendChild(linkEl);
@@ -296,7 +294,7 @@ export default class LabelsSelect {
if (selected && selected.id === 0) {
this.selected = [];
- return __('No Label');
+ return __('No label');
} else if (isSelected) {
this.selected.push(title);
} else if (!isSelected && title) {
@@ -311,9 +309,8 @@ export default class LabelsSelect {
firstLabel: selectedLabels[0],
labelCount: selectedLabels.length - 1,
});
- } else {
- return defaultLabel;
}
+ return defaultLabel;
},
fieldName: $dropdown.data('fieldName'),
id(label) {
@@ -325,9 +322,8 @@ export default class LabelsSelect {
if ($dropdown.hasClass('js-filter-submit') && label.isAny == null) {
return label.title;
- } else {
- return label.id;
}
+ return label.id;
},
hidden() {
const page = $('body').attr('data-page');
@@ -436,7 +432,7 @@ export default class LabelsSelect {
if (isScopedLabel(label)) {
const prevIds = oldLabels.map(label => label.id);
const newIds = boardsStore.detail.issue.labels.map(label => label.id);
- const differentIds = _.difference(prevIds, newIds);
+ const differentIds = prevIds.filter(x => !newIds.includes(x));
$dropdown.data('marked', newIds);
$dropdownMenu
.find(differentIds.map(id => `[data-label-id="${id}"]`).join(','))
@@ -483,7 +479,7 @@ export default class LabelsSelect {
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">';
const spanOpenTag =
'<span class="gl-label-text" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">';
- const labelTemplate = _.template(
+ const labelTemplate = template(
[
'<span class="gl-label">',
linkOpenTag,
@@ -499,15 +495,7 @@ export default class LabelsSelect {
return escapeStr(label.text_color === '#FFFFFF' ? label.color : label.text_color);
};
- const infoIconTemplate = _.template(
- [
- '<a href="<%= scopedLabelsDocumentationLink %>" class="gl-link gl-label-icon" target="_blank" rel="noopener">',
- '<i class="fa fa-question-circle"></i>',
- '</a>',
- ].join(''),
- );
-
- const scopedLabelTemplate = _.template(
+ const scopedLabelTemplate = template(
[
'<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">',
linkOpenTag,
@@ -518,12 +506,11 @@ export default class LabelsSelect {
'<%- label.title.slice(label.title.lastIndexOf("::") + 2) %>',
'</span>',
'</a>',
- '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>',
'</span>',
].join(''),
);
- const tooltipTitleTemplate = _.template(
+ const tooltipTitleTemplate = template(
[
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
"<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
@@ -535,12 +522,12 @@ export default class LabelsSelect {
].join(''),
);
- const tpl = _.template(
+ const tpl = template(
[
- '<% _.each(labels, function(label){ %>',
+ '<% labels.forEach(function(label){ %>',
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
'<span class="d-inline-block position-relative scoped-label-wrapper">',
- '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, infoIconTemplate, scopedLabelsDocumentationLink, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
+ '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
'</span>',
'<% } else { %>',
'<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>',
@@ -553,11 +540,10 @@ export default class LabelsSelect {
...tplData,
labelTemplate,
rightLabelTextColor,
- infoIconTemplate,
scopedLabelTemplate,
tooltipTitleTemplate,
isScopedLabel,
- escapeStr: _.escape,
+ escapeStr: escape,
});
}
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 8d3b87d5cc0..b6c41ffa7ab 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -33,6 +33,7 @@ export default (resolvers = {}, config = {}) => {
};
return new ApolloClient({
+ typeDefs: config.typeDefs,
link: ApolloLink.split(
operation => operation.getContext().hasUpload || operation.getContext().isSingleRequest,
createUploadLink(httpOptions),
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index f6077673ad5..6b69d2febe0 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -57,6 +57,19 @@ export const getMonthNames = abbreviated => {
export const pad = (val, len = 2) => `0${val}`.slice(-len);
/**
+ * Returns i18n weekday names array.
+ */
+export const getWeekdayNames = () => [
+ __('Sunday'),
+ __('Monday'),
+ __('Tuesday'),
+ __('Wednesday'),
+ __('Thursday'),
+ __('Friday'),
+ __('Saturday'),
+];
+
+/**
* Given a date object returns the day of the week in English
* @param {date} date
* @returns {String}
diff --git a/app/assets/javascripts/lib/utils/downloader.js b/app/assets/javascripts/lib/utils/downloader.js
new file mode 100644
index 00000000000..2297f5f90ce
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/downloader.js
@@ -0,0 +1,20 @@
+/**
+ * Helper function to trigger a download.
+ *
+ * - If the `fileName` is `_blank` it will open the file in a new tab.
+ * - If `fileData` is provided, it will inline the content and use data URLs to
+ * download the file. In this case the `url` property will be ignored. Please
+ * note that `fileData` needs to be Base64 encoded.
+ */
+export default ({ fileName, url, fileData }) => {
+ let href = url;
+
+ if (fileData) {
+ href = `data:text/plain;base64,${fileData}`;
+ }
+
+ const anchor = document.createElement('a');
+ anchor.download = fileName;
+ anchor.href = href;
+ anchor.click();
+};
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
index 16bffc5c2cf..618266f7a09 100644
--- a/app/assets/javascripts/lib/utils/keycodes.js
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -1,3 +1,6 @@
+// `e.keyCode` is deprecated, these values should be migrated
+// See: https://gitlab.com/gitlab-org/gitlab/-/issues/216102
+
export const BACKSPACE_KEY_CODE = 8;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js
new file mode 100644
index 00000000000..8e5420e87ea
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/keys.js
@@ -0,0 +1,4 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+export const ESC_KEY = 'Escape';
+export const ESC_KEY_IE11 = 'Esc'; // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index cccf9ad311c..0dfc144c363 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-param-reassign, operator-assignment, no-else-return, consistent-return */
+/* eslint-disable func-names, no-param-reassign, operator-assignment, consistent-return */
import $ from 'jquery';
import { insertText } from '~/lib/utils/common_utils';
@@ -217,16 +217,15 @@ export function insertMarkdownText({
}
if (val.indexOf(tag) === 0) {
return String(val.replace(tag, ''));
- } else {
- return String(tag) + val;
}
+ return String(tag) + val;
})
.join('\n');
}
} else if (tag.indexOf(textPlaceholder) > -1) {
textToInsert = tag.replace(textPlaceholder, selected);
} else {
- textToInsert = String(startChar) + tag + selected + (wrap ? tag : ' ');
+ textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
}
if (removedFirstNewLine) {
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index a495d2040d3..966e6d42b80 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -63,15 +63,22 @@ export function getParameterValues(sParam, url = window.location) {
}, []);
}
-// @param {Object} params - url keys and value to merge
-// @param {String} url
+/**
+ * Merges a URL to a set of params replacing value for
+ * those already present.
+ *
+ * Also removes `null` param values from the resulting URL.
+ *
+ * @param {Object} params - url keys and value to merge
+ * @param {String} url
+ */
export function mergeUrlParams(params, url) {
const re = /^([^?#]*)(\?[^#]*)?(.*)/;
const merged = {};
- const urlparts = url.match(re);
+ const [, fullpath, query, fragment] = url.match(re);
- if (urlparts[2]) {
- urlparts[2]
+ if (query) {
+ query
.substr(1)
.split('&')
.forEach(part => {
@@ -84,11 +91,15 @@ export function mergeUrlParams(params, url) {
Object.assign(merged, params);
- const query = Object.keys(merged)
+ const newQuery = Object.keys(merged)
+ .filter(key => merged[key] !== null)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`)
.join('&');
- return `${urlparts[1]}?${query}${urlparts[3]}`;
+ if (newQuery) {
+ return `${fullpath}?${newQuery}${fragment}`;
+ }
+ return `${fullpath}${fragment}`;
}
/**
@@ -213,12 +224,45 @@ export function getBaseURL() {
}
/**
+ * Returns true if url is an absolute URL
+ *
+ * @param {String} url
+ */
+export function isAbsolute(url) {
+ return /^https?:\/\//.test(url);
+}
+
+/**
+ * Returns true if url is a root-relative URL
+ *
+ * @param {String} url
+ */
+export function isRootRelative(url) {
+ return /^\//.test(url);
+}
+
+/**
* Returns true if url is an absolute or root-relative URL
*
* @param {String} url
*/
export function isAbsoluteOrRootRelative(url) {
- return /^(https?:)?\//.test(url);
+ return isAbsolute(url) || isRootRelative(url);
+}
+
+/**
+ * Converts a relative path to an absolute or a root relative path depending
+ * on what is passed as a basePath.
+ *
+ * @param {String} path Relative path, eg. ../img/img.png
+ * @param {String} basePath Absolute or root relative path, eg. /user/project or
+ * https://gitlab.com/user/project
+ */
+export function relativePathToAbsolute(path, basePath) {
+ const absolute = isAbsolute(basePath);
+ const base = absolute ? basePath : `file:///${basePath}`;
+ const url = new URL(path, base);
+ return absolute ? url.href : decodeURIComponent(url.pathname);
}
/**
@@ -259,8 +303,10 @@ export function getWebSocketUrl(path) {
export function queryToObject(query) {
const removeQuestionMarkFromQuery = String(query).startsWith('?') ? query.slice(1) : query;
return removeQuestionMarkFromQuery.split('&').reduce((accumulator, curr) => {
- const p = curr.split('=');
- accumulator[decodeURIComponent(p[0])] = decodeURIComponent(p[1]);
+ const [key, value] = curr.split('=');
+ if (value !== undefined) {
+ accumulator[decodeURIComponent(key)] = decodeURIComponent(value);
+ }
return accumulator;
}, {});
}
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index dd868bb9f4c..ed10c7646a8 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return, no-else-return */
+/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return */
import $ from 'jquery';
@@ -54,6 +54,7 @@ LineHighlighter.prototype.bindEvents = function() {
$fileHolder.on('click', 'a[data-line-number]', this.clickHandler);
$fileHolder.on('highlight:line', this.highlightHash);
+ window.addEventListener('hashchange', e => this.highlightHash(e.target.location.hash));
};
LineHighlighter.prototype.highlightHash = function(newHash) {
@@ -127,9 +128,8 @@ LineHighlighter.prototype.hashToRange = function(hash) {
const first = parseInt(matches[1], 10);
const last = matches[2] ? parseInt(matches[2], 10) : null;
return [first, last];
- } else {
- return [null, null];
}
+ return [null, null];
};
// Highlight a single line
@@ -152,9 +152,8 @@ LineHighlighter.prototype.highlightRange = function(range) {
}
return results;
- } else {
- return this.highlightLine(range[0]);
}
+ return this.highlightLine(range[0]);
};
// Set the URL hash string
diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
index 7ab4e725d99..b4658a159d7 100644
--- a/app/assets/javascripts/locale/sprintf.js
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -5,7 +5,7 @@ import { escape } from 'lodash';
@param input (translated) text with parameters (e.g. '%{num_users} users use us')
@param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 })
- @param {Boolean} escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape)
+ @param {Boolean} escapeParameters whether parameter values should be escaped (see https://lodash.com/docs/4.17.15#escape)
@returns {String} the text with parameters replaces (e.g. '5 users use us')
@see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 6c8f6372795..713f57a2b27 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -106,12 +106,14 @@ function deferredInitialisation() {
initLogoAnimation();
initUsagePingConsent();
initUserPopovers();
- initUserTracking();
initBroadcastNotifications();
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();
addSelectOnFocusBehaviour('.js-select-on-focus');
@@ -187,6 +189,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
+ initUserTracking();
initLayoutNav();
// Set the default path for all cookies to GitLab's root directory
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index 0dabb28ea66..ef7d8cc9efe 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -29,8 +29,6 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d
onSelect(dateText) {
$input.val(calendar.toString(dateText));
- $input.trigger('change');
-
toggleClearInput.call($input);
},
firstDay: gon.first_day_of_week,
@@ -49,7 +47,6 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d
const calendar = input.data('pikaday');
calendar.setDate(null);
- input.trigger('change');
toggleClearInput.call(input);
});
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 87de58443e0..1795a0dbdf8 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,9 +1,9 @@
/* eslint-disable no-new, class-methods-use-this */
import $ from 'jquery';
-import Vue from 'vue';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
+import createEventHub from '~/helpers/event_hub_factory';
import axios from './lib/utils/axios_utils';
import flash from './flash';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
@@ -93,7 +93,7 @@ export default class MergeRequestTabs {
this.pipelinesLoaded = false;
this.commitsLoaded = false;
this.fixedLayoutPref = null;
- this.eventHub = new Vue();
+ this.eventHub = createEventHub();
this.setUrl = setUrl !== undefined ? setUrl : true;
this.setCurrentAction = this.setCurrentAction.bind(this);
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index d15e4ecb537..e14212254a8 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -1,9 +1,9 @@
-/* eslint-disable one-var, no-else-return, no-self-compare, consistent-return, no-param-reassign, no-shadow */
+/* eslint-disable one-var, no-self-compare, consistent-return, no-param-reassign, no-shadow */
/* global Issuable */
/* global ListMilestone */
import $ from 'jquery';
-import _ from 'underscore';
+import { template, escape } from 'lodash';
import { __ } from '~/locale';
import '~/gl_dropdown';
import axios from './lib/utils/axios_utils';
@@ -56,11 +56,11 @@ export default class MilestoneSelect {
const $loading = $block.find('.block-loading').fadeOut();
selectedMilestoneDefault = showAny ? '' : null;
selectedMilestoneDefault =
- showNo && defaultNo ? __('No Milestone') : selectedMilestoneDefault;
+ showNo && defaultNo ? __('No milestone') : selectedMilestoneDefault;
selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
if (issueUpdateURL) {
- milestoneLinkTemplate = _.template(
+ milestoneLinkTemplate = template(
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
);
milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`;
@@ -74,14 +74,14 @@ export default class MilestoneSelect {
extraOptions.push({
id: null,
name: null,
- title: __('Any Milestone'),
+ title: __('Any milestone'),
});
}
if (showNo) {
extraOptions.push({
id: -1,
- name: __('No Milestone'),
- title: __('No Milestone'),
+ name: __('No milestone'),
+ title: __('No milestone'),
});
}
if (showUpcoming) {
@@ -106,12 +106,12 @@ export default class MilestoneSelect {
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
- $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active');
+ $(`[data-milestone-id="${escape(selectedMilestone)}"] > a`).addClass('is-active');
}),
renderRow: milestone => `
- <li data-milestone-id="${_.escape(milestone.name)}">
+ <li data-milestone-id="${escape(milestone.name)}">
<a href='#' class='dropdown-menu-milestone-link'>
- ${_.escape(milestone.title)}
+ ${escape(milestone.title)}
</a>
</li>
`,
@@ -123,19 +123,17 @@ export default class MilestoneSelect {
toggleLabel: (selected, el) => {
if (selected && 'id' in selected && $(el).hasClass('is-active')) {
return selected.title;
- } else {
- return defaultLabel;
}
+ return defaultLabel;
},
defaultLabel,
fieldName: $dropdown.data('fieldName'),
- text: milestone => _.escape(milestone.title),
+ text: milestone => escape(milestone.title),
id: milestone => {
if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
return milestone.name;
- } else {
- return milestone.id;
}
+ return milestone.id;
},
hidden: () => {
$selectBox.hide();
@@ -148,7 +146,7 @@ export default class MilestoneSelect {
selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
}
$('a.is-active', $el).removeClass('is-active');
- $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
+ $(`[data-milestone-id="${escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: clickEvent => {
@@ -244,13 +242,12 @@ export default class MilestoneSelect {
)
.find('span')
.text(data.milestone.title);
- } else {
- $value.html(milestoneLinkNoneTemplate);
- return $sidebarCollapsedValue
- .attr('data-original-title', __('Milestone'))
- .find('span')
- .text(__('None'));
}
+ $value.html(milestoneLinkNoneTemplate);
+ return $sidebarCollapsedValue
+ .attr('data-original-title', __('Milestone'))
+ .find('span')
+ .text(__('None'));
})
.catch(() => {
// eslint-disable-next-line no-jquery/no-fade
diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue
new file mode 100644
index 00000000000..19148d6184f
--- /dev/null
+++ b/app/assets/javascripts/milestones/project_milestone_combobox.vue
@@ -0,0 +1,228 @@
+<script>
+import {
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownHeader,
+ GlNewDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlIcon,
+} from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import Api from '~/api';
+import createFlash from '~/flash';
+import { intersection, debounce } from 'lodash';
+
+export default {
+ components: {
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownHeader,
+ GlNewDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlIcon,
+ },
+ model: {
+ prop: 'preselectedMilestones',
+ event: 'change',
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ preselectedMilestones: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ extraLinks: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ },
+ data() {
+ return {
+ searchQuery: '',
+ projectMilestones: [],
+ searchResults: [],
+ selectedMilestones: [],
+ requestCount: 0,
+ };
+ },
+ translations: {
+ milestone: __('Milestone'),
+ selectMilestone: __('Select milestone'),
+ noMilestone: __('No milestone'),
+ noResultsLabel: __('No matching results'),
+ searchMilestones: __('Search Milestones'),
+ },
+ computed: {
+ selectedMilestonesLabel() {
+ if (this.milestoneTitles.length === 1) {
+ return this.milestoneTitles[0];
+ }
+
+ if (this.milestoneTitles.length > 1) {
+ const firstMilestoneName = this.milestoneTitles[0];
+ const numberOfOtherMilestones = this.milestoneTitles.length - 1;
+ return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), {
+ firstMilestoneName,
+ numberOfOtherMilestones,
+ });
+ }
+
+ return this.$options.translations.noMilestone;
+ },
+ milestoneTitles() {
+ return this.preselectedMilestones.map(milestone => milestone.title);
+ },
+ dropdownItems() {
+ return this.searchResults.length ? this.searchResults : this.projectMilestones;
+ },
+ noResults() {
+ return this.searchQuery.length > 2 && this.searchResults.length === 0;
+ },
+ isLoading() {
+ return this.requestCount !== 0;
+ },
+ },
+ mounted() {
+ this.fetchMilestones();
+ },
+ methods: {
+ fetchMilestones() {
+ this.requestCount += 1;
+
+ Api.projectMilestones(this.projectId)
+ .then(({ data }) => {
+ this.projectMilestones = this.getTitles(data);
+ this.selectedMilestones = intersection(this.projectMilestones, this.milestoneTitles);
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while loading milestones'));
+ })
+ .finally(() => {
+ this.requestCount -= 1;
+ });
+ },
+ searchMilestones: debounce(function searchMilestones() {
+ this.requestCount += 1;
+ const options = {
+ search: this.searchQuery,
+ scope: 'milestones',
+ };
+
+ if (this.searchQuery.length < 3) {
+ this.requestCount -= 1;
+ this.searchResults = [];
+ return;
+ }
+
+ Api.projectSearch(this.projectId, options)
+ .then(({ data }) => {
+ const searchResults = this.getTitles(data);
+
+ this.searchResults = searchResults.length ? searchResults : [];
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while searching for milestones'));
+ })
+ .finally(() => {
+ this.requestCount -= 1;
+ });
+ }, 100),
+ toggleMilestoneSelection(clickedMilestone) {
+ if (!clickedMilestone) return [];
+
+ let milestones = [...this.preselectedMilestones];
+ const hasMilestone = this.milestoneTitles.includes(clickedMilestone);
+
+ if (hasMilestone) {
+ milestones = milestones.filter(({ title }) => title !== clickedMilestone);
+ } else {
+ milestones.push({ title: clickedMilestone });
+ }
+
+ return milestones;
+ },
+ onMilestoneClicked(clickedMilestone) {
+ const milestones = this.toggleMilestoneSelection(clickedMilestone);
+ this.$emit('change', milestones);
+
+ this.selectedMilestones = intersection(
+ this.projectMilestones,
+ milestones.map(milestone => milestone.title),
+ );
+ },
+ isSelectedMilestone(milestoneTitle) {
+ return this.selectedMilestones.includes(milestoneTitle);
+ },
+ getTitles(milestones) {
+ return milestones.filter(({ state }) => state === 'active').map(({ title }) => title);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-new-dropdown>
+ <template slot="button-content">
+ <span ref="buttonText" class="flex-grow-1 ml-1 text-muted">{{
+ selectedMilestonesLabel
+ }}</span>
+ <gl-icon name="chevron-down" />
+ </template>
+
+ <gl-new-dropdown-header>
+ <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span>
+ </gl-new-dropdown-header>
+
+ <gl-new-dropdown-divider />
+
+ <gl-search-box-by-type
+ v-model.trim="searchQuery"
+ class="m-2"
+ :placeholder="this.$options.translations.searchMilestones"
+ @input="searchMilestones"
+ />
+
+ <gl-new-dropdown-item @click="onMilestoneClicked(null)">
+ <span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }">
+ {{ $options.translations.noMilestone }}
+ </span>
+ </gl-new-dropdown-item>
+
+ <gl-new-dropdown-divider />
+
+ <template v-if="isLoading">
+ <gl-loading-icon />
+ <gl-new-dropdown-divider />
+ </template>
+ <template v-else-if="noResults">
+ <div class="dropdown-item-space">
+ <span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span>
+ </div>
+ <gl-new-dropdown-divider />
+ </template>
+ <template v-else-if="dropdownItems.length">
+ <gl-new-dropdown-item
+ v-for="item in dropdownItems"
+ :key="item"
+ role="milestone option"
+ @click="onMilestoneClicked(item)"
+ >
+ <span :class="{ 'pl-4': true, 'selected-item': isSelectedMilestone(item) }">
+ {{ item }}
+ </span>
+ </gl-new-dropdown-item>
+ <gl-new-dropdown-divider />
+ </template>
+
+ <gl-new-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url">
+ <span class="pl-4">{{ item.text }}</span>
+ </gl-new-dropdown-item>
+ </gl-new-dropdown>
+</template>
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 2276a723326..986785fdfbe 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import Flash from '~/flash';
@@ -161,7 +161,7 @@ export default class SSHMirror {
const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list');
let fingerprints = '';
sshHostKeys.fingerprints.forEach(fingerprint => {
- const escFingerprints = esc(fingerprint.fingerprint);
+ const escFingerprints = escape(fingerprint.fingerprint);
fingerprints += `<code>${escFingerprints}</code>`;
});
diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue
new file mode 100644
index 00000000000..86a793c854e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/alert_widget.vue
@@ -0,0 +1,286 @@
+<script>
+import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import createFlash from '~/flash';
+import AlertWidgetForm from './alert_widget_form.vue';
+import AlertsService from '../services/alerts_service';
+import { alertsValidator, queriesValidator } from '../validators';
+import { OPERATORS } from '../constants';
+import { values, get } from 'lodash';
+
+export default {
+ components: {
+ AlertWidgetForm,
+ GlBadge,
+ GlLoadingIcon,
+ GlIcon,
+ GlTooltip,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ alertsEndpoint: {
+ type: String,
+ required: true,
+ },
+ showLoadingState: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls.
+ // Includes only the metrics/alerts to be managed by this widget.
+ alertsToManage: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ validator: alertsValidator,
+ },
+ // [{ metric+query_attributes }]. Represents queries (and alerts) we know about
+ // on intial fetch. Essentially used for reference.
+ relevantQueries: {
+ type: Array,
+ required: true,
+ validator: queriesValidator,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ service: null,
+ errorMessage: null,
+ isLoading: false,
+ apiAction: 'create',
+ };
+ },
+ i18n: {
+ alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'),
+ singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'),
+ multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'),
+ firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'),
+ },
+ computed: {
+ singleAlertSummary() {
+ return {
+ message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0],
+ alert: this.thresholds[0],
+ };
+ },
+ multipleAlertsSummary() {
+ return {
+ message: this.isFiring
+ ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}`
+ : this.$options.i18n.alertsCountMsg,
+ count: this.thresholds.length,
+ firingCount: this.firingAlerts.length,
+ };
+ },
+ shouldShowLoadingIcon() {
+ return this.showLoadingState && this.isLoading;
+ },
+ thresholds() {
+ const alertsToManage = Object.keys(this.alertsToManage);
+ return alertsToManage.map(this.formatAlertSummary);
+ },
+ hasAlerts() {
+ return Boolean(Object.keys(this.alertsToManage).length);
+ },
+ hasMultipleAlerts() {
+ return this.thresholds.length > 1;
+ },
+ isFiring() {
+ return Boolean(this.firingAlerts.length);
+ },
+ firingAlerts() {
+ return values(this.alertsToManage).filter(alert =>
+ this.passedAlertThreshold(this.getQueryData(alert), alert),
+ );
+ },
+ formattedFiringAlerts() {
+ return this.firingAlerts.map(alert => this.formatAlertSummary(alert.alert_path));
+ },
+ configuredAlert() {
+ return this.hasAlerts ? values(this.alertsToManage)[0].metricId : '';
+ },
+ },
+ created() {
+ this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint });
+ this.fetchAlertData();
+ },
+ methods: {
+ fetchAlertData() {
+ this.isLoading = true;
+
+ const queriesWithAlerts = this.relevantQueries.filter(query => query.alert_path);
+
+ return Promise.all(
+ queriesWithAlerts.map(query =>
+ this.service
+ .readAlert(query.alert_path)
+ .then(alertAttributes => this.setAlert(alertAttributes, query.metricId)),
+ ),
+ )
+ .then(() => {
+ this.isLoading = false;
+ })
+ .catch(() => {
+ createFlash(s__('PrometheusAlerts|Error fetching alert'));
+ this.isLoading = false;
+ });
+ },
+ setAlert(alertAttributes, metricId) {
+ this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId });
+ },
+ removeAlert(alertPath) {
+ this.$emit('setAlerts', alertPath, null);
+ },
+ formatAlertSummary(alertPath) {
+ const alert = this.alertsToManage[alertPath];
+ const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId);
+
+ return `${alertQuery.label} ${alert.operator} ${alert.threshold}`;
+ },
+ passedAlertThreshold(data, alert) {
+ const { threshold, operator } = alert;
+
+ switch (operator) {
+ case OPERATORS.greaterThan:
+ return data.some(value => value > threshold);
+ case OPERATORS.lessThan:
+ return data.some(value => value < threshold);
+ case OPERATORS.equalTo:
+ return data.some(value => value === threshold);
+ default:
+ return false;
+ }
+ },
+ getQueryData(alert) {
+ const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId);
+
+ return get(alertQuery, 'result[0].values', []).map(value => get(value, '[1]', null));
+ },
+ showModal() {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ },
+ hideModal() {
+ this.errorMessage = null;
+ this.$root.$emit('bv::hide::modal', this.modalId);
+ },
+ handleSetApiAction(apiAction) {
+ this.apiAction = apiAction;
+ },
+ handleCreate({ operator, threshold, prometheus_metric_id }) {
+ const newAlert = { operator, threshold, prometheus_metric_id };
+ this.isLoading = true;
+ this.service
+ .createAlert(newAlert)
+ .then(alertAttributes => {
+ this.setAlert(alertAttributes, prometheus_metric_id);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error creating alert');
+ this.isLoading = false;
+ });
+ },
+ handleUpdate({ alert, operator, threshold }) {
+ const updatedAlert = { operator, threshold };
+ this.isLoading = true;
+ this.service
+ .updateAlert(alert, updatedAlert)
+ .then(alertAttributes => {
+ this.setAlert(alertAttributes, this.alertsToManage[alert].metricId);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error saving alert');
+ this.isLoading = false;
+ });
+ },
+ handleDelete({ alert }) {
+ this.isLoading = true;
+ this.service
+ .deleteAlert(alert)
+ .then(() => {
+ this.removeAlert(alert);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error deleting alert');
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden">
+ <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" />
+ <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{
+ errorMessage
+ }}</span>
+ <span
+ v-else-if="hasAlerts"
+ ref="alertCurrentSetting"
+ class="alert-current-setting cursor-pointer d-flex"
+ @click="showModal"
+ >
+ <gl-badge
+ :variant="isFiring ? 'danger' : 'secondary'"
+ pill
+ 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
+ :message="
+ hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message
+ "
+ >
+ <template #alert>
+ {{ singleAlertSummary.alert }}
+ </template>
+ <template #count>
+ {{ multipleAlertsSummary.count }}
+ </template>
+ <template #firingCount>
+ {{ multipleAlertsSummary.firingCount }}
+ </template>
+ </gl-sprintf>
+ </span>
+ </gl-badge>
+ <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting">
+ <gl-sprintf :message="$options.i18n.firingAlertsTooltip">
+ <template #alerts>
+ <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path">
+ {{ alert }}
+ </div>
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </span>
+ <alert-widget-form
+ ref="widgetForm"
+ :disabled="isLoading"
+ :alerts-to-manage="alertsToManage"
+ :relevant-queries="relevantQueries"
+ :error-message="errorMessage"
+ :configured-alert="configuredAlert"
+ :modal-id="modalId"
+ @create="handleCreate"
+ @update="handleUpdate"
+ @delete="handleDelete"
+ @cancel="hideModal"
+ @setAction="handleSetApiAction"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
new file mode 100644
index 00000000000..74324daa1e3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
@@ -0,0 +1,307 @@
+<script>
+import { isEmpty, findKey } from 'lodash';
+import Vue from 'vue';
+import {
+ GlLink,
+ GlDeprecatedButton,
+ GlButtonGroup,
+ GlFormGroup,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import Translate from '~/vue_shared/translate';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import Icon from '~/vue_shared/components/icon.vue';
+import { alertsValidator, queriesValidator } from '../validators';
+import { OPERATORS } from '../constants';
+
+Vue.use(Translate);
+
+const SUBMIT_ACTION_TEXT = {
+ create: __('Add'),
+ update: __('Save'),
+ delete: __('Delete'),
+};
+
+const SUBMIT_BUTTON_CLASS = {
+ create: 'btn-success',
+ update: 'btn-success',
+ delete: 'btn-remove',
+};
+
+export default {
+ components: {
+ GlDeprecatedButton,
+ GlButtonGroup,
+ GlFormGroup,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlLink,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ errorMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ configuredAlert: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ alertsToManage: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ validator: alertsValidator,
+ },
+ relevantQueries: {
+ type: Array,
+ required: true,
+ validator: queriesValidator,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ operators: OPERATORS,
+ operator: null,
+ threshold: null,
+ prometheusMetricId: null,
+ selectedAlert: {},
+ alertQuery: '',
+ };
+ },
+ computed: {
+ isValidQuery() {
+ // TODO: Add query validation check (most likely via http request)
+ return this.alertQuery.length ? true : null;
+ },
+ currentQuery() {
+ return this.relevantQueries.find(query => query.metricId === this.prometheusMetricId) || {};
+ },
+ formDisabled() {
+ // We need a prometheusMetricId to determine whether we're
+ // creating/updating/deleting
+ return this.disabled || !(this.prometheusMetricId || this.isValidQuery);
+ },
+ supportsComputedAlerts() {
+ return this.glFeatures.prometheusComputedAlerts;
+ },
+ queryDropdownLabel() {
+ return this.currentQuery.label || s__('PrometheusAlerts|Select query');
+ },
+ haveValuesChanged() {
+ return (
+ this.operator &&
+ this.threshold === Number(this.threshold) &&
+ (this.operator !== this.selectedAlert.operator ||
+ this.threshold !== this.selectedAlert.threshold)
+ );
+ },
+ submitAction() {
+ if (isEmpty(this.selectedAlert)) return 'create';
+ if (this.haveValuesChanged) return 'update';
+ return 'delete';
+ },
+ submitActionText() {
+ return SUBMIT_ACTION_TEXT[this.submitAction];
+ },
+ submitButtonClass() {
+ return SUBMIT_BUTTON_CLASS[this.submitAction];
+ },
+ isSubmitDisabled() {
+ return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged);
+ },
+ dropdownTitle() {
+ return this.submitAction === 'create'
+ ? s__('PrometheusAlerts|Add alert')
+ : s__('PrometheusAlerts|Edit alert');
+ },
+ },
+ watch: {
+ alertsToManage() {
+ this.resetAlertData();
+ },
+ submitAction() {
+ this.$emit('setAction', this.submitAction);
+ },
+ },
+ methods: {
+ selectQuery(queryId) {
+ const existingAlertPath = findKey(this.alertsToManage, alert => alert.metricId === queryId);
+ const existingAlert = this.alertsToManage[existingAlertPath];
+
+ if (existingAlert) {
+ this.selectedAlert = existingAlert;
+ this.operator = existingAlert.operator;
+ this.threshold = existingAlert.threshold;
+ } else {
+ this.selectedAlert = {};
+ this.operator = this.operators.greaterThan;
+ this.threshold = null;
+ }
+
+ this.prometheusMetricId = queryId;
+ },
+ handleHidden() {
+ this.resetAlertData();
+ this.$emit('cancel');
+ },
+ handleSubmit(e) {
+ e.preventDefault();
+ this.$emit(this.submitAction, {
+ alert: this.selectedAlert.alert_path,
+ operator: this.operator,
+ threshold: this.threshold,
+ prometheus_metric_id: this.prometheusMetricId,
+ });
+ },
+ handleShown() {
+ if (this.configuredAlert) {
+ this.selectQuery(this.configuredAlert);
+ } else if (this.relevantQueries.length === 1) {
+ this.selectQuery(this.relevantQueries[0].metricId);
+ }
+ },
+ resetAlertData() {
+ this.operator = null;
+ this.threshold = null;
+ this.prometheusMetricId = null;
+ this.selectedAlert = {};
+ },
+ getAlertFormActionTrackingOption() {
+ const label = `${this.submitAction}_alert`;
+ return {
+ category: document.body.dataset.page,
+ action: 'click_button',
+ label,
+ };
+ },
+ },
+ alertQueryText: {
+ label: __('Query'),
+ validFeedback: __('Query is valid'),
+ invalidFeedback: __('Invalid query'),
+ descriptionTooltip: __(
+ 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="alertModal"
+ :title="dropdownTitle"
+ :modal-id="modalId"
+ :ok-variant="submitAction === 'delete' ? 'danger' : 'success'"
+ :ok-disabled="formDisabled"
+ @ok="handleSubmit"
+ @hidden="handleHidden"
+ @shown="handleShown"
+ >
+ <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div>
+ <div class="alert-form">
+ <gl-form-group
+ v-if="supportsComputedAlerts"
+ :label="$options.alertQueryText.label"
+ label-for="alert-query-input"
+ :valid-feedback="$options.alertQueryText.validFeedback"
+ :invalid-feedback="$options.alertQueryText.invalidFeedback"
+ :state="isValidQuery"
+ >
+ <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" />
+ <template #description>
+ <div class="d-flex align-items-center">
+ {{ __('Single or combined queries') }}
+ <icon
+ v-gl-tooltip="$options.alertQueryText.descriptionTooltip"
+ name="question"
+ class="prepend-left-4"
+ />
+ </div>
+ </template>
+ </gl-form-group>
+ <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label">
+ <gl-dropdown
+ id="alert-query-dropdown"
+ :text="queryDropdownLabel"
+ toggle-class="dropdown-menu-toggle qa-alert-query-dropdown"
+ >
+ <gl-dropdown-item
+ v-for="query in relevantQueries"
+ :key="query.metricId"
+ data-qa-selector="alert_query_option"
+ @click="selectQuery(query.metricId)"
+ >
+ {{ query.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </gl-form-group>
+ <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')">
+ <gl-deprecated-button
+ :class="{ active: operator === operators.greaterThan }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.greaterThan"
+ >
+ {{ operators.greaterThan }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ :class="{ active: operator === operators.equalTo }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.equalTo"
+ >
+ {{ operators.equalTo }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ :class="{ active: operator === operators.lessThan }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.lessThan"
+ >
+ {{ operators.lessThan }}
+ </gl-deprecated-button>
+ </gl-button-group>
+ <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold">
+ <gl-form-input
+ id="alerts-threshold"
+ v-model.number="threshold"
+ :disabled="formDisabled"
+ type="number"
+ data-qa-selector="alert_threshold_field"
+ />
+ </gl-form-group>
+ </div>
+ <template #modal-ok>
+ <gl-link
+ v-track-event="getAlertFormActionTrackingOption()"
+ class="text-reset text-decoration-none"
+ >
+ {{ submitActionText }}
+ </gl-link>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 447f8845506..34da5885c97 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -3,7 +3,7 @@ import { flattenDeep, isNumber } from 'lodash';
import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { hexToRgb } from '~/lib/utils/color_utils';
-import { areaOpacityValues, symbolSizes, colorValues } from '../../constants';
+import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants';
import { graphDataValidatorForAnomalyValues } from '../../utils';
import MonitorTimeSeriesChart from './time_series.vue';
@@ -91,7 +91,7 @@ export default {
]);
return {
...this.graphData,
- type: 'line-chart',
+ type: panelTypes.LINE_CHART,
metrics: [metricQuery],
};
},
@@ -209,7 +209,7 @@ export default {
:series-config="metricSeriesConfig"
>
<slot></slot>
- <template v-slot:tooltipContent="slotProps">
+ <template #tooltip-content="slotProps">
<div
v-for="(content, seriesIndex) in slotProps.tooltip.content"
:key="seriesIndex"
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index 0a0165a113e..55a25ee09fd 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -63,7 +63,7 @@ export default {
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize" class="col-12 col-lg-6">
+ <div v-gl-resize-observer-directive="onResize">
<gl-heatmap
ref="heatmapChart"
v-bind="$attrs"
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index bf40e8f448e..8f37a12af75 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -6,7 +6,7 @@ import dateFormat from 'dateformat';
import { s__, __ } from '~/locale';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
-import { chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants';
+import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants';
import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options';
import { annotationsYAxis, generateAnnotationsSeries } from './annotations';
import { makeDataSeries } from '~/helpers/monitor_helper';
@@ -64,10 +64,10 @@ export default {
required: false,
default: '',
},
- singleEmbed: {
- type: Boolean,
+ height: {
+ type: Number,
required: false,
- default: false,
+ default: chartHeight,
},
thresholds: {
type: Array,
@@ -100,7 +100,6 @@ export default {
sha: '',
},
width: 0,
- height: chartHeight,
svgs: {},
primaryColor: null,
throttledDatazoom: null,
@@ -211,8 +210,8 @@ export default {
},
glChartComponent() {
const chartTypes = {
- 'area-chart': GlAreaChart,
- 'line-chart': GlLineChart,
+ [panelTypes.AREA_CHART]: GlAreaChart,
+ [panelTypes.LINE_CHART]: GlLineChart,
};
return chartTypes[this.graphData.type] || GlAreaChart;
},
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 4d60b02d0df..2018c706b11 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,8 +1,10 @@
<script>
-import { debounce, pickBy } from 'lodash';
+import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
+ GlIcon,
+ GlButton,
GlDeprecatedButton,
GlDropdown,
GlDropdownItem,
@@ -14,10 +16,10 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DashboardPanel from './dashboard_panel.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+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 invalidUrl from '~/lib/utils/invalid_url';
@@ -28,17 +30,27 @@ 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 TrackEventDirective from '~/vue_shared/directives/track_event';
-import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
+import {
+ getAddMetricTrackingOptions,
+ timeRangeToUrl,
+ timeRangeFromUrl,
+ panelToUrl,
+ expandedPanelPayloadFromUrl,
+ convertVariablesForURL,
+} from '../utils';
import { metricStates } from '../constants';
import { defaultTimeRange, timeRanges } from '~/vue_shared/constants';
export default {
components: {
VueDraggable,
- PanelType,
+ DashboardPanel,
Icon,
+ GlIcon,
+ GlButton,
GlDeprecatedButton,
GlDropdown,
GlLoadingIcon,
@@ -54,13 +66,14 @@ export default {
EmptyState,
GroupEmptyState,
DashboardsDropdown,
+
+ VariablesSection,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
externalDashboardUrl: {
type: String,
@@ -197,7 +210,6 @@ export default {
},
data() {
return {
- state: 'gettingStarted',
formIsValid: null,
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
hasValidDates: true,
@@ -212,16 +224,16 @@ export default {
'showEmptyState',
'useDashboardEndpoint',
'allDashboards',
- 'additionalPanelTypesEnabled',
'environmentsLoading',
+ 'expandedPanel',
+ 'promVariables',
+ 'isUpdatingStarredValue',
+ ]),
+ ...mapGetters('monitoringDashboard', [
+ 'selectedDashboard',
+ 'getMetricStates',
+ 'filteredEnvironments',
]),
- ...mapGetters('monitoringDashboard', ['getMetricStates', 'filteredEnvironments']),
- firstDashboard() {
- return this.allDashboards.length > 0 ? this.allDashboards[0] : {};
- },
- selectedDashboard() {
- return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard;
- },
showRearrangePanelsBtn() {
return !this.showEmptyState && this.rearrangePanelsAvailable;
},
@@ -229,20 +241,44 @@ export default {
return (
this.customMetricsAvailable &&
!this.showEmptyState &&
- this.firstDashboard === this.selectedDashboard
- );
- },
- hasHeaderButtons() {
- return (
- this.addingMetricsAvailable ||
- this.showRearrangePanelsBtn ||
- this.selectedDashboard.can_edit ||
- this.externalDashboardUrl.length
+ // 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;
},
+ shouldShowVariablesSection() {
+ return Object.keys(this.promVariables).length > 0;
+ },
+ },
+ watch: {
+ dashboard(newDashboard) {
+ try {
+ const expandedPanel = expandedPanelPayloadFromUrl(newDashboard);
+ if (expandedPanel) {
+ this.setExpandedPanel(expandedPanel);
+ }
+ } catch {
+ createFlash(
+ s__(
+ 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.',
+ ),
+ );
+ }
+ },
+ expandedPanel: {
+ handler({ group, panel }) {
+ const dashboardPath = this.currentDashboard || this.selectedDashboard?.path;
+ updateHistory({
+ url: panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), group, panel),
+ title: document.title,
+ });
+ },
+ deep: true,
+ },
},
created() {
this.setInitialState({
@@ -255,6 +291,10 @@ export default {
logsPath: this.logsPath,
currentEnvironmentName: this.currentEnvironmentName,
});
+ window.addEventListener('keyup', this.onKeyup);
+ },
+ destroyed() {
+ window.removeEventListener('keyup', this.onKeyup);
},
mounted() {
if (!this.hasMetrics) {
@@ -273,6 +313,9 @@ export default {
'setInitialState',
'setPanelGroupMetrics',
'filterEnvironments',
+ 'setExpandedPanel',
+ 'clearExpandedPanel',
+ 'toggleStarredValue',
]),
updatePanels(key, panels) {
this.setPanelGroupMetrics({
@@ -299,11 +342,9 @@ export default {
// As a fallback, switch to default time range instead
this.selectedTimeRange = defaultTimeRange;
},
-
- generateLink(group, title, yLabel) {
- const dashboard = this.currentDashboard || this.firstDashboard.path;
- const params = pickBy({ dashboard, group, title, y_label: yLabel }, value => value != null);
- return mergeUrlParams(params, window.location.href);
+ generatePanelUrl(groupKey, panel) {
+ const dashboardPath = this.currentDashboard || this.selectedDashboard?.path;
+ return panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), groupKey, panel);
},
hideAddMetricModal() {
this.$refs.addMetricModal.hide();
@@ -366,11 +407,28 @@ export default {
});
this.selectedTimeRange = { start, end };
},
+ onExpandPanel(group, panel) {
+ this.setExpandedPanel({ group, panel });
+ },
+ onGoBack() {
+ this.clearExpandedPanel();
+ },
+ onKeyup(event) {
+ const { key } = event;
+ if (key === ESC_KEY || key === ESC_KEY_IE11) {
+ this.clearExpandedPanel();
+ }
+ },
},
addMetric: {
title: s__('Metrics|Add metric'),
modalId: 'add-metric',
},
+ i18n: {
+ goBackLabel: s__('Metrics|Go back (Esc)'),
+ starDashboard: s__('Metrics|Star dashboard'),
+ unstarDashboard: s__('Metrics|Unstar dashboard'),
+ },
};
</script>
@@ -388,7 +446,6 @@ export default {
class="flex-grow-1"
toggle-class="dropdown-menu-toggle"
:default-branch="defaultBranch"
- :selected-dashboard="selectedDashboard"
@selectDashboard="selectDashboard($event)"
/>
</div>
@@ -443,7 +500,7 @@ export default {
<date-time-picker
ref="dateTimePicker"
class="flex-grow-1 show-last-dropdown"
- data-qa-selector="show_last_dropdown"
+ data-qa-selector="range_picker_dropdown"
:value="selectedTimeRange"
:options="timeRanges"
@input="onDateTimePickerInput"
@@ -467,6 +524,32 @@ export default {
<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"
@@ -516,7 +599,10 @@ export default {
</gl-modal>
</div>
- <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block">
+ <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"
@@ -539,61 +625,92 @@ export default {
</div>
</div>
</div>
-
+ <variables-section v-if="shouldShowVariablesSection && !showEmptyState" />
<div v-if="!showEmptyState">
- <graph-group
- v-for="(groupData, index) in dashboard.panelGroups"
- :key="`${groupData.group}.${groupData.priority}`"
- :name="groupData.group"
- :show-panels="showPanels"
- :collapse-group="collapseGroup(groupData.key)"
+ <dashboard-panel
+ v-show="expandedPanel.panel"
+ ref="expandedPanel"
+ :settings-path="settingsPath"
+ :clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)"
+ :graph-data="expandedPanel.panel"
+ :alerts-endpoint="alertsEndpoint"
+ :height="600"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ @timerangezoom="onTimeRangeZoom"
>
- <vue-draggable
- v-if="!groupSingleEmptyState(groupData.key)"
- :value="groupData.panels"
- group="metrics-dashboard"
- :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
- :disabled="!isRearrangingPanels"
- @input="updatePanels(groupData.key, $event)"
+ <template #topLeft>
+ <gl-button
+ ref="goBackBtn"
+ v-gl-tooltip
+ class="mr-3 my-3"
+ :title="$options.i18n.goBackLabel"
+ @click="onGoBack"
+ >
+ <gl-icon
+ name="arrow-left"
+ :aria-label="$options.i18n.goBackLabel"
+ class="text-secondary"
+ />
+ </gl-button>
+ </template>
+ </dashboard-panel>
+
+ <div v-show="!expandedPanel.panel">
+ <graph-group
+ v-for="groupData in dashboard.panelGroups"
+ :key="`${groupData.group}.${groupData.priority}`"
+ :name="groupData.group"
+ :show-panels="showPanels"
+ :collapse-group="collapseGroup(groupData.key)"
>
- <div
- v-for="(graphData, graphIndex) in groupData.panels"
- :key="`panel-type-${graphIndex}`"
- class="col-12 col-lg-6 px-2 mb-2 draggable"
- :class="{ 'draggable-enabled': isRearrangingPanels }"
+ <vue-draggable
+ v-if="!groupSingleEmptyState(groupData.key)"
+ :value="groupData.panels"
+ group="metrics-dashboard"
+ :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
+ :disabled="!isRearrangingPanels"
+ @input="updatePanels(groupData.key, $event)"
>
- <div class="position-relative draggable-panel js-draggable-panel">
- <div
- v-if="isRearrangingPanels"
- class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
- @click="removePanel(groupData.key, groupData.panels, graphIndex)"
- >
- <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')">
- <icon name="close" />
- </a>
- </div>
+ <div
+ v-for="(graphData, graphIndex) in groupData.panels"
+ :key="`dashboard-panel-${graphIndex}`"
+ class="col-12 col-lg-6 px-2 mb-2 draggable"
+ :class="{ 'draggable-enabled': isRearrangingPanels }"
+ >
+ <div class="position-relative draggable-panel js-draggable-panel">
+ <div
+ v-if="isRearrangingPanels"
+ class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
+ @click="removePanel(groupData.key, groupData.panels, graphIndex)"
+ >
+ <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')">
+ <icon name="close" />
+ </a>
+ </div>
- <panel-type
- :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
- :graph-data="graphData"
- :alerts-endpoint="alertsEndpoint"
- :prometheus-alerts-available="prometheusAlertsAvailable"
- :index="`${index}-${graphIndex}`"
- @timerangezoom="onTimeRangeZoom"
- />
+ <dashboard-panel
+ :settings-path="settingsPath"
+ :clipboard-text="generatePanelUrl(groupData.group, graphData)"
+ :graph-data="graphData"
+ :alerts-endpoint="alertsEndpoint"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ @timerangezoom="onTimeRangeZoom"
+ @expand="onExpandPanel(groupData.group, graphData)"
+ />
+ </div>
</div>
+ </vue-draggable>
+ <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
+ <group-empty-state
+ ref="empty-group"
+ :documentation-path="documentationPath"
+ :settings-path="settingsPath"
+ :selected-state="groupSingleEmptyState(groupData.key)"
+ :svg-path="emptyNoDataSmallSvgPath"
+ />
</div>
- </vue-draggable>
- <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
- <group-empty-state
- ref="empty-group"
- :documentation-path="documentationPath"
- :settings-path="settingsPath"
- :selected-state="groupSingleEmptyState(groupData.key)"
- :svg-path="emptyNoDataSmallSvgPath"
- />
- </div>
- </graph-group>
+ </graph-group>
+ </div>
</div>
<empty-state
v-else
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 2beae0d9540..48825fda5c8 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -14,6 +14,9 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { __, n__ } from '~/locale';
+import { panelTypes } from '../constants';
+
+import MonitorEmptyChart from './charts/empty_chart.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
@@ -21,22 +24,20 @@ import MonitorHeatmapChart from './charts/heatmap.vue';
import MonitorColumnChart from './charts/column.vue';
import MonitorBarChart from './charts/bar.vue';
import MonitorStackedColumnChart from './charts/stacked_column.vue';
-import MonitorEmptyChart from './charts/empty_chart.vue';
+
import TrackEventDirective from '~/vue_shared/directives/track_event';
+import AlertWidget from './alert_widget.vue';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
const events = {
timeRangeZoom: 'timerangezoom',
+ expand: 'expand',
};
export default {
components: {
- MonitorSingleStatChart,
- MonitorColumnChart,
- MonitorBarChart,
- MonitorHeatmapChart,
- MonitorStackedColumnChart,
MonitorEmptyChart,
+ AlertWidget,
GlIcon,
GlLoadingIcon,
GlTooltip,
@@ -58,28 +59,41 @@ export default {
},
graphData: {
type: Object,
- required: true,
- },
- index: {
- type: String,
required: false,
- default: '',
+ default: null,
},
groupId: {
type: String,
required: false,
- default: 'panel-type-chart',
+ default: 'dashboard-panel',
},
namespace: {
type: String,
required: false,
default: 'monitoringDashboard',
},
+ alertsEndpoint: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ prometheusAlertsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ settingsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
showTitleTooltip: false,
zoomedTimeRange: null,
+ allAlerts: {},
+ expandBtnAvailable: Boolean(this.$listeners[events.expand]),
};
},
computed: {
@@ -101,23 +115,18 @@ export default {
timeRange(state) {
return state[this.namespace].timeRange;
},
+ metricsSavedToDb(state, getters) {
+ return getters[`${this.namespace}/metricsSavedToDb`];
+ },
}),
title() {
- return this.graphData.title || '';
- },
- alertWidgetAvailable() {
- // This method is extended by ee functionality
- return false;
+ return this.graphData?.title || '';
},
graphDataHasResult() {
- return (
- this.graphData.metrics &&
- this.graphData.metrics[0].result &&
- this.graphData.metrics[0].result.length > 0
- );
+ return this.graphData?.metrics?.[0]?.result?.length > 0;
},
graphDataIsLoading() {
- const { metrics = [] } = this.graphData;
+ const metrics = this.graphData?.metrics || [];
return metrics.some(({ loading }) => loading);
},
logsPathWithTimeRange() {
@@ -129,7 +138,7 @@ export default {
return null;
},
csvText() {
- const chartData = this.graphData.metrics[0].result[0].values;
+ const chartData = this.graphData?.metrics[0].result[0].values || [];
const yLabel = this.graphData.y_label;
const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/require-i18n-strings
return chartData.reduce((csv, data) => {
@@ -141,27 +150,77 @@ export default {
const data = new Blob([this.csvText], { type: 'text/plain' });
return window.URL.createObjectURL(data);
},
- timeChartComponent() {
- if (this.isPanelType('anomaly-chart')) {
+
+ /**
+ * A chart is "basic" if it doesn't support
+ * the same features as the TimeSeries based components
+ * such as "annotations".
+ *
+ * @returns Vue Component wrapping a basic visualization
+ */
+ basicChartComponent() {
+ if (this.isPanelType(panelTypes.SINGLE_STAT)) {
+ return MonitorSingleStatChart;
+ }
+ if (this.isPanelType(panelTypes.HEATMAP)) {
+ return MonitorHeatmapChart;
+ }
+ if (this.isPanelType(panelTypes.BAR)) {
+ return MonitorBarChart;
+ }
+ if (this.isPanelType(panelTypes.COLUMN)) {
+ return MonitorColumnChart;
+ }
+ if (this.isPanelType(panelTypes.STACKED_COLUMN)) {
+ return MonitorStackedColumnChart;
+ }
+ if (this.isPanelType(panelTypes.ANOMALY_CHART)) {
+ return MonitorAnomalyChart;
+ }
+ return null;
+ },
+
+ /**
+ * In monitoring, Time Series charts typically support
+ * a larger feature set like "annotations", "deployment
+ * data", alert "thresholds" and "datazoom".
+ *
+ * This is intentional as Time Series are more frequently
+ * used.
+ *
+ * @returns Vue Component wrapping a time series visualization,
+ * Area Charts are rendered by default.
+ */
+ timeSeriesChartComponent() {
+ if (this.isPanelType(panelTypes.ANOMALY_CHART)) {
return MonitorAnomalyChart;
}
return MonitorTimeSeriesChart;
},
isContextualMenuShown() {
- return (
- this.graphDataHasResult &&
- !this.isPanelType('single-stat') &&
- !this.isPanelType('heatmap') &&
- !this.isPanelType('column') &&
- !this.isPanelType('stacked-column')
- );
+ return Boolean(this.graphDataHasResult && !this.basicChartComponent);
},
editCustomMetricLink() {
+ if (this.graphData.metrics.length > 1) {
+ return this.settingsPath;
+ }
return this.graphData?.metrics[0].edit_path;
},
editCustomMetricLinkText() {
return n__('Metrics|Edit metric', 'Metrics|Edit metrics', this.graphData.metrics.length);
},
+ hasMetricsInDb() {
+ const { metrics = [] } = this.graphData;
+ return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId));
+ },
+ alertWidgetAvailable() {
+ return (
+ this.prometheusAlertsAvailable &&
+ this.alertsEndpoint &&
+ this.graphData &&
+ this.hasMetricsInDb
+ );
+ },
},
mounted() {
this.refreshTitleTooltip();
@@ -176,7 +235,7 @@ export default {
return Object.values(this.getGraphAlerts(queries));
},
isPanelType(type) {
- return this.graphData.type && this.graphData.type === type;
+ return this.graphData?.type === type;
},
showToast() {
this.$toast.show(__('Link copied'));
@@ -197,15 +256,27 @@ export default {
this.zoomedTimeRange = { start, end };
this.$emit(events.timeRangeZoom, { start, end });
},
+ onExpand() {
+ this.$emit(events.expand);
+ },
+ setAlerts(alertPath, alertAttributes) {
+ if (alertAttributes) {
+ this.$set(this.allAlerts, alertPath, alertAttributes);
+ } else {
+ this.$delete(this.allAlerts, alertPath);
+ }
+ },
},
+ panelTypes,
};
</script>
<template>
<div v-gl-resize-observer="onResize" class="prometheus-graph">
<div class="d-flex align-items-center mr-3">
+ <slot name="topLeft"></slot>
<h5
ref="graphTitle"
- class="prometheus-graph-title gl-font-size-large font-weight-bold text-truncate append-right-8"
+ class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate append-right-8"
>
{{ title }}
</h5>
@@ -215,7 +286,7 @@ export default {
<alert-widget
v-if="isContextualMenuShown && alertWidgetAvailable"
class="mx-1"
- :modal-id="`alert-modal-${index}`"
+ :modal-id="`alert-modal-${graphData.id}`"
:alerts-endpoint="alertsEndpoint"
:relevant-queries="graphData.metrics"
:alerts-to-manage="getGraphAlerts(graphData.metrics)"
@@ -243,6 +314,14 @@ export default {
<gl-icon name="ellipsis_v" class="text-secondary" />
</template>
<gl-dropdown-item
+ v-if="expandBtnAvailable"
+ ref="expandBtn"
+ :href="clipboardText"
+ @click.prevent="onExpand"
+ >
+ {{ s__('Metrics|Expand panel') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
v-if="editCustomMetricLink"
ref="editMetricLink"
:href="editCustomMetricLink"
@@ -271,13 +350,14 @@ export default {
ref="copyChartLink"
v-track-event="generateLinkToChartOptions(clipboardText)"
:data-clipboard-text="clipboardText"
+ data-qa-selector="generate_chart_link_menu_item"
@click="showToast(clipboardText)"
>
{{ __('Copy link to chart') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="alertWidgetAvailable"
- v-gl-modal="`alert-modal-${index}`"
+ v-gl-modal="`alert-modal-${graphData.id}`"
data-qa-selector="alert_widget_menu_item"
>
{{ __('Alerts') }}
@@ -287,38 +367,27 @@ export default {
</div>
</div>
- <monitor-single-stat-chart
- v-if="isPanelType('single-stat') && graphDataHasResult"
- :graph-data="graphData"
- />
- <monitor-heatmap-chart
- v-else-if="isPanelType('heatmap') && graphDataHasResult"
- :graph-data="graphData"
- />
- <monitor-bar-chart
- v-else-if="isPanelType('bar') && graphDataHasResult"
- :graph-data="graphData"
- />
- <monitor-column-chart
- v-else-if="isPanelType('column') && graphDataHasResult"
- :graph-data="graphData"
- />
- <monitor-stacked-column-chart
- v-else-if="isPanelType('stacked-column') && graphDataHasResult"
+ <monitor-empty-chart v-if="!graphDataHasResult" />
+ <component
+ :is="basicChartComponent"
+ v-else-if="basicChartComponent"
:graph-data="graphData"
+ v-bind="$attrs"
+ v-on="$listeners"
/>
<component
- :is="timeChartComponent"
- v-else-if="graphDataHasResult"
- ref="timeChart"
+ :is="timeSeriesChartComponent"
+ v-else
+ ref="timeSeriesChart"
:graph-data="graphData"
:deployment-data="deploymentData"
:annotations="annotations"
:project-path="projectPath"
:thresholds="getGraphAlertValues(graphData.metrics)"
:group-id="groupId"
+ v-bind="$attrs"
+ v-on="$listeners"
@datazoom="onDatazoom"
/>
- <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 8f3e0a6ec75..8b86890715f 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -1,7 +1,8 @@
<script>
-import { mapState, mapActions } from 'vuex';
+import { mapState, mapActions, mapGetters } from 'vuex';
import {
GlAlert,
+ GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
@@ -21,6 +22,7 @@ const events = {
export default {
components: {
GlAlert,
+ GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
@@ -34,11 +36,6 @@ export default {
GlModal: GlModalDirective,
},
props: {
- selectedDashboard: {
- type: Object,
- required: false,
- default: () => ({}),
- },
defaultBranch: {
type: String,
required: true,
@@ -54,26 +51,41 @@ export default {
},
computed: {
...mapState('monitoringDashboard', ['allDashboards']),
+ ...mapGetters('monitoringDashboard', ['selectedDashboard']),
isSystemDashboard() {
- return this.selectedDashboard.system_dashboard;
+ return this.selectedDashboard?.system_dashboard;
},
selectedDashboardText() {
- return this.selectedDashboard.display_name;
+ return this.selectedDashboard?.display_name;
+ },
+ selectedDashboardPath() {
+ return this.selectedDashboard?.path;
},
+
filteredDashboards() {
- return this.allDashboards.filter(({ display_name }) =>
+ return this.allDashboards.filter(({ display_name = '' }) =>
display_name.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
},
shouldShowNoMsgContainer() {
return this.filteredDashboards.length === 0;
},
+ starredDashboards() {
+ return this.filteredDashboards.filter(({ starred }) => starred);
+ },
+ nonStarredDashboards() {
+ return this.filteredDashboards.filter(({ starred }) => !starred);
+ },
+
okButtonText() {
return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
},
},
methods: {
...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
+ dashboardDisplayName(dashboard) {
+ return dashboard.display_name || dashboard.path || '';
+ },
selectDashboard(dashboard) {
this.$emit(events.selectDashboard, dashboard);
},
@@ -127,15 +139,34 @@ export default {
v-model="searchTerm"
class="m-2"
/>
+
<div class="flex-fill overflow-auto">
<gl-dropdown-item
- v-for="dashboard in filteredDashboards"
+ v-for="dashboard in starredDashboards"
+ :key="dashboard.path"
+ :active="dashboard.path === selectedDashboardPath"
+ active-class="is-active"
+ @click="selectDashboard(dashboard)"
+ >
+ <div class="d-flex">
+ {{ dashboardDisplayName(dashboard) }}
+ <gl-icon class="text-muted ml-auto" name="star" />
+ </div>
+ </gl-dropdown-item>
+
+ <gl-dropdown-divider
+ v-if="starredDashboards.length && nonStarredDashboards.length"
+ ref="starredListDivider"
+ />
+
+ <gl-dropdown-item
+ v-for="dashboard in nonStarredDashboards"
:key="dashboard.path"
- :active="dashboard.path === selectedDashboard.path"
+ :active="dashboard.path === selectedDashboardPath"
active-class="is-active"
@click="selectDashboard(dashboard)"
>
- {{ dashboard.display_name || dashboard.path }}
+ {{ dashboardDisplayName(dashboard) }}
</gl-dropdown-item>
</div>
diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
index 3f8b0f76997..1557a49137e 100644
--- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
+++ b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapActions } from 'vuex';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { defaultTimeRange } from '~/vue_shared/constants';
import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils';
@@ -10,7 +10,7 @@ let sidebarMutationObserver;
export default {
components: {
- PanelType,
+ DashboardPanel,
},
props: {
containerClass: {
@@ -113,9 +113,9 @@ export default {
</script>
<template>
<div class="metrics-embed p-0 d-flex flex-wrap" :class="embedClass">
- <panel-type
+ <dashboard-panel
v-for="(graphData, graphIndex) in charts"
- :key="`panel-type-${graphIndex}`"
+ :key="`dashboard-panel-${graphIndex}`"
:class="panelClass"
:graph-data="graphData"
:group-id="dashboardUrl"
diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/custom_variable.vue
new file mode 100644
index 00000000000..0ac7c0b80df
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/variables/custom_variable.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ options: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ defaultText() {
+ const selectedOpt = this.options.find(opt => opt.value === this.value);
+ return selectedOpt?.text || this.value;
+ },
+ },
+ methods: {
+ onUpdate(value) {
+ this.$emit('onUpdate', this.name, value);
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group :label="label">
+ <gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText">
+ <gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{
+ opt.text
+ }}</gl-dropdown-item>
+ </gl-dropdown>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/variables/text_variable.vue b/app/assets/javascripts/monitoring/components/variables/text_variable.vue
new file mode 100644
index 00000000000..ce0d19760e2
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/variables/text_variable.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ onUpdate(event) {
+ this.$emit('onUpdate', this.name, event.target.value);
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group :label="label">
+ <gl-form-input
+ :value="value"
+ :name="name"
+ @keyup.native.enter="onUpdate"
+ @blur.native="onUpdate"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue
new file mode 100644
index 00000000000..e054c9d8e26
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/variables_section.vue
@@ -0,0 +1,56 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import CustomVariable from './variables/custom_variable.vue';
+import TextVariable from './variables/text_variable.vue';
+import { setPromCustomVariablesFromUrl } from '../utils';
+
+export default {
+ components: {
+ CustomVariable,
+ TextVariable,
+ },
+ computed: {
+ ...mapState('monitoringDashboard', ['promVariables']),
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['fetchDashboardData', 'updateVariableValues']),
+ refreshDashboard(variable, value) {
+ if (this.promVariables[variable].value !== value) {
+ const changedVariable = { key: variable, value };
+ // update the Vuex store
+ this.updateVariableValues(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();
+ }
+ },
+ variableComponent(type) {
+ const types = {
+ text: TextVariable,
+ custom: CustomVariable,
+ };
+ return types[type] || TextVariable;
+ },
+ },
+};
+</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">
+ <component
+ :is="variableComponent(variable.type)"
+ class="mb-0 flex-grow-1"
+ :label="variable.label"
+ :value="variable.value"
+ :name="key"
+ :options="variable.options"
+ @onUpdate="refreshDashboard"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 0b393f19789..0c2eafeed54 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -48,6 +48,55 @@ export const metricStates = {
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
};
+/**
+ * Supported panel types in dashboards, values of `panel.type`.
+ *
+ * Values should not be changed as they correspond to
+ * values in users the `.yml` dashboard definition.
+ */
+export const panelTypes = {
+ /**
+ * Area Chart
+ *
+ * Time Series chart with an area
+ */
+ AREA_CHART: 'area-chart',
+ /**
+ * Line Chart
+ *
+ * Time Series chart with a line
+ */
+ LINE_CHART: 'line-chart',
+ /**
+ * Anomaly Chart
+ *
+ * Time Series chart with 3 metrics
+ */
+ ANOMALY_CHART: 'anomaly-chart',
+ /**
+ * Single Stat
+ *
+ * Single data point visualization
+ */
+ SINGLE_STAT: 'single-stat',
+ /**
+ * Heatmap
+ */
+ HEATMAP: 'heatmap',
+ /**
+ * Bar chart
+ */
+ BAR: 'bar',
+ /**
+ * Column chart
+ */
+ COLUMN: 'column',
+ /**
+ * Stacked column chart
+ */
+ STACKED_COLUMN: 'stacked-column',
+};
+
export const sidebarAnimationDuration = 300; // milliseconds.
export const chartHeight = 300;
@@ -143,3 +192,38 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z';
* https://gitlab.com/gitlab-org/gitlab/-/issues/214671
*/
export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
+
+export const OPERATORS = {
+ greaterThan: '>',
+ equalTo: '==',
+ lessThan: '<',
+};
+
+/**
+ * Dashboard yml files support custom user-defined variables that
+ * are rendered as input elements in the monitoring dashboard.
+ * These values can be edited by the user and are passed on to the
+ * the backend and eventually to Prometheus API proxy.
+ *
+ * As of 13.0, the supported types are:
+ * simple custom -> dropdown elements
+ * advanced custom -> dropdown elements
+ * text -> text input elements
+ *
+ * Custom variables have a simple and a advanced variant.
+ */
+export const VARIABLE_TYPES = {
+ custom: 'custom',
+ text: 'text',
+};
+
+/**
+ * The names of templating variables defined in the dashboard yml
+ * file are prefixed with a constant so that it doesn't collide with
+ * other URL params that the monitoring dashboard relies on for
+ * features like panel fullscreen etc.
+ *
+ * The prefix is added before it is appended to the URL and removed
+ * before passing the data to the backend.
+ */
+export const VARIABLE_PREFIX = 'var-';
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index d296f5b7a66..2bbf9ef9d78 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
-import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
+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';
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js
new file mode 100644
index 00000000000..afe5ee0938d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js
@@ -0,0 +1,13 @@
+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/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js
new file mode 100644
index 00000000000..4b7337972fe
--- /dev/null
+++ b/app/assets/javascripts/monitoring/services/alerts_service.js
@@ -0,0 +1,32 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default class AlertsService {
+ constructor({ alertsEndpoint }) {
+ this.alertsEndpoint = alertsEndpoint;
+ }
+
+ getAlerts() {
+ return axios.get(this.alertsEndpoint).then(resp => resp.data);
+ }
+
+ createAlert({ prometheus_metric_id, operator, threshold }) {
+ return axios
+ .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold })
+ .then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ readAlert(alertPath) {
+ return axios.get(alertPath).then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ updateAlert(alertPath, { operator, threshold }) {
+ return axios.put(alertPath, { operator, threshold }).then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ deleteAlert(alertPath) {
+ return axios.delete(alertPath).then(resp => resp.data);
+ }
+}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index f04f775761c..b057afa2264 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -3,6 +3,8 @@ 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,
@@ -13,11 +15,7 @@ import trackDashboardLoad from '../monitoring_tracking_helper';
import getEnvironments from '../queries/getEnvironments.query.graphql';
import getAnnotations from '../queries/getAnnotations.query.graphql';
import statusCodes from '../../lib/utils/http_status';
-import {
- backOff,
- convertObjectPropsToCamelCase,
- isFeatureFlagEnabled,
-} from '../../lib/utils/common_utils';
+import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
import {
@@ -80,6 +78,10 @@ export const setTimeRange = ({ commit }, timeRange) => {
commit(types.SET_TIME_RANGE, timeRange);
};
+export const setVariables = ({ commit }, variables) => {
+ commit(types.SET_VARIABLES, variables);
+};
+
export const filterEnvironments = ({ commit, dispatch }, searchTerm) => {
commit(types.SET_ENVIRONMENTS_FILTER, searchTerm);
dispatch('fetchEnvironmentsData');
@@ -89,19 +91,30 @@ export const setShowErrorBanner = ({ commit }, enabled) => {
commit(types.SET_SHOW_ERROR_BANNER, enabled);
};
+export const setExpandedPanel = ({ commit }, { group, panel }) => {
+ commit(types.SET_EXPANDED_PANEL, { group, panel });
+};
+
+export const clearExpandedPanel = ({ commit }) => {
+ commit(types.SET_EXPANDED_PANEL, {
+ group: null,
+ panel: null,
+ });
+};
+
// All Data
+/**
+ * Fetch all dashboard data.
+ *
+ * @param {Object} store
+ * @returns A promise that resolves when the dashboard
+ * skeleton has been loaded.
+ */
export const fetchData = ({ dispatch }) => {
dispatch('fetchEnvironmentsData');
dispatch('fetchDashboard');
- /**
- * Annotations data is not yet fetched. This will be
- * ready after the BE piece is implemented.
- * https://gitlab.com/gitlab-org/gitlab/-/issues/211330
- */
- if (isFeatureFlagEnabled('metricsDashboardAnnotations')) {
- dispatch('fetchAnnotations');
- }
+ dispatch('fetchAnnotations');
};
// Metrics dashboard
@@ -148,6 +161,7 @@ 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');
@@ -200,12 +214,19 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
*
* @param {metric} metric
*/
-export const fetchPrometheusMetric = ({ commit }, { metric, defaultQueryParams }) => {
+export const fetchPrometheusMetric = (
+ { commit, state, getters },
+ { metric, defaultQueryParams },
+) => {
const queryParams = { ...defaultQueryParams };
if (metric.step) {
queryParams.step = metric.step;
}
+ if (Object.keys(state.promVariables).length > 0) {
+ queryParams.variables = getters.getCustomVariablesArray;
+ }
+
commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId });
return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams)
@@ -327,6 +348,35 @@ export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_AN
// Dashboard manipulation
+export const toggleStarredValue = ({ commit, state, getters }) => {
+ const { selectedDashboard } = getters;
+
+ if (state.isUpdatingStarredValue) {
+ // Prevent repeating requests for the same change
+ return;
+ }
+ if (!selectedDashboard) {
+ return;
+ }
+
+ const method = selectedDashboard.starred ? 'DELETE' : 'POST';
+ const url = selectedDashboard.user_starred_path;
+ const newStarredValue = !selectedDashboard.starred;
+
+ commit(types.REQUEST_DASHBOARD_STARRING);
+
+ axios({
+ url,
+ method,
+ })
+ .then(() => {
+ commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, newStarredValue);
+ })
+ .catch(() => {
+ commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE);
+ });
+};
+
/**
* Set a new array of metrics to a panel group
* @param {*} data An object containing
@@ -364,5 +414,11 @@ export const duplicateSystemDashboard = ({ state }, payload) => {
});
};
+// Variables manipulation
+
+export const updateVariableValues = ({ commit }, updatedVariable) => {
+ commit(types.UPDATE_VARIABLE_VALUES, updatedVariable);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
index a6d80c5063e..ae3ff5596e1 100644
--- a/app/assets/javascripts/monitoring/stores/getters.js
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -1,9 +1,25 @@
+import { flatMap } from 'lodash';
import { NOT_IN_DB_PREFIX } from '../constants';
const metricsIdsInPanel = panel =>
panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
/**
+ * Returns a reference to the currently selected dashboard
+ * from the list of dashboards.
+ *
+ * @param {Object} state
+ */
+export const selectedDashboard = state => {
+ const { allDashboards } = state;
+ return (
+ allDashboards.find(d => d.path === state.currentDashboard) ||
+ allDashboards.find(d => d.default) ||
+ null
+ );
+};
+
+/**
* Get all state for metric in the dashboard or a group. The
* states are not repeated so the dashboard or group can show
* a global state.
@@ -96,5 +112,17 @@ export const filteredEnvironments = state =>
env.name.toLowerCase().includes((state.environmentsSearchTerm || '').trim().toLowerCase()),
);
+/**
+ * Maps an variables object to an array along with stripping
+ * the variable prefix.
+ *
+ * @param {Object} variables - Custom variables provided by the user
+ * @returns {Array} The custom variables array to be send to the API
+ * in the format of [variable1, variable1_value]
+ */
+
+export const getCustomVariablesArray = state =>
+ flatMap(state.promVariables, (variable, key) => [key, variable.value]);
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 27a9a67edaa..d60334609fd 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -1,7 +1,13 @@
-// Dashboard "skeleton", groups, panels and metrics
+// Dashboard "skeleton", groups, panels, metrics, query variables
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 REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING';
+export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS';
+export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE';
// Annotations
export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS';
@@ -31,5 +37,5 @@ export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'
export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
-
export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER';
+export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index aa31b6642d7..f41cf3fc477 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,5 +1,7 @@
-import pick from 'lodash/pick';
+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';
@@ -71,6 +73,23 @@ export default {
state.showEmptyState = true;
},
+ [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);
+
+ 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 });
+ },
+ [types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) {
+ state.isUpdatingStarredValue = false;
+ },
+
/**
* Deployments and environments
*/
@@ -134,6 +153,8 @@ export default {
metric.loading = false;
metric.result = null;
},
+
+ // Parameters and other information
[types.SET_INITIAL_STATE](state, initialState = {}) {
Object.assign(state, pick(initialState, initialStateKeys));
},
@@ -163,4 +184,17 @@ export default {
[types.SET_ENVIRONMENTS_FILTER](state, searchTerm) {
state.environmentsSearchTerm = searchTerm;
},
+ [types.SET_EXPANDED_PANEL](state, { group, panel }) {
+ state.expandedPanel.group = group;
+ state.expandedPanel.panel = panel;
+ },
+ [types.SET_VARIABLES](state, variables) {
+ state.promVariables = variables;
+ },
+ [types.UPDATE_VARIABLE_VALUES](state, updatedVariable) {
+ Object.assign(state.promVariables[updatedVariable.key], {
+ ...state.promVariables[updatedVariable.key],
+ value: updatedVariable.value,
+ });
+ },
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index e60510e747b..9ae1da93e5f 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -14,10 +14,27 @@ export default () => ({
emptyState: 'gettingStarted',
showEmptyState: true,
showErrorBanner: true,
+ isUpdatingStarredValue: false,
dashboard: {
panelGroups: [],
},
+ /**
+ * Panel that is currently "zoomed" in as
+ * a single panel in view.
+ */
+ expandedPanel: {
+ /**
+ * {?String} Panel's group name.
+ */
+ group: null,
+ /**
+ * {?Object} Panel content from `dashboard`
+ * null when no panel is expanded.
+ */
+ panel: null,
+ },
allDashboards: [],
+ promVariables: {},
// Other project data
annotations: [],
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 9f06d18c46f..a47e5f598f5 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -144,6 +144,7 @@ const mapYAxisToViewModel = ({
* @returns {Object}
*/
const mapPanelToViewModel = ({
+ id = null,
title = '',
type,
x_axis = {},
@@ -162,6 +163,7 @@ const mapPanelToViewModel = ({
const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line babel/camelcase
return {
+ id,
title,
type,
xLabel: xAxis.name,
diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js
new file mode 100644
index 00000000000..bfb469da19e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js
@@ -0,0 +1,167 @@
+import { isString } from 'lodash';
+import { VARIABLE_TYPES } from '../constants';
+
+/**
+ * This file exclusively deals with parsing user-defined variables
+ * in dashboard yml file.
+ *
+ * As of 13.0, simple text, advanced text, simple custom and
+ * advanced custom variables are supported.
+ *
+ * In the future iterations, text and query variables will be
+ * supported
+ *
+ */
+
+/**
+ * Simple text variable is a string value only.
+ * This method parses such variables to a standard format.
+ *
+ * @param {String|Object} simpleTextVar
+ * @returns {Object}
+ */
+const textSimpleVariableParser = simpleTextVar => ({
+ type: VARIABLE_TYPES.text,
+ label: null,
+ value: simpleTextVar,
+});
+
+/**
+ * Advanced text variable is an object.
+ * This method parses such variables to a standard format.
+ *
+ * @param {Object} advTextVar
+ * @returns {Object}
+ */
+const textAdvancedVariableParser = advTextVar => ({
+ type: VARIABLE_TYPES.text,
+ label: advTextVar.label,
+ value: advTextVar.options.default_value,
+});
+
+/**
+ * Normalize simple and advanced custom variable options to a standard
+ * format
+ * @param {Object} custom variable option
+ * @returns {Object} normalized custom variable options
+ */
+const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({
+ default: defaultOpt,
+ text,
+ value,
+});
+
+/**
+ * Custom advanced variables are rendered as dropdown elements in the dashboard
+ * header. This method parses advanced custom variables.
+ *
+ * The default value is the option with default set to true or the first option
+ * if none of the options have default prop true.
+ *
+ * @param {Object} advVariable advance custom variable
+ * @returns {Object}
+ */
+const customAdvancedVariableParser = advVariable => {
+ const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions);
+ const defaultOpt = options.find(opt => opt.default === true) || options[0];
+ return {
+ type: VARIABLE_TYPES.custom,
+ label: advVariable.label,
+ value: defaultOpt?.value,
+ options,
+ };
+};
+
+/**
+ * Simple custom variables have an array of values.
+ * This method parses such variables options to a standard format.
+ *
+ * @param {String} opt option from simple custom variable
+ * @returns {Object}
+ */
+const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
+
+/**
+ * Custom simple variables are rendered as dropdown elements in the dashboard
+ * header. This method parses simple custom variables.
+ *
+ * Simple custom variables do not have labels so its set to null here.
+ *
+ * The default value is set to the first option as the user cannot
+ * set a default value for this format
+ *
+ * @param {Array} customVariable array of options
+ * @returns {Object}
+ */
+const customSimpleVariableParser = simpleVar => {
+ const options = (simpleVar || []).map(parseSimpleCustomOptions);
+ return {
+ type: VARIABLE_TYPES.custom,
+ value: options[0].value,
+ label: null,
+ options: options.map(normalizeCustomVariableOptions),
+ };
+};
+
+/**
+ * Utility method to determine if a custom variable is
+ * simple or not. If its not simple, it is advanced.
+ *
+ * @param {Array|Object} customVar Array if simple, object if advanced
+ * @returns {Boolean} true if simple, false if advanced
+ */
+const isSimpleCustomVariable = customVar => Array.isArray(customVar);
+
+/**
+ * This method returns a parser based on the type of the variable.
+ * Currently, the supported variables are simple custom and
+ * advanced custom only. In the future, this method will support
+ * text and query variables.
+ *
+ * @param {Array|Object} variable
+ * @return {Function} parser method
+ */
+const getVariableParser = variable => {
+ if (isSimpleCustomVariable(variable)) {
+ return customSimpleVariableParser;
+ } else if (variable.type === VARIABLE_TYPES.custom) {
+ return customAdvancedVariableParser;
+ } else if (variable.type === VARIABLE_TYPES.text) {
+ return textAdvancedVariableParser;
+ } else if (isString(variable)) {
+ return textSimpleVariableParser;
+ }
+ return () => null;
+};
+
+/**
+ * This method parses the templating property in the dashboard yml file.
+ * The templating property has variables that are rendered as input elements
+ * for the user to edit. The values from input elements are relayed to
+ * backend and eventually Prometheus API.
+ *
+ * This method currently is not used anywhere. Once the issue
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed,
+ * this method will have been used by the monitoring dashboard.
+ *
+ * @param {Object} templating templating variables from the dashboard yml file
+ * @returns {Object} a map of processed templating variables
+ */
+export const parseTemplatingVariables = ({ variables = {} } = {}) =>
+ Object.entries(variables).reduce((acc, [key, variable]) => {
+ // get the parser
+ const parser = getVariableParser(variable);
+ // parse the variable
+ const parsedVar = parser(variable);
+ // for simple custom variable label is null and it should be
+ // replace with key instead
+ if (parsedVar) {
+ acc[key] = {
+ ...parsedVar,
+ label: parsedVar.label || key,
+ };
+ }
+ return acc;
+ }, {});
+
+export default {};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 7c6cd19eb7b..1f028ffbcad 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,9 +1,23 @@
-import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
+import { pickBy, mapKeys } from 'lodash';
+import {
+ queryToObject,
+ mergeUrlParams,
+ removeParams,
+ updateHistory,
+} from '~/lib/utils/url_utility';
import {
timeRangeParamNames,
timeRangeFromParams,
timeRangeToParams,
} from '~/lib/utils/datetime_range';
+import { VARIABLE_PREFIX } from './constants';
+
+/**
+ * List of non time range url parameters
+ * This will be removed once we add support for free text variables
+ * via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689
+ */
+export const dashboardParams = ['dashboard', 'group', 'title', 'y_label', 'embedded'];
/**
* This method is used to validate if the graph data format for a chart component
@@ -28,7 +42,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
);
};
-/* eslint-disable @gitlab/require-i18n-strings */
/**
* Checks that element that triggered event is located on cluster health check dashboard
* @param {HTMLElement} element to check against
@@ -36,6 +49,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
*/
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
+/* eslint-disable @gitlab/require-i18n-strings */
/**
* Tracks snowplow event when user generates link to metric chart
* @param {String} chart link that will be sent as a property for the event
@@ -71,6 +85,7 @@ export const downloadCSVOptions = title => {
return { category, action, label: 'Chart title', property: title };
};
+/* eslint-enable @gitlab/require-i18n-strings */
/**
* Generate options for snowplow to track adding a new metric via the dashboard
@@ -113,6 +128,78 @@ export const timeRangeFromUrl = (search = window.location.search) => {
};
/**
+ * Variable labels are used as names for the dropdowns and also
+ * as URL params. Prefixing the name reduces the risk of
+ * collision with other URL params
+ *
+ * @param {String} label label for the template variable
+ * @returns {String}
+ */
+export const addPrefixToLabel = label => `${VARIABLE_PREFIX}${label}`;
+
+/**
+ * Before the templating variables are passed to the backend the
+ * prefix needs to be removed.
+ *
+ * This method removes the prefix at the beginning of the string.
+ *
+ * @param {String} label label to remove prefix from
+ * @returns {String}
+ */
+export const removePrefixFromLabel = label =>
+ (label || '').replace(new RegExp(`^${VARIABLE_PREFIX}`), '');
+
+/**
+ * Convert parsed template variables to an object
+ * with just keys and values. Prepare the promVariables
+ * 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.
+ *
+ * @param {Object} variables
+ * @returns {Object}
+ */
+export const convertVariablesForURL = variables =>
+ Object.keys(variables || {}).reduce((acc, key) => {
+ acc[addPrefixToLabel(key)] = variables[key]?.value;
+ return acc;
+ }, {});
+
+/**
+ * User-defined variables from the URL are extracted. The variables
+ * begin with a constant prefix so that it doesn't collide with
+ * other URL params.
+ *
+ * @param {String} New URL
+ * @returns {Object} The custom variables defined by the user in the URL
+ */
+
+export const getPromCustomVariablesFromUrl = (search = window.location.search) => {
+ const params = queryToObject(search);
+ // pick the params with variable prefix
+ const paramsWithVars = pickBy(params, (val, key) => key.startsWith(VARIABLE_PREFIX));
+ // remove the prefix before storing in the Vuex store
+ return mapKeys(paramsWithVars, (val, key) => removePrefixFromLabel(key));
+};
+
+/**
+ * Update the URL with promVariables. This usually get triggered when
+ * the user interacts with the dynamic input elements in the monitoring
+ * dashboard header.
+ *
+ * @param {Object} promVariables user defined variables
+ */
+export const setPromCustomVariablesFromUrl = promVariables => {
+ // prep the variables to append to URL
+ const parsedVariables = convertVariablesForURL(promVariables);
+ // update the URL
+ updateHistory({
+ url: mergeUrlParams(parsedVariables, window.location.href),
+ title: document.title,
+ });
+};
+
+/**
* Returns a URL with no time range based on the current URL.
*
* @param {String} New URL
@@ -133,6 +220,81 @@ export const timeRangeToUrl = (timeRange, url = window.location.href) => {
};
/**
+ * Locates a panel (and its corresponding group) given a (URL) search query. Returns
+ * it as payload for the store to set the right expandaded panel.
+ *
+ * Params used to locate a panel are:
+ * - group: Group identifier
+ * - title: Panel title
+ * - y_label: Panel y_label
+ *
+ * @param {Object} dashboard - Dashboard reference from the Vuex store
+ * @param {String} search - URL location search query
+ * @returns {Object} payload - Payload for expanded panel to be displayed
+ * @returns {String} payload.group - Group where panel is located
+ * @returns {Object} payload.panel - Dashboard panel (graphData) reference
+ * @throws Will throw an error if Panel cannot be located.
+ */
+export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => {
+ const params = queryToObject(search);
+
+ // Search for the panel if any of the search params is identified
+ if (params.group || params.title || params.y_label) {
+ const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group);
+ const panel = panelGroup.panels.find(
+ // eslint-disable-next-line babel/camelcase
+ ({ y_label, title }) => y_label === params.y_label && title === params.title,
+ );
+
+ if (!panel) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Panel could no found by URL parameters.');
+ }
+ return { group: panelGroup.group, panel };
+ }
+ return null;
+};
+
+/**
+ * Convert panel information to a URL for the user to
+ * bookmark or share highlighting a specific panel.
+ *
+ * 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 {?String} group - Group Identifier
+ * @param {?Object} panel - Panel object from the dashboard
+ * @param {?String} url - Base URL including current search params
+ * @returns Dashboard URL which expands a panel (chart)
+ */
+export const panelToUrl = (
+ dashboard = null,
+ promVariables,
+ group,
+ panel,
+ url = window.location.href,
+) => {
+ const params = {
+ dashboard,
+ ...promVariables,
+ };
+
+ if (group && panel) {
+ params.group = group;
+ params.title = panel.title;
+ params.y_label = panel.y_label;
+ } else {
+ // Remove existing parameters if any
+ params.group = null;
+ params.title = null;
+ params.y_label = null;
+ }
+
+ return mergeUrlParams(params, url);
+};
+
+/**
* Get the metric value from first data point.
* Currently only used for bar charts
*
@@ -191,4 +353,39 @@ export const barChartsDataParser = (data = []) =>
{},
);
+/**
+ * Custom variables are defined in the dashboard yml file
+ * and their values can be passed through the URL.
+ *
+ * On component load, this method merges variables data
+ * from the yml file with URL data to store in the Vuex store.
+ * Not all params coming from the URL need to be stored. Only
+ * the ones that have a corresponding variable defined in the
+ * yml file.
+ *
+ * This ensures that there is always a single source of truth
+ * for variables
+ *
+ * This method can be improved further. See the below issue
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/217713
+ *
+ * @param {Object} varsFromYML template variables from yml file
+ * @returns {Object}
+ */
+export const mergeURLVariables = (varsFromYML = {}) => {
+ const varsFromURL = getPromCustomVariablesFromUrl();
+ const variables = {};
+ Object.keys(varsFromYML).forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(varsFromURL, key)) {
+ variables[key] = {
+ ...varsFromYML[key],
+ value: varsFromURL[key],
+ };
+ } else {
+ variables[key] = varsFromYML[key];
+ }
+ });
+ return variables;
+};
+
export default {};
diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js
new file mode 100644
index 00000000000..cd426f1a221
--- /dev/null
+++ b/app/assets/javascripts/monitoring/validators.js
@@ -0,0 +1,44 @@
+// Prop validator for alert information, expecting an object like the example below.
+//
+// {
+// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': {
+// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37",
+// metricId: '1',
+// operator: ">",
+// query: "rate(http_requests_total[5m])[30m:1m]",
+// threshold: 0.002,
+// title: "Core Usage (Total)",
+// }
+// }
+export function alertsValidator(value) {
+ return Object.keys(value).every(key => {
+ const alert = value[key];
+ return (
+ alert.alert_path &&
+ key === alert.alert_path &&
+ alert.metricId &&
+ typeof alert.metricId === 'string' &&
+ alert.operator &&
+ typeof alert.threshold === 'number'
+ );
+ });
+}
+
+// Prop validator for query information, expecting an array like the example below.
+//
+// [
+// {
+// metricId: '16',
+// label: 'Total Cores'
+// },
+// {
+// metricId: '17',
+// label: 'Sub-total Cores'
+// }
+// ]
+export function queriesValidator(value) {
+ return value.every(
+ query =>
+ query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string',
+ );
+}
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index 8671f0fd783..b96a111cf13 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-else-return */
-
import $ from 'jquery';
import '~/gl_dropdown';
import Api from './api';
@@ -23,9 +21,8 @@ export default class NamespaceSelect {
toggleLabel(selected) {
if (selected.id == null) {
return selected.text;
- } else {
- return `${selected.kind}: ${selected.full_path}`;
}
+ return `${selected.kind}: ${selected.full_path}`;
},
data(term, dataCallback) {
return Api.namespaces(term, namespaces => {
@@ -43,9 +40,8 @@ export default class NamespaceSelect {
text(namespace) {
if (namespace.id == null) {
return namespace.text;
- } else {
- return `${namespace.kind}: ${namespace.full_path}`;
}
+ return `${namespace.kind}: ${namespace.full_path}`;
},
renderRow: this.renderRow,
clicked(options) {
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index be3ea4e680c..9d064894433 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, no-return-assign, no-else-return, @gitlab/require-i18n-strings */
+/* eslint-disable func-names, consistent-return, no-return-assign, @gitlab/require-i18n-strings */
import $ from 'jquery';
import RefSelectDropdown from './ref_select_dropdown';
@@ -76,9 +76,8 @@ export default class NewBranchForm {
const matched = this.name.val().match(restriction.pattern);
if (matched) {
return errors.concat(formatter(matched.reduce(unique, []), restriction));
- } else {
- return errors;
}
+ return errors;
};
const errors = this.restrictions.reduce(validator, []);
if (errors.length > 0) {
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index dab27cf8269..fcb09ea90db 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -36,9 +36,9 @@ const katexRegexString = `(
.replace(/\s/g, '')
.trim();
-renderer.paragraph = t => {
+function renderKatex(t) {
let text = t;
- let inline = false;
+ let numInline = 0; // number of successfull converted math formulas
if (typeof katex !== 'undefined') {
const katexString = text
@@ -50,24 +50,40 @@ renderer.paragraph = t => {
const numberOfMatches = katexString.match(regex);
if (numberOfMatches && numberOfMatches.length !== 0) {
+ let matches = regex.exec(katexString);
if (matchLocation > 0) {
- let matches = regex.exec(katexString);
- inline = true;
+ numInline += 1;
while (matches !== null) {
- const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, ''));
- text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
+ try {
+ const renderedKatex = katex.renderToString(
+ matches[0].replace(/\$/g, '').replace(/&#39;/g, "'"),
+ ); // get the tick ' back again from HTMLified string
+ text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
+ } catch {
+ numInline -= 1;
+ }
matches = regex.exec(katexString);
}
} else {
- const matches = regex.exec(katexString);
- text = katex.renderToString(matches[2]);
+ try {
+ text = katex.renderToString(matches[2].replace(/&#39;/g, "'"));
+ } catch (error) {
+ numInline -= 1;
+ }
}
}
}
-
+ return [text, numInline > 0];
+}
+renderer.paragraph = t => {
+ const [text, inline] = renderKatex(t);
return `<p class="${inline ? 'inline-katex' : ''}">${text}</p>`;
};
+renderer.listitem = t => {
+ const [text, inline] = renderKatex(t);
+ return `<li class="${inline ? 'inline-katex' : ''}">${text}</li>`;
+};
marked.setOptions({
renderer,
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 61626f7aaf5..f2d3796cccf 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -63,6 +63,9 @@ export default {
},
rawCode(output) {
if (output.text) {
+ if (typeof output.text === 'string') {
+ return output.text;
+ }
return output.text.join('');
}
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 9e2231922b7..6e695de447d 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,6 +1,6 @@
/* eslint-disable no-restricted-properties, babel/camelcase,
no-unused-expressions, default-case,
-consistent-return, no-alert, no-param-reassign, no-else-return,
+consistent-return, no-alert, no-param-reassign,
no-shadow, no-useless-escape,
class-methods-use-this */
@@ -256,7 +256,7 @@ export default class Notes {
discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- if (!window.confirm(__('Are you sure you want to cancel creating this comment?'))) {
+ if (!window.confirm(__('Your comment will be discarded.'))) {
return;
}
}
@@ -268,7 +268,7 @@ export default class Notes {
originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
- if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) {
+ if (!window.confirm(__('Are you sure you want to discard this comment?'))) {
return;
}
}
@@ -964,11 +964,11 @@ export default class Notes {
form
.prepend(
- `<div class="avatar-note-form-holder"><div class="content"><a href="${escape(
+ `<a href="${escape(
gon.current_username,
)}" class="user-avatar-link d-none d-sm-block"><img class="avatar s40" src="${encodeURI(
- gon.current_user_avatar_url,
- )}" alt="${escape(gon.current_user_fullname)}" /></a></div></div>`,
+ gon.current_user_avatar_url || gon.default_avatar_url,
+ )}" alt="${escape(gon.current_user_fullname)}" /></a>`,
)
.append('</div>')
.find('.js-close-discussion-note-form')
@@ -1123,10 +1123,9 @@ export default class Notes {
if (row.is('.js-temp-notes-holder')) {
// remove temporary row for diff lines
return row.remove();
- } else {
- // only remove the form
- return form.remove();
}
+ // only remove the form
+ return form.remove();
}
cancelDiscussionForm(e) {
@@ -1397,7 +1396,7 @@ export default class Notes {
}
/**
- * Check if note does not exists on page
+ * Check if note does not exist on page
*/
static isNewNote(noteEntity, noteIds) {
return $.inArray(noteEntity.id, noteIds) === -1;
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 9a809b71a58..a070cf8866a 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -3,6 +3,7 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { isEmpty } from 'lodash';
import Autosize from 'autosize';
+import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
@@ -34,6 +35,10 @@ export default {
userAvatarLink,
loadingButton,
TimelineEntryItem,
+ GlAlert,
+ GlIntersperse,
+ GlLink,
+ GlSprintf,
},
mixins: [issuableStateMixin],
props: {
@@ -57,8 +62,9 @@ export default {
'getNoteableData',
'getNotesData',
'openState',
+ 'getBlockedByIssues',
]),
- ...mapState(['isToggleStateButtonLoading']),
+ ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']),
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
@@ -159,6 +165,7 @@ export default {
'reopenIssue',
'toggleIssueLocalState',
'toggleStateButtonLoading',
+ 'toggleBlockedIssueWarning',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!isEmpty(note) && !isSubmitting) {
@@ -220,22 +227,17 @@ export default {
this.isSubmitting = false;
},
toggleIssueState() {
+ if (
+ this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE &&
+ this.isOpen &&
+ this.getBlockedByIssues &&
+ this.getBlockedByIssues.length > 0
+ ) {
+ this.toggleBlockedIssueWarning(true);
+ return;
+ }
if (this.isOpen) {
- this.closeIssue()
- .then(() => {
- this.enableButton();
- refreshUserMergeRequestCounts();
- })
- .catch(() => {
- this.enableButton();
- this.toggleStateButtonLoading(false);
- Flash(
- sprintf(
- __('Something went wrong while closing the %{issuable}. Please try again later'),
- { issuable: this.noteableDisplayName },
- ),
- );
- });
+ this.forceCloseIssue();
} else {
this.reopenIssue()
.then(() => {
@@ -258,6 +260,23 @@ export default {
});
}
},
+ forceCloseIssue() {
+ this.closeIssue()
+ .then(() => {
+ this.enableButton();
+ refreshUserMergeRequestCounts();
+ })
+ .catch(() => {
+ this.enableButton();
+ this.toggleStateButtonLoading(false);
+ Flash(
+ sprintf(
+ __('Something went wrong while closing the %{issuable}. Please try again later'),
+ { issuable: this.noteableDisplayName },
+ ),
+ );
+ });
+ },
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
// `focus` is needed to remain cursor in the textarea.
@@ -361,6 +380,36 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
>
</textarea>
</markdown-field>
+ <gl-alert
+ v-if="isToggleBlockedIssueWarning"
+ class="prepend-top-16"
+ :title="__('Are you sure you want to close this blocked issue?')"
+ :primary-button-text="__('Yes, close issue')"
+ :secondary-button-text="__('Cancel')"
+ variant="warning"
+ :dismissible="false"
+ @primaryAction="forceCloseIssue"
+ @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()"
+ >
+ <p>
+ <gl-sprintf
+ :message="
+ __('This issue is currently blocked by the following issues: %{issues}.')
+ "
+ >
+ <template #issues>
+ <gl-intersperse>
+ <gl-link
+ v-for="blockingIssue in getBlockedByIssues"
+ :key="blockingIssue.web_url"
+ :href="blockingIssue.web_url"
+ >#{{ blockingIssue.iid }}</gl-link
+ >
+ </gl-intersperse>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-alert>
<div class="note-form-actions">
<div
class="float-left btn-group
@@ -427,7 +476,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
</div>
<loading-button
- v-if="canToggleIssueState"
+ v-if="canToggleIssueState && !isToggleBlockedIssueWarning"
:loading="isToggleStateButtonLoading"
:container-class="[
actionButtonClassNames,
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 07952f9edd9..4a1a1086329 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -29,9 +29,6 @@ export default {
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
- resolvedDiscussionsCount() {
- return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount;
- },
toggeableDiscussions() {
return this.discussions.filter(discussion => !discussion.individual_note);
},
@@ -60,15 +57,15 @@ export default {
<div class="full-width-mobile d-flex d-sm-flex">
<div class="line-resolve-all">
<span
- :class="{ 'is-active': allResolved }"
- class="line-resolve-btn is-disabled"
- type="button"
+ :class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }"
>
- <icon :name="allResolved ? 'check-circle-filled' : 'check-circle'" />
- </span>
- <span class="line-resolve-text">
- {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }}
- {{ n__('thread resolved', 'threads resolved', resolvableDiscussionsCount) }}
+ <template v-if="allResolved">
+ <icon name="check-circle-filled" />
+ {{ __('All threads resolved') }}
+ </template>
+ <template v-else>
+ {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
+ </template>
</span>
</div>
<div
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index b024884bea0..21d0bffdf1c 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -328,7 +328,8 @@ export default {
<button
class="btn note-edit-cancel js-close-discussion-note-form"
type="button"
- @click="cancelHandler()"
+ data-testid="cancelBatchCommentsEnabled"
+ @click="cancelHandler(true)"
>
{{ __('Cancel') }}
</button>
@@ -353,7 +354,8 @@ export default {
<button
class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
type="button"
- @click="cancelHandler()"
+ data-testid="cancel"
+ @click="cancelHandler(true)"
>
{{ __('Cancel') }}
</button>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index f82b3554cac..81812ee2279 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,12 +1,17 @@
<script>
import { mapActions } from 'vuex';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import GitlabTeamMemberBadge from '~/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue';
export default {
components: {
timeAgoTooltip,
- GitlabTeamMemberBadge,
+ GitlabTeamMemberBadge: () =>
+ import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
author: {
@@ -44,6 +49,18 @@ export default {
required: false,
default: true,
},
+ isConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isUsernameLinkHovered: false,
+ emojiTitle: '',
+ authorStatusHasTooltip: false,
+ };
},
computed: {
toggleChevronClass() {
@@ -55,10 +72,29 @@ export default {
hasAuthor() {
return this.author && Object.keys(this.author).length;
},
- showGitlabTeamMemberBadge() {
- return this.author?.is_gitlab_employee;
+ authorLinkClasses() {
+ return {
+ hover: this.isUsernameLinkHovered,
+ 'text-underline': this.isUsernameLinkHovered,
+ 'author-name-link': true,
+ 'js-user-link': true,
+ };
+ },
+ authorStatus() {
+ return this.author.status_tooltip_html;
+ },
+ emojiElement() {
+ return this.$refs?.authorStatus?.querySelector('gl-emoji');
},
},
+ mounted() {
+ this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : '';
+
+ const authorStatusTitle = this.$refs?.authorStatus
+ ?.querySelector('.user-status-emoji')
+ ?.getAttribute('title');
+ this.authorStatusHasTooltip = authorStatusTitle && authorStatusTitle !== '';
+ },
methods: {
...mapActions(['setTargetNoteHash']),
handleToggle() {
@@ -69,6 +105,20 @@ export default {
this.setTargetNoteHash(this.noteTimestampLink);
}
},
+ removeEmojiTitle() {
+ this.emojiElement.removeAttribute('title');
+ },
+ addEmojiTitle() {
+ this.emojiElement.setAttribute('title', this.emojiTitle);
+ },
+ handleUsernameMouseEnter() {
+ this.$refs.authorNameLink.dispatchEvent(new Event('mouseenter'));
+ this.isUsernameLinkHovered = true;
+ },
+ handleUsernameMouseLeave() {
+ this.$refs.authorNameLink.dispatchEvent(new Event('mouseleave'));
+ this.isUsernameLinkHovered = false;
+ },
},
};
</script>
@@ -87,18 +137,34 @@ export default {
</div>
<template v-if="hasAuthor">
<a
- v-once
+ ref="authorNameLink"
:href="author.path"
- class="js-user-link"
+ :class="authorLinkClasses"
:data-user-id="author.id"
:data-username="author.username"
>
<slot name="note-header-info"></slot>
<span class="note-header-author-name bold">{{ author.name }}</span>
- <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
- <span class="note-headline-light">@{{ author.username }}</span>
</a>
- <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" />
+ <span
+ v-if="authorStatus"
+ ref="authorStatus"
+ v-on="
+ authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {}
+ "
+ v-html="authorStatus"
+ ></span>
+ <span class="text-nowrap author-username">
+ <a
+ ref="authorUsernameLink"
+ class="author-username-link"
+ :href="author.path"
+ @mouseenter="handleUsernameMouseEnter"
+ @mouseleave="handleUsernameMouseLeave"
+ ><span class="note-headline-light">@{{ author.username }}</span>
+ </a>
+ <gitlab-team-member-badge v-if="author && author.is_gitlab_employee" />
+ </span>
</template>
<span v-else>{{ __('A deleted user') }}</span>
<span class="note-headline-light note-headline-meta">
@@ -118,6 +184,15 @@ export default {
</a>
<time-ago-tooltip v-else ref="noteTimestamp" :time="createdAt" tooltip-placement="bottom" />
</template>
+ <gl-icon
+ v-if="isConfidential"
+ v-gl-tooltip:tooltipcontainer.bottom
+ data-testid="confidentialIndicator"
+ name="eye-slash"
+ :size="14"
+ :title="s__('Notes|Private comments are accessible by internal staff only')"
+ class="gl-ml-1 gl-text-gray-800 align-middle"
+ />
<slot name="extra-controls"></slot>
<i
v-if="showSpinner"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index dea782683f2..37675e20b3d 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -255,10 +255,16 @@ export default {
</div>
<div class="timeline-content">
<div class="note-header">
- <note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id">
+ <note-header
+ v-once
+ :author="author"
+ :created-at="note.created_at"
+ :note-id="note.id"
+ :is-confidential="note.confidential"
+ >
<slot slot="note-header-info" name="note-header-info"></slot>
<span v-if="commit" v-html="actionText"></span>
- <span v-else class="d-none d-sm-inline">&middot;</span>
+ <span v-else-if="note.created_at" class="d-none d-sm-inline">&middot;</span>
</note-header>
<note-actions
:author-id="author.id"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index c1dd56aedf2..faa6006945d 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -230,10 +230,11 @@ export default {
const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') };
if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) {
- return Object.assign({}, defaultConfig, {
+ return {
+ ...defaultConfig,
filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE,
persistFilter: false,
- });
+ };
}
return defaultConfig;
},
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 8f9e2359e0d..ba814649078 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -2,11 +2,9 @@ import Vue from 'vue';
import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
import initSortDiscussions from './sort_discussions';
-import createStore from './stores';
+import { store } from './stores';
document.addEventListener('DOMContentLoaded', () => {
- const store = createStore();
-
// eslint-disable-next-line no-new
new Vue({
el: '#js-vue-notes',
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 08c7efd69a6..c9026352d18 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,6 +1,6 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { scrollToElement } from '~/lib/utils/common_utils';
-import eventHub from '../../notes/event_hub';
+import eventHub from '../event_hub';
/**
* @param {string} selector
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 1b80b59621a..0999d0aa7ac 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -185,12 +185,27 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
});
};
+export const toggleBlockedIssueWarning = ({ commit }, value) => {
+ commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value);
+ // Hides Close issue button at the top of issue page
+ const closeDropdown = document.querySelector('.js-issuable-close-dropdown');
+ if (closeDropdown) {
+ closeDropdown.classList.toggle('d-none');
+ } else {
+ const closeButton = document.querySelector(
+ '.detail-page-header-actions .btn-close.btn-grouped',
+ );
+ closeButton.classList.toggle('d-md-block');
+ }
+};
+
export const closeIssue = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return axios.put(state.notesData.closePath).then(({ data }) => {
commit(types.CLOSE_ISSUE);
dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false);
+ dispatch('toggleBlockedIssueWarning', false);
});
};
@@ -233,7 +248,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const hasQuickActions = utils.hasQuickActions(placeholderText);
const replyId = noteData.data.in_reply_to_discussion_id;
let methodToDispatch;
- const postData = Object.assign({}, noteData);
+ const postData = { ...noteData };
if (postData.isDraft === true) {
methodToDispatch = replyId
? 'batchComments/addDraftToDiscussion'
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index eb877083bca..85997b44bcc 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -35,6 +35,8 @@ export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop];
+export const getBlockedByIssues = state => state.noteableData.blocked_by_issues;
+
export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note);
export const openState = state => state.noteableData.state;
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
index d41b02b4a4b..c4895f58656 100644
--- a/app/assets/javascripts/notes/stores/index.js
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -4,4 +4,8 @@ import notesModule from './modules';
Vue.use(Vuex);
-export default () => new Vuex.Store(notesModule());
+// NOTE: Giving the option to either use a singleton or new instance of notes.
+const notesStore = () => new Vuex.Store(notesModule());
+
+export default notesStore;
+export const store = notesStore();
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 81844ad6e98..25f0f546103 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -14,6 +14,7 @@ export default () => ({
// View layer
isToggleStateButtonLoading: false,
+ isToggleBlockedIssueWarning: false,
isNotesFetched: false,
isLoading: true,
isLoadingDescriptionVersion: false,
@@ -24,6 +25,7 @@ export default () => ({
},
userData: {},
noteableData: {
+ confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes.
current_user: {},
preview_note_path: 'path/to/preview',
},
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 5b7225bb3d2..2f7b2788d8a 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -33,6 +33,7 @@ export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT';
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
export const REOPEN_ISSUE = 'REOPEN_ISSUE';
export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
+export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING';
// Description version
export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index dab09d1d05c..f06874991f0 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -249,6 +249,10 @@ export default {
Object.assign(state, { isToggleStateButtonLoading: value });
},
+ [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) {
+ Object.assign(state, { isToggleBlockedIssueWarning: value });
+ },
+
[types.SET_NOTES_FETCHED_STATE](state, value) {
Object.assign(state, { isNotesFetched: value });
},
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js
index 5ec9688a6e4..8183e81fb02 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js
@@ -1,3 +1,3 @@
-import initUserInternalRegexPlaceholder from '../../application_settings/account_and_limits';
+import initUserInternalRegexPlaceholder from '../account_and_limits';
document.addEventListener('DOMContentLoaded', initUserInternalRegexPlaceholder());
diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
index 78a5c4c27be..ae2209b0292 100644
--- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
+++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
@@ -1,3 +1,3 @@
-import DueDateSelectors from '~/due_date_select';
+import initExpiresAtField from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
+document.addEventListener('DOMContentLoaded', initExpiresAtField);
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 a99fde54981..b22fbf6b833 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
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { s__, sprintf } from '~/locale';
@@ -34,7 +34,7 @@ export default {
return sprintf(
s__('AdminProjects|Delete Project %{projectName}?'),
{
- projectName: `'${esc(this.projectName)}'`,
+ projectName: `'${escape(this.projectName)}'`,
},
false,
);
@@ -46,7 +46,7 @@ export default {
and all related resources including issues, merge requests, etc.. Once you confirm and press
%{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`),
{
- projectName: `<strong>${esc(this.projectName)}</strong>`,
+ projectName: `<strong>${escape(this.projectName)}</strong>`,
strong_start: '<strong>',
strong_end: '</strong>',
},
@@ -57,7 +57,7 @@ export default {
return sprintf(
s__('AdminUsers|To confirm, type %{projectName}'),
{
- projectName: `<code>${esc(this.projectName)}</code>`,
+ projectName: `<code>${escape(this.projectName)}</code>`,
},
false,
);
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
index 5b7c8141084..71df677c7fd 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -56,7 +56,7 @@ export default {
return sprintf(
this.content,
{
- username: `<strong>${esc(this.username)}</strong>`,
+ username: `<strong>${escape(this.username)}</strong>`,
strong_start: '<strong>',
strong_end: '</strong>',
},
@@ -67,7 +67,7 @@ export default {
return sprintf(
s__('AdminUsers|To confirm, type %{username}'),
{
- username: `<code>${esc(this.username)}</code>`,
+ username: `<code>${escape(this.username)}</code>`,
},
false,
);
@@ -121,7 +121,7 @@ export default {
/>
</form>
</template>
- <template slot="modal-footer">
+ <template #modal-footer>
<gl-deprecated-button variant="secondary" @click="onCancel">{{
s__('Cancel')
}}</gl-deprecated-button>
diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
index 061044eba84..58dba41277d 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
@@ -123,7 +123,7 @@ Once deleted, it cannot be undone or recovered.`),
kind="danger"
@submit="onSubmit"
>
- <template slot="body" slot-scope="props">
+ <template #body="props">
<p v-html="props.text"></p>
</template>
</deprecated-modal>
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
index 26adf4cbbe0..e18732d0fd5 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -68,7 +68,7 @@ export default {
footer-primary-button-variant="warning"
@submit="onSubmit"
>
- <template slot="title">
+ <template #title>
{{ title }}
</template>
<div>
diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/pages/milestones/shared/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/pages/milestones/shared/event_hub.js
+++ b/app/assets/javascripts/pages/milestones/shared/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
index 78a5c4c27be..ae2209b0292 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -1,3 +1,3 @@
-import DueDateSelectors from '~/due_date_select';
+import initExpiresAtField from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
+document.addEventListener('DOMContentLoaded', initExpiresAtField);
diff --git a/app/assets/javascripts/pages/projects/alert_management/details/index.js b/app/assets/javascripts/pages/projects/alert_management/details/index.js
new file mode 100644
index 00000000000..0124795e1af
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/alert_management/details/index.js
@@ -0,0 +1,5 @@
+import AlertDetails from '~/alert_management/details';
+
+document.addEventListener('DOMContentLoaded', () => {
+ AlertDetails('#js-alert_details');
+});
diff --git a/app/assets/javascripts/pages/projects/alert_management/index/index.js b/app/assets/javascripts/pages/projects/alert_management/index/index.js
new file mode 100644
index 00000000000..1e98bcfd2eb
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/alert_management/index/index.js
@@ -0,0 +1,5 @@
+import AlertManagementList from '~/alert_management/list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ AlertManagementList();
+});
diff --git a/app/assets/javascripts/pages/projects/blob/new/index.js b/app/assets/javascripts/pages/projects/blob/new/index.js
index 720cb249052..189053f3ed7 100644
--- a/app/assets/javascripts/pages/projects/blob/new/index.js
+++ b/app/assets/javascripts/pages/projects/blob/new/index.js
@@ -1,12 +1,3 @@
import initBlobBundle from '~/blob_edit/blob_bundle';
-import initPopover from '~/blob/suggest_gitlab_ci_yml';
-document.addEventListener('DOMContentLoaded', () => {
- initBlobBundle();
-
- const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml');
-
- if (suggestEl) {
- initPopover(suggestEl);
- }
-});
+document.addEventListener('DOMContentLoaded', initBlobBundle);
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 557aea0c5de..e5e4670a5d7 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -32,9 +32,10 @@ document.addEventListener('DOMContentLoaded', () => {
GpgBadges.fetch();
- if (gon.features?.codeNavigation) {
- const el = document.getElementById('js-code-navigation');
- const { codeNavigationPath, blobPath, definitionPathPrefix } = el.dataset;
+ const codeNavEl = document.getElementById('js-code-navigation');
+
+ if (gon.features?.codeNavigation && codeNavEl) {
+ const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
// eslint-disable-next-line promise/catch-or-return
import('~/code_navigation').then(m =>
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
index 0d69a689316..31ec4e29ad2 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 'ee_else_ce/monitoring/monitoring_bundle';
+import monitoringBundle from '~/monitoring/monitoring_bundle_with_alerts';
document.addEventListener('DOMContentLoaded', monitoringBundle);
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index bf54ca972b2..e8e0cda2139 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -7,6 +7,7 @@ import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+import initIssuablesList from '~/issuables_list';
import initManualOrdering from '~/manual_ordering';
document.addEventListener('DOMContentLoaded', () => {
@@ -16,9 +17,11 @@ document.addEventListener('DOMContentLoaded', () => {
page: FILTERED_SEARCH.ISSUES,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
- new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+ new IssuableIndex(ISSUABLE_INDEX.ISSUE);
new ShortcutsNavigation();
new UsersSelect();
+
initManualOrdering();
+ initIssuablesList();
});
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 75df80a0f6c..46c9b2fe0af 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -12,6 +12,16 @@ export default function() {
initIssueableApp();
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
+
+ // .js-design-management is currently EE-only.
+ // This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend
+ // at which point this conditional can be removed.
+ if (document.querySelector('.js-design-management')) {
+ import(/* webpackChunkName: 'design_management' */ '~/design_management')
+ .then(module => module.default())
+ .catch(() => {});
+ }
+
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
index 12e16b79d37..3b26047455d 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
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
@@ -49,7 +49,7 @@ export default {
const label = `<span
class="label color-label"
style="background-color: ${this.labelColor}; color: ${this.labelTextColor};"
- >${esc(this.labelTitle)}</span>`;
+ >${escape(this.labelTitle)}</span>`;
return sprintf(
s__('Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>'),
diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/pages/projects/labels/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/pages/projects/labels/event_hub.js
+++ b/app/assets/javascripts/pages/projects/labels/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 3a0d9c17228..4efabcb7df3 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -1,5 +1,13 @@
<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { getWeekdayNames } from '~/lib/utils/datetime_utility';
+
export default {
+ components: {
+ GlSprintf,
+ GlLink,
+ },
props: {
initialCronInterval: {
type: String,
@@ -9,25 +17,51 @@ export default {
},
data() {
return {
+ isEditingCustom: false,
+ randomHour: this.generateRandomHour(),
+ randomWeekDayIndex: this.generateRandomWeekDayIndex(),
+ randomDay: this.generateRandomDay(),
inputNameAttribute: 'schedule[cron]',
cronInterval: this.initialCronInterval,
- cronIntervalPresets: {
- everyDay: '0 4 * * *',
- everyWeek: '0 4 * * 0',
- everyMonth: '0 4 1 * *',
- },
cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
- customInputEnabled: false,
};
},
computed: {
+ cronIntervalPresets() {
+ return {
+ everyDay: `0 ${this.randomHour} * * *`,
+ everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
+ everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`,
+ };
+ },
intervalIsPreset() {
return Object.values(this.cronIntervalPresets).includes(this.cronInterval);
},
- // The text input is editable when there's a custom interval, or when it's
- // a preset interval and the user clicks the 'custom' radio button
- isEditable() {
- return Boolean(this.customInputEnabled || !this.intervalIsPreset);
+ formattedTime() {
+ if (this.randomHour > 12) {
+ return `${this.randomHour - 12}:00pm`;
+ } else if (this.randomHour === 12) {
+ return `12:00pm`;
+ }
+ return `${this.randomHour}:00am`;
+ },
+ weekday() {
+ return getWeekdayNames()[this.randomWeekDayIndex];
+ },
+ everyDayText() {
+ return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime });
+ },
+ everyWeekText() {
+ return sprintf(s__('Every week (%{weekday} at %{time})'), {
+ weekday: this.weekday,
+ time: this.formattedTime,
+ });
+ },
+ everyMonthText() {
+ return sprintf(s__('Every month (Day %{day} at %{time})'), {
+ day: this.randomDay,
+ time: this.formattedTime,
+ });
},
},
watch: {
@@ -39,14 +73,31 @@ export default {
});
},
},
- created() {
- if (this.intervalIsPreset) {
- this.enableCustomInput = false;
+ // If at the mounting stage the default is still an empty string, we
+ // know we are not editing an existing field so we update it so
+ // that the default is the first radio option
+ mounted() {
+ if (this.cronInterval === '') {
+ this.cronInterval = this.cronIntervalPresets.everyDay;
}
},
methods: {
+ setCustomInput(e) {
+ if (!this.isEditingCustom) {
+ this.isEditingCustom = true;
+ this.$refs.customInput.click();
+ // Because we need to manually trigger the click on the radio btn,
+ // it will add a space to update the v-model. If the user is typing
+ // and the space is added, it will feel very unituitive so we reset
+ // the value to the original
+ this.cronInterval = e.target.value;
+ }
+ if (this.intervalIsPreset) {
+ this.isEditingCustom = false;
+ }
+ },
toggleCustomInput(shouldEnable) {
- this.customInputEnabled = shouldEnable;
+ this.isEditingCustom = shouldEnable;
if (shouldEnable) {
// We need to change the value so other radios don't remain selected
@@ -54,6 +105,15 @@ export default {
this.cronInterval = `${this.cronInterval} `;
}
},
+ generateRandomHour() {
+ return Math.floor(Math.random() * 23);
+ },
+ generateRandomWeekDayIndex() {
+ return Math.floor(Math.random() * 6);
+ },
+ generateRandomDay() {
+ return Math.floor(Math.random() * 28);
+ },
},
};
</script>
@@ -62,24 +122,6 @@ export default {
<div class="interval-pattern-form-group">
<div class="cron-preset-radio-input">
<input
- id="custom"
- :name="inputNameAttribute"
- :value="cronInterval"
- :checked="isEditable"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(true)"
- />
-
- <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
-
- <span class="cron-syntax-link-wrap">
- (<a :href="cronSyntaxUrl" target="_blank"> {{ __('Cron syntax') }} </a>)
- </span>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
id="every-day"
v-model="cronInterval"
:name="inputNameAttribute"
@@ -89,7 +131,9 @@ export default {
@click="toggleCustomInput(false)"
/>
- <label class="label-bold" for="every-day"> {{ __('Every day (at 4:00am)') }} </label>
+ <label class="label-bold" for="every-day">
+ {{ everyDayText }}
+ </label>
</div>
<div class="cron-preset-radio-input">
@@ -104,7 +148,7 @@ export default {
/>
<label class="label-bold" for="every-week">
- {{ __('Every week (Sundays at 4:00am)') }}
+ {{ everyWeekText }}
</label>
</div>
@@ -120,20 +164,43 @@ export default {
/>
<label class="label-bold" for="every-month">
- {{ __('Every month (on the 1st at 4:00am)') }}
+ {{ everyMonthText }}
</label>
</div>
+ <div class="cron-preset-radio-input">
+ <input
+ id="custom"
+ ref="customInput"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronInterval"
+ class="label-bold"
+ type="radio"
+ @click="toggleCustomInput(true)"
+ />
+
+ <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
+
+ <gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')">
+ <template #link="{content}">
+ <gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
<div class="cron-interval-input-wrapper">
<input
id="schedule_cron"
v-model="cronInterval"
:placeholder="__('Define a custom pattern with cron syntax')"
:name="inputNameAttribute"
- :disabled="!isEditable"
class="form-control inline cron-interval-input"
type="text"
required="true"
+ @input="setCustomInput"
/>
</div>
</div>
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 22512a6f12a..da96e6f36b4 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -2,7 +2,8 @@
import Vue from 'vue';
import Cookies from 'js-cookie';
import Translate from '../../../../../vue_shared/translate';
-import illustrationSvg from '../icons/intro_illustration.svg';
+// Full path is needed for Jest to be able to correctly mock this file
+import illustrationSvg from '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Translate);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index dc6df27f1c7..497e2c9c0ae 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -11,9 +11,7 @@ Vue.use(Translate);
function initIntervalPatternInput() {
const intervalPatternMount = document.getElementById('interval-pattern-input');
- const initialCronInterval = intervalPatternMount
- ? intervalPatternMount.dataset.initialInterval
- : '';
+ const initialCronInterval = intervalPatternMount?.dataset?.initialInterval;
return new Vue({
el: intervalPatternMount,
diff --git a/app/assets/javascripts/pages/projects/pipelines/dag/index.js b/app/assets/javascripts/pages/projects/pipelines/dag/index.js
new file mode 100644
index 00000000000..d19c22ba556
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/dag/index.js
@@ -0,0 +1,2 @@
+// /dag is an alias for show
+import '../show/index';
diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js
index 4b4a274794d..bbad3238ec4 100644
--- a/app/assets/javascripts/pages/projects/pipelines/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js
@@ -50,6 +50,7 @@ document.addEventListener(
hasGitlabCi: parseBoolean(this.dataset.hasGitlabCi),
ciLintPath: this.dataset.ciLintPath,
resetCachePath: this.dataset.resetCachePath,
+ projectId: this.dataset.projectId,
},
});
},
diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
new file mode 100644
index 00000000000..ae2209b0292
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
@@ -0,0 +1,3 @@
+import initExpiresAtField from '~/access_tokens';
+
+document.addEventListener('DOMContentLoaded', initExpiresAtField);
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 6efddec1172..ab32fe18972 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -1,9 +1,8 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
+import { GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
import { s__ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectFeatureSetting from './project_feature_setting.vue';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import projectSettingRow from './project_setting_row.vue';
@@ -12,6 +11,7 @@ import {
visibilityLevelDescriptions,
featureAccessLevelMembers,
featureAccessLevelEveryone,
+ featureAccessLevel,
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
@@ -24,8 +24,9 @@ export default {
projectSettingRow,
GlSprintf,
GlLink,
+ GlFormCheckbox,
},
- mixins: [settingsMixin, glFeatureFlagsMixin()],
+ mixins: [settingsMixin],
props: {
currentSettings: {
@@ -127,7 +128,7 @@ export default {
wikiAccessLevel: 20,
snippetsAccessLevel: 20,
pagesAccessLevel: 20,
- metricsAccessLevel: visibilityOptions.PRIVATE,
+ metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
containerRegistryEnabled: true,
lfsEnabled: true,
requestAccessEnabled: true,
@@ -174,6 +175,10 @@ export default {
return options;
},
+ metricsOptionsDropdownEnabled() {
+ return this.featureAccessLevelOptions.length < 2;
+ },
+
repositoryEnabled() {
return this.repositoryAccessLevel > 0;
},
@@ -195,10 +200,6 @@ export default {
'ProjectSettings|View and edit files in this project. Non-project members will only have read access',
);
},
-
- metricsDashboardVisibilitySwitchingAvailable() {
- return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable;
- },
},
watch: {
@@ -211,6 +212,7 @@ export default {
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) {
// When from Internal->Private narrow access for only members
this.pagesAccessLevel = 10;
@@ -225,6 +227,7 @@ export default {
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;
this.highlightChanges();
}
},
@@ -473,7 +476,6 @@ export default {
/>
</project-setting-row>
<project-setting-row
- v-if="metricsDashboardVisibilitySwitchingAvailable"
ref="metrics-visibility-settings"
:label="__('Metrics Dashboard')"
:help-text="
@@ -485,17 +487,18 @@ export default {
<div class="project-feature-controls">
<div class="select-wrapper">
<select
- v-model="metricsAccessLevel"
+ v-model="metricsDashboardAccessLevel"
+ :disabled="metricsOptionsDropdownEnabled"
name="project[project_feature_attributes][metrics_dashboard_access_level]"
- class="form-control select-control"
+ class="form-control project-repo-select select-control"
>
<option
- :value="visibilityOptions.PRIVATE"
- :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ :value="featureAccessLevelMembers[0]"
+ :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
>{{ featureAccessLevelMembers[1] }}</option
>
<option
- :value="visibilityOptions.PUBLIC"
+ :value="featureAccessLevelEveryone[0]"
:disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
>{{ featureAccessLevelEveryone[1] }}</option
>
@@ -517,5 +520,23 @@ export default {
)
}}</span>
</project-setting-row>
+ <project-setting-row class="mb-3">
+ <input
+ :value="showDefaultAwardEmojis"
+ type="hidden"
+ name="project[project_setting_attributes][show_default_award_emojis]"
+ />
+ <gl-form-checkbox
+ v-model="showDefaultAwardEmojis"
+ name="project[project_setting_attributes][show_default_award_emojis]"
+ >
+ {{ s__('ProjectSettings|Show default award emojis') }}
+ <template #help>{{
+ s__(
+ 'ProjectSettings|When enabled, issues, merge requests, and snippets will always show thumbs-up and thumbs-down award emoji buttons.',
+ )
+ }}</template>
+ </gl-form-checkbox>
+ </project-setting-row>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
index 6af346ace67..580cca49b5e 100644
--- a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
+++ b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlModal, GlModalDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -38,7 +38,7 @@ export default {
return sprintf(
s__('WikiPageConfirmDelete|Delete page %{pageTitle}?'),
{
- pageTitle: esc(this.pageTitle),
+ pageTitle: escape(this.pageTitle),
},
false,
);
@@ -46,6 +46,7 @@ export default {
},
methods: {
onSubmit() {
+ window.onbeforeunload = null;
this.$refs.form.submit();
},
},
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
index 93afdc54ce1..ed67219383b 100644
--- a/app/assets/javascripts/pages/projects/wikis/wikis.js
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -44,6 +44,19 @@ export default class Wikis {
linkExample.innerHTML = MARKDOWN_LINK_TEXT[e.target.value];
});
}
+
+ const wikiTextarea = document.querySelector('form.wiki-form #wiki_content');
+ const wikiForm = document.querySelector('form.wiki-form');
+
+ if (wikiTextarea) {
+ wikiTextarea.addEventListener('input', () => {
+ window.onbeforeunload = () => '';
+ });
+
+ wikiForm.addEventListener('submit', () => {
+ window.onbeforeunload = null;
+ });
+ }
}
handleWikiTitleChange(e) {
diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js
index 7fdf4ee0bf3..e54e32199f0 100644
--- a/app/assets/javascripts/pages/search/init_filtered_search.js
+++ b/app/assets/javascripts/pages/search/init_filtered_search.js
@@ -1,4 +1,4 @@
-import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
+import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
export default ({
page,
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 24ae900b445..e1a0e2df0e0 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -116,7 +116,9 @@ export default {
</template>
</table>
- <div slot="footer"></div>
+ <template #footer>
+ <div></div>
+ </template>
</gl-modal>
{{ title }}
<request-warning :html-id="htmlId" :warnings="warnings" />
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 4598626718c..b3068c46bcb 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -18,6 +18,11 @@ export default class PersistentUserCallout {
init() {
const closeButton = this.container.querySelector('.js-close');
+
+ if (!closeButton) {
+ return;
+ }
+
closeButton.addEventListener('click', event => this.dismiss(event));
if (this.deferLinks) {
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index ebd7a17040a..15c220a554d 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -69,7 +69,9 @@ export default {
>
<ci-icon :status="group.status" />
- <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ <span
+ class="ci-status-text text-truncate mw-70p gl-pl-1-deprecated-no-really-do-not-use-me d-inline-block align-bottom"
+ >
{{ group.name }}
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index 7125790ac3d..74a261f35d7 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -27,7 +27,9 @@ export default {
<template>
<span class="ci-job-name-component mw-100">
<ci-icon :status="status" />
- <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ <span
+ class="ci-status-text text-truncate mw-70p gl-pl-1-deprecated-no-really-do-not-use-me d-inline-block align-bottom"
+ >
{{ name }}
</span>
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 3d3dabbdf22..bed0ed51d5f 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,5 +1,5 @@
<script>
-import { isEmpty, escape as esc } from 'lodash';
+import { isEmpty, escape } from 'lodash';
import stageColumnMixin from '../../mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
@@ -44,7 +44,7 @@ export default {
},
methods: {
groupId(group) {
- return `ci-badge-${esc(group.name)}`;
+ return `ci-badge-${escape(group.name)}`;
},
pipelineActionRequestComplete() {
this.$emit('refreshPipelineGraph');
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index d4f23697e09..fc93635bdb5 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -9,14 +9,18 @@ import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue';
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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
TablePagination,
NavigationTabs,
NavigationControls,
+ PipelinesFilteredSearch,
},
- mixins: [pipelinesMixin, CIPaginationMixin],
+ mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()],
props: {
store: {
type: Object,
@@ -78,6 +82,10 @@ export default {
required: false,
default: null,
},
+ projectId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -209,6 +217,9 @@ export default {
},
];
},
+ canFilterPipelines() {
+ return this.glFeatures.filterPipelinesSearch;
+ },
},
created() {
this.service = new PipelinesService(this.endpoint);
@@ -238,6 +249,30 @@ export default {
createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.'));
});
},
+ resetRequestData() {
+ this.requestData = { page: this.page, scope: this.scope };
+ },
+ filterPipelines(filters) {
+ this.resetRequestData();
+
+ 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) {
+ this.requestData[filter.type] = filter.value.data;
+ }
+
+ if (!filter.type) {
+ createFlash(RAW_TEXT_WARNING, 'warning');
+ }
+ });
+
+ if (filters.length === 0) {
+ this.resetRequestData();
+ }
+
+ this.updateContent(this.requestData);
+ },
},
};
</script>
@@ -267,6 +302,13 @@ export default {
/>
</div>
+ <pipelines-filtered-search
+ v-if="canFilterPipelines"
+ :pipelines="state.pipelines"
+ :project-id="projectId"
+ @filterPipelines="filterPipelines"
+ />
+
<div class="content-list pipelines">
<gl-loading-icon
v-if="stateToRender === $options.stateMap.loading"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
new file mode 100644
index 00000000000..8f9c3eb70a2
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
@@ -0,0 +1,91 @@
+<script>
+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';
+
+export default {
+ components: {
+ GlFilteredSearch,
+ },
+ props: {
+ pipelines: {
+ type: Array,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projectUsers: null,
+ projectBranches: null,
+ };
+ },
+ computed: {
+ tokens() {
+ return [
+ {
+ type: 'username',
+ 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',
+ icon: 'branch',
+ title: s__('Pipeline|Branch name'),
+ unique: true,
+ token: PipelineBranchNameToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ branches: this.projectBranches,
+ projectId: this.projectId,
+ },
+ ];
+ },
+ },
+ 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;
+ });
+ },
+ methods: {
+ onSubmit(filters) {
+ this.$emit('filterPipelines', filters);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row-content-block">
+ <gl-filtered-search
+ :placeholder="__('Filter pipelines')"
+ :available-tokens="tokens"
+ @submit="onSubmit"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index e25f8ab4790..981914dd046 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -99,9 +99,10 @@ export default {
// 3. If GitLab user does not have avatar, they might have a Gravatar
} else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
+ commitAuthorInformation = {
+ ...this.pipeline.commit.author,
avatar_url: this.pipeline.commit.author_gravatar_url,
- });
+ };
}
// 4. If committer is not a GitLab User, they can have a Gravatar
} else {
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 7426936515a..569920a4f31 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -137,7 +137,7 @@ export default {
},
isDropdownOpen() {
- return this.$el.classList.contains('open');
+ return this.$el.classList.contains('show');
},
pipelineActionRequestComplete() {
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 388b300b39d..06ab45adf80 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -21,7 +21,8 @@ export default {
return this.selectedSuite.total_count > 0;
},
showTests() {
- return this.testReports.total_count > 0;
+ const { test_suites: testSuites = [] } = this.testReports;
+ return testSuites.length > 0;
},
},
methods: {
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 9739ef76867..80a1c83f171 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -29,7 +29,14 @@ export default {
successPercentage() {
// Returns a full number when the decimals equal .00.
// Otherwise returns a float to two decimal points
- return Number(((this.report.success_count / this.report.total_count) * 100 || 0).toFixed(2));
+ // Do not include skipped tests as part of the total when doing success calculations.
+
+ const totalCompletedCount = this.report.total_count - this.report.skipped_count;
+
+ if (totalCompletedCount > 0) {
+ return Number(((this.report.success_count / totalCompletedCount) * 100 || 0).toFixed(2));
+ }
+ return 0;
},
formattedDuration() {
return formatTime(secondsToMilliseconds(this.report.total_time));
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 6effd6e949d..4dfb67dd8e8 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -1,14 +1,19 @@
<script>
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import store from '~/pipelines/stores/test_reports';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
export default {
name: 'TestsSummaryTable',
components: {
+ GlIcon,
SmartVirtualList,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
store,
props: {
heading: {
@@ -75,7 +80,10 @@ export default {
v-for="(testSuite, index) in getTestSuites"
:key="index"
role="row"
- class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row"
+ class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row"
+ :class="{
+ 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error,
+ }"
@click="tableRowClick(testSuite)"
>
<div class="table-section section-25">
@@ -84,6 +92,14 @@ export default {
</div>
<div class="table-mobile-content underline cgray pl-3">
{{ testSuite.name }}
+ <gl-icon
+ v-if="testSuite.suite_error"
+ ref="suiteErrorIcon"
+ v-gl-tooltip
+ name="error"
+ :title="testSuite.suite_error"
+ class="vertical-align-middle"
+ />
</div>
</div>
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
new file mode 100644
index 00000000000..a7a3f986255
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import Api from '~/api';
+import { FETCH_BRANCH_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 {
+ branches: this.config.branches,
+ loading: true,
+ };
+ },
+ methods: {
+ fetchBranchBySearchTerm(searchTerm) {
+ Api.branches(this.config.projectId, searchTerm)
+ .then(res => {
+ this.branches = res.data.map(branch => branch.name);
+ this.loading = false;
+ })
+ .catch(err => {
+ createFlash(FETCH_BRANCH_ERROR_MESSAGE);
+ this.loading = false;
+ throw err;
+ });
+ },
+ searchBranches: debounce(function debounceSearch({ data }) {
+ this.fetchBranchBySearchTerm(data);
+ }, FILTER_PIPELINES_SEARCH_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchBranches"
+ >
+ <template #suggestions>
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="(branch, index) in branches"
+ :key="index"
+ :value="branch"
+ >
+ {{ branch }}
+ </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
new file mode 100644
index 00000000000..83e3558e1a1
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
@@ -0,0 +1,114 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import Api from '~/api';
+import createFlash from '~/flash';
+import { debounce } from 'lodash';
+import {
+ ANY_TRIGGER_AUTHOR,
+ FETCH_AUTHOR_ERROR_MESSAGE,
+ FILTER_PIPELINES_SEARCH_DELAY,
+} from '../../constants';
+
+export default {
+ anyTriggerAuthor: ANY_TRIGGER_AUTHOR,
+ components: {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ users: this.config.triggerAuthors,
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeUser() {
+ return this.users.find(user => {
+ return user.username.toLowerCase() === this.currentValue;
+ });
+ },
+ },
+ methods: {
+ fetchAuthorBySearchTerm(searchTerm) {
+ Api.projectUsers(this.config.projectId, searchTerm)
+ .then(res => {
+ this.users = res;
+ this.loading = false;
+ })
+ .catch(err => {
+ createFlash(FETCH_AUTHOR_ERROR_MESSAGE);
+ this.loading = false;
+ throw err;
+ });
+ },
+ searchAuthors: debounce(function debounceSearch({ data }) {
+ this.fetchAuthorBySearchTerm(data);
+ }, FILTER_PIPELINES_SEARCH_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="activeUser"
+ :size="16"
+ :src="activeUser.avatar_url"
+ shape="circle"
+ class="gl-mr-2"
+ />
+ <span>{{ activeUser ? activeUser.name : inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{
+ $options.anyTriggerAuthor
+ }}</gl-filtered-search-suggestion>
+ <gl-dropdown-divider />
+
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="user in users"
+ :key="user.username"
+ :value="user.username"
+ >
+ <div class="d-flex">
+ <gl-avatar :size="32" :src="user.avatar_url" />
+ <div>
+ <div>{{ user.name }}</div>
+ <div>@{{ user.username }}</div>
+ </div>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index c9655d18a04..d694430830b 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -1,9 +1,19 @@
+import { s__, __ } from '~/locale';
+
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
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 TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
SUCCESS: 'success',
};
+
+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 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 d76425c96b7..01295874e56 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -14,13 +14,7 @@ import axios from '~/lib/utils/axios_utils';
Vue.use(Translate);
-export default () => {
- const { dataset } = document.querySelector('.js-pipeline-details-vue');
-
- const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
-
- mediator.fetchPipeline();
-
+const createPipelinesDetailApp = mediator => {
// eslint-disable-next-line no-new
new Vue({
el: '#js-pipeline-graph-vue',
@@ -50,7 +44,9 @@ export default () => {
});
},
});
+};
+const createPipelineHeaderApp = mediator => {
// eslint-disable-next-line no-new
new Vue({
el: '#js-pipeline-header-vue',
@@ -94,7 +90,9 @@ export default () => {
});
},
});
+};
+const createPipelinesTabs = dataset => {
const tabsElement = document.querySelector('.pipelines-tabs');
const testReportsEnabled =
window.gon && window.gon.features && window.gon.features.junitPipelineView;
@@ -119,27 +117,40 @@ export default () => {
tabsElement.addEventListener('click', tabClickHandler);
}
+ }
+};
- // eslint-disable-next-line no-new
- new Vue({
- el: '#js-pipeline-tests-detail',
- components: {
- TestReports,
- },
- render(createElement) {
- return createElement('test-reports');
- },
- });
+const createTestDetails = detailsEndpoint => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-pipeline-tests-detail',
+ components: {
+ TestReports,
+ },
+ render(createElement) {
+ return createElement('test-reports');
+ },
+ });
- axios
- .get(dataset.testReportsCountEndpoint)
- .then(({ data }) => {
- if (!data.total_count) {
- return;
- }
+ axios
+ .get(detailsEndpoint)
+ .then(({ data }) => {
+ if (!data.total_count) {
+ return;
+ }
- document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count;
- })
- .catch(() => {});
- }
+ document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count;
+ })
+ .catch(() => {});
+};
+
+export default () => {
+ const { dataset } = document.querySelector('.js-pipeline-details-vue');
+ const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
+ mediator.fetchPipeline();
+
+ createPipelinesDetailApp(mediator);
+ createPipelineHeaderApp(mediator);
+ createPipelinesTabs(dataset);
+ createTestDetails(dataset.testReportsCountEndpoint);
};
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index 3c755db23dc..ae94d7a7ca0 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -19,13 +19,23 @@ export default class PipelinesService {
}
getPipelines(data = {}) {
- const { scope, page } = data;
+ const { scope, page, username, ref } = data;
const { CancelToken } = axios;
+ const queryParams = { scope, page };
+
+ if (username) {
+ queryParams.username = username;
+ }
+
+ if (ref) {
+ queryParams.ref = ref;
+ }
+
this.cancelationSource = CancelToken.source();
return axios.get(this.endpoint, {
- params: { scope, page },
+ params: queryParams,
cancelToken: this.cancelationSource.token,
});
}
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
index 1ef73760e02..c6f65277c8d 100644
--- a/app/assets/javascripts/pipelines/stores/pipeline_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -15,7 +15,7 @@ export default class PipelineStore {
* @param {Object} pipeline
*/
storePipeline(pipeline = {}) {
- const pipelineCopy = Object.assign({}, pipeline);
+ const pipelineCopy = { ...pipeline };
if (pipelineCopy.triggered_by) {
pipelineCopy.triggered_by = [pipelineCopy.triggered_by];
diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
index 85c5c073a74..aeb69fb1c05 100644
--- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -85,7 +85,7 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`),
kind="danger"
@submit="onSubmit"
>
- <template slot="body" slot-scope="props">
+ <template #body="props">
<p v-html="props.text"></p>
<form ref="form" :action="actionUrl" method="post">
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index fa09e063552..feb83e07607 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
@@ -43,10 +43,10 @@ You are going to change the username %{currentUsernameBold} to %{newUsernameBold
Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group.
Please update your Git repository remotes as soon as possible.`),
{
- currentUsernameBold: `<strong>${esc(this.username)}</strong>`,
- newUsernameBold: `<strong>${esc(this.newUsername)}</strong>`,
- currentUsername: esc(this.username),
- newUsername: esc(this.newUsername),
+ currentUsernameBold: `<strong>${escape(this.username)}</strong>`,
+ newUsernameBold: `<strong>${escape(this.newUsername)}</strong>`,
+ currentUsername: escape(this.username),
+ newUsername: escape(this.newUsername),
},
false,
);
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 15c7c09366c..2b2c365dd54 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-else-return */
+/* eslint-disable func-names */
import $ from 'jquery';
import Api from './api';
@@ -74,18 +74,17 @@ const projectSelect = () => {
},
projectsCallback,
);
- } else {
- return Api.projects(
- query.term,
- {
- order_by: this.orderBy,
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- membership: !this.allProjects,
- },
- projectsCallback,
- );
}
+ return Api.projects(
+ query.term,
+ {
+ order_by: this.orderBy,
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ membership: !this.allProjects,
+ },
+ projectsCallback,
+ );
},
id(project) {
if (simpleFilter) return project.id;
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index 78f9389b80c..eb514b5c070 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -110,6 +110,7 @@ export default {
<gl-new-dropdown
:text="dropdownText"
:disabled="hasSearchParam"
+ toggle-class="gl-py-3"
class="gl-dropdown w-100 mt-2 mt-sm-0"
>
<gl-new-dropdown-header>
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index daeae071d6a..a3a53c2f975 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/browser';
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
@@ -26,6 +27,9 @@ export default {
},
})
.then(({ data }) => dispatch('receiveAuthorsSuccess', data))
- .catch(() => dispatch('receiveAuthorsError'));
+ .catch(error => {
+ Sentry.captureException(error);
+ dispatch('receiveAuthorsError');
+ });
},
};
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 4dc1c512689..cdf03a5013f 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -113,6 +113,9 @@ export default {
</script>
<template>
<div>
+ <div class="mb-3">
+ <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
+ </div>
<h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
<div class="row">
<div class="col-md-6">
diff --git a/app/assets/javascripts/registry/explorer/components/image_list.vue b/app/assets/javascripts/registry/explorer/components/image_list.vue
new file mode 100644
index 00000000000..bc209b12738
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/image_list.vue
@@ -0,0 +1,124 @@
+<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/constants.js b/app/assets/javascripts/registry/explorer/constants.js
index d4b9d25b212..7cbe657bfc0 100644
--- a/app/assets/javascripts/registry/explorer/constants.js
+++ b/app/assets/javascripts/registry/explorer/constants.js
@@ -37,16 +37,31 @@ 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 deleting the tag.',
+ '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_TAG_SUCCESS_MESSAGE = s__('ContainerRegistry|Tag deleted successfully');
export const DELETE_TAGS_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while deleting the tags.',
+ '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 DELETE_TAGS_SUCCESS_MESSAGE = s__('ContainerRegistry|Tags deleted successfully');
export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 10;
@@ -65,6 +80,27 @@ 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__(
diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js
index 9269aa074f8..2bba3ee4ff9 100644
--- a/app/assets/javascripts/registry/explorer/index.js
+++ b/app/assets/javascripts/registry/explorer/index.js
@@ -19,7 +19,7 @@ export default () => {
const { endpoint } = el.dataset;
const store = createStore();
- const router = createRouter(endpoint, store);
+ const router = createRouter(endpoint);
store.dispatch('setInitialState', el.dataset);
const attachMainComponent = () =>
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index 6afd4d1107a..cc2dc531dc8 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -9,12 +9,14 @@ import {
GlPagination,
GlModal,
GlSprintf,
+ GlAlert,
+ GlLink,
GlEmptyState,
GlResizeObserverDirective,
GlSkeletonLoader,
} from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { n__, s__ } from '~/locale';
+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';
@@ -35,6 +37,14 @@ import {
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';
export default {
@@ -49,6 +59,8 @@ export default {
GlSkeletonLoader,
GlSprintf,
GlEmptyState,
+ GlAlert,
+ GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -60,6 +72,19 @@ export default {
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,
+ },
data() {
return {
selectedItems: [],
@@ -67,6 +92,7 @@ export default {
selectAllChecked: false,
modalDescription: null,
isDesktop: true,
+ deleteAlertType: false,
};
},
computed: {
@@ -78,9 +104,15 @@ export default {
},
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` },
+ {
+ 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 },
@@ -110,20 +142,43 @@ 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: s__(`ContainerRegistry|You are about to remove %{item} tags. Are you sure?`),
+ message: REMOVE_TAGS_CONFIRMATION_TEXT,
item: this.itemsToBeDeleted.length,
};
} else {
const { path } = this.tags[itemIndex];
this.modalDescription = {
- message: s__(`ContainerRegistry|You are about to remove %{item}. Are you sure?`),
+ message: REMOVE_TAG_CONFIRMATION_TEXT,
item: path,
};
}
@@ -179,19 +234,17 @@ export default {
this.track('click_button');
this.$refs.deleteModal.show();
},
- handleSingleDelete(itemToDelete) {
+ handleSingleDelete(index) {
+ const itemToDelete = this.tags[index];
this.itemsToBeDeleted = [];
+ this.selectedItems = this.selectedItems.filter(i => i !== index);
return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id })
- .then(() =>
- this.$toast.show(DELETE_TAG_SUCCESS_MESSAGE, {
- type: 'success',
- }),
- )
- .catch(() =>
- this.$toast.show(DELETE_TAG_ERROR_MESSAGE, {
- type: 'error',
- }),
- );
+ .then(() => {
+ this.deleteAlertType = 'success_tag';
+ })
+ .catch(() => {
+ this.deleteAlertType = 'danger_tag';
+ });
},
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
@@ -202,24 +255,19 @@ export default {
ids: itemsToBeDeleted.map(x => this.tags[x].name),
params: this.$route.params.id,
})
- .then(() =>
- this.$toast.show(DELETE_TAGS_SUCCESS_MESSAGE, {
- type: 'success',
- }),
- )
- .catch(() =>
- this.$toast.show(DELETE_TAGS_ERROR_MESSAGE, {
- type: 'error',
- }),
- );
+ .then(() => {
+ this.deleteAlertType = 'success_tags';
+ })
+ .catch(() => {
+ this.deleteAlertType = 'danger_tags';
+ });
},
onDeletionConfirmed() {
this.track('confirm_delete');
if (this.isMultiDelete) {
this.handleMultipleDelete();
} else {
- const index = this.itemsToBeDeleted[0];
- this.handleSingleDelete(this.tags[index]);
+ this.handleSingleDelete(this.itemsToBeDeleted[0]);
}
},
handleResize() {
@@ -231,9 +279,24 @@ 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"
+ 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="s__('ContainerRegistry|%{imageName} tags')">
+ <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
<template #imageName>
{{ imageName }}
</template>
@@ -256,8 +319,8 @@ export default {
:disabled="!selectedItems || selectedItems.length === 0"
class="float-right"
variant="danger"
- :title="s__('ContainerRegistry|Remove selected tags')"
- :aria-label="s__('ContainerRegistry|Remove selected tags')"
+ :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
+ :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
@click="deleteMultipleItems()"
>
<gl-icon name="remove" />
@@ -272,17 +335,24 @@ export default {
@change="updateSelectedItems(index)"
/>
</template>
- <template #cell(name)="{item}">
- <span ref="rowName">
- {{ 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"
- />
+ <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">
@@ -299,15 +369,15 @@ export default {
</span>
</template>
<template #cell(created_at)="{value}">
- <span ref="rowTime">
+ <span ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{index, item}">
<gl-deprecated-button
ref="singleDeleteButton"
- :title="s__('ContainerRegistry|Remove tag')"
- :aria-label="s__('ContainerRegistry|Remove tag')"
+ :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"
@@ -337,15 +407,9 @@ export default {
</template>
<gl-empty-state
v-else
- :title="s__('ContainerRegistry|This image has no active tags')"
+ :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
:svg-path="config.noContainersImage"
- :description="
- 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.`,
- )
- "
+ :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
class="mx-auto my-0"
/>
</template>
diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue
index 95d83c82987..709a163d56d 100644
--- a/app/assets/javascripts/registry/explorer/pages/index.vue
+++ b/app/assets/javascripts/registry/explorer/pages/index.vue
@@ -1,46 +1,9 @@
<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { mapState, mapActions, mapGetters } from 'vuex';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlAlert,
- GlSprintf,
- GlLink,
- },
- i18n: {
- garbageCollectionTipText: s__(
- 'ContainerRegistry|This Registry contains deleted image tag data. Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
- ),
- },
- computed: {
- ...mapState(['config']),
- ...mapGetters(['showGarbageCollection']),
- },
- methods: {
- ...mapActions(['setShowGarbageCollectionTip']),
- },
-};
+export default {};
</script>
<template>
<div>
- <gl-alert
- v-if="showGarbageCollection"
- variant="tip"
- class="my-2"
- @dismiss="setShowGarbageCollectionTip(false)"
- >
- <gl-sprintf :message="$options.i18n.garbageCollectionTipText">
- <template #docLink="{content}">
- <gl-link :href="config.garbageCollectionHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
-
<transition name="slide">
<router-view ref="router-view" />
</transition>
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 8923c305b2d..4efa6f08d84 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -2,53 +2,52 @@
import { mapState, mapActions } from 'vuex';
import {
GlEmptyState,
- GlPagination,
GlTooltipDirective,
- GlDeprecatedButton,
- GlIcon,
GlModal,
GlSprintf,
GlLink,
GlAlert,
GlSkeletonLoader,
+ GlSearchBoxByClick,
} from '@gitlab/ui';
import Tracking from '~/tracking';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
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 {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
- ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
LIST_INTRO_TEXT,
- LIST_DELETE_BUTTON_DISABLED,
- REMOVE_REPOSITORY_LABEL,
REMOVE_REPOSITORY_MODAL_TEXT,
- ROW_SCHEDULED_FOR_DELETION,
+ REMOVE_REPOSITORY_LABEL,
+ SEARCH_PLACEHOLDER_TEXT,
+ IMAGE_REPOSITORY_LIST_LABEL,
+ EMPTY_RESULT_TITLE,
+ EMPTY_RESULT_MESSAGE,
} from '../constants';
export default {
name: 'RegistryListApp',
components: {
GlEmptyState,
- GlPagination,
ProjectEmptyState,
GroupEmptyState,
ProjectPolicyAlert,
- ClipboardButton,
QuickstartDropdown,
- GlDeprecatedButton,
- GlIcon,
+ ImageList,
GlModal,
GlSprintf,
GlLink,
GlAlert,
GlSkeletonLoader,
+ GlSearchBoxByClick,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -60,20 +59,23 @@ export default {
height: 40,
},
i18n: {
- containerRegistryTitle: CONTAINER_REGISTRY_TITLE,
- connectionErrorTitle: CONNECTION_ERROR_TITLE,
- connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
- introText: LIST_INTRO_TEXT,
- deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED,
- removeRepositoryLabel: REMOVE_REPOSITORY_LABEL,
- removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT,
- rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION,
- asyncDeleteErrorMessage: ASYNC_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,
},
data() {
return {
itemToDelete: {},
deleteAlertType: null,
+ search: null,
+ isEmpty: false,
};
},
computed: {
@@ -83,14 +85,6 @@ export default {
label: 'registry_repository_delete',
};
},
- currentPage: {
- get() {
- return this.pagination.page;
- },
- set(page) {
- this.requestImagesList({ page });
- },
- },
showQuickStartDropdown() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
@@ -103,8 +97,19 @@ export default {
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
+ mounted() {
+ this.loadImageList(this.$route.name);
+ },
methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']),
+ loadImageList(fromName) {
+ if (!fromName || !this.images?.length) {
+ return this.requestImagesList().then(() => {
+ this.isEmpty = this.images.length === 0;
+ });
+ }
+ return Promise.resolve();
+ },
deleteImage(item) {
this.track('click_button');
this.itemToDelete = item;
@@ -120,10 +125,6 @@ export default {
this.deleteAlertType = 'danger';
});
},
- encodeListItem(item) {
- const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
- return window.btoa(params);
- },
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
@@ -152,12 +153,12 @@ export default {
<gl-empty-state
v-if="config.characterError"
- :title="$options.i18n.connectionErrorTitle"
+ :title="$options.i18n.CONNECTION_ERROR_TITLE"
:svg-path="config.containersErrorImage"
>
<template #description>
<p>
- <gl-sprintf :message="$options.i18n.connectionErrorMessage">
+ <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE">
<template #docLink="{content}">
<gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
{{ content }}
@@ -171,11 +172,11 @@ export default {
<template v-else>
<div>
<div class="d-flex justify-content-between align-items-center">
- <h4>{{ $options.i18n.containerRegistryTitle }}</h4>
+ <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.introText">
+ <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT">
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">
{{ content }}
@@ -199,73 +200,40 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <div v-if="images.length" ref="imagesList" class="d-flex flex-column">
- <div
- v-for="(listItem, index) in images"
- :key="index"
- ref="rowItem"
- v-gl-tooltip="{
- placement: 'left',
- disabled: !listItem.deleting,
- title: $options.i18n.rowScheduledForDeletion,
- }"
- >
- <div
- class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom"
- :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
- >
- <div class="d-felx align-items-center">
- <router-link
- ref="detailsLink"
- :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
- >
- {{ listItem.path }}
- </router-link>
- <clipboard-button
- v-if="listItem.location"
- ref="clipboardButton"
- :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.asyncDeleteErrorMessage"
- 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.deleteButtonDisabled"
- >
- <gl-deprecated-button
- ref="deleteImageButton"
- v-gl-tooltip
- :disabled="!listItem.destroy_path || listItem.deleting"
- :title="$options.i18n.removeRepositoryLabel"
- :aria-label="$options.i18n.removeRepositoryLabel"
- class="btn-inverted"
- variant="danger"
- @click="deleteImage(listItem)"
- >
- <gl-icon name="remove" />
- </gl-deprecated-button>
- </div>
+ <template v-if="!isEmpty">
+ <div class="gl-display-flex gl-p-1" data-testid="listHeader">
+ <div class="gl-flex-fill-1">
+ <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
+ </div>
+ <div>
+ <gl-search-box-by-click
+ v-model="search"
+ :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
+ @submit="requestImagesList({ name: $event })"
+ />
</div>
</div>
- <gl-pagination
- v-model="currentPage"
- :per-page="pagination.perPage"
- :total-items="pagination.total"
- align="center"
- class="w-100 mt-2"
+
+ <image-list
+ v-if="images.length"
+ :images="images"
+ :pagination="pagination"
+ @pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
+ @delete="deleteImage"
/>
- </div>
+ <gl-empty-state
+ v-else
+ :svg-path="config.noContainersImage"
+ data-testid="emptySearch"
+ :title="$options.i18n.EMPTY_RESULT_TITLE"
+ class="container-message"
+ >
+ <template #description>
+ {{ $options.i18n.EMPTY_RESULT_MESSAGE }}
+ </template>
+ </gl-empty-state>
+ </template>
<template v-else>
<project-empty-state v-if="!config.isGroupPage" />
<group-empty-state v-else />
@@ -279,9 +247,9 @@ export default {
@ok="handleDeleteImage"
@cancel="track('cancel_delete')"
>
- <template #modal-title>{{ $options.i18n.removeRepositoryLabel }}</template>
+ <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
<p>
- <gl-sprintf :message="$options.i18n.removeRepositoryModalText">
+ <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
<template #title>
<b>{{ itemToDelete.path }}</b>
</template>
diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js
index 28df3177df4..478eaca1a68 100644
--- a/app/assets/javascripts/registry/explorer/router.js
+++ b/app/assets/javascripts/registry/explorer/router.js
@@ -7,7 +7,7 @@ import { decodeAndParse } from './utils';
Vue.use(VueRouter);
-export default function createRouter(base, store) {
+export default function createRouter(base) {
const router = new VueRouter({
base,
mode: 'history',
@@ -20,12 +20,6 @@ export default function createRouter(base, store) {
nameGenerator: () => s__('ContainerRegistry|Container Registry'),
root: true,
},
- beforeEnter: (to, from, next) => {
- if (!from.name || !store.state.images?.length) {
- store.dispatch('requestImagesList');
- }
- next();
- },
},
{
name: 'details',
@@ -34,10 +28,6 @@ export default function createRouter(base, store) {
meta: {
nameGenerator: route => decodeAndParse(route.params.id).name,
},
- beforeEnter: (to, from, next) => {
- store.dispatch('requestTagsList', { params: to.params.id });
- next();
- },
},
],
});
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
index b4f66dbbcd6..7f80bc21d6e 100644
--- a/app/assets/javascripts/registry/explorer/stores/actions.js
+++ b/app/assets/javascripts/registry/explorer/stores/actions.js
@@ -23,12 +23,15 @@ export const receiveTagsListSuccess = ({ commit }, { data, headers }) => {
commit(types.SET_TAGS_PAGINATION, headers);
};
-export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) => {
+export const requestImagesList = (
+ { commit, dispatch, state },
+ { pagination = {}, name = null } = {},
+) => {
commit(types.SET_MAIN_LOADING, true);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
- .get(state.config.endpoint, { params: { page, per_page: perPage } })
+ .get(state.config.endpoint, { params: { page, per_page: perPage, name } })
.then(({ data, headers }) => {
dispatch('receiveImagesListSuccess', { data, headers });
})
@@ -66,7 +69,7 @@ export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) =
dispatch('setShowGarbageCollectionTip', true);
return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
})
- .catch(() => {
+ .finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
@@ -83,7 +86,7 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params })
dispatch('setShowGarbageCollectionTip', true);
return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
})
- .catch(() => {
+ .finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js
index b3ff2e6e002..153032e37d3 100644
--- a/app/assets/javascripts/registry/explorer/stores/index.js
+++ b/app/assets/javascripts/registry/explorer/stores/index.js
@@ -15,4 +15,5 @@ export const createStore = () =>
mutations,
});
+// Deprecated and to be removed
export default createStore();
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
index 6ae1dbb72c4..a318aa2a694 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
-import store from './store/';
+import store from './store';
import RegistrySettingsApp from './components/registry_settings_app.vue';
Vue.use(GlToast);
diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js
index ef4b4f0ba02..ac1a931d8e0 100644
--- a/app/assets/javascripts/registry/settings/store/getters.js
+++ b/app/assets/javascripts/registry/settings/store/getters.js
@@ -16,6 +16,7 @@ export const getSettings = (state, getters) => ({
older_than: getters.getOlderThan,
keep_n: getters.getKeepN,
name_regex: state.settings.name_regex,
+ name_regex_keep: state.settings.name_regex_keep,
});
export const getIsEdited = state => !isEqual(state.original, state.settings);
diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js
index bb7071b020b..3ba13419b98 100644
--- a/app/assets/javascripts/registry/settings/store/mutations.js
+++ b/app/assets/javascripts/registry/settings/store/mutations.js
@@ -21,7 +21,7 @@ export default {
state.original = Object.freeze(settings);
},
[types.RESET_SETTINGS](state) {
- state.settings = Object.assign({}, state.original);
+ state.settings = { ...state.original };
},
[types.TOGGLE_LOADING](state) {
state.isLoading = !state.isLoading;
diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
index 3e212f09e35..04a547db07e 100644
--- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
+++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
@@ -1,8 +1,23 @@
<script>
import { uniqueId } from 'lodash';
import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlSprintf } from '@gitlab/ui';
-import { s__, __ } from '~/locale';
-import { NAME_REGEX_LENGTH } from '../constants';
+import {
+ NAME_REGEX_LENGTH,
+ ENABLED_TEXT,
+ DISABLED_TEXT,
+ TEXT_AREA_INVALID_FEEDBACK,
+ EXPIRATION_INTERVAL_LABEL,
+ EXPIRATION_SCHEDULE_LABEL,
+ KEEP_N_LABEL,
+ NAME_REGEX_LABEL,
+ NAME_REGEX_PLACEHOLDER,
+ NAME_REGEX_DESCRIPTION,
+ NAME_REGEX_KEEP_LABEL,
+ NAME_REGEX_KEEP_PLACEHOLDER,
+ NAME_REGEX_KEEP_DESCRIPTION,
+ ENABLE_TOGGLE_LABEL,
+ ENABLE_TOGGLE_DESCRIPTION,
+} from '../constants';
import { mapComputedToEvent } from '../utils';
export default {
@@ -40,42 +55,73 @@ export default {
default: 'right',
},
},
- nameRegexPlaceholder: '.*',
+ i18n: {
+ textAreaInvalidFeedback: TEXT_AREA_INVALID_FEEDBACK,
+ enableToggleLabel: ENABLE_TOGGLE_LABEL,
+ enableToggleDescription: ENABLE_TOGGLE_DESCRIPTION,
+ },
selectList: [
{
name: 'expiration-policy-interval',
- label: s__('ContainerRegistry|Expiration interval:'),
+ label: EXPIRATION_INTERVAL_LABEL,
model: 'older_than',
optionKey: 'olderThan',
},
{
name: 'expiration-policy-schedule',
- label: s__('ContainerRegistry|Expiration schedule:'),
+ label: EXPIRATION_SCHEDULE_LABEL,
model: 'cadence',
optionKey: 'cadence',
},
{
name: 'expiration-policy-latest',
- label: s__('ContainerRegistry|Number of tags to retain:'),
+ label: KEEP_N_LABEL,
model: 'keep_n',
optionKey: 'keepN',
},
],
+ textAreaList: [
+ {
+ name: 'expiration-policy-name-matching',
+ label: NAME_REGEX_LABEL,
+ model: 'name_regex',
+ placeholder: NAME_REGEX_PLACEHOLDER,
+ stateVariable: 'nameRegexState',
+ description: NAME_REGEX_DESCRIPTION,
+ },
+ {
+ name: 'expiration-policy-keep-name',
+ label: NAME_REGEX_KEEP_LABEL,
+ model: 'name_regex_keep',
+ placeholder: NAME_REGEX_KEEP_PLACEHOLDER,
+ stateVariable: 'nameKeepRegexState',
+ description: NAME_REGEX_KEEP_DESCRIPTION,
+ },
+ ],
data() {
return {
uniqueId: uniqueId(),
};
},
computed: {
- ...mapComputedToEvent(['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex'], 'value'),
+ ...mapComputedToEvent(
+ ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'],
+ 'value',
+ ),
policyEnabledText() {
- return this.enabled ? __('enabled') : __('disabled');
+ return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
},
- nameRegexState() {
- return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null;
+ textAreaState() {
+ return {
+ nameRegexState: this.validateNameRegex(this.name_regex),
+ nameKeepRegexState: this.validateNameRegex(this.name_regex_keep),
+ };
},
fieldsValidity() {
- return this.nameRegexState !== false;
+ return (
+ this.textAreaState.nameRegexState !== false &&
+ this.textAreaState.nameKeepRegexState !== false
+ );
},
isFormElementDisabled() {
return !this.enabled || this.isLoading;
@@ -94,6 +140,9 @@ export default {
},
},
methods: {
+ validateNameRegex(value) {
+ return value ? value.length <= NAME_REGEX_LENGTH : null;
+ },
idGenerator(id) {
return `${id}_${this.uniqueId}`;
},
@@ -111,7 +160,7 @@ export default {
:label-cols="labelCols"
:label-align="labelAlign"
:label-for="idGenerator('expiration-policy-toggle')"
- :label="s__('ContainerRegistry|Expiration policy:')"
+ :label="$options.i18n.enableToggleLabel"
>
<div class="d-flex align-items-start">
<gl-toggle
@@ -120,9 +169,7 @@ export default {
:disabled="isLoading"
/>
<span class="mb-2 ml-1 lh-2">
- <gl-sprintf
- :message="s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}')"
- >
+ <gl-sprintf :message="$options.i18n.enableToggleDescription">
<template #toggleStatus>
<strong>{{ policyEnabledText }}</strong>
</template>
@@ -157,35 +204,34 @@ export default {
</gl-form-group>
<gl-form-group
- :id="idGenerator('expiration-policy-name-matching-group')"
+ v-for="textarea in $options.textAreaList"
+ :id="idGenerator(`${textarea.name}-group`)"
+ :key="textarea.name"
:label-cols="labelCols"
:label-align="labelAlign"
- :label-for="idGenerator('expiration-policy-name-matching')"
- :label="
- s__('ContainerRegistry|Docker tags with names matching this regex pattern will expire:')
- "
- :state="nameRegexState"
- :invalid-feedback="
- s__('ContainerRegistry|The value of this input should be less than 255 characters')
- "
+ :label-for="idGenerator(textarea.name)"
+ :state="textAreaState[textarea.stateVariable]"
+ :invalid-feedback="$options.i18n.textAreaInvalidFeedback"
>
+ <template #label>
+ <gl-sprintf :message="textarea.label">
+ <template #italic="{content}">
+ <i>{{ content }}</i>
+ </template>
+ </gl-sprintf>
+ </template>
<gl-form-textarea
- :id="idGenerator('expiration-policy-name-matching')"
- v-model="name_regex"
- :placeholder="$options.nameRegexPlaceholder"
- :state="nameRegexState"
+ :id="idGenerator(textarea.name)"
+ :value="value[textarea.model]"
+ :placeholder="textarea.placeholder"
+ :state="textAreaState[textarea.stateVariable]"
:disabled="isFormElementDisabled"
trim
+ @input="updateModel($event, textarea.model)"
/>
<template #description>
<span ref="regex-description">
- <gl-sprintf
- :message="
- s__(
- 'ContainerRegistry|Wildcards such as %{codeStart}.*-stable%{codeEnd} or %{codeStart}production/.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
- )
- "
- >
+ <gl-sprintf :message="textarea.description">
<template #code="{content}">
<code>{{ content }}</code>
</template>
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
index c0dac466b29..4689d01b1c8 100644
--- a/app/assets/javascripts/registry/shared/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const FETCH_SETTINGS_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the expiration policy.',
@@ -13,3 +13,33 @@ export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__(
);
export const NAME_REGEX_LENGTH = 255;
+
+export const ENABLED_TEXT = __('enabled');
+export const DISABLED_TEXT = __('disabled');
+
+export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Expiration policy:');
+export const ENABLE_TOGGLE_DESCRIPTION = s__(
+ 'ContainerRegistry|Docker tag expiration policy is %{toggleStatus}',
+);
+
+export const TEXT_AREA_INVALID_FEEDBACK = s__(
+ 'ContainerRegistry|The value of this input should be less than 255 characters',
+);
+
+export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:');
+export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Expiration schedule:');
+export const KEEP_N_LABEL = s__('ContainerRegistry|Number of tags to retain:');
+export const NAME_REGEX_LABEL = s__(
+ 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}',
+);
+export const NAME_REGEX_PLACEHOLDER = '.*';
+export const NAME_REGEX_DESCRIPTION = s__(
+ 'ContainerRegistry|Regular expressions such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
+);
+export const NAME_REGEX_KEEP_LABEL = s__(
+ 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}',
+);
+export const NAME_REGEX_KEEP_PLACEHOLDER = '';
+export const NAME_REGEX_KEEP_DESCRIPTION = s__(
+ 'ContainerRegistry|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
+);
diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit.vue
index 8d68ff02116..01dd0638023 100644
--- a/app/assets/javascripts/releases/components/app_edit.vue
+++ b/app/assets/javascripts/releases/components/app_edit.vue
@@ -1,7 +1,7 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
@@ -9,6 +9,7 @@ import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
export default {
name: 'ReleaseEditApp',
@@ -18,6 +19,7 @@ export default {
GlButton,
MarkdownField,
AssetLinksForm,
+ MilestoneCombobox,
},
directives: {
autofocusonshow,
@@ -32,6 +34,10 @@ export default {
'markdownPreviewPath',
'releasesPagePath',
'updateReleaseApiDocsPath',
+ 'release',
+ 'newMilestonePath',
+ 'manageMilestonesPath',
+ 'projectId',
]),
...mapGetters('detail', ['isValid']),
showForm() {
@@ -58,7 +64,7 @@ export default {
'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}',
),
{
- linkStart: `<a href="${esc(
+ linkStart: `<a href="${escape(
this.updateReleaseApiDocsPath,
)}" target="_blank" rel="noopener noreferrer">`,
linkEnd: '</a>',
@@ -82,6 +88,14 @@ export default {
this.updateReleaseNotes(notes);
},
},
+ releaseMilestones: {
+ get() {
+ return this.$store.state.detail.release.milestones;
+ },
+ set(milestones) {
+ this.updateReleaseMilestones(milestones);
+ },
+ },
cancelPath() {
return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath;
},
@@ -91,6 +105,18 @@ export default {
isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid;
},
+ milestoneComboboxExtraLinks() {
+ return [
+ {
+ text: __('Create new'),
+ url: this.newMilestonePath,
+ },
+ {
+ text: __('Manage milestones'),
+ url: this.manageMilestonesPath,
+ },
+ ];
+ },
},
created() {
this.fetchRelease();
@@ -101,6 +127,7 @@ export default {
'updateRelease',
'updateReleaseTitle',
'updateReleaseNotes',
+ 'updateReleaseMilestones',
]),
},
};
@@ -137,6 +164,16 @@ export default {
class="form-control"
/>
</gl-form-group>
+ <gl-form-group class="w-50">
+ <label>{{ __('Milestones') }}</label>
+ <div class="d-flex flex-column col-md-6 col-sm-10 pl-0">
+ <milestone-combobox
+ v-model="releaseMilestones"
+ :project-id="projectId"
+ :extra-links="milestoneComboboxExtraLinks"
+ />
+ </div>
+ </gl-form-group>
<gl-form-group>
<label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3">
@@ -147,19 +184,19 @@ export default {
:add-spacing-classes="false"
class="prepend-top-10 append-bottom-10"
>
- <textarea
- id="release-notes"
- slot="textarea"
- v-model="releaseNotes"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- dir="auto"
- data-supports-quick-actions="false"
- :aria-label="__('Release notes')"
- :placeholder="__('Write your release notes or drag your files here…')"
- @keydown.meta.enter="updateRelease()"
- @keydown.ctrl.enter="updateRelease()"
- >
- </textarea>
+ <template #textarea>
+ <textarea
+ id="release-notes"
+ v-model="releaseNotes"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Release notes')"
+ :placeholder="__('Write your release notes or drag your files here…')"
+ @keydown.meta.enter="updateRelease()"
+ @keydown.ctrl.enter="updateRelease()"
+ ></textarea>
+ </template>
</markdown-field>
</div>
</gl-form-group>
@@ -174,12 +211,9 @@ export default {
type="submit"
:aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled"
+ >{{ __('Save changes') }}</gl-button
>
- {{ __('Save changes') }}
- </gl-button>
- <gl-button :href="cancelPath" class="js-cancel-button">
- {{ __('Cancel') }}
- </gl-button>
+ <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 215a376fc76..67085ecca2b 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlSkeletonLoading, GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlSkeletonLoading, GlEmptyState, GlLink, GlButton } from '@gitlab/ui';
import {
getParameterByName,
historyPushState,
@@ -18,6 +18,7 @@ export default {
ReleaseBlock,
TablePagination,
GlLink,
+ GlButton,
},
props: {
projectId: {
@@ -69,14 +70,16 @@ export default {
</script>
<template>
<div class="flex flex-column mt-2">
- <gl-link
+ <gl-button
v-if="newReleasePath"
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
- class="btn btn-success align-self-end mb-2 js-new-release-btn"
+ category="primary"
+ variant="success"
+ class="align-self-end mb-2 js-new-release-btn"
>
{{ __('New release') }}
- </gl-link>
+ </gl-button>
<gl-skeleton-loading v-if="isLoading" class="js-loading" />
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index 4bdc88f01dd..0698ca5e31f 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -162,7 +162,7 @@ export default {
:state="isNameValid(link)"
@change="onLinkTitleInput(link.id, $event)"
/>
- <template v-slot:invalid-feedback>
+ <template #invalid-feedback>
<span v-if="hasEmptyName(link)" class="invalid-feedback d-inline">
{{ __('Link title is required') }}
</span>
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index 59c1b3eb48e..acae6fda533 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -76,11 +76,13 @@ export default {
</gl-link>
<expand-button>
- <template slot="short">
+ <template #short>
<span class="js-short monospace">{{ shortSha(index) }}</span>
</template>
- <template slot="expanded">
- <span class="js-expanded monospace gl-pl-1">{{ sha(index) }}</span>
+ <template #expanded>
+ <span class="js-expanded monospace gl-pl-1-deprecated-no-really-do-not-use-me">{{
+ sha(index)
+ }}</span>
</template>
</expand-button>
<clipboard-button
diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue
index a95fbc0b373..26154272d39 100644
--- a/app/assets/javascripts/releases/components/release_block_footer.vue
+++ b/app/assets/javascripts/releases/components/release_block_footer.vue
@@ -57,6 +57,11 @@ export default {
? sprintf(__("%{username}'s avatar"), { username: this.author.username })
: null;
},
+ createdTime() {
+ const now = new Date();
+ const isFuture = now < new Date(this.releasedAt);
+ return isFuture ? __('Will be created') : __('Created');
+ },
},
};
</script>
@@ -86,7 +91,7 @@ export default {
v-if="releasedAt || author"
class="float-left d-flex align-items-center js-author-date-info"
>
- <span class="text-secondary">{{ __('Created') }}&nbsp;</span>
+ <span class="text-secondary">{{ createdTime }}&nbsp;</span>
<template v-if="releasedAt">
<span
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index 6f7e1dcfe2f..ed49841757a 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import { setUrlParams } from '~/lib/utils/url_utility';
@@ -10,6 +10,7 @@ export default {
GlLink,
GlBadge,
Icon,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -41,7 +42,7 @@ export default {
<template>
<div class="card-header d-flex align-items-center bg-white pr-0">
- <h2 class="card-title my-2 mr-auto gl-font-size-20">
+ <h2 class="card-title my-2 mr-auto gl-font-size-20-deprecated-no-really-do-not-use-me">
<gl-link v-if="selfLink" :href="selfLink" class="font-size-inherit">
{{ release.name }}
</gl-link>
@@ -50,14 +51,16 @@ export default {
__('Upcoming Release')
}}</gl-badge>
</h2>
- <gl-link
+ <gl-button
v-if="editLink"
v-gl-tooltip
- class="btn btn-default append-right-10 js-edit-button ml-2"
+ category="primary"
+ variant="default"
+ class="append-right-10 js-edit-button ml-2 pb-2"
:title="__('Edit this release')"
:href="editLink"
>
<icon name="pencil" />
- </gl-link>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue
index 052e4088a5f..40133941011 100644
--- a/app/assets/javascripts/releases/components/release_block_metadata.vue
+++ b/app/assets/javascripts/releases/components/release_block_metadata.vue
@@ -38,9 +38,12 @@ export default {
return Boolean(this.author);
},
releasedTimeAgo() {
- return sprintf(__('released %{time}'), {
- time: this.timeFormatted(this.release.releasedAt),
- });
+ const now = new Date();
+ const isFuture = now < new Date(this.release.releasedAt);
+ const time = this.timeFormatted(this.release.releasedAt);
+ return isFuture
+ ? sprintf(__('will be released %{time}'), { time })
+ : sprintf(__('released %{time}'), { time });
},
shouldRenderMilestones() {
return Boolean(this.release.milestones?.length);
@@ -74,7 +77,11 @@ export default {
<div class="append-right-4">
&bull;
- <span v-gl-tooltip.bottom :title="tooltipTitle(release.releasedAt)">
+ <span
+ v-gl-tooltip.bottom
+ class="js-release-date-info"
+ :title="tooltipTitle(release.releasedAt)"
+ >
{{ releasedTimeAgo }}
</span>
</div>
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 01ad0cbf732..d9fbd2884b7 100644
--- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
@@ -3,7 +3,7 @@ import {
GlProgressBar,
GlLink,
GlBadge,
- GlDeprecatedButton,
+ GlButton,
GlTooltipDirective,
GlSprintf,
} from '@gitlab/ui';
@@ -17,7 +17,7 @@ export default {
GlProgressBar,
GlLink,
GlBadge,
- GlDeprecatedButton,
+ GlButton,
GlSprintf,
},
directives: {
@@ -134,13 +134,9 @@ export default {
<span :key="'bullet-' + milestone.id" class="append-right-4">&bull;</span>
</template>
<template v-if="shouldRenderShowMoreLink(index)">
- <gl-deprecated-button
- :key="'more-button-' + milestone.id"
- variant="link"
- @click="toggleShowAll"
- >
+ <gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll">
{{ moreText }}
- </gl-deprecated-button>
+ </gl-button>
</template>
</template>
</div>
diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js
index 7b84c18242c..3bc427dfa16 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js
@@ -18,7 +18,12 @@ export const fetchRelease = ({ dispatch, state }) => {
return api
.release(state.projectId, state.tagName)
- .then(({ data: release }) => {
+ .then(({ data }) => {
+ const release = {
+ ...data,
+ milestones: data.milestones || [],
+ };
+
dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
})
.catch(error => {
@@ -28,6 +33,8 @@ export const fetchRelease = ({ dispatch, state }) => {
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
+export const updateReleaseMilestones = ({ commit }, milestones) =>
+ commit(types.UPDATE_RELEASE_MILESTONES, milestones);
export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
@@ -45,12 +52,14 @@ export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease');
const { release } = state;
+ const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return (
api
.updateRelease(state.projectId, state.tagName, {
name: release.name,
description: release.description,
+ milestones,
})
/**
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 04944b76e42..1d6356990ce 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
@@ -4,6 +4,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
+export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
index 3d97e3a75c2..5c29b402cba 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
@@ -28,6 +28,10 @@ export default {
state.release.description = notes;
},
+ [types.UPDATE_RELEASE_MILESTONES](state, milestones) {
+ state.release.milestones = milestones;
+ },
+
[types.REQUEST_UPDATE_RELEASE](state) {
state.isUpdatingRelease = true;
},
diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js
index b513e1bed79..6d0d102c719 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/state.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/state.js
@@ -6,6 +6,8 @@ export default ({
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
+ manageMilestonesPath,
+ newMilestonePath,
}) => ({
projectId,
tagName,
@@ -14,6 +16,8 @@ export default ({
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
+ manageMilestonesPath,
+ newMilestonePath,
/** The Release object */
release: null,
diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
index 6aae9195be1..653dcced98b 100644
--- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
+++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
@@ -26,18 +26,11 @@ export default {
* The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation.
* Here we simply split the string on `.` and get the code in the 5th position
*/
- if (this.issue.code === undefined) {
- return null;
- }
-
- return this.issue.code.split('.')[4] || null;
+ return this.issue.code?.split('.')[4];
},
learnMoreUrl() {
- if (this.parsedTECHSCode === null) {
- return 'https://www.w3.org/TR/WCAG20-TECHS/Overview.html';
- }
-
- return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode}.html`;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode || 'Overview'}.html`;
},
},
};
@@ -52,10 +45,19 @@ export default {
>
{{ s__('AccessibilityReport|New') }}
</div>
- {{ issue.name }}
- <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{
- s__('AccessibilityReport|Learn More')
- }}</gl-link>
+ <div>
+ {{
+ sprintf(
+ s__(
+ 'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}',
+ ),
+ { code: issue.code },
+ )
+ }}
+ <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{
+ s__('AccessibilityReport|Learn More')
+ }}</gl-link>
+ </div>
{{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }}
</div>
</div>
diff --git a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
new file mode 100644
index 00000000000..6f8ddd01df8
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
@@ -0,0 +1,64 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import { componentNames } from '~/reports/components/issue_body';
+import ReportSection from '~/reports/components/report_section.vue';
+import IssuesList from '~/reports/components/issues_list.vue';
+import createStore from './store';
+
+export default {
+ name: 'GroupedAccessibilityReportsApp',
+ store: createStore(),
+ components: {
+ ReportSection,
+ IssuesList,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ componentNames,
+ computed: {
+ ...mapGetters([
+ 'summaryStatus',
+ 'groupedSummaryText',
+ 'shouldRenderIssuesList',
+ 'unresolvedIssues',
+ 'resolvedIssues',
+ 'newIssues',
+ ]),
+ },
+ created() {
+ this.setEndpoint(this.endpoint);
+
+ this.fetchReport();
+ },
+ methods: {
+ ...mapActions(['fetchReport', 'setEndpoint']),
+ },
+};
+</script>
+<template>
+ <report-section
+ :status="summaryStatus"
+ :success-text="groupedSummaryText"
+ :loading-text="groupedSummaryText"
+ :error-text="groupedSummaryText"
+ :has-issues="shouldRenderIssuesList"
+ class="mr-widget-section grouped-security-reports mr-report"
+ >
+ <template #body>
+ <div class="mr-widget-grouped-section report-block">
+ <issues-list
+ v-if="shouldRenderIssuesList"
+ :unresolved-issues="unresolvedIssues"
+ :new-issues="newIssues"
+ :resolved-issues="resolvedIssues"
+ :component="$options.componentNames.AccessibilityIssueBody"
+ class="report-block-group-list"
+ />
+ </div>
+ </template>
+ </report-section>
+</template>
diff --git a/app/assets/javascripts/reports/accessibility_report/store/actions.js b/app/assets/javascripts/reports/accessibility_report/store/actions.js
new file mode 100644
index 00000000000..446cfd79984
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/actions.js
@@ -0,0 +1,79 @@
+import Visibility from 'visibilityjs';
+import Poll from '~/lib/utils/poll';
+import httpStatusCodes from '~/lib/utils/http_status';
+import axios from '~/lib/utils/axios_utils';
+import * as types from './mutation_types';
+
+let eTagPoll;
+
+export const clearEtagPoll = () => {
+ eTagPoll = null;
+};
+
+export const stopPolling = () => {
+ if (eTagPoll) eTagPoll.stop();
+};
+
+export const restartPolling = () => {
+ if (eTagPoll) eTagPoll.restart();
+};
+
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+/**
+ * We need to poll the report endpoint while they are being parsed in the Backend.
+ * This can take up to one minute.
+ *
+ * Poll.js will handle etag response.
+ * While http status code is 204, it means it's parsing, and we'll keep polling
+ * When http status code is 200, it means parsing is done, we can show the results & stop polling
+ * When http status code is 500, it means parsing went wrong and we stop polling
+ */
+export const fetchReport = ({ state, dispatch, commit }) => {
+ commit(types.REQUEST_REPORT);
+
+ eTagPoll = new Poll({
+ resource: {
+ getReport(endpoint) {
+ return axios.get(endpoint);
+ },
+ },
+ data: state.endpoint,
+ method: 'getReport',
+ successCallback: ({ status, data }) => dispatch('receiveReportSuccess', { status, data }),
+ errorCallback: () => dispatch('receiveReportError'),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ } else {
+ axios
+ .get(state.endpoint)
+ .then(({ status, data }) => dispatch('receiveReportSuccess', { status, data }))
+ .catch(() => dispatch('receiveReportError'));
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden() && state.isLoading) {
+ dispatch('restartPolling');
+ } else {
+ dispatch('stopPolling');
+ }
+ });
+};
+
+export const receiveReportSuccess = ({ commit, dispatch }, { status, data }) => {
+ if (status === httpStatusCodes.OK) {
+ commit(types.RECEIVE_REPORT_SUCCESS, data);
+ // Stop polling since we have the information already parsed and it won't be changing
+ dispatch('stopPolling');
+ }
+};
+
+export const receiveReportError = ({ commit, dispatch }) => {
+ commit(types.RECEIVE_REPORT_ERROR);
+ dispatch('stopPolling');
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/reports/accessibility_report/store/getters.js b/app/assets/javascripts/reports/accessibility_report/store/getters.js
new file mode 100644
index 00000000000..9aff427e644
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/getters.js
@@ -0,0 +1,48 @@
+import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants';
+import { s__, n__ } from '~/locale';
+
+export const groupedSummaryText = state => {
+ if (state.isLoading) {
+ return s__('Reports|Accessibility scanning results are being parsed');
+ }
+
+ if (state.hasError) {
+ return s__('Reports|Accessibility scanning failed loading results');
+ }
+
+ const numberOfResults = state.report?.summary?.errored || 0;
+ if (numberOfResults === 0) {
+ return s__('Reports|Accessibility scanning detected no issues for the source branch only');
+ }
+
+ return n__(
+ 'Reports|Accessibility scanning detected %d issue for the source branch only',
+ 'Reports|Accessibility scanning detected %d issues for the source branch only',
+ numberOfResults,
+ );
+};
+
+export const summaryStatus = state => {
+ if (state.isLoading) {
+ return LOADING;
+ }
+
+ if (state.hasError || state.status === STATUS_FAILED) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+export const shouldRenderIssuesList = state =>
+ Object.values(state.report).some(x => Array.isArray(x) && x.length > 0);
+
+// We could just map state, but we're going to iterate in the future
+// to add notes and warnings to these issue lists, so I'm going to
+// keep these as getters
+export const unresolvedIssues = state => state.report.existing_errors;
+export const resolvedIssues = state => state.report.resolved_errors;
+export const newIssues = state => state.report.new_errors;
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/static_site_editor/store/index.js b/app/assets/javascripts/reports/accessibility_report/store/index.js
index 43256979ddd..047964260ad 100644
--- a/app/assets/javascripts/static_site_editor/store/index.js
+++ b/app/assets/javascripts/reports/accessibility_report/store/index.js
@@ -1,19 +1,16 @@
-import Vuex from 'vuex';
import Vue from 'vue';
-import createState from './state';
-import * as getters from './getters';
+import Vuex from 'vuex';
import * as actions from './actions';
+import * as getters from './getters';
import mutations from './mutations';
+import state from './state';
Vue.use(Vuex);
-const createStore = ({ initialState } = {}) => {
- return new Vuex.Store({
- state: createState(initialState),
- getters,
+export default initialState =>
+ new Vuex.Store({
actions,
+ getters,
mutations,
+ state: state(initialState),
});
-};
-
-export default createStore;
diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js
new file mode 100644
index 00000000000..22e2330e1ea
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+
+export const REQUEST_REPORT = 'REQUEST_REPORT';
+export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS';
+export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR';
diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutations.js b/app/assets/javascripts/reports/accessibility_report/store/mutations.js
new file mode 100644
index 00000000000..20d3e5be9a3
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/mutations.js
@@ -0,0 +1,20 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.REQUEST_REPORT](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_REPORT_SUCCESS](state, report) {
+ state.hasError = false;
+ state.isLoading = false;
+ state.report = report;
+ },
+ [types.RECEIVE_REPORT_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ state.report = {};
+ },
+};
diff --git a/app/assets/javascripts/reports/accessibility_report/store/state.js b/app/assets/javascripts/reports/accessibility_report/store/state.js
new file mode 100644
index 00000000000..2a4cefea5e6
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/state.js
@@ -0,0 +1,28 @@
+export default (initialState = {}) => ({
+ endpoint: initialState.endpoint || '',
+
+ isLoading: initialState.isLoading || false,
+ hasError: initialState.hasError || false,
+
+ /**
+ * Report will have the following format:
+ * {
+ * status: {String},
+ * summary: {
+ * total: {Number},
+ * resolved: {Number},
+ * errored: {Number},
+ * },
+ * existing_errors: {Array.<Object>},
+ * existing_notes: {Array.<Object>},
+ * existing_warnings: {Array.<Object>},
+ * new_errors: {Array.<Object>},
+ * new_notes: {Array.<Object>},
+ * new_warnings: {Array.<Object>},
+ * resolved_errors: {Array.<Object>},
+ * resolved_notes: {Array.<Object>},
+ * resolved_warnings: {Array.<Object>},
+ * }
+ */
+ report: initialState.report || {},
+});
diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue
new file mode 100644
index 00000000000..97587636644
--- /dev/null
+++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue
@@ -0,0 +1,93 @@
+<script>
+import { s__ } from '~/locale';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+import ReportItem from '~/reports/components/report_item.vue';
+
+export default {
+ components: {
+ ReportItem,
+ SmartVirtualList,
+ },
+ props: {
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ resolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ unresolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ resolvedHeading: {
+ type: String,
+ required: false,
+ default: s__('ciReport|Fixed'),
+ },
+ unresolvedHeading: {
+ type: String,
+ required: false,
+ default: s__('ciReport|New'),
+ },
+ },
+ groups: ['unresolved', 'resolved'],
+ typicalReportItemHeight: 32,
+ maxShownReportItems: 20,
+ computed: {
+ groups() {
+ return this.$options.groups
+ .map(group => ({
+ name: group,
+ issues: this[`${group}Issues`],
+ heading: this[`${group}Heading`],
+ }))
+ .filter(({ issues }) => issues.length > 0);
+ },
+ listLength() {
+ // every group has a header which is rendered as a list item
+ const groupsCount = this.groups.length;
+ const issuesCount = this.groups.reduce(
+ (totalIssues, { issues }) => totalIssues + issues.length,
+ 0,
+ );
+
+ return groupsCount + issuesCount;
+ },
+ },
+};
+</script>
+
+<template>
+ <smart-virtual-list
+ :length="listLength"
+ :remain="$options.maxShownReportItems"
+ :size="$options.typicalReportItemHeight"
+ class="report-block-container"
+ wtag="ul"
+ wclass="report-block-list"
+ >
+ <template v-for="(group, groupIndex) in groups">
+ <h2
+ :key="group.name"
+ :data-testid="`${group.name}Heading`"
+ :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']"
+ class="h5 mb-1"
+ >
+ {{ group.heading }}
+ </h2>
+ <report-item
+ v-for="(issue, issueIndex) in group.issues"
+ :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`"
+ :issue="issue"
+ :show-report-section-status-icon="false"
+ :component="component"
+ status="none"
+ />
+ </template>
+ </smart-virtual-list>
+</template>
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 88d174f96ed..0f7a0e60dc0 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
+import { sprintf, s__ } from '~/locale';
import { componentNames } from './issue_body';
import ReportSection from './report_section.vue';
import SummaryRow from './summary_row.vue';
@@ -52,8 +52,17 @@ export default {
methods: {
...mapActions(['setEndpoint', 'fetchReports']),
reportText(report) {
- const summary = report.summary || {};
- return reportTextBuilder(report.name, summary);
+ const { name, summary } = report || {};
+
+ if (report.status === 'error') {
+ return sprintf(s__('Reports|An error occurred while loading %{name} results'), { name });
+ }
+
+ if (!report.name) {
+ return s__('Reports|An error occured while loading report');
+ }
+
+ return reportTextBuilder(name, summary);
},
getReportIcon(report) {
return statusIcon(report.status);
diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue
index 62a9338b864..d79e3ddd798 100644
--- a/app/assets/javascripts/reports/components/issue_status_icon.vue
+++ b/app/assets/javascripts/reports/components/issue_status_icon.vue
@@ -8,7 +8,6 @@ export default {
Icon,
},
props: {
- // failed || success
status: {
type: String,
required: true,
@@ -27,7 +26,7 @@ export default {
return 'status_success_borderless';
}
- return 'status_created_borderless';
+ return 'dash';
},
isStatusFailed() {
return this.status === STATUS_FAILED;
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 20b0c52dbda..68956fc6d2b 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -167,7 +167,7 @@ export default {
<div class="media">
<status-icon :status="statusIconName" :size="24" class="align-self-center" />
<div class="media-body d-flex flex-align-self-center align-items-center">
- <div class="js-code-text code-text">
+ <div data-testid="report-section-code-text" class="js-code-text code-text">
<div>
{{ headerText }}
<slot :name="slotName"></slot>
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index 1845b51e6b2..b3905cbfcfb 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -22,3 +22,6 @@ export const status = {
ERROR: 'ERROR',
SUCCESS: 'SUCCESS',
};
+
+export const ACCESSIBILITY_ISSUE_ERROR = 'error';
+export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js
index 68f6de3a7ee..35ab72bf694 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -8,8 +8,7 @@ export default {
state.isLoading = true;
},
[types.RECEIVE_REPORTS_SUCCESS](state, response) {
- // Make sure to clean previous state in case it was an error
- state.hasError = false;
+ state.hasError = response.suites.some(suite => suite.status === 'error');
state.isLoading = false;
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 886e9d76cca..45c343c3f7f 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -242,7 +242,7 @@ export default {
</li>
<li v-if="renderAddToTreeDropdown" class="breadcrumb-item">
<gl-dropdown toggle-class="add-to-tree qa-add-to-tree ml-1">
- <template slot="button-content">
+ <template #button-content>
<span class="sr-only">{{ __('Add to tree') }}</span>
<icon name="plus" :size="16" class="float-left" />
<icon name="chevron-down" :size="16" class="float-left" />
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index a13f8ac65cf..010fc9a5d1a 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -121,9 +121,8 @@ export default {
:href="commit.webUrl"
:class="{ 'font-italic': !commit.message }"
class="commit-row-message item-title"
- >
- {{ commit.title }}
- </gl-link>
+ v-html="commit.titleHtml"
+ />
<gl-deprecated-button
v-if="commit.description"
:class="{ open: showDescription }"
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index f741a6df5d9..34424121390 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -167,9 +167,8 @@ export default {
:href="commit.commitPath"
:title="commit.message"
class="str-truncated-100 tree-commit-link"
- >
- {{ commit.message }}
- </gl-link>
+ v-html="commit.titleHtml"
+ />
<gl-skeleton-loading v-else :lines="1" class="h-auto" />
</td>
<td class="tree-time-ago text-right cursor-default">
diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql
index 9bb13c475c7..be6897b9a16 100644
--- a/app/assets/javascripts/repository/queries/commit.fragment.graphql
+++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql
@@ -1,6 +1,7 @@
fragment TreeEntryCommit on LogTreeCommit {
sha
message
+ titleHtml
committedDate
commitPath
fileName
diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
index a22cadf0e8d..f54f09fd647 100644
--- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
@@ -5,6 +5,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
lastCommit {
sha
title
+ titleHtml
description
message
webUrl
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index 49e024ca4ff..c5646c32850 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -7,17 +7,28 @@ import TreePage from './pages/tree.vue';
Vue.use(VueRouter);
export default function createRouter(base, baseRef) {
+ const treePathRoute = {
+ component: TreePage,
+ props: route => ({
+ path: route.params.path?.replace(/^\//, '') || '/',
+ }),
+ };
+
return new VueRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
routes: [
{
- path: `(/-)?/tree/${baseRef}/:path*`,
+ name: 'treePathDecoded',
+ // Sometimes the ref needs decoding depending on how the backend sends it to us
+ path: `(/-)?/tree/${decodeURI(baseRef)}/:path*`,
+ ...treePathRoute,
+ },
+ {
name: 'treePath',
- component: TreePage,
- props: route => ({
- path: route.params.path?.replace(/^\//, '') || '/',
- }),
+ // Support without decoding as well just in case the ref doesn't need to be decoded
+ path: `(/-)?/tree/${baseRef}/:path*`,
+ ...treePathRoute,
},
{
path: '/',
diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js
index 3973798605d..90ac01c5874 100644
--- a/app/assets/javascripts/repository/utils/commit.js
+++ b/app/assets/javascripts/repository/utils/commit.js
@@ -3,6 +3,7 @@ export function normalizeData(data, path, extra = () => {}) {
return data.map(d => ({
sha: d.commit.id,
message: d.commit.message,
+ titleHtml: d.commit_title_html,
committedDate: d.commit.committed_date,
commitPath: d.commit_path,
fileName: d.file_name,
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 550ec3cb0d1..0bb33de0234 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,7 +1,6 @@
/* eslint-disable func-names, consistent-return, no-param-reassign */
import $ from 'jquery';
-import _ from 'underscore';
import Cookies from 'js-cookie';
import flash from './flash';
import axios from './lib/utils/axios_utils';
@@ -142,7 +141,7 @@ Sidebar.prototype.sidebarCollapseClicked = function(e) {
};
Sidebar.prototype.openDropdown = function(blockOrName) {
- const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
+ const $block = typeof blockOrName === 'string' ? this.getBlock(blockOrName) : blockOrName;
if (!this.isOpen()) {
this.setCollapseAfterUpdate($block);
this.toggleSidebar('open');
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 3eaa34c8a93..d8eb981c106 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,7 +1,7 @@
/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
-import { escape, throttle } from 'underscore';
+import { escape, throttle } from 'lodash';
import { s__, __ } from '~/locale';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
@@ -407,7 +407,7 @@ export class SearchAutocomplete {
disableAutocomplete() {
if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('js-autocomplete-disabled');
- this.dropdown.dropdown('toggle');
+ this.dropdownToggle.dropdown('toggle');
this.restoreMenu();
}
}
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
index 272c0bd5614..29a61cfbbfe 100644
--- a/app/assets/javascripts/serverless/components/area.vue
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -138,8 +138,8 @@ export default {
:width="width"
:include-legend-avg-max="false"
>
- <template slot="tooltipTitle">{{ tooltipPopoverTitle }}</template>
- <template slot="tooltipContent">{{ tooltipPopoverContent }}</template>
+ <template #tooltipTitle>{{ tooltipPopoverTitle }}</template>
+ <template #tooltipContent>{{ tooltipPopoverContent }}</template>
</gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/serverless/event_hub.js b/app/assets/javascripts/serverless/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/serverless/event_hub.js
+++ b/app/assets/javascripts/serverless/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/set_status_modal/event_hub.js b/app/assets/javascripts/set_status_modal/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/set_status_modal/event_hub.js
+++ b/app/assets/javascripts/set_status_modal/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index f16b16a6837..3baf4bf0742 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -1,6 +1,6 @@
<script>
-import CollapsedAssigneeList from '../assignees/collapsed_assignee_list.vue';
-import UncollapsedAssigneeList from '../assignees/uncollapsed_assignee_list.vue';
+import CollapsedAssigneeList from './collapsed_assignee_list.vue';
+import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue';
export default {
// name: 'Assignees' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
new file mode 100644
index 00000000000..bf0c52b2341
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -0,0 +1,75 @@
+<script>
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
+import actionCable from '~/actioncable_consumer';
+
+export default {
+ subscription: null,
+ name: 'AssigneesRealtime',
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ issuableIid: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ project: {
+ query,
+ variables() {
+ return {
+ iid: this.issuableIid,
+ fullPath: this.projectPath,
+ };
+ },
+ result(data) {
+ this.handleFetchResult(data);
+ },
+ },
+ },
+ mounted() {
+ this.initActionCablePolling();
+ },
+ beforeDestroy() {
+ this.$options.subscription.unsubscribe();
+ },
+ methods: {
+ received(data) {
+ if (data.event === 'updated') {
+ this.$apollo.queries.project.refetch();
+ }
+ },
+ initActionCablePolling() {
+ this.$options.subscription = actionCable.subscriptions.create(
+ {
+ channel: 'IssuesChannel',
+ project_path: this.projectPath,
+ iid: this.issuableIid,
+ },
+ { received: this.received },
+ );
+ },
+ handleFetchResult({ data }) {
+ const { nodes } = data.project.issue.assignees;
+
+ const assignees = nodes.map(n => ({
+ ...n,
+ avatar_url: n.avatarUrl,
+ id: getIdFromGraphQLId(n.id),
+ }));
+
+ this.mediator.store.setAssigneesFromRealtime(assignees);
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index ce592720531..0906d5abec3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -3,8 +3,10 @@ import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue';
+import AssigneesRealtime from './assignees_realtime.vue';
import { __ } from '~/locale';
export default {
@@ -12,7 +14,9 @@ export default {
components: {
AssigneeTitle,
Assignees,
+ AssigneesRealtime,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
mediator: {
type: Object,
@@ -32,6 +36,14 @@ export default {
required: false,
default: 'issue',
},
+ issuableIid: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -39,6 +51,12 @@ export default {
loading: false,
};
},
+ computed: {
+ shouldEnableRealtime() {
+ // Note: Realtime is only available on issues right now, future support for MR wil be built later.
+ return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
+ },
+ },
created() {
this.removeAssignee = this.store.removeAssignee.bind(this.store);
this.addAssignee = this.store.addAssignee.bind(this.store);
@@ -84,6 +102,12 @@ export default {
<template>
<div>
+ <assignees-realtime
+ v-if="shouldEnableRealtime"
+ :issuable-iid="issuableIid"
+ :project-path="projectPath"
+ :mediator="mediator"
+ />
<assignee-title
:number-of-assignees="store.assignees.length"
:loading="loading || store.isFetching.assignees"
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index 3d112bba668..fed9e5886c0 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -66,7 +66,7 @@ export default {
<template>
<assignee-avatar-link
v-if="hasOneUser"
- v-slot="{ user }"
+ #default="{ user }"
tooltip-placement="left"
:tooltip-has-name="false"
:user="firstUser"
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index 5b3c3642290..550a1be1e64 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -1,15 +1,16 @@
<script>
+import { mapState } from 'vuex';
import { __ } from '~/locale';
import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
-import editForm from './edit_form.vue';
+import EditForm from './edit_form.vue';
import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
export default {
components: {
- editForm,
+ EditForm,
Icon,
},
directives: {
@@ -17,10 +18,6 @@ export default {
},
mixins: [recaptchaModalImplementor],
props: {
- isConfidential: {
- required: true,
- type: Boolean,
- },
isEditable: {
required: true,
type: Boolean,
@@ -36,11 +33,12 @@ export default {
};
},
computed: {
+ ...mapState({ confidential: ({ noteableData }) => noteableData.confidential }),
confidentialityIcon() {
- return this.isConfidential ? 'eye-slash' : 'eye';
+ return this.confidential ? 'eye-slash' : 'eye';
},
tooltipLabel() {
- return this.isConfidential ? __('Confidential') : __('Not confidential');
+ return this.confidential ? __('Confidential') : __('Not confidential');
},
},
created() {
@@ -95,17 +93,16 @@ export default {
data-track-label="right_sidebar"
data-track-property="confidentiality"
@click.prevent="toggleForm"
+ >{{ __('Edit') }}</a
>
- {{ __('Edit') }}
- </a>
</div>
<div class="value sidebar-item-value hide-collapsed">
- <editForm
+ <edit-form
v-if="edit"
- :is-confidential="isConfidential"
+ :is-confidential="confidential"
:update-confidential-attribute="updateConfidentialAttribute"
/>
- <div v-if="!isConfidential" class="no-value sidebar-item-value">
+ <div v-if="!confidential" class="no-value sidebar-item-value">
<icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" />
{{ __('Not confidential') }}
</div>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
index 5d0e39e8195..e106afea9f5 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -40,7 +40,12 @@ export default {
<button type="button" class="btn btn-default append-right-10" @click="closeForm">
{{ __('Cancel') }}
</button>
- <button type="button" class="btn btn-close" @click.prevent="submitForm">
+ <button
+ type="button"
+ class="btn btn-close"
+ data-testid="confidential-toggle"
+ @click.prevent="submitForm"
+ >
{{ toggleButtonText }}
</button>
</div>
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index db2e51c3aca..d2904f4157c 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -95,22 +95,18 @@ export default {
@click="onClickCollapsedIcon"
>
<i class="fa fa-users" aria-hidden="true"> </i>
- <gl-loading-icon v-if="loading" class="js-participants-collapsed-loading-icon" />
- <span v-else class="js-participants-collapsed-count"> {{ participantCount }} </span>
+ <gl-loading-icon v-if="loading" />
+ <span v-else data-testid="collapsed-count"> {{ participantCount }} </span>
</div>
<div v-if="showParticipantLabel" class="title hide-collapsed">
- <gl-loading-icon
- v-if="loading"
- :inline="true"
- class="js-participants-expanded-loading-icon"
- />
+ <gl-loading-icon v-if="loading" :inline="true" />
{{ participantLabel }}
</div>
<div class="participants-list hide-collapsed">
<div
v-for="participant in visibleParticipants"
:key="participant.id"
- class="participants-author js-participants-author"
+ class="participants-author"
>
<a :href="participant.web_url" class="author-link">
<user-avatar-image
@@ -125,11 +121,7 @@ export default {
</div>
</div>
<div v-if="hasMoreParticipants" class="participants-more hide-collapsed">
- <button
- type="button"
- class="btn-transparent btn-link js-toggle-participants-button"
- @click="toggleMoreParticipants"
- >
+ <button type="button" class="btn-transparent btn-link" @click="toggleMoreParticipants">
{{ toggleLabel }}
</button>
</div>
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 2a61f7b5c05..0fb9cf22653 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import '~/gl_dropdown';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __ } from '~/locale';
function isValidProjectId(id) {
@@ -49,7 +49,7 @@ class SidebarMoveIssue {
renderRow: project => `
<li>
<a href="#" class="js-move-issue-dropdown-item">
- ${esc(project.name_with_namespace)}
+ ${escape(project.name_with_namespace)}
</a>
</li>
`,
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 6f8214b18ee..e371091fc53 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
@@ -8,17 +9,29 @@ import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue';
import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import Translate from '../vue_shared/translate';
+import createDefaultClient from '~/lib/graphql';
+import { store } from '~/notes/stores';
Vue.use(Translate);
+Vue.use(VueApollo);
+
+function getSidebarOptions() {
+ return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+}
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
if (!el) return;
+ const { iid, fullPath } = getSidebarOptions();
// eslint-disable-next-line no-new
new Vue({
el,
+ apolloProvider,
components: {
SidebarAssignees,
},
@@ -26,6 +39,8 @@ function mountAssigneesComponent(mediator) {
createElement('sidebar-assignees', {
props: {
mediator,
+ issuableIid: String(iid),
+ projectPath: fullPath,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
@@ -45,8 +60,8 @@ function mountConfidentialComponent(mediator) {
const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
new ConfidentialComp({
+ store,
propsData: {
- isConfidential: initialData.is_confidential,
isEditable: initialData.is_editable,
service: mediator.service,
},
@@ -144,6 +159,4 @@ export function mountSidebar(mediator) {
mountTimeTrackingComponent();
}
-export function getSidebarOptions() {
- return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
-}
+export { getSidebarOptions };
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 66f7f9e3c66..095f93b72a9 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -89,6 +89,10 @@ export default class SidebarStore {
this.assignees = [];
}
+ setAssigneesFromRealtime(data) {
+ this.assignees = data;
+ }
+
setAutocompleteProjects(projects) {
this.autocompleteProjects = projects;
}
diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
index a3ed8d9c632..76a1f6d1458 100644
--- a/app/assets/javascripts/snippet/snippet_bundle.js
+++ b/app/assets/javascripts/snippet/snippet_bundle.js
@@ -3,18 +3,6 @@ import setupCollapsibleInputs from './collapsible_input';
let editor;
-const initAce = () => {
- const editorEl = document.getElementById('editor');
- const form = document.querySelector('.snippet-form-holder form');
- const content = document.querySelector('.snippet-file-content');
-
- editor = initEditorLite({ el: editorEl });
-
- form.addEventListener('submit', () => {
- content.value = editor.getValue();
- });
-};
-
const initMonaco = () => {
const editorEl = document.getElementById('editor');
const contentEl = document.querySelector('.snippet-file-content');
@@ -36,15 +24,7 @@ const initMonaco = () => {
});
};
-export const initEditor = () => {
- if (window?.gon?.features?.monacoSnippets) {
- initMonaco();
- } else {
- initAce();
- }
- setupCollapsibleInputs();
-};
-
export default () => {
- initEditor();
+ initMonaco();
+ setupCollapsibleInputs();
};
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 2185b1d67e4..e8d6c005435 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -81,7 +81,10 @@ export default {
return this.isUpdating ? __('Saving') : __('Save changes');
},
cancelButtonHref() {
- return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`;
+ if (this.newSnippet) {
+ return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`;
+ }
+ return this.snippet.webUrl;
},
titleFieldId() {
return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`;
@@ -173,7 +176,13 @@ export default {
class="loading-animation prepend-top-20 append-bottom-20"
/>
<template v-else>
- <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" />
+ <title-field
+ :id="titleFieldId"
+ v-model="snippet.title"
+ data-qa-selector="snippet_title"
+ required
+ :autofocus="true"
+ />
<snippet-description-edit
:id="descriptionFieldId"
v-model="snippet.description"
@@ -198,12 +207,15 @@ export default {
category="primary"
variant="success"
:disabled="updatePrevented"
+ data-qa-selector="submit_button"
@click="handleFormSubmit"
>{{ saveButtonLabel }}</gl-button
>
</template>
<template #append>
- <gl-button :href="cancelButtonHref">{{ __('Cancel') }}</gl-button>
+ <gl-button data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{
+ __('Cancel')
+ }}</gl-button>
</template>
</form-footer-actions>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index 44b4607e5a9..dd03902417d 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -33,7 +33,11 @@ export default {
<div class="form-group file-editor">
<label>{{ s__('Snippets|File') }}</label>
<div class="file-holder snippet">
- <blob-header-edit :value="fileName" @input="$emit('name-change', $event)" />
+ <blob-header-edit
+ :value="fileName"
+ data-qa-selector="snippet_file_name"
+ @input="$emit('name-change', $event)"
+ />
<gl-loading-icon
v-if="isLoading"
:label="__('Loading snippet')"
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index 02a0fc7686d..6b218b21e56 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -7,7 +7,12 @@ import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
import GetBlobContent from '../queries/snippet.blob.content.query.graphql';
-import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
+import {
+ SIMPLE_BLOB_VIEWER,
+ RICH_BLOB_VIEWER,
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
+} from '~/blob/components/constants';
export default {
components: {
@@ -27,6 +32,16 @@ export default {
},
update: data =>
data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData,
+ result() {
+ if (this.activeViewerType === RICH_BLOB_VIEWER) {
+ this.blob.richViewer.renderError = null;
+ } else {
+ this.blob.simpleViewer.renderError = null;
+ }
+ },
+ skip() {
+ return this.viewer.renderError;
+ },
},
},
props: {
@@ -62,9 +77,15 @@ export default {
},
methods: {
switchViewer(newViewer) {
- this.activeViewerType = newViewer;
+ this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
+ },
+ forceQuery() {
+ this.$apollo.queries.blobContent.skip = false;
+ this.$apollo.queries.blobContent.refetch();
},
},
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
};
</script>
<template>
@@ -75,12 +96,21 @@ export default {
<template #actions>
<clone-dropdown-button
v-if="canBeCloned"
+ class="mr-2"
:ssh-link="snippet.sshUrlToRepo"
:http-link="snippet.httpUrlToRepo"
+ data-qa-selector="clone_button"
/>
</template>
</blob-header>
- <blob-content :loading="isContentLoading" :content="blobContent" :active-viewer="viewer" />
+ <blob-content
+ :loading="isContentLoading"
+ :content="blobContent"
+ :active-viewer="viewer"
+ :blob="blob"
+ @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery"
+ @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer"
+ />
</article>
</div>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index 6f3a86be8d7..0fe539a5de7 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -51,9 +51,9 @@ export default {
>
<textarea
slot="textarea"
- class="note-textarea js-gfm-input js-autosize markdown-area
- qa-description-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')"
diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue
new file mode 100644
index 00000000000..72afcc30be6
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue
@@ -0,0 +1,21 @@
+<script>
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
+
+export default {
+ components: {
+ MarkdownFieldView,
+ },
+ props: {
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+<template>
+ <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_field">
+ <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 30a23b51bc4..c0967e9093c 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -10,6 +10,7 @@ import {
GlDropdown,
GlDropdownItem,
GlButton,
+ GlTooltipDirective,
} from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -30,6 +31,9 @@ export default {
TimeAgoTooltip,
GlButton,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
apollo: {
canCreateSnippet: {
query() {
@@ -43,7 +47,7 @@ export default {
update(data) {
return this.snippet.project
? data.project.userPermissions.createSnippet
- : data.currentUser.userPermissions.createSnippet;
+ : data.currentUser?.userPermissions.createSnippet;
},
},
},
@@ -67,6 +71,10 @@ export default {
condition: this.snippet.userPermissions.updateSnippet,
text: __('Edit'),
href: this.editLink,
+ disabled: this.snippet.blob.binary,
+ title: this.snippet.blob.binary
+ ? __('Snippets with non-text files can only be edited via Git.')
+ : undefined,
},
{
condition: this.snippet.userPermissions.adminSnippet,
@@ -119,7 +127,7 @@ export default {
},
methods: {
redirectToSnippets() {
- window.location.pathname = 'dashboard/snippets';
+ window.location.pathname = `${this.snippet.project?.fullPath || 'dashboard'}/snippets`;
},
closeDeleteModal() {
this.$refs.deleteModal.hide();
@@ -186,18 +194,26 @@ export default {
<div class="detail-page-header-actions">
<div class="d-none d-sm-flex">
<template v-for="(action, index) in personalSnippetActions">
- <gl-button
+ <div
v-if="action.condition"
:key="index"
- :disabled="action.disabled"
- :variant="action.variant"
- :category="action.category"
- :class="action.cssClass"
- :href="action.href"
- @click="action.click ? action.click() : undefined"
+ v-gl-tooltip
+ :title="action.title"
+ class="d-inline-block"
>
- {{ action.text }}
- </gl-button>
+ <gl-button
+ :disabled="action.disabled"
+ :variant="action.variant"
+ :category="action.category"
+ :class="action.cssClass"
+ :href="action.href"
+ data-qa-selector="snippet_action_button"
+ :data-qa-action="action.text"
+ @click="action.click ? action.click() : undefined"
+ >
+ {{ action.text }}
+ </gl-button>
+ </div>
</template>
</div>
<div class="d-block d-sm-none dropdown">
@@ -205,6 +221,8 @@ export default {
<gl-dropdown-item
v-for="(action, index) in personalSnippetActions"
:key="index"
+ :disabled="action.disabled"
+ :title="action.title"
:href="action.href"
@click="action.click ? action.click() : undefined"
>{{ action.text }}</gl-dropdown-item
diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue
index 1fc0423a06c..5267c3748ca 100644
--- a/app/assets/javascripts/snippets/components/snippet_title.vue
+++ b/app/assets/javascripts/snippets/components/snippet_title.vue
@@ -1,11 +1,14 @@
<script>
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { GlSprintf } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import SnippetDescription from './snippet_description_view.vue';
+
export default {
components: {
TimeAgoTooltip,
GlSprintf,
+ SnippetDescription,
},
props: {
snippet: {
@@ -20,9 +23,8 @@ export default {
<h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title">
{{ snippet.title }}
</h2>
- <div v-if="snippet.description" class="description" data-qa-selector="snippet_description">
- <div class="md js-snippet-description" v-html="snippet.descriptionHtml"></div>
- </div>
+
+ <snippet-description v-if="snippet.description" :description="snippet.descriptionHtml" />
<small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text">
<gl-sprintf :message="__('Edited %{timeago}')">
diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
index d793d0b6bb4..e7765dfd8ba 100644
--- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
+++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
@@ -17,6 +17,8 @@ fragment SnippetBase on Snippet {
path
rawPath
size
+ externalStorage
+ renderedAsText
simpleViewer {
...BlobViewer
}
diff --git a/app/assets/javascripts/static_site_editor/components/app.vue b/app/assets/javascripts/static_site_editor/components/app.vue
new file mode 100644
index 00000000000..98240aef810
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/app.vue
@@ -0,0 +1,3 @@
+<template>
+ <router-view />
+</template>
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 921d93669c5..dff21d919a9 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue
@@ -1,18 +1,61 @@
<script>
-import { GlFormTextarea } from '@gitlab/ui';
+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';
export default {
components: {
- GlFormTextarea,
+ RichContentEditor,
+ PublishToolbar,
+ EditHeader,
},
props: {
- value: {
+ title: {
type: String,
required: true,
},
+ content: {
+ type: String,
+ required: true,
+ },
+ savingChanges: {
+ type: Boolean,
+ required: true,
+ },
+ returnUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ editableContent: this.content,
+ saveable: false,
+ };
+ },
+ computed: {
+ modified() {
+ return this.content !== this.editableContent;
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.$emit('submit', { content: this.editableContent });
+ },
},
};
</script>
<template>
- <gl-form-textarea :value="value" v-on="$listeners" />
+ <div class="d-flex flex-grow-1 flex-column">
+ <edit-header class="py-2" :title="title" />
+ <rich-content-editor v-model="editableContent" class="mb-9" />
+ <publish-toolbar
+ class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
+ :return-url="returnUrl"
+ :saveable="modified"
+ :saving-changes="savingChanges"
+ @submit="onSubmit"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
index 274d2f71749..6cd2a4dd700 100644
--- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
+++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
@@ -1,10 +1,9 @@
<script>
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
export default {
components: {
GlButton,
- GlLoadingIcon,
},
props: {
returnUrl: {
@@ -26,14 +25,18 @@ export default {
};
</script>
<template>
- <div class="d-flex bg-light border-top justify-content-between align-items-center py-3 px-4">
- <gl-loading-icon :class="{ invisible: !savingChanges }" size="md" />
+ <div class="d-flex bg-light border-top justify-content-end align-items-center py-3 px-4">
<div>
<gl-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{
s__('StaticSiteEditor|Return to site')
}}</gl-button>
- <gl-button variant="success" :disabled="!saveable || savingChanges" @click="$emit('submit')">
- {{ __('Submit Changes') }}
+ <gl-button
+ variant="success"
+ :disabled="!saveable"
+ :loading="savingChanges"
+ @click="$emit('submit')"
+ >
+ <span>{{ __('Submit Changes') }}</span>
</gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
index 41cb901720c..dd907570114 100644
--- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
+++ b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
@@ -28,7 +28,8 @@ export default {
},
returnUrl: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
};
@@ -46,7 +47,7 @@ export default {
}}
</p>
<div class="d-flex justify-content-end">
- <gl-button ref="returnToSiteButton" :href="returnUrl">{{
+ <gl-button v-if="returnUrl" ref="returnToSiteButton" :href="returnUrl">{{
s__('StaticSiteEditor|Return to site')
}}</gl-button>
<gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success">
@@ -60,7 +61,7 @@ export default {
<ul>
<li>
{{ s__('StaticSiteEditor|You created a new branch:') }}
- <span ref="branchLink">{{ branch.label }}</span>
+ <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link>
</li>
<li>
{{ s__('StaticSiteEditor|You created a merge request:') }}
diff --git a/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue
new file mode 100644
index 00000000000..1b6179883aa
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue
@@ -0,0 +1,19 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+<template>
+ <gl-skeleton-loader :width="500" :height="102">
+ <rect width="500" height="16" rx="4" />
+ <rect y="20" width="375" height="16" rx="4" />
+ <rect x="380" y="20" width="120" height="16" rx="4" />
+ <rect y="40" width="250" height="16" rx="4" />
+ <rect x="255" y="40" width="150" height="16" rx="4" />
+ <rect x="410" y="40" width="90" height="16" rx="4" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
deleted file mode 100644
index 82917319fc3..00000000000
--- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<script>
-import { mapState, mapGetters, mapActions } from 'vuex';
-import { GlSkeletonLoader } from '@gitlab/ui';
-
-import EditArea from './edit_area.vue';
-import EditHeader from './edit_header.vue';
-import SavedChangesMessage from './saved_changes_message.vue';
-import Toolbar from './publish_toolbar.vue';
-import InvalidContentMessage from './invalid_content_message.vue';
-import SubmitChangesError from './submit_changes_error.vue';
-
-export default {
- components: {
- EditArea,
- EditHeader,
- InvalidContentMessage,
- GlSkeletonLoader,
- SavedChangesMessage,
- Toolbar,
- SubmitChangesError,
- },
- computed: {
- ...mapState([
- 'content',
- 'isLoadingContent',
- 'isSavingChanges',
- 'isContentLoaded',
- 'isSupportedContent',
- 'returnUrl',
- 'title',
- 'submitChangesError',
- 'savedContentMeta',
- ]),
- ...mapGetters(['contentChanged']),
- },
- mounted() {
- if (this.isSupportedContent) {
- this.loadContent();
- }
- },
- methods: {
- ...mapActions(['loadContent', 'setContent', 'submitChanges', 'dismissSubmitChangesError']),
- },
-};
-</script>
-<template>
- <div class="d-flex justify-content-center h-100 pt-2">
- <!-- Success view -->
- <saved-changes-message
- v-if="savedContentMeta"
- :branch="savedContentMeta.branch"
- :commit="savedContentMeta.commit"
- :merge-request="savedContentMeta.mergeRequest"
- :return-url="returnUrl"
- />
-
- <!-- Main view -->
- <template v-else-if="isSupportedContent">
- <div v-if="isLoadingContent" class="w-50 h-50">
- <gl-skeleton-loader :width="500" :height="102">
- <rect width="500" height="16" rx="4" />
- <rect y="20" width="375" height="16" rx="4" />
- <rect x="380" y="20" width="120" height="16" rx="4" />
- <rect y="40" width="250" height="16" rx="4" />
- <rect x="255" y="40" width="150" height="16" rx="4" />
- <rect x="410" y="40" width="90" height="16" rx="4" />
- </gl-skeleton-loader>
- </div>
- <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column">
- <submit-changes-error
- v-if="submitChangesError"
- class="w-75 align-self-center"
- :error="submitChangesError"
- @retry="submitChanges"
- @dismiss="dismissSubmitChangesError"
- />
- <edit-header class="w-75 align-self-center py-2" :title="title" />
- <edit-area
- class="w-75 h-100 shadow-none align-self-center"
- :value="content"
- @input="setContent"
- />
- <toolbar
- :return-url="returnUrl"
- :saveable="contentChanged"
- :saving-changes="isSavingChanges"
- @submit="submitChanges"
- />
- </div>
- </template>
-
- <!-- Error view -->
- <invalid-content-message v-else class="w-75" />
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index d7ce2a93a56..4794cf5eead 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const BRANCH_SUFFIX_COUNT = 8;
export const DEFAULT_TARGET_BRANCH = 'master';
@@ -10,5 +10,10 @@ export const SUBMIT_CHANGES_COMMIT_ERROR = s__(
export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__(
'StaticSiteEditor|Could not create merge request.',
);
+export const LOAD_CONTENT_ERROR = __(
+ 'An error ocurred while loading your content. Please try again.',
+);
export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
+
+export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
new file mode 100644
index 00000000000..0a5d8c07ad9
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import typeDefs from './typedefs.graphql';
+import fileResolver from './resolvers/file';
+import submitContentChangesResolver from './resolvers/submit_content_changes';
+
+Vue.use(VueApollo);
+
+const createApolloProvider = appData => {
+ const defaultClient = createDefaultClient(
+ {
+ Project: {
+ file: fileResolver,
+ },
+ Mutation: {
+ submitContentChanges: submitContentChangesResolver,
+ },
+ },
+ {
+ typeDefs,
+ },
+ );
+
+ defaultClient.cache.writeData({
+ data: {
+ appData: {
+ __typename: 'AppData',
+ ...appData,
+ },
+ },
+ });
+
+ return new VueApollo({
+ defaultClient,
+ });
+};
+
+export default createApolloProvider;
diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
new file mode 100644
index 00000000000..2840d419966
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
@@ -0,0 +1,7 @@
+mutation submitContentChanges($input: SubmitContentChangesInput) {
+ submitContentChanges(input: $input) @client {
+ branch
+ commit
+ mergeRequest
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
new file mode 100644
index 00000000000..fdbf4459aee
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
@@ -0,0 +1,9 @@
+query appData {
+ appData @client {
+ isSupportedContent
+ project
+ sourcePath
+ username,
+ returnUrl
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql
new file mode 100644
index 00000000000..c29b6f93b81
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql
@@ -0,0 +1,3 @@
+query savedContentMeta {
+ savedContentMeta @client
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
new file mode 100644
index 00000000000..e36d244ae57
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
@@ -0,0 +1,9 @@
+query sourceContent($project: ID!, $sourcePath: String!) {
+ project(fullPath: $project) {
+ fullPath,
+ file(path: $sourcePath) @client {
+ title
+ content
+ }
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js
new file mode 100644
index 00000000000..16f176581cb
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js
@@ -0,0 +1,11 @@
+import loadSourceContent from '../../services/load_source_content';
+
+const fileResolver = ({ fullPath: projectId }, { path: sourcePath }) => {
+ return loadSourceContent({ projectId, sourcePath }).then(sourceContent => ({
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'File',
+ ...sourceContent,
+ }));
+};
+
+export default fileResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
new file mode 100644
index 00000000000..6c4e3a4d973
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
@@ -0,0 +1,24 @@
+import submitContentChanges from '../../services/submit_content_changes';
+import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
+
+const submitContentChangesResolver = (
+ _,
+ { input: { project: projectId, username, sourcePath, content } },
+ { cache },
+) => {
+ return submitContentChanges({ projectId, username, sourcePath, content }).then(
+ savedContentMeta => {
+ cache.writeQuery({
+ query: savedContentMetaQuery,
+ data: {
+ savedContentMeta: {
+ __typename: 'SavedContentMeta',
+ ...savedContentMeta,
+ },
+ },
+ });
+ },
+ );
+};
+
+export default submitContentChangesResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
new file mode 100644
index 00000000000..59da2e27144
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
@@ -0,0 +1,43 @@
+type File {
+ title: String
+ content: String!
+}
+
+type SavedContentField {
+ label: String!
+ url: String!
+}
+
+type SavedContentMeta {
+ mergeRequest: SavedContentField!
+ commit: SavedContentField!
+ branch: SavedContentField!
+}
+
+type AppData {
+ isSupportedContent: Boolean!
+ project: String!
+ returnUrl: String
+ sourcePath: String!
+ username: String!
+}
+
+type SubmitContentChangesInput {
+ project: String!
+ sourcePath: String!
+ content: String!
+ username: String!
+}
+
+extend type Project {
+ file(path: ID!): File
+}
+
+extend type Query {
+ appData: AppData!
+ savedContentMeta: SavedContentMeta
+}
+
+extend type Mutation {
+ submitContentChanges(input: SubmitContentChangesInput!): SavedContentMeta
+}
diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js
index 15d668fd431..12aa301e02f 100644
--- a/app/assets/javascripts/static_site_editor/index.js
+++ b/app/assets/javascripts/static_site_editor/index.js
@@ -1,29 +1,32 @@
import Vue from 'vue';
-import StaticSiteEditor from './components/static_site_editor.vue';
-import createStore from './store';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import App from './components/app.vue';
+import createRouter from './router';
+import createApolloProvider from './graphql';
const initStaticSiteEditor = el => {
- const { projectId, path: sourcePath, returnUrl } = el.dataset;
- const isSupportedContent = 'isSupportedContent' in el.dataset;
+ const { isSupportedContent, path: sourcePath, baseUrl, namespace, project } = el.dataset;
+ const { current_username: username } = window.gon;
+ const returnUrl = el.dataset.returnUrl || null;
- const store = createStore({
- initialState: {
- isSupportedContent,
- projectId,
- returnUrl,
- sourcePath,
- username: window.gon.current_username,
- },
+ const router = createRouter(baseUrl);
+ const apolloProvider = createApolloProvider({
+ isSupportedContent: parseBoolean(isSupportedContent),
+ project: `${namespace}/${project}`,
+ returnUrl,
+ sourcePath,
+ username,
});
return new Vue({
el,
- store,
+ router,
+ apolloProvider,
components: {
- StaticSiteEditor,
+ App,
},
render(createElement) {
- return createElement('static-site-editor', StaticSiteEditor);
+ return createElement('app');
},
});
};
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
new file mode 100644
index 00000000000..f65b648acd6
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -0,0 +1,120 @@
+<script>
+import SkeletonLoader from '../components/skeleton_loader.vue';
+import EditArea from '../components/edit_area.vue';
+import InvalidContentMessage from '../components/invalid_content_message.vue';
+import SubmitChangesError from '../components/submit_changes_error.vue';
+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 { SUCCESS_ROUTE } from '../router/constants';
+
+export default {
+ components: {
+ SkeletonLoader,
+ EditArea,
+ InvalidContentMessage,
+ SubmitChangesError,
+ },
+ apollo: {
+ appData: {
+ query: appDataQuery,
+ },
+ sourceContent: {
+ query: sourceContentQuery,
+ update: ({
+ project: {
+ file: { title, content },
+ },
+ }) => {
+ return { title, content };
+ },
+ variables() {
+ return {
+ project: this.appData.project,
+ sourcePath: this.appData.sourcePath,
+ };
+ },
+ skip() {
+ return !this.appData.isSupportedContent;
+ },
+ error() {
+ createFlash(LOAD_CONTENT_ERROR);
+ },
+ },
+ },
+ data() {
+ return {
+ content: null,
+ submitChangesError: null,
+ isSavingChanges: false,
+ };
+ },
+ computed: {
+ isLoadingContent() {
+ return this.$apollo.queries.sourceContent.loading;
+ },
+ isContentLoaded() {
+ return Boolean(this.sourceContent);
+ },
+ },
+ methods: {
+ onDismissError() {
+ this.submitChangesError = null;
+ },
+ onSubmit({ content }) {
+ this.content = content;
+ this.submitChanges();
+ },
+ submitChanges() {
+ this.isSavingChanges = true;
+
+ this.$apollo
+ .mutate({
+ mutation: submitContentChangesMutation,
+ variables: {
+ input: {
+ project: this.appData.project,
+ username: this.appData.username,
+ sourcePath: this.appData.sourcePath,
+ content: this.content,
+ },
+ },
+ })
+ .then(() => {
+ this.$router.push(SUCCESS_ROUTE);
+ })
+ .catch(e => {
+ this.submitChangesError = e.message;
+ })
+ .finally(() => {
+ this.isSavingChanges = false;
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="container d-flex gl-flex-direction-column pt-2 h-100">
+ <template v-if="appData.isSupportedContent">
+ <skeleton-loader v-if="isLoadingContent" class="w-75 gl-align-self-center gl-mt-5" />
+ <submit-changes-error
+ v-if="submitChangesError"
+ :error="submitChangesError"
+ @retry="submitChanges"
+ @dismiss="onDismissError"
+ />
+ <edit-area
+ v-if="isContentLoaded"
+ :title="sourceContent.title"
+ :content="sourceContent.content"
+ :saving-changes="isSavingChanges"
+ :return-url="appData.returnUrl"
+ @submit="onSubmit"
+ />
+ </template>
+
+ <invalid-content-message v-else class="w-75" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue
new file mode 100644
index 00000000000..123683b2833
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/pages/success.vue
@@ -0,0 +1,35 @@
+<script>
+import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql';
+import appDataQuery from '../graphql/queries/app_data.query.graphql';
+import SavedChangesMessage from '../components/saved_changes_message.vue';
+import { HOME_ROUTE } from '../router/constants';
+
+export default {
+ components: {
+ SavedChangesMessage,
+ },
+ apollo: {
+ savedContentMeta: {
+ query: savedContentMetaQuery,
+ },
+ appData: {
+ query: appDataQuery,
+ },
+ },
+ created() {
+ if (!this.savedContentMeta) {
+ this.$router.push(HOME_ROUTE);
+ }
+ },
+};
+</script>
+<template>
+ <div v-if="savedContentMeta" class="container">
+ <saved-changes-message
+ :branch="savedContentMeta.branch"
+ :commit="savedContentMeta.commit"
+ :merge-request="savedContentMeta.mergeRequest"
+ :return-url="appData.returnUrl"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/router/constants.js b/app/assets/javascripts/static_site_editor/router/constants.js
new file mode 100644
index 00000000000..fd715f918ce
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/router/constants.js
@@ -0,0 +1,2 @@
+export const HOME_ROUTE = { name: 'home' };
+export const SUCCESS_ROUTE = { name: 'success' };
diff --git a/app/assets/javascripts/static_site_editor/router/index.js b/app/assets/javascripts/static_site_editor/router/index.js
new file mode 100644
index 00000000000..12692612bbc
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/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/static_site_editor/router/routes.js b/app/assets/javascripts/static_site_editor/router/routes.js
new file mode 100644
index 00000000000..6fb9dbe0182
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/router/routes.js
@@ -0,0 +1,21 @@
+import Home from '../pages/home.vue';
+import Success from '../pages/success.vue';
+
+import { HOME_ROUTE, SUCCESS_ROUTE } from './constants';
+
+export default [
+ {
+ ...HOME_ROUTE,
+ path: '/',
+ component: Home,
+ },
+ {
+ ...SUCCESS_ROUTE,
+ path: '/success',
+ component: Success,
+ },
+ {
+ path: '*',
+ redirect: HOME_ROUTE,
+ },
+];
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 ff591e4b245..49135d2141b 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
@@ -1,4 +1,5 @@
import Api from '~/api';
+import Tracking from '~/tracking';
import { s__, sprintf } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
@@ -8,6 +9,7 @@ import {
SUBMIT_CHANGES_BRANCH_ERROR,
SUBMIT_CHANGES_COMMIT_ERROR,
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
+ TRACKING_ACTION_CREATE_COMMIT,
} from '../constants';
const createBranch = (projectId, branch) =>
@@ -18,8 +20,10 @@ const createBranch = (projectId, branch) =>
throw new Error(SUBMIT_CHANGES_BRANCH_ERROR);
});
-const commitContent = (projectId, message, branch, sourcePath, content) =>
- Api.commitMultiple(
+const commitContent = (projectId, message, branch, sourcePath, content) => {
+ Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT);
+
+ return Api.commitMultiple(
projectId,
convertObjectPropsToSnakeCase({
branch,
@@ -35,6 +39,7 @@ const commitContent = (projectId, message, branch, sourcePath, content) =>
).catch(() => {
throw new Error(SUBMIT_CHANGES_COMMIT_ERROR);
});
+};
const createMergeRequest = (projectId, title, sourceBranch, targetBranch = DEFAULT_TARGET_BRANCH) =>
Api.createProjectMergeRequest(
@@ -56,8 +61,8 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
const meta = {};
return createBranch(projectId, branch)
- .then(() => {
- Object.assign(meta, { branch: { label: branch } });
+ .then(({ data: { web_url: url } }) => {
+ Object.assign(meta, { branch: { label: branch, url } });
return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content);
})
@@ -67,7 +72,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
return createMergeRequest(projectId, mergeRequestTitle, branch);
})
.then(({ data: { iid: label, web_url: url } }) => {
- Object.assign(meta, { mergeRequest: { label, url } });
+ Object.assign(meta, { mergeRequest: { label: label.toString(), url } });
return meta;
});
diff --git a/app/assets/javascripts/static_site_editor/store/actions.js b/app/assets/javascripts/static_site_editor/store/actions.js
deleted file mode 100644
index 9f5e9e8c589..00000000000
--- a/app/assets/javascripts/static_site_editor/store/actions.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-
-import * as mutationTypes from './mutation_types';
-import loadSourceContent from '~/static_site_editor/services/load_source_content';
-import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
-
-export const loadContent = ({ commit, state: { sourcePath, projectId } }) => {
- commit(mutationTypes.LOAD_CONTENT);
-
- return loadSourceContent({ sourcePath, projectId })
- .then(data => commit(mutationTypes.RECEIVE_CONTENT_SUCCESS, data))
- .catch(() => {
- commit(mutationTypes.RECEIVE_CONTENT_ERROR);
- createFlash(__('An error ocurred while loading your content. Please try again.'));
- });
-};
-
-export const setContent = ({ commit }, content) => {
- commit(mutationTypes.SET_CONTENT, content);
-};
-
-export const submitChanges = ({ state: { projectId, content, sourcePath, username }, commit }) => {
- commit(mutationTypes.SUBMIT_CHANGES);
-
- return submitContentChanges({ content, projectId, sourcePath, username })
- .then(data => commit(mutationTypes.SUBMIT_CHANGES_SUCCESS, data))
- .catch(error => {
- commit(mutationTypes.SUBMIT_CHANGES_ERROR, error.message);
- });
-};
-
-export const dismissSubmitChangesError = ({ commit }) => {
- commit(mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR);
-};
-
-export default () => {};
diff --git a/app/assets/javascripts/static_site_editor/store/getters.js b/app/assets/javascripts/static_site_editor/store/getters.js
deleted file mode 100644
index ebc68f8e9e6..00000000000
--- a/app/assets/javascripts/static_site_editor/store/getters.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// eslint-disable-next-line import/prefer-default-export
-export const contentChanged = ({ originalContent, content }) => originalContent !== content;
diff --git a/app/assets/javascripts/static_site_editor/store/mutation_types.js b/app/assets/javascripts/static_site_editor/store/mutation_types.js
deleted file mode 100644
index 9cf356aecc5..00000000000
--- a/app/assets/javascripts/static_site_editor/store/mutation_types.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export const LOAD_CONTENT = 'loadContent';
-export const RECEIVE_CONTENT_SUCCESS = 'receiveContentSuccess';
-export const RECEIVE_CONTENT_ERROR = 'receiveContentError';
-export const SET_CONTENT = 'setContent';
-export const SUBMIT_CHANGES = 'submitChanges';
-export const SUBMIT_CHANGES_SUCCESS = 'submitChangesSuccess';
-export const SUBMIT_CHANGES_ERROR = 'submitChangesError';
-export const DISMISS_SUBMIT_CHANGES_ERROR = 'dismissSubmitChangesError';
diff --git a/app/assets/javascripts/static_site_editor/store/mutations.js b/app/assets/javascripts/static_site_editor/store/mutations.js
deleted file mode 100644
index 72fe71f1c9b..00000000000
--- a/app/assets/javascripts/static_site_editor/store/mutations.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.LOAD_CONTENT](state) {
- state.isLoadingContent = true;
- },
- [types.RECEIVE_CONTENT_SUCCESS](state, { title, content }) {
- state.isLoadingContent = false;
- state.isContentLoaded = true;
- state.title = title;
- state.content = content;
- state.originalContent = content;
- },
- [types.RECEIVE_CONTENT_ERROR](state) {
- state.isLoadingContent = false;
- },
- [types.SET_CONTENT](state, content) {
- state.content = content;
- },
- [types.SUBMIT_CHANGES](state) {
- state.isSavingChanges = true;
- state.submitChangesError = '';
- },
- [types.SUBMIT_CHANGES_SUCCESS](state, meta) {
- state.savedContentMeta = meta;
- state.isSavingChanges = false;
- state.originalContent = state.content;
- },
- [types.SUBMIT_CHANGES_ERROR](state, error) {
- state.submitChangesError = error;
- state.isSavingChanges = false;
- },
- [types.DISMISS_SUBMIT_CHANGES_ERROR](state) {
- state.submitChangesError = '';
- },
-};
diff --git a/app/assets/javascripts/static_site_editor/store/state.js b/app/assets/javascripts/static_site_editor/store/state.js
deleted file mode 100644
index 8c524b4ffe9..00000000000
--- a/app/assets/javascripts/static_site_editor/store/state.js
+++ /dev/null
@@ -1,23 +0,0 @@
-const createState = (initialState = {}) => ({
- username: null,
- projectId: null,
- returnUrl: null,
- sourcePath: null,
-
- isLoadingContent: false,
- isSavingChanges: false,
- isSupportedContent: false,
-
- isContentLoaded: false,
-
- originalContent: '',
- content: '',
- title: '',
-
- submitChangesError: '',
- savedContentMeta: null,
-
- ...initialState,
-});
-
-export default createState;
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 37f3dd4b496..474b5132bc6 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -1,4 +1,4 @@
-/* eslint-disable consistent-return, no-else-return */
+/* eslint-disable consistent-return */
import $ from 'jquery';
@@ -16,11 +16,10 @@ export default function syntaxHighlight(el) {
if ($(el).hasClass('js-syntax-highlight')) {
// Given the element itself, apply highlighting
return $(el).addClass(gon.user_color_scheme);
- } else {
- // Given a parent element, recurse to any of its applicable children
- const $children = $(el).find('.js-syntax-highlight');
- if ($children.length) {
- return syntaxHighlight($children);
- }
+ }
+ // Given a parent element, recurse to any of its applicable children
+ const $children = $(el).find('.js-syntax-highlight');
+ if ($children.length) {
+ return syntaxHighlight($children);
}
}
diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js
index f4e546e4d4e..cf9064aba57 100644
--- a/app/assets/javascripts/terminal/terminal.js
+++ b/app/assets/javascripts/terminal/terminal.js
@@ -13,14 +13,11 @@ Terminal.applyAddon(webLinks);
export default class GLTerminal {
constructor(element, options = {}) {
- this.options = Object.assign(
- {},
- {
- cursorBlink: true,
- screenKeys: true,
- },
- options,
- );
+ this.options = {
+ cursorBlink: true,
+ screenKeys: true,
+ ...options,
+ };
this.container = element;
this.onDispose = [];
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 09fe952e5f0..10510595570 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { omitBy, isUndefined } from 'lodash';
const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl',
@@ -14,11 +14,8 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
linkClickTracking: false,
};
-const eventHandler = (e, func, opts = {}) => {
- const el = e.target.closest('[data-track-event]');
- const action = el && el.dataset.trackEvent;
- if (!action) return;
-
+const createEventPayload = (el, { suffix = '' } = {}) => {
+ const action = el.dataset.trackEvent + (suffix || '');
let value = el.dataset.trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) value = false;
@@ -29,7 +26,19 @@ const eventHandler = (e, func, opts = {}) => {
context: el.dataset.trackContext,
};
- func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined));
+ return {
+ action,
+ data: omitBy(data, isUndefined),
+ };
+};
+
+const eventHandler = (e, func, opts = {}) => {
+ const el = e.target.closest('[data-track-event]');
+
+ if (!el) return;
+
+ const { action, data } = createEventPayload(el, opts);
+ func(opts.category, action, data);
};
const eventHandlers = (category, func) => {
@@ -62,17 +71,30 @@ export default class Tracking {
return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
}
- static bindDocument(category = document.body.dataset.page, documentOverride = null) {
- const el = documentOverride || document;
- if (!this.enabled() || el.trackingBound) return [];
+ static bindDocument(category = document.body.dataset.page, parent = document) {
+ if (!this.enabled() || parent.trackingBound) return [];
- el.trackingBound = true;
+ // eslint-disable-next-line no-param-reassign
+ parent.trackingBound = true;
const handlers = eventHandlers(category, (...args) => this.event(...args));
- handlers.forEach(event => el.addEventListener(event.name, event.func));
+ handlers.forEach(event => parent.addEventListener(event.name, event.func));
return handlers;
}
+ static trackLoadEvents(category = document.body.dataset.page, parent = document) {
+ if (!this.enabled()) return [];
+
+ const loadEvents = parent.querySelectorAll('[data-track-event="render"]');
+
+ loadEvents.forEach(element => {
+ const { action, data } = createEventPayload(element);
+ this.event(category, action, data);
+ });
+
+ return loadEvents;
+ }
+
static mixin(opts = {}) {
return {
computed: {
@@ -111,6 +133,7 @@ export function initUserTracking() {
if (opts.linkClickTracking) window.snowplow('enableLinkClickTracking');
Tracking.bindDocument();
+ Tracking.trackLoadEvents();
document.dispatchEvent(new Event('SnowplowInitialized'));
}
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index 59276ee79d8..947246b2fbb 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */
+/* eslint-disable func-names, consistent-return, one-var, class-methods-use-this */
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
@@ -15,9 +15,8 @@ export default class TreeView {
if (e.metaKey || e.which === 2) {
e.preventDefault();
return window.open(path, '_blank');
- } else {
- return visitUrl(path);
}
+ return visitUrl(path);
}
});
// Show the "Loading commit data" for only the first element
diff --git a/app/assets/javascripts/users_select/constants.js b/app/assets/javascripts/users_select/constants.js
new file mode 100644
index 00000000000..64df1e1748c
--- /dev/null
+++ b/app/assets/javascripts/users_select/constants.js
@@ -0,0 +1,18 @@
+export const AJAX_USERS_SELECT_OPTIONS_MAP = {
+ projectId: 'projectId',
+ groupId: 'groupId',
+ showCurrentUser: 'currentUser',
+ authorId: 'authorId',
+ skipUsers: 'skipUsers',
+};
+
+export const AJAX_USERS_SELECT_PARAMS_MAP = {
+ project_id: 'projectId',
+ group_id: 'groupId',
+ skip_ldap: 'skipLdap',
+ todo_filter: 'todoFilter',
+ todo_state_filter: 'todoStateFilter',
+ current_user: 'showCurrentUser',
+ author_id: 'authorId',
+ skip_users: 'skipUsers',
+};
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select/index.js
index 6821df57b5a..2dbe5a8171e 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -1,13 +1,18 @@
-/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */
+/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */
/* global Issuable */
/* global emitSidebarEvent */
import $ from 'jquery';
-import _ from 'underscore';
-import axios from './lib/utils/axios_utils';
-import { s__, __, sprintf } from './locale';
-import ModalStore from './boards/stores/modal_store';
-import { parseBoolean } from './lib/utils/common_utils';
+import { escape, template, uniqBy } from 'lodash';
+import axios from '../lib/utils/axios_utils';
+import { s__, __, sprintf } from '../locale';
+import ModalStore from '../boards/stores/modal_store';
+import { parseBoolean } from '../lib/utils/common_utils';
+import {
+ AJAX_USERS_SELECT_OPTIONS_MAP,
+ AJAX_USERS_SELECT_PARAMS_MAP,
+} from 'ee_else_ce/users_select/constants';
+import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -81,7 +86,7 @@ function UsersSelect(currentUser, els, options = {}) {
const userName = currentUserInfo.name;
const userId = currentUserInfo.id || currentUser.id;
- const inputHtmlString = _.template(`
+ const inputHtmlString = template(`
<input type="hidden" name="<%- fieldName %>"
data-meta="<%- userName %>"
value="<%- userId %>" />
@@ -148,12 +153,11 @@ function UsersSelect(currentUser, els, options = {}) {
name: selectedUser.name,
length: otherSelected.length,
});
- } else {
- return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
- name: firstUser.name,
- length: selectedUsers.length - 1,
- });
}
+ return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
+ name: firstUser.name,
+ length: selectedUsers.length - 1,
+ });
};
$('.assign-to-me-link').on('click', e => {
@@ -205,7 +209,7 @@ function UsersSelect(currentUser, els, options = {}) {
username: data.assignee.username,
avatar: data.assignee.avatar_url,
};
- tooltipTitle = _.escape(user.name);
+ tooltipTitle = escape(user.name);
} else {
user = {
name: s__('UsersSelect|Unassigned'),
@@ -219,10 +223,10 @@ function UsersSelect(currentUser, els, options = {}) {
return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
});
};
- collapsedAssigneeTemplate = _.template(
+ collapsedAssigneeTemplate = template(
'<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>',
);
- assigneeTemplate = _.template(
+ assigneeTemplate = template(
`<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
openingTag: '<a href="#" class="js-assign-yourself">',
@@ -248,7 +252,7 @@ function UsersSelect(currentUser, els, options = {}) {
// Potential duplicate entries when dealing with issue board
// because issue board is also managed by vue
- const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
+ const selectedUsers = uniqBy(selectedInputs, a => a.value)
.filter(input => {
const userId = parseInt(input.value, 10);
const inUsersArray = users.find(u => u.id === userId);
@@ -375,13 +379,11 @@ function UsersSelect(currentUser, els, options = {}) {
$dropdown.find('.dropdown-toggle-text').removeClass('is-default');
if (selected.text) {
return selected.text;
- } else {
- return selected.name;
}
- } else {
- $dropdown.find('.dropdown-toggle-text').addClass('is-default');
- return defaultLabel;
+ return selected.name;
}
+ $dropdown.find('.dropdown-toggle-text').addClass('is-default');
+ return defaultLabel;
},
defaultLabel,
hidden() {
@@ -543,7 +545,7 @@ function UsersSelect(currentUser, els, options = {}) {
let img = '';
if (user.beforeDivider != null) {
- `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape(
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${escape(
user.name,
)}</a></li>`;
} else {
@@ -558,13 +560,8 @@ function UsersSelect(currentUser, els, options = {}) {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
$('.ajax-users-select').each((i, select) => {
- const options = {};
+ const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP);
options.skipLdap = $(select).hasClass('skip_ldap');
- options.projectId = $(select).data('projectId');
- options.groupId = $(select).data('groupId');
- options.showCurrentUser = $(select).data('currentUser');
- options.authorId = $(select).data('authorId');
- options.skipUsers = $(select).data('skipUsers');
const showNullUser = $(select).data('nullUser');
const showAnyUser = $(select).data('anyUser');
const showEmailUser = $(select).data('emailUser');
@@ -672,10 +669,10 @@ UsersSelect.prototype.formatResult = function(user) {
</div>
<div class='user-info'>
<div class='user-name dropdown-menu-user-full-name'>
- ${_.escape(user.name)}
+ ${escape(user.name)}
</div>
<div class='user-username dropdown-menu-user-username text-secondary'>
- ${!user.invite ? `@${_.escape(user.username)}` : ''}
+ ${!user.invite ? `@${escape(user.username)}` : ''}
</div>
</div>
</div>
@@ -683,7 +680,7 @@ UsersSelect.prototype.formatResult = function(user) {
};
UsersSelect.prototype.formatSelection = function(user) {
- return _.escape(user.name);
+ return escape(user.name);
};
UsersSelect.prototype.user = function(user_id, callback) {
@@ -705,14 +702,7 @@ UsersSelect.prototype.users = function(query, options, callback) {
const params = {
search: query,
active: true,
- project_id: options.projectId || null,
- group_id: options.groupId || null,
- skip_ldap: options.skipLdap || null,
- todo_filter: options.todoFilter || null,
- todo_state_filter: options.todoStateFilter || null,
- current_user: options.showCurrentUser || null,
- author_id: options.authorId || null,
- skip_users: options.skipUsers || null,
+ ...getAjaxUsersSelectParams(options, AJAX_USERS_SELECT_PARAMS_MAP),
};
if (options.issuableType === 'merge_request') {
@@ -746,7 +736,7 @@ UsersSelect.prototype.renderRow = function(issuableType, user, selected, usernam
${this.renderRowAvatar(issuableType, user, img)}
<span class="d-flex flex-column overflow-hidden">
<strong class="dropdown-menu-user-full-name">
- ${_.escape(user.name)}
+ ${escape(user.name)}
</strong>
${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''}
</span>
diff --git a/app/assets/javascripts/users_select/utils.js b/app/assets/javascripts/users_select/utils.js
new file mode 100644
index 00000000000..b46fd15fb77
--- /dev/null
+++ b/app/assets/javascripts/users_select/utils.js
@@ -0,0 +1,27 @@
+/**
+ * Get options from data attributes on passed `$select`.
+ * @param {jQuery} $select
+ * @param {Object} optionsMap e.g. { optionKeyName: 'dataAttributeName' }
+ */
+export const getAjaxUsersSelectOptions = ($select, optionsMap) => {
+ return Object.keys(optionsMap).reduce((accumulator, optionKey) => {
+ const dataKey = optionsMap[optionKey];
+ accumulator[optionKey] = $select.data(dataKey);
+
+ return accumulator;
+ }, {});
+};
+
+/**
+ * Get query parameters used for users request from passed `options` parameter
+ * @param {Object} options e.g. { currentUserId: 1, fooBar: 'baz' }
+ * @param {Object} paramsMap e.g. { user_id: 'currentUserId', foo_bar: 'fooBar' }
+ */
+export const getAjaxUsersSelectParams = (options, paramsMap) => {
+ return Object.keys(paramsMap).reduce((accumulator, paramKey) => {
+ const optionKey = paramsMap[paramKey];
+ accumulator[paramKey] = options[optionKey] || null;
+
+ return accumulator;
+ }, {});
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index 33db9b87b17..2f922b990d9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -75,7 +75,7 @@ export default {
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
- class="js-deploy-meta gl-font-size-12"
+ class="js-deploy-meta gl-font-sm"
>
{{ deployment.name }}
</gl-link>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index ba8da46d207..294871ca5c2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -51,7 +51,7 @@ export default {
<div class="mr-widget-extension d-flex align-items-center pl-3">
<div v-if="hasError" class="ci-widget media">
<div class="media-body">
- <span class="gl-font-size-small mr-widget-margin-left gl-line-height-24 js-error-state">{{
+ <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">{{
title
}}</span>
</div>
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 c38272ab239..2433ba879aa 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
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { n__, s__, sprintf } from '~/locale';
import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
@@ -35,7 +35,7 @@ export default {
'mrWidget|The source branch is %{commitsBehindLinkStart}%{commitsBehind}%{commitsBehindLinkEnd} the target branch',
),
{
- commitsBehindLinkStart: `<a href="${esc(this.mr.targetBranchPath)}">`,
+ commitsBehindLinkStart: `<a href="${escape(this.mr.targetBranchPath)}">`,
commitsBehind: n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount),
commitsBehindLinkEnd: '</a>',
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 42db1935123..6df53311ef0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -42,6 +42,10 @@ export default {
type: String,
required: false,
},
+ pipelineMustSucceed: {
+ type: Boolean,
+ required: false,
+ },
sourceBranchLink: {
type: String,
required: false,
@@ -60,7 +64,10 @@ export default {
return this.pipeline && Object.keys(this.pipeline).length > 0;
},
hasCIError() {
- return this.hasCi && !this.ciStatus;
+ return (this.hasCi && !this.ciStatus) || this.hasPipelineMustSucceedConflict;
+ },
+ hasPipelineMustSucceedConflict() {
+ return !this.hasCi && this.pipelineMustSucceed;
},
status() {
return this.pipeline.details && this.pipeline.details.status
@@ -76,9 +83,13 @@ export default {
return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
},
errorText() {
+ if (this.hasPipelineMustSucceedConflict) {
+ return s__('Pipeline|No pipeline has been run for this commit.');
+ }
+
return sprintf(
s__(
- 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}',
+ 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.',
),
{
linkStart: `<a href="${this.troubleshootingDocsPath}">`,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index d81e99d3c09..8fba0e2981f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -79,11 +79,12 @@ export default {
:pipeline-coverage-delta="mr.pipelineCoverageDelta"
:ci-status="mr.ciStatus"
:has-ci="mr.hasCI"
+ :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds"
:source-branch="branch"
:source-branch-link="branchLink"
:troubleshooting-docs-path="mr.troubleshootingDocsPath"
/>
- <template v-slot:footer>
+ <template #footer>
<div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts">
<artifacts-app :endpoint="mr.exposedArtifactsPath" />
</div>
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 edf90085a5b..8313b8afb1b 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
@@ -5,7 +5,6 @@ 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';
-import Visibility from 'visibilityjs';
export default {
name: 'MRWidgetTerraformPlan',
@@ -68,7 +67,11 @@ export default {
method: 'fetchPlans',
successCallback: ({ data }) => {
this.plans = data;
- this.loading = false;
+
+ if (Object.keys(this.plan).length) {
+ this.loading = false;
+ poll.stop();
+ }
},
errorCallback: () => {
this.plans = {};
@@ -77,17 +80,7 @@ export default {
},
});
- if (!Visibility.hidden()) {
- poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- poll.restart();
- } else {
- poll.stop();
- }
- });
+ poll.makeRequest();
},
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
index dcf02a29f52..e4f4032776b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -1,6 +1,6 @@
<script>
import { GlDeprecatedButton } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __, n__, sprintf, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
@@ -60,7 +60,7 @@ export default {
{
commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`,
mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`,
- targetBranch: `<span class="label-branch">${esc(this.targetBranch)}</span>`,
+ targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`,
},
false,
);
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index a368e29d086..92848e86e76 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -2,7 +2,7 @@
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
-import MrWidgetAuthor from '../../components/mr_widget_author.vue';
+import MrWidgetAuthor from '../mr_widget_author.vue';
import eventHub from '../../event_hub';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
import { __ } from '~/locale';
@@ -52,7 +52,6 @@ export default {
.then(res => res.data)
.then(data => {
eventHub.$emit('UpdateWidgetData', data);
- eventHub.$emit('MRWidgetUpdateRequested');
})
.catch(() => {
this.isCancellingAutoMerge = false;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
index a5c75369fa1..302a30dab54 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -1,5 +1,5 @@
<script>
-import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
+import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 139cbe17e35..d421b744fa1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { s__, sprintf } from '~/locale';
import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover';
import StatusIcon from '../mr_widget_status_icon.vue';
@@ -50,7 +50,7 @@ export default {
content: sprintf(
s__('mrWidget|%{link_start}Learn more about resolving conflicts%{link_end}'),
{
- link_start: `<a href="${esc(
+ link_start: `<a href="${escape(
this.mr.conflictsDocsPath,
)}" target="_blank" rel="noopener noreferrer">`,
link_end: '</a>',
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index 7279aaf0809..1a6e186a371 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -5,7 +5,7 @@ import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
import { s__, __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
+import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue
index 01a195049ba..f6bfb178437 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue
@@ -1,5 +1,4 @@
<script>
-import { s__, sprintf } from '~/locale';
import { GlPopover, GlDeprecatedButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import Cookies from 'js-cookie';
@@ -15,18 +14,6 @@ export default {
dismissTrackValue: 20,
showTrackValue: 10,
trackEvent: 'click_button',
- popoverContent: sprintf(
- '%{messageText1}%{lineBreak}%{messageText2}%{lineBreak}%{messageText3}%{lineBreak}%{messageText4}%{lineBreak}%{messageText5}',
- {
- messageText1: s__('mrWidget|Detect issues before deployment with a CI pipeline'),
- messageText2: s__('mrWidget|that continuously tests your code. We created'),
- messageText3: s__("mrWidget|a quick guide that'll show you how to create"),
- messageText4: s__('mrWidget|one. Make your code more secure and more'),
- messageText5: s__('mrWidget|robust in just a minute.'),
- lineBreak: '<br/>',
- },
- false,
- ),
components: {
GlPopover,
GlDeprecatedButton,
@@ -110,7 +97,13 @@ export default {
<div class="svg-content svg-150 pt-1">
<img :src="pipelineSvgPath" />
</div>
- <p v-html="$options.popoverContent"></p>
+ <p>
+ {{
+ s__(
+ 'mrWidget|Detect issues before deployment with a CI pipeline that continuously tests your code. We created a quick guide that will show you how to create one. Make your code more secure and more robust in just a minute.',
+ )
+ }}
+ </p>
<gl-deprecated-button
ref="ok"
category="primary"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 360a75c3946..82be5eeb5ff 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -1,6 +1,6 @@
<script>
import { isEmpty } from 'lodash';
-import { GlIcon, GlDeprecatedButton } from '@gitlab/ui';
+import { GlIcon, GlDeprecatedButton, GlSprintf, GlLink } from '@gitlab/ui';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
@@ -26,6 +26,8 @@ export default {
CommitEdit,
CommitMessageDropdown,
GlIcon,
+ GlSprintf,
+ GlLink,
GlDeprecatedButton,
MergeImmediatelyConfirmationDialog: () =>
import(
@@ -56,7 +58,7 @@ export default {
status() {
const { pipeline, isPipelineFailed, hasCI, ciStatus } = this.mr;
- if (hasCI && !ciStatus) {
+ if ((hasCI && !ciStatus) || this.hasPipelineMustSucceedConflict) {
return 'failed';
} else if (this.isAutoMergeAvailable) {
return 'pending';
@@ -97,6 +99,9 @@ export default {
return __('Merge');
},
+ hasPipelineMustSucceedConflict() {
+ return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -343,9 +348,19 @@ export default {
/>
</template>
<template v-else>
- <span class="bold js-resolve-mr-widget-items-message">
- {{ mergeDisabledText }}
- </span>
+ <div class="bold js-resolve-mr-widget-items-message">
+ <gl-sprintf
+ v-if="hasPipelineMustSucceedConflict"
+ :message="pipelineMustSucceedConflictText"
+ >
+ <template #link="{ content }">
+ <gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="mergeDisabledText" />
+ </div>
</template>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 98f682c2e8a..5305894873f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -51,7 +51,7 @@ export default {
rel="noopener noreferrer nofollow"
data-container="body"
>
- <icon name="question-o" />
+ <icon name="question" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/vue_merge_request_widget/event_hub.js
+++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 32a2b7b83f4..39fa5e465b8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,6 +1,9 @@
import { __ } from '~/locale';
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
+export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __(
+ 'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}',
+);
export default {
computed: {
@@ -16,6 +19,9 @@ export default {
mergeDisabledText() {
return MERGE_DISABLED_TEXT;
},
+ pipelineMustSucceedConflictText() {
+ return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT;
+ },
autoMergeText() {
// MWPS is currently the only auto merge strategy available in CE
return __('Merge when pipeline succeeds');
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 05f73c4cdaf..265ff81f39f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -39,6 +39,7 @@ import SourceBranchRemovalStatus from './components/source_branch_removal_status
import TerraformPlan from './components/mr_widget_terraform_plan.vue';
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils';
+import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
export default {
el: '#js-vue-mr-widget',
@@ -76,6 +77,7 @@ export default {
SourceBranchRemovalStatus,
GroupedTestReportsApp,
TerraformPlan,
+ GroupedAccessibilityReportsApp,
},
props: {
mrData: {
@@ -100,8 +102,11 @@ export default {
shouldRenderMergeHelp() {
return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
},
+ hasPipelineMustSucceedConflict() {
+ return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
shouldRenderPipelines() {
- return this.mr.hasCI;
+ return this.mr.hasCI || this.hasPipelineMustSucceedConflict;
},
shouldSuggestPipelines() {
return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath;
@@ -138,6 +143,9 @@ export default {
mergeError,
});
},
+ shouldShowAccessibilityReport() {
+ return this.mr.accessibilityReportPath;
+ },
},
watch: {
state(newVal, oldVal) {
@@ -380,6 +388,11 @@ export default {
<terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" />
+ <grouped-accessibility-reports-app
+ v-if="shouldShowAccessibilityReport"
+ :endpoint="mr.accessibilityReportPath"
+ />
+
<div class="mr-widget-section">
<component :is="componentName" :mr="mr" :service="service" />
@@ -415,7 +428,9 @@ export default {
<source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
</div>
</div>
- <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div>
+ <div v-if="shouldRenderMergeHelp" class="mr-widget-footer">
+ <mr-widget-merge-help />
+ </div>
</div>
<mr-widget-pipeline-container
v-if="shouldRenderMergedPipeline"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index a298331c1fc..a2ee0bc3ca1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -21,7 +21,7 @@ export default function deviseState(data) {
return stateKey.unresolvedDiscussions;
} else if (this.isPipelineBlocked) {
return stateKey.pipelineBlocked;
- } else if (this.isSHAMismatch) {
+ } else if (this.canMerge && this.isSHAMismatch) {
return stateKey.shaMismatch;
} else if (this.autoMergeEnabled) {
return this.mergeError ? stateKey.autoMergeFailed : stateKey.autoMergeEnabled;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 9f001dda540..d61e122d612 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -103,6 +103,7 @@ export default class MergeRequestStore {
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.terraformReportsPath = data.terraform_reports_path;
this.testResultsPath = data.test_reports_path;
+ this.accessibilityReportPath = data.accessibility_report_path;
this.exposedArtifactsPath = data.exposed_artifacts_path;
this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
@@ -123,15 +124,13 @@ export default class MergeRequestStore {
const currentUser = data.current_user;
- if (currentUser) {
- this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
- this.revertInForkPath = currentUser.revert_in_fork_path;
+ this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
+ this.revertInForkPath = currentUser.revert_in_fork_path;
- this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
- this.canCreateIssue = currentUser.can_create_issue || false;
- this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
- this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
- }
+ this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
+ this.canCreateIssue = currentUser.can_create_issue || false;
+ this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
+ this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
this.setState(data);
}
@@ -162,6 +161,7 @@ export default class MergeRequestStore {
// Paths are set on the first load of the page and not auto-refreshed
this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path;
this.troubleshootingDocsPath = data.troubleshooting_docs_path;
+ this.pipelineMustSucceedDocsPath = data.pipeline_must_succeed_docs_path;
this.mergeRequestBasicPath = data.merge_request_basic_path;
this.mergeRequestWidgetPath = data.merge_request_widget_path;
this.mergeRequestCachedWidgetPath = data.merge_request_cached_widget_path;
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 848295cc984..c0a42e08dee 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -34,10 +34,21 @@ export default {
required: false,
default: '',
},
+ defaultAwards: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
+ groupedDefaultAwards() {
+ return this.defaultAwards.reduce((obj, key) => Object.assign(obj, { [key]: [] }), {});
+ },
groupedAwards() {
- const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name);
+ const { thumbsup, thumbsdown, ...rest } = {
+ ...this.groupedDefaultAwards,
+ ...groupBy(this.awards, x => x.name),
+ };
return [
...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []),
@@ -73,6 +84,10 @@ export default {
};
},
getAwardListTitle(awardsList) {
+ if (!awardsList.length) {
+ return '';
+ }
+
const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
let awardList = awardsList;
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index afbfb1e0ee2..52ce05f0d99 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -1,8 +1,12 @@
<script>
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
import ViewerMixin from './mixins';
import { handleBlobRichViewer } from '~/blob/viewer';
export default {
+ components: {
+ MarkdownFieldView,
+ },
mixins: [ViewerMixin],
mounted() {
handleBlobRichViewer(this.$refs.content, this.type);
@@ -10,5 +14,5 @@ export default {
};
</script>
<template>
- <div ref="content" v-html="content"></div>
+ <markdown-field-view ref="content" v-html="content" />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index e64c7132117..1eb05780206 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -44,7 +44,8 @@ export default {
</script>
<template>
<div
- class="file-content code js-syntax-highlight qa-file-content"
+ class="file-content code js-syntax-highlight"
+ data-qa-selector="file_content"
:class="$options.userColorScheme"
>
<div class="line-numbers">
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 162cfc02959..890dbe86c0d 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -1,5 +1,5 @@
<script>
-import Icon from '../../vue_shared/components/icon.vue';
+import Icon from './icon.vue';
/**
* Renders CI icon based on API response shared between all places where it is used.
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index d38dd258ce6..0234b6bf848 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -67,6 +67,7 @@ export default {
<template>
<gl-deprecated-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
+ v-gl-tooltip.hover.blur
:class="cssClass"
:title="title"
:data-clipboard-text="clipboardText"
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
index 7826c179889..ac95c88225e 100644
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
@@ -4,7 +4,6 @@ import {
GlNewDropdownHeader,
GlFormInputGroup,
GlButton,
- GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
@@ -16,7 +15,6 @@ export default {
GlNewDropdownHeader,
GlFormInputGroup,
GlButton,
- GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -59,9 +57,10 @@ export default {
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
:data-clipboard-text="sshLink"
- >
- <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" />
- </gl-button>
+ data-qa-selector="copy_ssh_url_button"
+ icon="copy-to-clipboard"
+ class="d-inline-flex"
+ />
</template>
</gl-form-input-group>
</div>
@@ -77,9 +76,10 @@ export default {
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
:data-clipboard-text="httpLink"
- >
- <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" />
- </gl-button>
+ data-qa-selector="copy_http_url_button"
+ icon="copy-to-clipboard"
+ class="d-inline-flex"
+ />
</template>
</gl-form-input-group>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue
index 3cca7a86bef..1928bf6dac5 100644
--- a/app/assets/javascripts/vue_shared/components/code_block.vue
+++ b/app/assets/javascripts/vue_shared/components/code_block.vue
@@ -6,11 +6,26 @@ export default {
type: String,
required: true,
},
+ maxHeight: {
+ type: String,
+ required: false,
+ default: 'initial',
+ },
+ },
+ computed: {
+ styleObject() {
+ const { maxHeight } = this;
+ const isScrollable = maxHeight !== 'initial';
+ const scrollableStyles = {
+ maxHeight,
+ overflowY: 'auto',
+ };
+
+ return isScrollable ? scrollableStyles : null;
+ },
},
};
</script>
<template>
- <pre class="code-block rounded">
- <code class="d-block">{{ code }}</code>
- </pre>
+ <pre class="code-block rounded" :style="styleObject"><code class="d-block">{{ code }}</code></pre>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index 356f733fb8c..23bea6c28b4 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -4,7 +4,7 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from './user_avatar/user_avatar_link.vue';
-import Icon from '../../vue_shared/components/icon.vue';
+import Icon from './icon.vue';
export default {
directives: {
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
index 2f5e5f35064..fe488ab6cfa 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
@@ -24,6 +24,11 @@ export default {
required: false,
default: '',
},
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
projectPath: {
type: String,
required: false,
@@ -34,6 +39,11 @@ export default {
required: false,
default: '',
},
+ images: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
computed: {
viewer() {
@@ -62,6 +72,8 @@ export default {
:file-size="fileSize"
:project-path="projectPath"
:content="content"
+ :images="images"
+ :commit-sha="commitSha"
/>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
index da0b45110e2..b7fa73bc197 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
@@ -26,8 +26,8 @@ const fileExtensionViewers = {
export function viewerInformationForPath(path) {
if (!path) return null;
const name = path.split('/').pop();
- const viewerName =
- fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || '';
+ const extension = name.includes('.') && name.split('.').pop();
+ const viewerName = fileNameViewers[name] || fileExtensionViewers[extension];
return viewers[viewerName];
}
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index eb3e489fb8c..1344c766e0e 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -1,8 +1,11 @@
<script>
import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
import { GlSkeletonLoading } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
+import { forEach, escape } from 'lodash';
const { CancelToken } = axios;
let axiosSource;
@@ -16,6 +19,11 @@ export default {
type: String,
required: true,
},
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
filePath: {
type: String,
required: false,
@@ -25,6 +33,11 @@ export default {
type: String,
required: true,
},
+ images: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -55,6 +68,9 @@ export default {
text: this.content,
path: this.filePath,
};
+ if (this.commitSha) {
+ postBody.ref = this.commitSha;
+ }
const postOptions = {
cancelToken: axiosSource.token,
};
@@ -66,11 +82,19 @@ export default {
postOptions,
)
.then(({ data }) => {
- this.previewContent = data.body;
+ let previewContent = data.body;
+ forEach(this.images, ({ src, title = '', alt }, key) => {
+ previewContent = previewContent.replace(
+ key,
+ `<img src="${escape(src)}" title="${escape(title)}" alt="${escape(alt)}">`,
+ );
+ });
+
+ this.previewContent = previewContent;
this.isLoading = false;
this.$nextTick(() => {
- $(this.$refs['markdown-preview']).renderGFM();
+ $(this.$refs.markdownPreview).renderGFM();
});
})
.catch(() => {
@@ -84,7 +108,7 @@ export default {
</script>
<template>
- <div ref="markdown-preview" class="md-previewer">
+ <div ref="markdownPreview" class="md-previewer">
<gl-skeleton-loading v-if="isLoading" />
<div v-else class="md" v-html="previewContent"></div>
</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 ffc616d7309..07748482204 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
@@ -169,15 +169,15 @@ export default {
menu-class="date-time-picker-menu"
toggle-class="date-time-picker-toggle text-truncate"
>
- <div class="d-flex justify-content-between gl-p-2">
+ <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-for="custom-from-time"
- label-class="gl-pb-1"
- class="custom-time-range-form-group col-md-7 gl-pl-1 gl-pr-0 m-0"
+ 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"
>
- <div class="gl-pt-2">
+ <div class="gl-pt-2-deprecated-no-really-do-not-use-me">
<date-time-picker-input
id="custom-time-from"
v-model="startInput"
@@ -198,14 +198,18 @@ export default {
</gl-deprecated-button>
</gl-form-group>
</gl-form-group>
- <gl-form-group label-for="group-id-dropdown" class="col-md-5 gl-pl-1 gl-pr-1 m-0">
+ <gl-form-group
+ label-for="group-id-dropdown"
+ class="col-md-5 gl-pl-1-deprecated-no-really-do-not-use-me gl-pr-1-deprecated-no-really-do-not-use-me m-0"
+ >
<template #label>
- <span class="gl-pl-5">{{ __('Quick range') }}</span>
+ <span class="gl-pl-5-deprecated-no-really-do-not-use-me">{{ __('Quick range') }}</span>
</template>
<gl-dropdown-item
v-for="(option, index) in options"
:key="index"
+ data-qa-selector="quick_range_item"
:active="isOptionActive(option)"
active-class="active"
@click="setQuickRange(option)"
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 73511879ff2..018e3a84c39 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -1,8 +1,8 @@
<script>
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import Icon from '~/vue_shared/components/icon.vue';
-import FileIcon from '../../../vue_shared/components/file_icon.vue';
-import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue';
+import FileIcon from '../file_icon.vue';
+import ChangedFileIcon from '../changed_file_icon.vue';
const MAX_PATH_LENGTH = 60;
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index 2f6640232dd..9ecae87c1a9 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -493,6 +493,7 @@ const fileNameIcons = {
'.npmignore': 'npm',
'.npmrc': 'npm',
'.yarnrc': 'yarn',
+ '.yarnrc.yml': 'yarn',
'yarn.lock': 'yarn',
'.yarnclean': 'yarn',
'.yarn-integrity': 'yarn',
@@ -575,6 +576,7 @@ const fileNameIcons = {
'.prettierrc.json': 'prettier',
'.prettierrc.yaml': 'prettier',
'.prettierrc.yml': 'prettier',
+ '.prettierignore': 'prettier',
'nodemon.json': 'nodemon',
'.sonarrc': 'sonar',
browserslist: 'browserlist',
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 0a5cc7b693c..0cc96309a92 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -148,19 +148,6 @@ export default {
cursor: pointer;
}
-.file-row:hover,
-.file-row:focus {
- background: #f2f2f2;
-}
-
-.file-row:active {
- background: #dfdfdf;
-}
-
-.file-row.is-active {
- background: #f2f2f2;
-}
-
.file-row-name-container {
display: flex;
width: 100%;
diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue
index fad69dc1e24..5d6633fa6d7 100644
--- a/app/assets/javascripts/vue_shared/components/form/title.vue
+++ b/app/assets/javascripts/vue_shared/components/form/title.vue
@@ -6,6 +6,7 @@ export default {
GlFormInput,
GlFormGroup,
},
+ inheritAttrs: false,
};
</script>
<template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index bbf293664a6..508f43afe61 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -34,7 +34,7 @@ function createMenuItemTemplate({ original }) {
return `${avatarTag}
${original.username}
- <small class="small font-weight-normal gl-color-inherit">${name}${count}</small>
+ <small class="small font-weight-normal gl-reset-color">${name}${count}</small>
${icon}`;
}
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
index 9dd61c8eada..87a995464fa 100644
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -4,7 +4,7 @@ import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar
export default {
props: {
entityId: {
- type: Number,
+ type: [Number, String],
required: true,
},
entityName: {
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
index 89a8595fc79..cb3cd18e5a7 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
@@ -1,11 +1,11 @@
<script>
import { GlLink } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
-import icon from '../../../vue_shared/components/icon.vue';
+import icon from '../icon.vue';
function buildDocsLinkStart(path) {
- return `<a href="${esc(path)}" target="_blank" rel="noopener noreferrer">`;
+ return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`;
}
export default {
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 5d7e9557aff..4f1b1c758b2 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -1,9 +1,9 @@
<script>
import '~/commons/bootstrap';
-import { GlTooltip, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltip, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale';
-import IssueMilestone from '../../components/issue/issue_milestone.vue';
-import IssueAssignees from '../../components/issue/issue_assignees.vue';
+import IssueMilestone from './issue_milestone.vue';
+import IssueAssignees from './issue_assignees.vue';
import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
import CiIcon from '../ci_icon.vue';
@@ -13,6 +13,7 @@ export default {
IssueMilestone,
IssueAssignees,
CiIcon,
+ GlIcon,
GlTooltip,
},
directives: {
@@ -44,6 +45,9 @@ export default {
visibility: 'hidden',
};
},
+ iconClasses() {
+ return `${this.iconClass} ic-${this.iconName}`;
+ },
},
};
</script>
@@ -54,30 +58,29 @@ export default {
'issuable-info-container': !canReorder,
'card-body': canReorder,
}"
- class="item-body d-flex align-items-center p-2 p-lg-3 py-xl-2 px-xl-3"
+ class="item-body d-flex align-items-center py-2 px-3"
>
<div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
<!-- Title area: Status icon (XL) and title -->
- <div class="item-title d-flex align-items-center mb-xl-0">
- <span ref="iconElementXL">
- <icon
+ <div class="item-title d-flex align-items-xl-center mb-xl-0">
+ <div ref="iconElementXL">
+ <gl-icon
v-if="hasState"
ref="iconElementXL"
- :class="iconClass"
+ class="mr-2 d-block"
+ :class="iconClasses"
:name="iconName"
- :size="16"
:title="stateTitle"
:aria-label="state"
/>
- </span>
+ </div>
<gl-tooltip :target="() => $refs.iconElementXL">
<span v-html="stateTitle"></span>
</gl-tooltip>
- <icon
+ <gl-icon
v-if="confidential"
v-gl-tooltip
name="eye-slash"
- :size="16"
:title="__('Confidential')"
class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
@@ -97,17 +100,6 @@ export default {
<div
class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
>
- <span ref="iconElement">
- <icon
- v-if="hasState"
- :class="iconClass"
- :name="iconName"
- :title="stateTitle"
- :aria-label="state"
- data-html="true"
- class="d-xl-none"
- />
- </span>
<gl-tooltip :target="() => this.$refs.iconElement">
<span v-html="stateTitle"></span>
</gl-tooltip>
@@ -159,7 +151,7 @@ export default {
v-gl-tooltip
:disabled="removeDisabled"
type="button"
- class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button mr-xl-0 align-self-xl-center"
+ class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button"
data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 26e878d56a0..8007ccb91d5 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,7 +1,7 @@
<script>
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
-import { unescape as unesc } from 'lodash';
+import { unescape } from 'lodash';
import { __, sprintf } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash';
@@ -115,7 +115,7 @@ export default {
return text;
}
- return unesc(stripHtml(richText).replace(/\n/g, ''));
+ return unescape(stripHtml(richText).replace(/\n/g, ''));
}
return '';
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue
new file mode 100644
index 00000000000..d77123371f2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue
@@ -0,0 +1,19 @@
+<script>
+import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
+export default {
+ mounted() {
+ this.renderGFM();
+ },
+ methods: {
+ renderGFM() {
+ $(this.$el).renderGFM();
+ },
+ },
+};
+</script>
+
+<template>
+ <div><slot></slot></div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
index a4e004c3341..e193883b6e9 100644
--- a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
@@ -1,9 +1,9 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
export const callbackName = 'recaptchaDialogCallback';
-export const eventHub = new Vue();
+export const eventHub = createEventHub();
const throwDuplicateCallbackError = () => {
throw new Error(`${callbackName} is already defined!`);
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
new file mode 100644
index 00000000000..457f1806452
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -0,0 +1,37 @@
+import { __ } from '~/locale';
+import { generateToolbarItem } from './toolbar_service';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+const TOOLBAR_ITEM_CONFIGS = [
+ { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
+ { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') },
+ { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') },
+ { icon: 'strikethrough', command: 'Strike', tooltip: __('Add strikethrough text') },
+ { 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') },
+ { icon: 'list-task', command: 'Task', tooltip: __('Add a task list') },
+ { icon: 'list-indent', command: 'Indent', tooltip: __('Indent') },
+ { icon: 'list-outdent', command: 'Outdent', tooltip: __('Outdent') },
+ { isDivider: true },
+ { icon: 'dash', command: 'HR', tooltip: __('Add a line') },
+ { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
+ { isDivider: true },
+ { icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
+];
+
+export const EDITOR_OPTIONS = {
+ toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)),
+};
+
+export const EDITOR_TYPES = {
+ wysiwyg: 'wysiwyg',
+};
+
+export const EDITOR_HEIGHT = '100%';
+
+export const EDITOR_PREVIEW_STYLE = 'horizontal';
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
new file mode 100644
index 00000000000..ba3696c8ad1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
@@ -0,0 +1,65 @@
+<script>
+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';
+
+export default {
+ components: {
+ ToastEditor: () =>
+ import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
+ toast => toast.Editor,
+ ),
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ options: {
+ type: Object,
+ required: false,
+ default: () => EDITOR_OPTIONS,
+ },
+ initialEditType: {
+ type: String,
+ required: false,
+ default: EDITOR_TYPES.wysiwyg,
+ },
+ height: {
+ type: String,
+ required: false,
+ default: EDITOR_HEIGHT,
+ },
+ previewStyle: {
+ type: String,
+ required: false,
+ default: EDITOR_PREVIEW_STYLE,
+ },
+ },
+ computed: {
+ editorOptions() {
+ return { ...EDITOR_OPTIONS, ...this.options };
+ },
+ },
+ methods: {
+ onContentChanged() {
+ this.$emit('input', this.getMarkdown());
+ },
+ getMarkdown() {
+ return this.$refs.editor.invoke('getMarkdown');
+ },
+ },
+};
+</script>
+<template>
+ <toast-editor
+ ref="editor"
+ :initial-value="value"
+ :options="editorOptions"
+ :preview-style="previewStyle"
+ :initial-edit-type="initialEditType"
+ :height="height"
+ @change="onContentChanged"
+ />
+</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
new file mode 100644
index 00000000000..58aaeef45f2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
@@ -0,0 +1,20 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ icon: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <button class="p-0 gl-display-flex toolbar-button">
+ <gl-icon class="gl-mx-auto" :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
new file mode 100644
index 00000000000..fff90f3e3fb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
@@ -0,0 +1,32 @@
+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/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
index 44cc11a6aaa..5eef439aa90 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -80,11 +80,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
computed: {
hiddenInputName() {
@@ -136,7 +131,6 @@ export default {
<dropdown-value
:labels="context.labels"
:label-filter-base-path="labelFilterBasePath"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
>
<slot></slot>
@@ -157,7 +151,6 @@ export default {
:namespace="namespace"
:labels="context.labels"
:show-extra-options="!showCreate"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
/>
<div
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index c3bc61d0053..30f7e6a5980 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -36,11 +36,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
computed: {
dropdownToggleText() {
@@ -72,7 +67,6 @@ export default {
:data-namespace-path="namespace"
:data-show-any="showExtraOptions"
:data-scoped-labels="enableScopedLabels"
- :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink"
type="button"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
index fe43f77b1ee..71d7069dd57 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
@@ -20,11 +20,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
computed: {
isEmpty() {
@@ -64,7 +59,6 @@ export default {
:title="label.title"
:description="label.description"
:scoped="showScopedLabels(label)"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
/>
</template>
</div>
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
new file mode 100644
index 00000000000..ab652c9356a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
@@ -0,0 +1,5 @@
+// eslint-disable-next-line import/prefer-default-export
+export const DropdownVariant = {
+ Sidebar: 'sidebar',
+ Standalone: 'standalone',
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
index 55fa1e4ef9c..f45c14f8344 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
@@ -1,21 +1,35 @@
<script>
-import { mapGetters } from 'vuex';
-import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+import { GlButton, GlIcon } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlIcon,
},
computed: {
- ...mapGetters(['dropdownButtonText']),
+ ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']),
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents']),
+ handleButtonClick(e) {
+ if (this.isDropdownVariantStandalone) {
+ this.toggleDropdownContents();
+ e.stopPropagation();
+ }
+ },
},
};
</script>
<template>
- <gl-deprecated-button class="labels-select-dropdown-button w-100 text-left">
- <span class="dropdown-toggle-text">{{ dropdownButtonText }}</span>
+ <gl-button
+ class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
+ @click="handleButtonClick"
+ >
+ <span class="dropdown-toggle-text flex-fill">
+ {{ dropdownButtonText }}
+ </span>
<gl-icon name="chevron-down" class="pull-right" />
- </gl-deprecated-button>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
index 6bb77f6b6f3..ba8d8391952 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -1,18 +1,10 @@
<script>
import { mapState, mapActions } from 'vuex';
-import {
- GlTooltipDirective,
- GlDeprecatedButton,
- GlIcon,
- GlFormInput,
- GlLink,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
GlFormInput,
GlLink,
GlLoadingIcon,
@@ -60,25 +52,23 @@ export default {
<template>
<div class="labels-select-contents-create js-labels-create">
<div class="dropdown-title d-flex align-items-center pt-0 pb-2">
- <gl-deprecated-button
+ <gl-button
:aria-label="__('Go back')"
variant="link"
- size="sm"
+ size="small"
class="js-btn-back dropdown-header-button p-0"
+ icon="arrow-left"
@click="toggleDropdownContentsCreateView"
- >
- <gl-icon name="arrow-left" />
- </gl-deprecated-button>
+ />
<span class="flex-grow-1">{{ labelsCreateTitle }}</span>
- <gl-deprecated-button
+ <gl-button
:aria-label="__('Close')"
variant="link"
- size="sm"
+ size="small"
class="dropdown-header-button p-0"
+ icon="close"
@click="toggleDropdownContents"
- >
- <gl-icon name="close" />
- </gl-deprecated-button>
+ />
</div>
<div class="dropdown-input">
<gl-form-input
@@ -107,21 +97,19 @@ export default {
</div>
</div>
<div class="dropdown-actions clearfix pt-2 px-2">
- <gl-deprecated-button
+ <gl-button
:disabled="disableCreate"
- variant="primary"
+ category="primary"
+ variant="success"
class="pull-left d-flex align-items-center"
@click="handleCreateClick"
>
<gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
{{ __('Create') }}
- </gl-deprecated-button>
- <gl-deprecated-button
- class="pull-right js-btn-cancel-create"
- @click="toggleDropdownContentsCreateView"
- >
+ </gl-button>
+ <gl-button class="pull-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
{{ __('Cancel') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</template>
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 a8e48bfe1a1..1ef2e8b3bed 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
@@ -1,16 +1,18 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { GlLoadingIcon, GlDeprecatedButton, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+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 LabelItem from './label_item.vue';
+
export default {
components: {
GlLoadingIcon,
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
GlSearchBoxByType,
GlLink,
+ LabelItem,
},
data() {
return {
@@ -20,6 +22,8 @@ export default {
},
computed: {
...mapState([
+ 'allowLabelCreate',
+ 'allowMultiselect',
'labelsManagePath',
'labels',
'labelsFetchInProgress',
@@ -27,7 +31,7 @@ export default {
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
- ...mapGetters(['selectedLabelsList']),
+ ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']),
visibleLabels() {
if (this.searchKey) {
return this.labels.filter(label =>
@@ -56,12 +60,8 @@ export default {
'toggleDropdownContentsCreateView',
'fetchLabels',
'updateSelectedLabels',
+ 'toggleDropdownContents',
]),
- getDropdownLabelBoxStyle(label) {
- return {
- backgroundColor: label.color,
- };
- },
isLabelSelected(label) {
return this.selectedLabelsList.includes(label.id);
},
@@ -111,6 +111,7 @@ export default {
},
handleLabelClick(label) {
this.updateSelectedLabels([label]);
+ if (!this.allowMultiselect) this.toggleDropdownContents();
},
},
};
@@ -123,54 +124,47 @@ export default {
class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100"
size="md"
/>
- <div class="dropdown-title d-flex align-items-center pt-0 pb-2">
+ <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2">
<span class="flex-grow-1">{{ labelsListTitle }}</span>
- <gl-deprecated-button
+ <gl-button
:aria-label="__('Close')"
variant="link"
- size="sm"
+ size="small"
class="dropdown-header-button p-0"
+ icon="close"
@click="toggleDropdownContents"
- >
- <gl-icon name="close" />
- </gl-deprecated-button>
+ />
</div>
- <div class="dropdown-input">
+ <div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type v-model="searchKey" :autofocus="true" />
</div>
- <div v-if="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
+ <div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
<ul class="list-unstyled mb-0">
<li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left">
- <gl-link
- class="d-flex align-items-baseline text-break-word label-item"
- :class="{ 'is-focused': index === currentHighlightItem }"
- @click="handleLabelClick(label)"
- >
- <gl-icon v-show="label.set" name="mobile-issue-close" class="mr-2 align-self-center" />
- <span v-show="!label.set" class="mr-3 pr-2"></span>
- <span class="dropdown-label-box" :style="getDropdownLabelBoxStyle(label)"></span>
- <span>{{ label.title }}</span>
- </gl-link>
+ <label-item
+ :label="label"
+ :highlight="index === currentHighlightItem"
+ @clickLabel="handleLabelClick(label)"
+ />
</li>
- <li v-if="!visibleLabels.length" class="p-2 text-center">
+ <li v-show="!visibleLabels.length" class="p-2 text-center">
{{ __('No matching results') }}
</li>
</ul>
</div>
- <div class="dropdown-footer">
+ <div v-if="isDropdownVariantSidebar" class="dropdown-footer">
<ul class="list-unstyled">
- <li>
- <gl-deprecated-button
- variant="link"
+ <li v-if="allowLabelCreate">
+ <gl-link
class="d-flex w-100 flex-row text-break-word label-item"
@click="toggleDropdownContentsCreateView"
- >{{ footerCreateLabelTitle }}</gl-deprecated-button
+ >{{ footerCreateLabelTitle }}</gl-link
>
</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/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index 695af775750..12ad2acf308 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -9,12 +9,7 @@ export default {
GlLabel,
},
computed: {
- ...mapState([
- 'selectedLabels',
- 'allowScopedLabels',
- 'labelsFilterBasePath',
- 'scopedLabelsDocumentationPath',
- ]),
+ ...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']),
},
methods: {
labelFilterUrl(label) {
@@ -45,7 +40,6 @@ export default {
:background-color="label.color"
:target="labelFilterUrl(label)"
:scoped="scopedLabel(label)"
- :scoped-labels-documentation-link="scopedLabelsDocumentationPath"
tooltip-placement="top"
/>
</template>
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
new file mode 100644
index 00000000000..c95221d71b5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ highlight: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isSet: this.label.set,
+ };
+ },
+ computed: {
+ labelBoxStyle() {
+ return {
+ backgroundColor: this.label.color,
+ };
+ },
+ },
+ methods: {
+ handleClick() {
+ this.isSet = !this.isSet;
+ this.$emit('clickLabel', this.label);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link
+ class="d-flex align-items-baseline text-break-word label-item"
+ :class="{ 'is-focused': highlight }"
+ @click="handleClick"
+ >
+ <gl-icon v-show="isSet" name="mobile-issue-close" class="mr-2 align-self-center" />
+ <span v-show="!isSet" data-testid="no-icon" class="mr-3 pr-2"></span>
+ <span class="dropdown-label-box" data-testid="label-color-box" :style="labelBoxStyle"></span>
+ <span>{{ label.title }}</span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 78102caacf5..f38b66fdfdf 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -1,7 +1,7 @@
<script>
import $ from 'jquery';
import Vue from 'vue';
-import Vuex, { mapState, mapActions } from 'vuex';
+import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { __ } from '~/locale';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
@@ -13,6 +13,8 @@ import DropdownValue from './dropdown_value.vue';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
+import { DropdownVariant } from './constants';
+
Vue.use(Vuex);
export default {
@@ -33,14 +35,19 @@ export default {
type: Boolean,
required: true,
},
+ allowMultiselect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
allowScopedLabels: {
type: Boolean,
required: true,
},
- dropdownOnly: {
- type: Boolean,
+ variant: {
+ type: String,
required: false,
- default: false,
+ default: DropdownVariant.Sidebar,
},
selectedLabels: {
type: Array,
@@ -67,11 +74,6 @@ export default {
required: false,
default: '',
},
- scopedLabelsDocumentationPath: {
- type: String,
- required: false,
- default: '',
- },
labelsListTitle: {
type: String,
required: false,
@@ -95,6 +97,10 @@ export default {
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
+ ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']),
+ dropdownButtonVisible() {
+ return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
+ },
},
watch: {
selectedLabels(selectedLabels) {
@@ -105,15 +111,15 @@ export default {
},
mounted() {
this.setInitialState({
- dropdownOnly: this.dropdownOnly,
+ variant: this.variant,
allowLabelEdit: this.allowLabelEdit,
allowLabelCreate: this.allowLabelCreate,
+ allowMultiselect: this.allowMultiselect,
allowScopedLabels: this.allowScopedLabels,
selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
labelsFilterBasePath: this.labelsFilterBasePath,
- scopedLabelsDocumentationPath: this.scopedLabelsDocumentationPath,
labelsListTitle: this.labelsListTitle,
labelsCreateTitle: this.labelsCreateTitle,
footerCreateLabelTitle: this.footerCreateLabelTitle,
@@ -154,13 +160,24 @@ export default {
// as the dropdown wrapper is not using `GlDropdown` as
// it will also require us to use `BDropdownForm`
// which is yet to be implemented in GitLab UI.
+ const hasExceptionClass = [
+ 'js-dropdown-button',
+ 'js-btn-cancel-create',
+ 'js-sidebar-dropdown-toggle',
+ ].some(
+ className =>
+ target?.classList.contains(className) ||
+ target?.parentElement.classList.contains(className),
+ );
+
+ const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
+ className => $(target).parents(className).length,
+ );
+
if (
- this.showDropdownButton &&
this.showDropdownContents &&
- !$(target).parents('.js-btn-back').length &&
- !$(target).parents('.js-labels-list').length &&
- !target?.classList.contains('js-btn-cancel-create') &&
- !target?.classList.contains('js-sidebar-dropdown-toggle') &&
+ !hadExceptionParent &&
+ !hasExceptionClass &&
!this.$refs.dropdownButtonCollapsed?.$el.contains(target) &&
!this.$refs.dropdownContents?.$el.contains(target)
) {
@@ -181,10 +198,12 @@ export default {
</script>
<template>
- <div class="labels-select-wrapper position-relative">
- <div v-if="!dropdownOnly">
+ <div
+ class="labels-select-wrapper position-relative"
+ :class="{ 'is-standalone': isDropdownVariantStandalone }"
+ >
+ <template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
- v-if="allowLabelCreate"
ref="dropdownButtonCollapsed"
:labels="selectedLabels"
@onValueClick="handleCollapsedValueClick"
@@ -196,8 +215,18 @@ export default {
<dropdown-value v-show="!showDropdownButton">
<slot></slot>
</dropdown-value>
- <dropdown-button v-show="showDropdownButton" />
- <dropdown-contents v-if="showDropdownButton && showDropdownContents" ref="dropdownContents" />
- </div>
+ <dropdown-button v-show="dropdownButtonVisible" />
+ <dropdown-contents
+ v-if="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ />
+ </template>
+ <template v-if="isDropdownVariantStandalone">
+ <dropdown-button v-show="dropdownButtonVisible" />
+ <dropdown-contents
+ v-if="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
index c08a8a8ea58..c39222959a9 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
@@ -1,4 +1,5 @@
import { __, s__, sprintf } from '~/locale';
+import { DropdownVariant } from '../constants';
/**
* Returns string representing current labels
@@ -6,8 +7,11 @@ import { __, s__, sprintf } from '~/locale';
*
* @param {object} state
*/
-export const dropdownButtonText = state => {
- const selectedLabels = state.labels.filter(label => label.set);
+export const dropdownButtonText = (state, getters) => {
+ const selectedLabels = getters.isDropdownVariantSidebar
+ ? state.labels.filter(label => label.set)
+ : state.selectedLabels;
+
if (!selectedLabels.length) {
return __('Label');
} else if (selectedLabels.length > 1) {
@@ -26,5 +30,19 @@ export const dropdownButtonText = state => {
*/
export const selectedLabelsList = state => state.selectedLabels.map(label => label.id);
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `sidebar`
+ * @param {object} state
+ */
+export const isDropdownVariantSidebar = state => state.variant === DropdownVariant.Sidebar;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `standalone`
+ * @param {object} state
+ */
+export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
index 32a78507e88..54f8c78b4e1 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -1,4 +1,5 @@
import * as types from './mutation_types';
+import { DropdownVariant } from '../constants';
export default {
[types.SET_INITIAL_STATE](state, props) {
@@ -10,7 +11,7 @@ export default {
},
[types.TOGGLE_DROPDOWN_CONTENTS](state) {
- if (!state.dropdownOnly) {
+ if (state.variant === DropdownVariant.Sidebar) {
state.showDropdownButton = !state.showDropdownButton;
}
state.showDropdownContents = !state.showDropdownContents;
@@ -57,20 +58,13 @@ export default {
},
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
- // Iterate over all the labels and update
- // `set` prop value to represent their current state.
- const labelIds = labels.map(label => label.id);
- state.labels = state.labels.reduce((allLabels, label) => {
- if (labelIds.includes(label.id)) {
- allLabels.push({
- ...label,
- touched: true,
- set: !label.set,
- });
- } else {
- allLabels.push(label);
- }
- return allLabels;
- }, []);
+ // Find the label to update from all the labels
+ // and change `set` prop value to represent their current state.
+ const labelId = labels.pop()?.id;
+ const candidateLabel = state.labels.find(label => labelId === label.id);
+ if (candidateLabel) {
+ candidateLabel.touched = true;
+ candidateLabel.set = !candidateLabel.set;
+ }
},
};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
index ceabc696693..6a6c0b4c0ee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
@@ -11,13 +11,13 @@ export default () => ({
namespace: '',
labelsFetchPath: '',
labelsFilterBasePath: '',
- scopedLabelsDocumentationPath: '#',
// UI Flags
+ variant: '',
allowLabelCreate: false,
allowLabelEdit: false,
allowScopedLabels: false,
- dropdownOnly: false,
+ allowMultiselect: false,
showDropdownButton: false,
showDropdownContents: false,
showDropdownContentsCreateView: false,
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue b/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue
deleted file mode 100644
index 527cbd458e2..00000000000
--- a/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<script>
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-const GITLAB_TEAM_MEMBER_LABEL = __('GitLab Team Member');
-
-export default {
- name: 'GitlabTeamMemberBadge',
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- components: { GlIcon },
- gitlabTeamMemberLabel: GITLAB_TEAM_MEMBER_LABEL,
-};
-</script>
-
-<template>
- <span
- v-gl-tooltip.hover
- :title="$options.gitlabTeamMemberLabel"
- role="img"
- :aria-label="$options.gitlabTeamMemberLabel"
- class="d-inline-block align-middle"
- >
- <gl-icon name="tanuki-verified" class="gl-text-purple d-block" />
- </span>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
deleted file mode 100644
index 7ed4da84120..00000000000
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<script>
-/* This is a re-usable vue component for rendering a user avatar svg (typically
- for a blank state). It will receive styles comparable to the user avatar,
- but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
- The svg and avatar size can be configured by props passed to this component.
-
- Sample configuration:
-
- <user-avatar-svg
- :svg="potentialApproverSvg"
- :size="20"
- />
-
-*/
-
-export default {
- props: {
- svg: {
- type: String,
- required: true,
- },
- size: {
- type: Number,
- required: false,
- default: 20,
- },
- },
- computed: {
- avatarSizeClass() {
- return `s${this.size}`;
- },
- },
-};
-</script>
-
-<template>
- <svg :class="avatarSizeClass" :height="size" :width="size" v-html="svg" />
-</template>
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 f9e3f3df0cc..c93b3d37a63 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
@@ -9,21 +9,46 @@ import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/c
export default {
methods: {
onChangeTab(scope) {
- this.updateContent({ scope, page: '1' });
+ let params = {
+ scope,
+ page: '1',
+ };
+
+ params = this.onChangeWithFilter(params);
+
+ this.updateContent(params);
},
onChangePage(page) {
/* URLS parameters are strings, we need to parse to match types */
- const params = {
+ let params = {
page: Number(page).toString(),
};
if (this.scope) {
params.scope = this.scope;
}
+
+ params = this.onChangeWithFilter(params);
+
this.updateContent(params);
},
+ onChangeWithFilter(params) {
+ const { username, ref } = this.requestData;
+ const paramsData = params;
+
+ if (username) {
+ paramsData.username = username;
+ }
+
+ if (ref) {
+ paramsData.ref = ref;
+ }
+
+ return paramsData;
+ },
+
updateInternalState(parameters) {
// stop polling
this.poll.stop();
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 657e52674db..cc4d13db150 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -37,4 +37,12 @@
@import "application_ee";
// CSS util classes
+/**
+ These are deprecated in favor of the Gitlab UI utilities imported below.
+ Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss
+ to see the available utility classes.
+**/
@import "utilities";
+
+// Gitlab UI util classes
+@import "@gitlab/ui/src/scss/utilities";
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index ed5c133950d..1c15400542a 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -211,7 +211,7 @@ h3.popover-header {
}
.info-well {
- background: $gray-50;
+ background: $gray-10;
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: 4px;
diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss
index 2e2c1fefc79..ce33aa94df3 100644
--- a/app/assets/stylesheets/components/dashboard_skeleton.scss
+++ b/app/assets/stylesheets/components/dashboard_skeleton.scss
@@ -68,7 +68,7 @@
background-size: cover;
background-image: linear-gradient(to right,
$gray-100 0%,
- $gray-50 20%,
+ $gray-10 20%,
$gray-100 40%,
$gray-100 100%);
border-radius: $gl-padding;
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
new file mode 100644
index 00000000000..1061aae2bbb
--- /dev/null
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -0,0 +1,140 @@
+.design-detail {
+ background-color: rgba($black, 0.9);
+
+ .with-performance-bar & {
+ top: 35px;
+ }
+
+ .inactive {
+ opacity: 0.5;
+ }
+}
+
+.design-presentation-wrapper {
+ top: 0;
+ left: 0;
+}
+
+.design-scaler {
+ z-index: 1;
+}
+
+.design-scaler-wrapper {
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.design-checkbox {
+ position: absolute;
+ top: $gl-padding;
+ left: 30px;
+}
+
+.image-notes {
+ overflow-y: scroll;
+ padding: $gl-padding;
+ padding-top: 50px;
+ background-color: $white;
+ flex-shrink: 0;
+ min-width: 400px;
+ flex-basis: 28%;
+
+ .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 {
+ margin: $gl-padding 0;
+
+ &::before {
+ content: '';
+ border-left: 1px solid $gray-200;
+ position: absolute;
+ left: 28px;
+ top: -18px;
+ height: 18px;
+ }
+
+ .design-note {
+ padding: $gl-padding;
+ list-style: none;
+
+ a {
+ color: inherit;
+ }
+
+ .note-text a {
+ color: $blue-600;
+ }
+ }
+
+ .reply-wrapper {
+ padding: $gl-padding;
+ }
+ }
+
+ .reply-wrapper {
+ border-top: 1px solid $border-color;
+ }
+
+ .new-discussion-disclaimer {
+ line-height: 20px;
+ }
+}
+
+@media (max-width: map-get($grid-breakpoints, lg)) {
+ .design-detail {
+ overflow-y: scroll;
+ }
+
+ .image-notes {
+ overflow-y: auto;
+ min-width: 100%;
+ flex-grow: 1;
+ flex-basis: auto;
+ }
+}
+
+.design-dropzone-border {
+ border: 2px dashed $gray-200;
+}
+
+.design-dropzone-card {
+ transition: border $general-hover-transition-duration $general-hover-transition-curve;
+
+ &:focus,
+ &:active {
+ outline: none;
+ border: 2px dashed $purple;
+ color: $gl-text-color;
+ }
+
+ &:hover {
+ border-color: $gray-500;
+ }
+}
+
+.design-dropzone-overlay {
+ border: 2px dashed $purple;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ opacity: 1;
+}
+
+.design-dropzone-fade-enter-active,
+.design-dropzone-fade-leave-active {
+ transition: opacity $general-hover-transition-duration $general-hover-transition-curve;
+}
+
+.design-dropzone-fade-enter,
+.design-dropzone-fade-leave-to {
+ opacity: 0;
+}
diff --git a/app/assets/stylesheets/components/design_management/design_list_item.scss b/app/assets/stylesheets/components/design_management/design_list_item.scss
new file mode 100644
index 00000000000..aacb1f91e59
--- /dev/null
+++ b/app/assets/stylesheets/components/design_management/design_list_item.scss
@@ -0,0 +1,19 @@
+.design-list-item {
+ height: 280px;
+ text-decoration: none;
+
+ .icon-version-status {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ }
+
+ .design-event {
+ top: $gl-padding;
+ right: $gl-padding;
+ }
+
+ .card-body {
+ height: 230px;
+ }
+}
diff --git a/app/assets/stylesheets/components/design_management/design_version_dropdown.scss b/app/assets/stylesheets/components/design_management/design_version_dropdown.scss
new file mode 100644
index 00000000000..f79d672e238
--- /dev/null
+++ b/app/assets/stylesheets/components/design_management/design_version_dropdown.scss
@@ -0,0 +1,3 @@
+.design-version-dropdown > button {
+ background: inherit;
+}
diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss
new file mode 100644
index 00000000000..e0637088bbb
--- /dev/null
+++ b/app/assets/stylesheets/components/milestone_combobox.scss
@@ -0,0 +1,13 @@
+.selected-item::before {
+ content: '\f00c';
+ color: $green-500;
+ position: absolute;
+ left: 16px;
+ top: 16px;
+ transform: translateY(-50%);
+ font: 14px FontAwesome;
+}
+
+.dropdown-item-space {
+ padding: 8px 12px;
+}
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index ce1039832d3..61f971a3185 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -1,9 +1,11 @@
$item-path-max-width: 160px;
$item-milestone-max-width: 120px;
$item-weight-max-width: 48px;
+$item-remove-button-space: 42px;
.related-items-list {
padding: $gl-padding-4;
+ padding-right: $gl-padding-6;
&,
.list-item:last-child {
@@ -11,16 +13,16 @@ $item-weight-max-width: 48px;
}
}
-.sortable-link {
- max-width: 85%;
-}
-
.related-items-tree {
.card-header {
.gl-label {
line-height: $gl-line-height;
}
}
+
+ .sortable-link {
+ white-space: normal;
+ }
}
.item-body {
@@ -48,17 +50,12 @@ $item-weight-max-width: 48px;
cursor: help;
}
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- margin-right: $gl-padding-4;
- }
-
.confidential-icon {
color: $orange-600;
}
.item-title-wrapper {
- max-width: 100%;
+ max-width: calc(100% - #{$item-remove-button-space});
}
.item-title {
@@ -69,11 +66,6 @@ $item-weight-max-width: 48px;
font-weight: $gl-font-weight-bold;
}
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- display: none;
- }
-
.sortable-link {
color: $gray-900;
font-weight: normal;
@@ -90,17 +82,14 @@ $item-weight-max-width: 48px;
white-space: nowrap;
}
- @include media-breakpoint-down(lg) {
- .issue-count-badge {
- padding-left: 0;
- }
+ .health-label-short {
+ display: none;
}
}
.item-body,
.card-header {
.health-label-short {
- display: initial;
max-width: 0;
}
@@ -135,6 +124,12 @@ $item-weight-max-width: 48px;
}
}
+.card-header {
+ .health-label-short {
+ display: initial;
+ }
+}
+
.item-meta {
flex-basis: 100%;
font-size: $gl-font-size;
@@ -227,25 +222,28 @@ $item-weight-max-width: 48px;
font-weight: $gl-font-weight-bold;
max-width: $item-path-max-width;
}
-
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- display: block;
- }
}
.btn-item-remove {
position: absolute;
- right: 0;
top: $gl-padding-4 / 2;
+ right: 0;
padding: $gl-padding-4;
margin-right: $gl-padding-4 / 2;
line-height: 0;
border-color: transparent;
color: $gl-text-color-secondary;
+ .related-items-tree & {
+ position: relative;
+ top: initial;
+ padding: $btn-sm-side-margin;
+ margin-right: initial;
+ }
+
&:hover {
color: $gl-text-color;
+ border-color: $border-color;
}
}
@@ -269,7 +267,6 @@ $item-weight-max-width: 48px;
max-width: 90%;
}
- .item-body,
.card-header {
.health-label-short {
max-width: 30px;
@@ -279,6 +276,15 @@ $item-weight-max-width: 48px;
/* 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%;
+ }
+
.sortable-link {
text-overflow: ellipsis;
overflow: hidden;
@@ -290,27 +296,8 @@ $item-weight-max-width: 48px;
.item-contents {
min-width: 0;
}
-
- .item-title {
- flex-basis: unset;
- // 95% because we compensate
- // for remove button which is
- // positioned absolutely
- width: 95%;
- }
-
- .btn-item-remove {
- order: 1;
- }
- }
-
- .item-meta {
- .item-meta-child {
- flex-basis: unset;
- }
}
- .item-body,
.card-header {
.health-label-short {
max-width: 60px;
@@ -330,7 +317,6 @@ $item-weight-max-width: 48px;
}
}
- .item-body,
.card-header {
.health-label-short {
max-width: 100px;
@@ -346,32 +332,13 @@ $item-weight-max-width: 48px;
@include media-breakpoint-up(xl) {
.item-body {
.item-title {
- min-width: 0;
width: auto;
flex-basis: auto;
flex-shrink: 1;
font-weight: $gl-font-weight-normal;
-
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- display: block;
- margin-right: $gl-padding-8;
- }
- }
-
- .item-title-wrapper {
- max-width: calc(100% - 500px);
- }
-
- .item-info-area {
- flex-basis: auto;
}
}
- .health-label-short {
- display: initial;
- }
-
.health-label-long {
display: none;
}
@@ -380,16 +347,7 @@ $item-weight-max-width: 48px;
overflow: hidden;
}
- .item-meta {
- flex: 1;
- }
-
.item-assignees {
- .avatar {
- height: $gl-padding-24;
- width: $gl-padding-24;
- }
-
.avatar-counter {
height: $gl-padding-24;
min-width: $gl-padding-24;
@@ -401,12 +359,8 @@ $item-weight-max-width: 48px;
.btn-item-remove {
position: relative;
top: initial;
- right: 0;
padding: $btn-sm-side-margin;
-
- &:hover {
- border-color: $border-color;
- }
+ margin-right: $gl-padding-4 / 2;
}
.sortable-link {
@@ -415,8 +369,7 @@ $item-weight-max-width: 48px;
}
@media only screen and (min-width: 1500px) {
- .card-header,
- .item-body {
+ .card-header {
.health-label-short {
display: none;
}
@@ -425,10 +378,4 @@ $item-weight-max-width: 48px;
display: initial;
}
}
-
- .item-body {
- .item-title-wrapper {
- max-width: calc(100% - 640px);
- }
- }
}
diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss
new file mode 100644
index 00000000000..eca0f1114af
--- /dev/null
+++ b/app/assets/stylesheets/components/rich_content_editor.scss
@@ -0,0 +1,11 @@
+// Overrides styles from ToastUI editor
+.tui-editor-defaultUI-toolbar .toolbar-button {
+ color: $gl-gray-600;
+ border: 0;
+
+ &:hover,
+ &:active {
+ color: $blue-500;
+ border: 0;
+ }
+}
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index d222fc4aefe..13174687e5d 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -193,7 +193,7 @@ a {
background-size: cover;
background-image: linear-gradient(to right,
$gray-100 0%,
- $gray-50 20%,
+ $gray-10 20%,
$gray-100 40%,
$gray-100 100%);
height: 10px;
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index ecf2097dc87..f47d0cab31f 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -507,6 +507,10 @@
opacity: 1 !important;
cursor: default !important;
+ &.cursor-not-allowed {
+ cursor: not-allowed !important;
+ }
+
i {
color: $gl-text-color-disabled !important;
}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 320bd4adaaa..93361c21642 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -392,6 +392,10 @@ img.emoji {
}
/** COMMON CLASSES **/
+/**
+ 🚨 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; }
@@ -434,6 +438,7 @@ img.emoji {
.append-bottom-20 { margin-bottom: 20px; }
.append-bottom-default { margin-bottom: $gl-padding; }
.prepend-bottom-32 { margin-bottom: 32px; }
+.ml-10 { margin-left: 4.5rem; }
.inline { display: inline-block; }
.center { text-align: center; }
.block { display: block; }
@@ -490,7 +495,8 @@ img.emoji {
🚨 Do not use these classes — they are deprecated and being removed. 🚨
See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details.
- Instead, if you need a spacing class, add it below using the following values.
+ Instead, if you need a spacing class, please use one from Gitlab UI —
+ https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss — which uses the following scale.
$gl-spacing-scale-0: 0;
$gl-spacing-scale-1: 2px;
$gl-spacing-scale-2: 4px;
@@ -505,21 +511,38 @@ img.emoji {
$gl-spacing-scale-11: 64px;
$gl-spacing-scale-12: 80px;
$gl-spacing-scale-13: 96px;
-
- E.g., a padding top of 96px can be added using:
- .gl-shim-pt-13 {
- padding-top: 96px;
- }
-
- Please use -shim- so it can be differentiated from the old scale classes.
- These will be replaced when the Gitlab UI utilities are included.
**/
@each $index, $padding in $spacing-scale {
- #{'.gl-p-#{$index}'} { padding: $padding; }
- #{'.gl-pl-#{$index}'} { padding-left: $padding; }
- #{'.gl-pr-#{$index}'} { padding-right: $padding; }
- #{'.gl-pt-#{$index}'} { padding-top: $padding; }
- #{'.gl-pb-#{$index}'} { padding-bottom: $padding; }
+ #{'.gl-p-#{$index}-deprecated-no-really-do-not-use-me'} { padding: $padding; }
+ #{'.gl-pl-#{$index}-deprecated-no-really-do-not-use-me'} { padding-left: $padding; }
+ #{'.gl-pr-#{$index}-deprecated-no-really-do-not-use-me'} { padding-right: $padding; }
+ #{'.gl-pt-#{$index}-deprecated-no-really-do-not-use-me'} { padding-top: $padding; }
+ #{'.gl-pb-#{$index}-deprecated-no-really-do-not-use-me'} { padding-bottom: $padding; }
+}
+
+/**
+ 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;
}
/**
@@ -610,15 +633,13 @@ img.emoji {
}
}
-.gl-font-size-small { font-size: $gl-font-size-small; }
-.gl-font-size-large { font-size: $gl-font-size-large; }
+.gl-font-sm { font-size: $gl-font-size-small; }
+.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-12 { font-size: $gl-font-size-12; }
-.gl-font-size-14 { font-size: $gl-font-size-14; }
-.gl-font-size-16 { font-size: $gl-font-size-16; }
.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/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index f746d7e6f69..1df9818a877 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -317,13 +317,6 @@
}
}
- // Temporary fix to ensure tick is aligned
- // Follow up Issue to remove after the GlNewDropdownItem component is fixed
- // > https://gitlab.com/gitlab-org/gitlab/-/issues/213948
- li:not(.gl-new-dropdown-item) .dropdown-item {
- @include dropdown-link;
- }
-
.divider {
height: 1px;
margin: #{$grid-size / 2} 0;
@@ -384,6 +377,10 @@
}
}
+.dropdown-item {
+ @include dropdown-link;
+}
+
.droplab-dropdown {
.dropdown-toggle > i {
pointer-events: none;
@@ -1032,6 +1029,16 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
}
.labels-select-wrapper {
+ &.is-standalone {
+ .labels-select-dropdown-contents {
+ max-height: 350px;
+
+ .dropdown-content {
+ height: 250px;
+ }
+ }
+ }
+
.labels-select-dropdown-contents {
min-height: $dropdown-min-height;
max-height: 330px;
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index a0a020ec548..2c7e9428ef1 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -553,6 +553,7 @@
vertical-align: text-top;
}
+ a.upgrade-plan-link gl-emoji,
a.ci-minutes-emoji gl-emoji,
a.trial-link gl-emoji {
font-size: $gl-font-size;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 79f203091f2..bd262b65dc3 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -146,11 +146,13 @@
display: inline-block;
position: relative;
- /* Medium devices (desktops, 992px and up) */
- @include media-breakpoint-up(md) { width: 200px; }
+ &:not[type='checkbox'] {
+ /* Medium devices (desktops, 992px and up) */
+ @include media-breakpoint-up(md) { width: 200px; }
- /* Large devices (large desktops, 1200px and up) */
- @include media-breakpoint-up(lg) { width: 250px; }
+ /* Large devices (large desktops, 1200px and up) */
+ @include media-breakpoint-up(lg) { width: 250px; }
+ }
}
@include media-breakpoint-down(sm) {
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 514bd090e28..5739f048e86 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -4,6 +4,21 @@
}
table {
+ /*
+ * TODO
+ * This is a temporary workaround until we fix the neutral
+ * color palette in https://gitlab.com/gitlab-org/gitlab/-/issues/213570
+ *
+ * The overwrites here affected the security dashboard tables, when removing
+ * this code, table-th-transparent and original-text-color classes should
+ * be removed there.
+ *
+ * Remove this code as soon as this happens
+ */
+ &.gl-table {
+ @include gl-text-gray-700;
+ }
+
&.table {
margin-bottom: $gl-padding;
@@ -32,8 +47,7 @@ table {
}
th {
- background-color: $gray-light;
- font-weight: $gl-font-weight-normal;
+ @include gl-bg-gray-100;
border-bottom: 0;
&.wide {
@@ -44,6 +58,11 @@ table {
background: none;
color: $gl-text-color-secondary;
}
+
+ &.original-gl-th {
+ @include gl-text-gray-700;
+ border-bottom: 1px solid $cycle-analytics-light-gray;
+ }
}
td {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 816dbc6931c..1afcbc6d514 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -86,7 +86,7 @@
line-height: 10px;
color: $gl-gray-700;
vertical-align: middle;
- background-color: $gray-50;
+ background-color: $gray-10;
border-width: 1px;
border-style: solid;
border-color: $gray-200 $gray-200 $gray-400;
@@ -533,6 +533,17 @@
margin: 0;
font-size: $gl-font-size-small;
}
+
+ ul.dropdown-menu {
+ margin-top: 4px;
+ margin-bottom: 24px;
+ padding: 8px 0;
+
+ li {
+ margin: 0;
+ padding: 0 1px;
+ }
+ }
}
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index c23623005b0..ac4d431ea57 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -163,7 +163,8 @@ $red-800: #8b2615;
$red-900: #711e11;
$red-950: #4b140b;
-$gray-50: #fafafa;
+$gray-10: #fafafa;
+$gray-50: #f0f0f0;
$gray-100: #f2f2f2;
$gray-200: #dfdfdf;
$gray-300: #ccc;
@@ -232,6 +233,7 @@ $reds: (
);
$grays: (
+ '10': $gray-10,
'50': $gray-50,
'100': $gray-100,
'200': $gray-200,
@@ -398,6 +400,7 @@ $tooltip-font-size: 12px;
* Padding
*/
$gl-padding-4: 4px;
+$gl-padding-6: 6px;
$gl-padding-8: 8px;
$gl-padding-12: 12px;
$gl-padding: 16px;
@@ -447,6 +450,7 @@ $breadcrumb-min-height: 48px;
$home-panel-title-row-height: 64px;
$home-panel-avatar-mobile-size: 24px;
$gl-line-height: 16px;
+$gl-line-height-18: 18px;
$gl-line-height-20: 20px;
$gl-line-height-24: 24px;
$gl-line-height-14: 14px;
@@ -697,7 +701,7 @@ $logs-p-color: #333;
*/
$input-height: 34px;
$input-danger-bg: #f2dede;
-$input-group-addon-bg: $gray-50;
+$input-group-addon-bg: $gray-10;
$gl-field-focus-shadow: rgba(0, 0, 0, 0.075);
$gl-field-focus-shadow-error: rgba($red-500, 0.6);
$input-short-width: 200px;
diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss
index 9465dd5bed6..48b8a7230b1 100644
--- a/app/assets/stylesheets/page_bundles/_ide_mixins.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss
@@ -9,7 +9,6 @@
top: 0;
font-size: 12px;
border-top-right-radius: $border-radius-default;
- margin-left: -$gl-padding;
.controllers {
@include build-controllers(15px, center, false, 0, inline, 0);
diff --git a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
index 1aa112e0957..5675835a622 100644
--- a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
@@ -69,8 +69,17 @@
display: none !important;
}
}
+}
+
+.multi-file-editor-holder {
+ height: 100%;
+ min-height: 0; // firefox fix
+}
- .monaco-diff-editor.vs {
+// Apply theme related overrides only to the white theme and none theme
+.theme-white .blob-editor-container,
+.theme-none .blob-editor-container {
+ .monaco-diff-editor {
.editor.modified {
box-shadow: none;
}
@@ -131,16 +140,14 @@
}
}
-.multi-file-editor-holder {
- height: 100%;
- min-height: 0; // firefox fix
-
- &.is-readonly .vs,
- .vs .editor.original {
+.theme-white .multi-file-editor-holder,
+.theme-none .multi-file-editor-holder {
+ &.is-readonly,
+ .editor.original {
.monaco-editor,
.monaco-editor-background,
.monaco-editor .inputarea.ime-input {
- background-color: $gray-50;
+ background-color: $gray-10;
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
new file mode 100644
index 00000000000..e4c01c2bd6c
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -0,0 +1,308 @@
+// -------
+// 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);
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6,
+ code,
+ .md table:not(.code),
+ .md,
+ .md p,
+ .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);
+ }
+
+ .drag-handle:hover,
+ .card-header .badge.badge-pill {
+ background-color: var(--ide-dropdown-hover-background);
+ }
+
+ .file-row .file-row-icon svg,
+ .file-row:hover .file-row-icon svg,
+ .controllers-buttons svg {
+ color: var(--ide-text-color-secondary);
+ }
+
+ .text-secondary {
+ color: var(--ide-text-color-secondary) !important;
+ }
+
+ input[type='search']::placeholder,
+ input[type='text']::placeholder,
+ textarea::placeholder,
+ .dropdown-input .fa {
+ color: var(--ide-input-border);
+ }
+
+ .ide-nav-form .input-icon {
+ color: var(--ide-input-border);
+ }
+
+ code,
+ .badge.badge-pill,
+ .card-header,
+ .bs-callout,
+ .ide-pipeline .top-bar,
+ .ide-terminal .top-bar {
+ background-color: var(--ide-background);
+ }
+
+ .bs-callout {
+ border-color: var(--ide-dropdown-background);
+
+ code {
+ background-color: var(--ide-dropdown-background);
+ }
+ }
+
+ .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);
+ }
+
+ &,
+ .md table:not(.code) tr th,
+ .common-note-form .md-area,
+ .card {
+ background-color: var(--ide-highlight-background);
+ }
+
+ .card,
+ .card-header,
+ .ide-terminal .top-bar,
+ .ide-pipeline .top-bar {
+ border-color: var(--ide-border-color);
+ }
+
+ 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);
+ }
+
+ .ide-sidebar-link.active {
+ color: var(--ide-highlight-accent);
+ box-shadow: inset 3px 0 var(--ide-highlight-accent);
+
+ &.is-right {
+ box-shadow: inset -3px 0 var(--ide-highlight-accent);
+ }
+ }
+
+ .nav-links li.active a,
+ .nav-links li a.active {
+ border-color: var(--ide-highlight-accent);
+ color: var(--ide-text-color);
+ }
+
+ .avatar-container {
+ &,
+ .avatar {
+ color: var(--ide-text-color);
+ background-color: var(--ide-highlight-background);
+ border-color: var(--ide-highlight-background);
+ }
+ }
+
+ input[type='text'],
+ input[type='search'],
+ .filtered-search-box {
+ border-color: var(--ide-input-border);
+ background: var(--ide-input-background) !important;
+ }
+
+ input[type='text'],
+ input[type='search'],
+ .filtered-search-box,
+ textarea {
+ color: var(--ide-input-color) !important;
+ }
+
+ .filtered-search-box input[type='search'] {
+ border-color: transparent;
+ }
+
+ .filtered-search-token .value-container,
+ .filtered-search-term .value-container {
+ background-color: var(--ide-dropdown-hover-background);
+ color: var(--ide-text-color);
+
+ &:hover {
+ background-color: var(--ide-input-border);
+ }
+ }
+
+ @function calc-btn-hover-padding($original-padding, $original-border: 1px) {
+ @return calc(#{$original-padding + $original-border} - var(--ide-btn-hover-border-width));
+ }
+
+ .btn:not(.btn-link):not([disabled]):hover {
+ border-width: var(--ide-btn-hover-border-width);
+ padding: calc-btn-hover-padding(6px) calc-btn-hover-padding(10px);
+ }
+
+ .btn:not([disabled]).btn-sm:hover {
+ padding: calc-btn-hover-padding(4px) calc-btn-hover-padding(10px);
+ }
+
+ .btn:not([disabled]).btn-block:hover {
+ 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);
+ }
+
+ .btn-inverted,
+ .btn-default {
+ &:hover,
+ &:focus {
+ border-color: var(--ide-btn-default-hover-border) !important;
+ }
+ }
+
+ .dropdown,
+ .dropdown-menu-toggle {
+ &:hover,
+ &:focus {
+ background-color: var(--ide-dropdown-btn-hover-background) !important;
+ border-color: var(--ide-dropdown-btn-hover-border) !important;
+ }
+ }
+
+ .dropdown-menu {
+ color: var(--ide-text-color);
+ border-color: var(--ide-background);
+ background-color: var(--ide-dropdown-background);
+
+ .divider,
+ .nav-links:not(.quick-links) {
+ background-color: var(--ide-dropdown-hover-background);
+ border-color: var(--ide-dropdown-hover-background);
+ }
+
+ .nav-links li a.active {
+ border-color: var(--ide-highlight-accent);
+ }
+
+ .nav-links:not(.quick-links) li:not(.md-header-toolbar) a {
+ color: var(--ide-text-color);
+
+ &.active {
+ color: var(--ide-text-color);
+ }
+ }
+
+ li > a:not(.disable-hover):hover,
+ li > a:not(.disable-hover):focus,
+ 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);
+ }
+ }
+
+ .dropdown-title,
+ .dropdown-input {
+ border-color: var(--ide-dropdown-hover-background) !important;
+ }
+
+ .btn-primary,
+ .btn-info {
+ background-color: var(--ide-btn-primary-background);
+ border-color: var(--ide-btn-primary-border) !important;
+
+ &:hover,
+ &:focus {
+ border-color: var(--ide-btn-primary-hover-border) !important;
+ }
+ }
+
+ .btn-success {
+ background-color: var(--ide-btn-success-background);
+ border-color: var(--ide-btn-success-border) !important;
+
+ &:hover,
+ &:focus {
+ border-color: var(--ide-btn-success-hover-border) !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;
+ }
+
+ pre code,
+ .md table:not(.code) tbody {
+ background-color: var(--ide-border-color);
+ }
+
+ .animation-container {
+ [class^='skeleton-line-'] {
+ background-color: var(--ide-animation-gradient-1);
+
+ &::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%);
+ }
+ }
+ }
+
+ .idiff.addition {
+ background-color: var(--ide-diff-insert);
+ }
+
+ .idiff.deletion {
+ background-color: var(--ide-diff-remove);
+ }
+}
+
+.navbar.theme-dark {
+ border-bottom-color: transparent;
+}
+
+.theme-dark ~ .popover {
+ box-shadow: none;
+}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 024c1781bf8..61914740ac0 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -2,6 +2,9 @@
@import 'framework/mixins';
@import './ide_mixins';
@import './ide_monaco_overrides';
+@import './ide_theme_overrides';
+
+@import './ide_themes/dark';
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
@@ -25,7 +28,7 @@ $ide-commit-header-height: 48px;
position: relative;
margin-top: 0;
padding-bottom: $ide-statusbar-height;
- color: $gl-text-color;
+ color: var(--ide-text-color, $gl-text-color);
min-height: 0; // firefox fix
&.is-collapsed {
@@ -61,14 +64,14 @@ $ide-commit-header-height: 48px;
display: flex;
flex-direction: column;
flex: 1;
- border-left: 1px solid $white-dark;
+ border-left: 1px solid var(--ide-border-color, $white-dark);
overflow: hidden;
}
.multi-file-tabs {
display: flex;
- background-color: $gray-light;
- box-shadow: inset 0 -1px $white-dark;
+ background-color: var(--ide-background, $gray-light);
+ box-shadow: inset 0 -1px var(--ide-border-color, $white-dark);
> ul {
display: flex;
@@ -79,13 +82,13 @@ $ide-commit-header-height: 48px;
display: flex;
align-items: center;
padding: $grid-size $gl-padding;
- background-color: $gray-normal;
- border-right: 1px solid $white-dark;
- border-bottom: 1px solid $white-dark;
+ background-color: var(--ide-background-hover, $gray-normal);
+ border-right: 1px solid var(--ide-border-color, $white-dark);
+ border-bottom: 1px solid var(--ide-border-color, $white-dark);
&.active {
- background-color: $white;
- border-bottom-color: $white;
+ background-color: var(--ide-highlight-background, $white);
+ border-bottom-color: var(--ide-border-color, $white);
}
&:not(.disabled) {
@@ -118,7 +121,7 @@ $ide-commit-header-height: 48px;
background: none;
border: 0;
border-radius: $border-radius-default;
- color: $gray-900;
+ color: var(--ide-text-color, $gray-900);
svg {
position: relative;
@@ -133,11 +136,11 @@ $ide-commit-header-height: 48px;
}
&:not([disabled]):hover {
- background-color: $gray-200;
+ background-color: var(--ide-input-border, $gray-200);
}
&:not([disabled]):focus {
- background-color: $blue-500;
+ background-color: var(--ide-link-color, $blue-500);
color: $white;
outline: 0;
@@ -164,10 +167,11 @@ $ide-commit-header-height: 48px;
height: 100%;
overflow: auto;
padding: $gl-padding;
+ background-color: var(--ide-border-color, transparent);
}
.file-container {
- background-color: $gray-darker;
+ background-color: var(--ide-border-color, $gray-darker);
display: flex;
height: 100%;
align-items: center;
@@ -183,13 +187,13 @@ $ide-commit-header-height: 48px;
.file-info {
font-size: $label-font-size;
- color: $diff-image-info-color;
+ color: var(--ide-text-color, $diff-image-info-color);
}
}
}
.ide-mode-tabs {
- border-bottom: 1px solid $white-dark;
+ border-bottom: 1px solid var(--ide-border-color, $white-dark);
li a {
padding: $gl-padding-8 $gl-padding;
@@ -203,9 +207,10 @@ $ide-commit-header-height: 48px;
}
.ide-status-bar {
- border-top: 1px solid $white-dark;
+ color: var(--ide-text-color, $gl-text-color);
+ border-top: 1px solid var(--ide-border-color, $white-dark);
padding: 2px $gl-padding-8 0;
- background: $white;
+ background-color: var(--ide-footer-background, $white);
display: flex;
justify-content: space-between;
height: $ide-statusbar-height;
@@ -278,8 +283,7 @@ $ide-commit-header-height: 48px;
position: relative;
width: 340px;
padding: 0;
- background-color: $gray-light;
- padding-right: 1px;
+ background-color: var(--ide-background, $gray-light);
.context-header {
width: auto;
@@ -306,9 +310,9 @@ $ide-commit-header-height: 48px;
display: flex;
flex: 1;
flex-direction: column;
- background-color: $white;
- border-left: 1px solid $white-dark;
- border-top: 1px solid $white-dark;
+ background-color: var(--ide-highlight-background, $white);
+ border-left: 1px solid var(--ide-border-color, $white-dark);
+ border-top: 1px solid var(--ide-border-color, $white-dark);
border-top-left-radius: $border-radius-small;
min-height: 0; // firefox fix
}
@@ -333,15 +337,10 @@ $ide-commit-header-height: 48px;
.multi-file-commit-panel-header {
height: $ide-commit-header-height;
- border-bottom: 1px solid $white-dark;
+ border-bottom: 1px solid var(--ide-border-color-alt, $white-dark);
padding: 12px 0;
}
-.multi-file-commit-panel-collapse-btn {
- border-left: 1px solid $white-dark;
- margin-left: auto;
-}
-
.multi-file-commit-list {
flex: 1;
overflow: auto;
@@ -363,7 +362,7 @@ $ide-commit-header-height: 48px;
display: block;
margin-left: auto;
margin-right: auto;
- color: $gray-700;
+ color: var(--ide-text-color-secondary, $gray-700);
}
.file-status-icon {
@@ -387,17 +386,17 @@ $ide-commit-header-height: 48px;
&:hover,
&:focus {
- background: $gray-100;
+ background: var(--ide-background, $gray-100);
outline: 0;
}
&:active {
- background: $gray-200;
+ background: var(--ide-background, $gray-200);
}
&.is-active {
- background-color: $white-normal;
+ background-color: var(--ide-background, $white-normal);
}
svg {
@@ -418,8 +417,8 @@ $ide-commit-header-height: 48px;
.multi-file-commit-form {
position: relative;
- background-color: $white;
- border-left: 1px solid $white-dark;
+ background-color: var(--ide-highlight-background, $white);
+ border-left: 1px solid var(--ide-border-color, $white-dark);
transition: all 0.3s ease;
> form,
@@ -427,7 +426,7 @@ $ide-commit-header-height: 48px;
padding: $gl-padding 0;
margin-left: $gl-padding;
margin-right: $gl-padding;
- border-top: 1px solid $white-dark;
+ border-top: 1px solid var(--ide-border-color-alt, $white-dark);
}
.btn {
@@ -488,6 +487,7 @@ $ide-commit-header-height: 48px;
height: 100vh;
align-items: center;
justify-content: center;
+ background-color: var(--ide-border-color, transparent);
}
.ide {
@@ -504,7 +504,7 @@ $ide-commit-header-height: 48px;
margin-right: $gl-padding;
&.is-first {
- border-bottom: 1px solid $white-dark;
+ border-bottom: 1px solid var(--ide-border-color-alt, $white-dark);
}
}
@@ -512,12 +512,7 @@ $ide-commit-header-height: 48px;
width: $ide-commit-row-height;
height: $ide-commit-row-height;
color: inherit;
-}
-
-.ide-commit-file-count {
- min-width: 22px;
- background-color: $gray-light;
- border: 1px solid $white-dark;
+ background-color: var(--ide-background, $white-normal);
}
.ide-commit-options {
@@ -549,7 +544,7 @@ $ide-commit-header-height: 48px;
height: 60px;
width: 100%;
padding: 0 $gl-padding;
- color: $gl-text-color-secondary;
+ color: var(--ide-text-color-secondary, $gl-text-color-secondary);
background-color: transparent;
border: 0;
border-top: 1px solid transparent;
@@ -562,22 +557,22 @@ $ide-commit-header-height: 48px;
}
&:hover {
- color: $gl-text-color;
- background-color: $gray-100;
+ color: var(--ide-text-color, $gl-text-color);
+ background-color: var(--ide-background-hover, $gray-100);
}
&:focus {
- color: $gl-text-color;
- background-color: $gray-200;
+ color: var(--ide-text-color, $gl-text-color);
+ background-color: var(--ide-background-hover, $gray-200);
}
&.active {
// extend width over border of sidebar section
width: calc(100% + 1px);
padding-right: $gl-padding + 1px;
- background-color: $white;
- border-top-color: $white-dark;
- border-bottom-color: $white-dark;
+ background-color: var(--ide-highlight-background, $white);
+ border-top-color: var(--ide-border-color, $white-dark);
+ border-bottom-color: var(--ide-border-color, $white-dark);
&::after {
content: '';
@@ -586,7 +581,7 @@ $ide-commit-header-height: 48px;
top: 0;
bottom: 0;
width: 1px;
- background: $white;
+ background: var(--ide-highlight-background, $white);
}
&.is-right {
@@ -609,7 +604,7 @@ $ide-commit-header-height: 48px;
.ide-commit-message-field {
height: 200px;
- background-color: $white;
+ background-color: var(--ide-highlight-background, $white);
.md-area {
display: flex;
@@ -623,7 +618,7 @@ $ide-commit-header-height: 48px;
.form-text.text-muted {
margin-top: 2px;
- color: $blue-500;
+ color: var(--ide-link-color, $blue-500);
cursor: pointer;
}
}
@@ -686,14 +681,14 @@ $ide-commit-header-height: 48px;
padding: 12px 0;
margin-left: $ide-tree-padding;
margin-right: $ide-tree-padding;
- border-bottom: 1px solid $white-dark;
+ border-bottom: 1px solid var(--ide-border-color-alt, $white-dark);
svg {
- color: $gray-700;
+ color: var(--ide-text-color-secondary, $gray-700);
&:focus,
&:hover {
- color: $blue-600;
+ color: var(--ide-link-color, $blue-600);
}
}
@@ -702,7 +697,7 @@ $ide-commit-header-height: 48px;
}
button {
- color: $gl-text-color;
+ color: var(--ide-text-color, $gl-text-color);
}
}
@@ -718,21 +713,21 @@ $ide-commit-header-height: 48px;
.dropdown-menu-toggle {
svg {
vertical-align: middle;
- color: $gray-700;
+ &,
&:hover {
- color: $gray-700;
+ color: var(--ide-text-color-secondary, $gray-700);
}
}
&:hover {
- background-color: $white-normal;
+ background-color: var(--ide-dropdown-btn-hover-background, $white-normal);
}
}
&.show {
.dropdown-menu-toggle {
- background-color: $white-dark;
+ background-color: var(--ide-input-background, $white-dark);
}
}
}
@@ -798,12 +793,12 @@ $ide-commit-header-height: 48px;
}
a {
- color: $blue-600;
+ color: var(--ide-link-color, $blue-600);
}
}
.ide-review-sub-header {
- color: $gl-text-color-secondary;
+ color: var(--ide-text-color-secondary, $gl-text-color-secondary);
}
.ide-tree-changes {
@@ -819,7 +814,7 @@ $ide-commit-header-height: 48px;
bottom: 0;
right: 0;
z-index: 10;
- background: $white;
+ background-color: var(--ide-highlight-background, $white);
overflow: auto;
display: flex;
flex-direction: column;
@@ -883,14 +878,18 @@ $ide-commit-header-height: 48px;
.ide-right-sidebar {
.ide-activity-bar {
- border-left: 1px solid $white-dark;
+ border-left: 1px solid var(--ide-border-color, $white-dark);
}
.multi-file-commit-panel-inner {
width: 350px;
- padding: $grid-size $gl-padding;
- background-color: $white;
- border-left: 1px solid $white-dark;
+ padding: $grid-size 0;
+ background-color: var(--ide-highlight-background, $white);
+ border-left: 1px solid var(--ide-border-color, $white-dark);
+ }
+
+ .ide-right-sidebar-jobs-detail {
+ padding-bottom: 0;
}
.ide-right-sidebar-clientside {
@@ -901,6 +900,10 @@ $ide-commit-header-height: 48px;
.ide-pipeline {
@include ide-trace-view();
+ svg {
+ --svg-status-bg: var(--ide-background, $white);
+ }
+
.empty-state {
p {
margin: $grid-size 0;
@@ -913,15 +916,12 @@ $ide-commit-header-height: 48px;
margin: 0;
}
}
-
- .build-trace {
- margin-left: -$gl-padding;
- }
}
.ide-pipeline-list {
flex: 1;
overflow: auto;
+ padding: 0 $gl-padding;
}
.ide-pipeline-header {
@@ -935,7 +935,7 @@ $ide-commit-header-height: 48px;
padding: 16px;
&:not(:last-child) {
- border-bottom: 1px solid $border-color;
+ border-bottom: 1px solid var(--ide-border-color, $border-color);
}
.ci-status-icon {
@@ -964,6 +964,7 @@ $ide-commit-header-height: 48px;
.ide-job-header {
min-height: 60px;
+ padding: 0 $gl-padding;
}
.ide-nav-form {
@@ -976,7 +977,7 @@ $ide-commit-header-height: 48px;
text-align: center;
&:not(.active) {
- background-color: $gray-light;
+ background-color: var(--ide-dropdown-background, $gray-light);
}
}
}
@@ -1025,7 +1026,7 @@ $ide-commit-header-height: 48px;
.ide-merge-request-project-path {
font-size: 12px;
line-height: 16px;
- color: $gl-text-color-secondary;
+ color: var(--ide-text-color-secondary, $gl-text-color-secondary);
}
.ide-merge-request-info {
@@ -1041,17 +1042,17 @@ $ide-commit-header-height: 48px;
.ide-entry-dropdown-toggle {
padding: $gl-padding-4;
- color: $gl-text-color;
- background-color: $gray-100;
+ color: var(--ide-text-color, $gl-text-color);
+ background-color: var(--ide-background, $gray-100);
&:hover {
- background-color: $gray-200;
+ background-color: var(--ide-file-row-btn-hover-background, $gray-200);
}
&:active,
&:focus {
color: $white-normal;
- background-color: $blue-500;
+ background-color: var(--ide-link-color, $blue-500);
outline: 0;
}
}
@@ -1065,14 +1066,14 @@ $ide-commit-header-height: 48px;
.dropdown.show .ide-entry-dropdown-toggle {
color: $white-normal;
- background-color: $blue-500;
+ background-color: var(--ide-link-color, $blue-500);
}
}
.ide-preview-header {
padding: 0 $grid-size;
- border-bottom: 1px solid $white-dark;
- background-color: $gray-light;
+ border-bottom: 1px solid var(--ide-border-color-alt, $white-dark);
+ background-color: var(--ide-highlight-background, $gray-light);
min-height: 44px;
}
@@ -1082,7 +1083,7 @@ $ide-commit-header-height: 48px;
max-width: 24px;
padding: 0;
margin: 0 ($grid-size / 2);
- color: $gl-gray-light;
+ color: var(--ide-text-color-secondary, $gl-gray-light);
&:first-child {
margin-left: 0;
@@ -1096,7 +1097,7 @@ $ide-commit-header-height: 48px;
&:focus {
outline: 0;
box-shadow: none;
- border-color: $gray-200;
+ border-color: var(--ide-border-color, $gray-200);
}
}
@@ -1108,8 +1109,8 @@ $ide-commit-header-height: 48px;
.ide-file-templates {
padding: $grid-size $gl-padding;
- background-color: $gray-light;
- border-bottom: 1px solid $white-dark;
+ background-color: var(--ide-background, $gray-light);
+ border-bottom: 1px solid var(--ide-border-color, $white-dark);
.dropdown {
min-width: 180px;
@@ -1123,8 +1124,8 @@ $ide-commit-header-height: 48px;
.ide-commit-editor-header {
height: 65px;
padding: 8px 16px;
- background-color: $gray-50;
- box-shadow: inset 0 -1px $white-dark;
+ background-color: var(--ide-background, $gray-10);
+ box-shadow: inset 0 -1px var(--ide-border-color, $white-dark);
}
.ide-commit-list-changed-icon {
@@ -1135,16 +1136,26 @@ $ide-commit-header-height: 48px;
.ide-file-icon-holder {
display: flex;
align-items: center;
- color: $gray-700;
+ color: var(--ide-text-color-secondary, $gray-700);
+}
+
+.file-row:active {
+ background: var(--ide-background, $gray-200);
+}
+
+.file-row.is-active {
+ background: var(--ide-background, $gray-100);
}
.file-row:hover,
.file-row:focus {
+ background: var(--ide-background, $gray-100);
+
.ide-new-btn {
display: block;
}
.folder-icon {
- fill: $gl-text-color-secondary;
+ fill: var(--ide-text-color-secondary, $gl-text-color-secondary);
}
}
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/README.md b/app/assets/stylesheets/page_bundles/ide_themes/README.md
new file mode 100644
index 00000000000..535179cc4c2
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/ide_themes/README.md
@@ -0,0 +1,53 @@
+# Web IDE Themes
+
+Web IDE currently supports 5 syntax highlighting themes based on themes from the user's profile preferences:
+
+* White
+* Dark
+* Monokai
+* Solarized Dark
+* Solarized Light
+
+Currently, the Web IDE supports the white theme by default, and the dark theme by the introduction of CSS
+variables.
+
+The Web IDE automatically adds an appropriate theme class to the `ide.vue` component based on the current syntax
+highlighting theme. Below are those theme classes, which come from the `gon.user_color_scheme` global setting:
+
+| # | Color Scheme | `gon.user_color_scheme` | Theme class |
+|---|-----------------|-------------------------|-------------------------|
+| 1 | White | `"white"` | `.theme-white` |
+| 2 | Dark | `"dark"` | `.theme-dark` |
+| 3 | Monokai | `"monokai"` | `.theme-monokai` |
+| 4 | Solarized Dark | `"solarized-dark"` | `.theme-solarized-dark` |
+| 5 | Solarized Light | `"solarized-light"` | `.theme-solarized-light` |
+| 6 | None | `"none"` | `.theme-none` |
+
+## Adding New Themes (SCSS)
+
+To add a new theme, follow the following steps:
+
+1. Pick a theme from the table above, lets say **Solarized Dark**.
+2. Create a new file in this folder called `_solarized_dark.scss`.
+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.
+
+## Modifying Monaco Themes
+
+Monaco themes are defined in Javascript and are stored in the `app/assets/javascripts/ide/lib/themes/` directory.
+To modify any syntax highlighting colors or to synchronize the theme colors with syntax highlighting colors, you
+can modify the files in that directory directly.
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
new file mode 100644
index 00000000000..37e6be9849b
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_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-dark {
+ --ide-border-color: #1d1f21;
+ --ide-border-color-alt: #333;
+ --ide-highlight-accent: #fff;
+ --ide-text-color: #ccc;
+ --ide-text-color-secondary: #ccc;
+ --ide-background: #333;
+ --ide-background-hover: #2d2d2d;
+ --ide-highlight-background: #252526;
+ --ide-link-color: #428fdc;
+ --ide-footer-background: #060606;
+
+ --ide-input-border: #868686;
+ --ide-input-background: transparent;
+ --ide-input-color: #fff;
+
+ --ide-btn-default-background: transparent;
+ --ide-btn-default-border: #bfbfbf;
+ --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: #404040;
+ --ide-dropdown-hover-background: #525252;
+
+ --ide-dropdown-btn-hover-border: #{$gray-200};
+ --ide-dropdown-btn-hover-background: #{$gray-900};
+
+ --ide-file-row-btn-hover-background: #{$gray-800};
+
+ --ide-diff-insert: rgba(155, 185, 85, 0.2);
+ --ide-diff-remove: rgba(255, 0, 0, 0.2);
+
+ --ide-animation-gradient-1: #{$gray-800};
+ --ide-animation-gradient-2: #{$gray-700};
+}
diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss
new file mode 100644
index 00000000000..89219e41644
--- /dev/null
+++ b/app/assets/stylesheets/pages/alert_management/details.scss
@@ -0,0 +1,42 @@
+.alert-management-details {
+ // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui
+ table {
+ tr {
+ td {
+ @include gl-border-0;
+ @include gl-p-5;
+ border-color: transparent;
+ border-bottom: 1px solid $table-border-color;
+
+ &:first-child {
+ div {
+ font-weight: bold;
+ }
+ }
+
+ &:not(:first-child) {
+ &::before {
+ color: $gray-700;
+ font-weight: normal !important;
+ }
+
+ div {
+ color: $gray-700;
+ }
+ }
+
+ @include media-breakpoint-up(sm) {
+ div {
+ text-align: left !important;
+ }
+ }
+ }
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .alert-details-create-issue-button {
+ width: 100%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/alert_management/list.scss b/app/assets/stylesheets/pages/alert_management/list.scss
new file mode 100644
index 00000000000..dc181342def
--- /dev/null
+++ b/app/assets/stylesheets/pages/alert_management/list.scss
@@ -0,0 +1,83 @@
+.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;
+
+ tr {
+ &:focus {
+ outline: none;
+ }
+
+ 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;
+ }
+ }
+
+ th {
+ background-color: transparent;
+ font-weight: $gl-font-weight-bold;
+ color: $gl-gray-600;
+ }
+
+ &:last-child {
+ td {
+ @include gl-border-0;
+ }
+ }
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ .alert-management-table {
+ .table-col {
+ min-height: 68px;
+
+ &:last-child {
+ background-color: $gray-10;
+
+ &::before {
+ content: none !important;
+ }
+
+ div {
+ width: 100% !important;
+ padding: 0 !important;
+ }
+ }
+ }
+ }
+ }
+
+ .gl-tab-nav-item {
+ color: $gl-gray-600;
+
+ > .gl-tab-counter-badge {
+ color: inherit;
+ @include gl-font-sm;
+ background-color: $white-normal;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/alert_management/severity-icons.scss b/app/assets/stylesheets/pages/alert_management/severity-icons.scss
new file mode 100644
index 00000000000..b400e80d5c5
--- /dev/null
+++ b/app/assets/stylesheets/pages/alert_management/severity-icons.scss
@@ -0,0 +1,26 @@
+.alert-management-list,
+.alert-management-details {
+ .icon-critical {
+ color: $red-800;
+ }
+
+ .icon-high {
+ color: $red-600;
+ }
+
+ .icon-medium {
+ color: $orange-400;
+ }
+
+ .icon-low {
+ color: $orange-300;
+ }
+
+ .icon-info {
+ color: $blue-400;
+ }
+
+ .icon-unknown {
+ color: $gray-400;
+ }
+}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 11291dad28b..d755170fe1f 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -548,3 +548,27 @@
*/
height: $input-height;
}
+
+.issue-boards-content.is-focused {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ background: $white;
+ z-index: 9000;
+
+ @include media-breakpoint-down(sm) {
+ padding-top: 10px;
+ }
+
+ .boards-list {
+ height: calc(100vh - #{$issue-boards-filter-height});
+ overflow-x: scroll;
+ }
+
+ .issue-boards-sidebar {
+ height: 100%;
+ top: 0;
+ }
+}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 230f390aa90..9a69afc6044 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -208,6 +208,18 @@
}
}
+.commit-nav-buttons {
+ a.btn,
+ button {
+ // See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/730
+ &:last-child > svg {
+ margin-left: 0.25rem;
+ margin-right: 0;
+ }
+ }
+}
+
+
.clipboard-group,
.commit-sha-group {
display: inline-flex;
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 0292919ea50..b97709e140f 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -187,7 +187,6 @@
.stage-events {
width: 60%;
- overflow: scroll;
min-height: 467px;
}
diff --git a/app/assets/stylesheets/pages/error_list.scss b/app/assets/stylesheets/pages/error_list.scss
index a61a85649b8..3ec3e4f6b43 100644
--- a/app/assets/stylesheets/pages/error_list.scss
+++ b/app/assets/stylesheets/pages/error_list.scss
@@ -17,7 +17,7 @@
min-height: 68px;
&:last-child {
- background-color: $gray-normal;
+ background-color: $gray-10;
&::before {
content: none !important;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 85fdcb753b4..b241d0a2bdc 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -167,10 +167,6 @@
}
}
- a.gl-label-icon {
- color: $gray-500;
- }
-
.gl-label .gl-label-link:hover {
text-decoration: none;
color: inherit;
@@ -180,11 +176,6 @@
}
}
- .gl-label .gl-label-icon:hover {
- text-decoration: none;
- color: $gray-500;
- }
-
.btn-link {
color: inherit;
}
@@ -826,10 +817,6 @@
}
}
}
-
- .gl-label-icon {
- color: $gray-500;
- }
}
@media(max-width: map-get($grid-breakpoints, lg)-1) {
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index e74b420f9e9..0dd25ec5360 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -303,3 +303,13 @@ ul.related-merge-requests > li {
}
}
}
+
+.issuable-list-root {
+ .gl-label-link {
+ text-decoration: none;
+
+ &:hover {
+ color: inherit;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 7f6542261b8..22c1cb127cd 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -307,7 +307,7 @@
}
.label-name {
- width: 150px;
+ width: 200px;
flex-shrink: 0;
.scoped-label-wrapper,
@@ -460,8 +460,7 @@
// Label inside title of Delete Label Modal
.modal-header .page-title {
.scoped-label-wrapper {
- .scoped-label,
- .gl-label-icon {
+ .scoped-label {
line-height: 20px;
}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index fa10ab022dc..c473cc44637 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -68,10 +68,6 @@ $status-box-line-height: 26px;
.gl-label-link {
color: inherit;
}
-
- .gl-label-icon {
- color: $gray-500;
- }
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 8b51ba7ae62..bed147aa3a7 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -588,7 +588,8 @@ $note-form-margin-left: 72px;
a {
color: inherit;
- &:hover {
+ &:hover,
+ &.hover {
color: $blue-600;
}
@@ -605,6 +606,21 @@ $note-form-margin-left: 72px;
.author-link {
color: $gl-text-color;
}
+
+ // Prevent flickering of link when hovering between `author-name-link` and `.author-username-link`
+ .author-name-link + .author-username .author-username-link {
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ right: 100%;
+ width: 0.25rem;
+ height: 100%;
+ top: 0;
+ bottom: 0;
+ }
+ }
}
.discussion-header {
@@ -672,8 +688,7 @@ $note-form-margin-left: 72px;
text-decoration: underline;
}
- .gl-label-link:hover,
- .gl-label-icon:hover {
+ .gl-label-link:hover {
text-decoration: none;
color: inherit;
@@ -892,11 +907,10 @@ $note-form-margin-left: 72px;
border-right: 0;
.line-resolve-btn {
- margin-right: 5px;
color: $gray-700;
svg {
- vertical-align: middle;
+ vertical-align: text-top;
}
}
diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss
index 85c4902eee2..81716991a36 100644
--- a/app/assets/stylesheets/pages/pipeline_schedules.scss
+++ b/app/assets/stylesheets/pages/pipeline_schedules.scss
@@ -21,11 +21,6 @@
.cron-interval-input {
margin: 10px 10px 0 0;
}
-
- .cron-syntax-link-wrap {
- margin-right: 10px;
- font-size: 12px;
- }
}
.pipeline-schedule-table-row {
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index f61245bed24..0f56b98a78d 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -13,6 +13,14 @@
.form-group {
margin-bottom: map-get($spacing-scale, 3);
}
+
+ .variables-section {
+ input {
+ @include media-breakpoint-up(sm) {
+ width: 160px;
+ }
+ }
+ }
}
.draggable {
@@ -143,7 +151,7 @@
> .arrow::after {
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
- border-left: 4px solid $gray-50;
+ border-left: 4px solid $gray-10;
}
.arrow-shadow {
@@ -165,7 +173,7 @@
> .arrow::after {
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
- border-right: 4px solid $gray-50;
+ border-right: 4px solid $gray-10;
}
.arrow-shadow {
@@ -199,7 +207,7 @@
}
> .popover-title {
- background-color: $gray-50;
+ background-color: $gray-10;
border-radius: $border-radius-default $border-radius-default 0 0;
}
}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 2c0ca792ec3..d26c07ce51b 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -335,15 +335,6 @@
}
}
-.deprecated-service {
- cursor: default;
-
- a {
- font-weight: $gl-font-weight-bold;
- color: $white;
- }
-}
-
.personal-access-tokens-never-expires-label {
color: $note-disabled-comment-color;
}
@@ -401,4 +392,3 @@
}
}
}
-
diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss
index 93a12cf28a2..d410a16a1d9 100644
--- a/app/assets/stylesheets/snippets.scss
+++ b/app/assets/stylesheets/snippets.scss
@@ -40,10 +40,9 @@
margin: 0;
padding: 0;
table-layout: fixed;
+ overflow-x: auto;
.blob-content {
- overflow-x: auto;
-
pre {
height: 100%;
padding: 10px;
@@ -61,6 +60,7 @@
font-family: $monospace-font;
font-size: $code-font-size;
line-height: $code-line-height;
+ display: inline-block;
}
}
@@ -73,7 +73,7 @@
font-family: $monospace-font;
display: block;
font-size: $code-font-size;
- min-height: $code-line-height;
+ line-height: $code-line-height;
white-space: nowrap;
color: $black-transparent;
min-width: 30px;
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index b829a7b518e..8cf5c533f1f 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -1,3 +1,10 @@
+/**
+ Please note: These are deprecated in favor of the Gitlab UI utility classes.
+ Check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss
+ to see the available utility classes. If you cannot find the class you need,
+ consider adding it to Gitlab UI before adding it here.
+**/
+
@each $variant, $range in $color-ranges {
@each $suffix, $color in $range {
#{'.bg-#{$variant}-#{$suffix}'} {
@@ -7,6 +14,12 @@
#{'.text-#{$variant}-#{$suffix}'} {
color: $color;
}
+
+ #{'.hover-text-#{$variant}-#{$suffix}'} {
+ &:hover {
+ color: $color;
+ }
+ }
}
}
@@ -44,6 +57,7 @@
.border-color-default { border-color: $border-color; }
.border-bottom-color-default { border-bottom-color: $border-color; }
.border-radius-default { border-radius: $border-radius-default; }
+.border-radius-small { border-radius: $border-radius-small; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
.gl-children-ml-sm-3 > * {
@@ -75,6 +89,19 @@
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;
+}
+
.gl-text-purple { color: $purple; }
.gl-text-gray-800 { color: $gray-800; }
.gl-bg-purple-light { background-color: $purple-light; }
@@ -85,6 +112,7 @@
.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; }
@@ -107,8 +135,14 @@
.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;
}
}
+
+.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; }
diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
new file mode 100644
index 00000000000..9aec2305390
--- /dev/null
+++ b/app/channels/application_cable/channel.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module ApplicationCable
+ class Channel < ActionCable::Channel::Base
+ end
+end
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
new file mode 100644
index 00000000000..87c833f3593
--- /dev/null
+++ b/app/channels/application_cable/connection.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ identified_by :current_user
+
+ def connect
+ self.current_user = find_user_from_session_store
+ end
+
+ private
+
+ def find_user_from_session_store
+ session = ActiveSession.sessions_from_ids([session_id]).first
+ Warden::SessionSerializer.new('rack.session' => session).fetch(:user)
+ end
+
+ def session_id
+ Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]])
+ end
+ end
+end
diff --git a/app/channels/issues_channel.rb b/app/channels/issues_channel.rb
new file mode 100644
index 00000000000..5f3909b7716
--- /dev/null
+++ b/app/channels/issues_channel.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class IssuesChannel < ApplicationCable::Channel
+ def subscribed
+ project = Project.find_by_full_path(params[:project_path])
+ return reject unless project
+
+ issue = project.issues.find_by_iid(params[:iid])
+ return reject unless issue && Ability.allowed?(current_user, :read_issue, issue)
+
+ stream_for issue
+ end
+end
diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb
index 383ec2a7d16..8405f2a5cf8 100644
--- a/app/controllers/admin/appearances_controller.rb
+++ b/app/controllers/admin/appearances_controller.rb
@@ -73,6 +73,7 @@ class Admin::AppearancesController < Admin::ApplicationController
favicon
favicon_cache
new_project_guidelines
+ profile_image_guidelines
updated_by
header_message
footer_message
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 16254c74ba4..355662bbb38 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -5,7 +5,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
# NOTE: Use @application_setting in this controller when you need to access
# application_settings after it has been modified. This is because the
- # ApplicationSetting model uses Gitlab::ThreadMemoryCache for caching and the
+ # ApplicationSetting model uses Gitlab::ProcessMemoryCache for caching and the
# cache might be stale immediately after an update.
# https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30233
before_action :set_application_setting, except: :integrations
@@ -43,7 +43,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
def usage_data
respond_to do |format|
format.html do
- usage_data_json = JSON.pretty_generate(Gitlab::UsageData.data)
+ usage_data_json = Gitlab::Json.pretty_generate(Gitlab::UsageData.data)
render html: Gitlab::Highlight.highlight('payload.json', usage_data_json, language: 'json')
end
diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb
new file mode 100644
index 00000000000..ca9b393550d
--- /dev/null
+++ b/app/controllers/admin/ci/variables_controller.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class Admin::Ci::VariablesController < Admin::ApplicationController
+ def show
+ respond_to do |format|
+ format.json { render_instance_variables }
+ end
+ end
+
+ def update
+ service = Ci::UpdateInstanceVariablesService.new(variables_params)
+
+ if service.execute
+ respond_to do |format|
+ format.json { render_instance_variables }
+ end
+ else
+ respond_to do |format|
+ format.json { render_error(service.errors) }
+ end
+ end
+ end
+
+ private
+
+ def variables
+ @variables ||= Ci::InstanceVariable.all
+ end
+
+ def render_instance_variables
+ render status: :ok,
+ json: {
+ variables: Ci::InstanceVariableSerializer.new.represent(variables)
+ }
+ end
+
+ def render_error(errors)
+ render status: :bad_request, json: errors
+ end
+
+ def variables_params
+ params.permit(variables_attributes: [*variable_params_attributes])
+ end
+
+ def variable_params_attributes
+ %i[id variable_type key secret_value protected masked _destroy]
+ end
+end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index cd95105a893..b7b535e70df 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -13,6 +13,7 @@ class Admin::DashboardController < Admin::ApplicationController
@users = User.order_id_desc.limit(10)
@groups = Group.order_id_desc.with_route.limit(10)
@notices = Gitlab::ConfigChecker::PumaRuggedChecker.check
+ @notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb
deleted file mode 100644
index 3ae0aef0fa4..00000000000
--- a/app/controllers/admin/logs_controller.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-class Admin::LogsController < Admin::ApplicationController
- before_action :loggers
-
- def show
- end
-
- private
-
- def loggers
- @loggers ||= [
- Gitlab::AppJsonLogger,
- Gitlab::GitLogger,
- Gitlab::EnvironmentLogger,
- Gitlab::SidekiqLogger,
- Gitlab::RepositoryCheckLogger,
- Gitlab::ProjectServiceLogger,
- Gitlab::Kubernetes::Logger
- ]
- end
-end
-
-Admin::LogsController.prepend_if_ee('EE::Admin::LogsController')
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index 1dc1cd5fb82..0c0bbaf4d93 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -3,6 +3,7 @@
class Admin::SessionsController < ApplicationController
include Authenticates2FAForAdminMode
include InternalRedirect
+ include RendersLdapServers
before_action :user_is_admin!
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 8414095d454..ee42baa8326 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -145,7 +145,7 @@ class Admin::UsersController < Admin::ApplicationController
password_confirmation: params[:user][:password_confirmation]
}
- password_params[:password_expires_at] = Time.now unless changing_own_password?
+ password_params[:password_expires_at] = Time.current unless changing_own_password?
user_params_with_pass.merge!(password_params)
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index b5695322eb6..54e3275662b 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -18,6 +18,9 @@ class ApplicationController < ActionController::Base
include Gitlab::Tracking::ControllerConcern
include Gitlab::Experimentation::ControllerConcern
include InitializesCurrentUserMode
+ include Impersonation
+ include Gitlab::Logging::CloudflareHelper
+ include Gitlab::Utils::StrongMemoize
before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms?
@@ -35,6 +38,10 @@ class ApplicationController < ActionController::Base
before_action :check_impersonation_availability
before_action :required_signup_info
+ # Make sure the `auth_user` is memoized so it can be logged, we do this after
+ # all other before filters that could have set the user.
+ before_action :auth_user
+
prepend_around_action :set_current_context
around_action :sessionless_bypass_admin_mode!, if: :sessionless_user?
@@ -141,16 +148,19 @@ class ApplicationController < ActionController::Base
payload[:ua] = request.env["HTTP_USER_AGENT"]
payload[:remote_ip] = request.remote_ip
+
payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id
+ payload[:metadata] = @current_context
logged_user = auth_user
-
if logged_user.present?
payload[:user_id] = logged_user.try(:id)
payload[:username] = logged_user.try(:username)
end
payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY]
+
+ store_cloudflare_headers!(payload, request)
end
##
@@ -158,10 +168,12 @@ class ApplicationController < ActionController::Base
# (e.g. tokens) to authenticate the user, whereas Devise sets current_user.
#
def auth_user
- if user_signed_in?
- current_user
- else
- try(:authenticated_user)
+ strong_memoize(:auth_user) do
+ if user_signed_in?
+ current_user
+ else
+ try(:authenticated_user)
+ end
end
end
@@ -453,11 +465,16 @@ class ApplicationController < ActionController::Base
def set_current_context(&block)
Gitlab::ApplicationContext.with_context(
- user: -> { auth_user },
- project: -> { @project },
- namespace: -> { @group },
- caller_id: full_action_name,
- &block)
+ # Avoid loading the auth_user again after the request. Otherwise calling
+ # `auth_user` again would also trigger the Warden callbacks again
+ user: -> { auth_user if strong_memoized?(:auth_user) },
+ project: -> { @project if @project&.persisted? },
+ namespace: -> { @group if @group&.persisted? },
+ caller_id: full_action_name) do
+ yield
+ ensure
+ @current_context = Labkit::Context.current.to_h
+ end
end
def set_locale(&block)
@@ -525,36 +542,6 @@ class ApplicationController < ActionController::Base
.execute
end
- def check_impersonation_availability
- return unless session[:impersonator_id]
-
- unless Gitlab.config.gitlab.impersonation_enabled
- stop_impersonation
- access_denied! _('Impersonation has been disabled')
- end
- end
-
- def stop_impersonation
- log_impersonation_event
-
- warden.set_user(impersonator, scope: :user)
- session[:impersonator_id] = nil
-
- impersonated_user
- end
-
- def impersonated_user
- current_user
- end
-
- def log_impersonation_event
- Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{impersonated_user.username}")
- end
-
- def impersonator
- @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id]
- end
-
def sentry_context(&block)
Gitlab::ErrorTracking.with_context(current_user, &block)
end
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 1bfff210ecf..a18c80b996e 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -20,9 +20,6 @@ module Boards
skip_before_action :authenticate_user!, only: [:index]
before_action :validate_id_list, only: [:bulk_move]
before_action :can_move_issues?, only: [:bulk_move]
- before_action do
- push_frontend_feature_flag(:board_search_optimization, board.group, default_enabled: true)
- end
def index
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb
index de14bd319e0..c533fe007d7 100644
--- a/app/controllers/clusters/applications_controller.rb
+++ b/app/controllers/clusters/applications_controller.rb
@@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController
end
def cluster_application_params
- params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode, :host, :port, :protocol)
+ params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode, :host, :port, :protocol, :waf_log_enabled, :cilium_log_enabled)
end
def cluster_application_destroy_params
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 2c9ee69c8c4..aa39d430b24 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -18,20 +18,19 @@ class Clusters::ClustersController < Clusters::BaseController
STATUS_POLLING_INTERVAL = 10_000
def index
- finder = ClusterAncestorsFinder.new(clusterable.subject, current_user)
- clusters = finder.execute
+ @clusters = cluster_list
- # Note: We are paginating through an array here but this should OK as:
- #
- # In CE, we can have a maximum group nesting depth of 21, so including
- # project cluster, we can have max 22 clusters for a group hierarchy.
- # In EE (Premium) we can have any number, as multiple clusters are
- # supported, but the number of clusters are fairly low currently.
- #
- # See https://gitlab.com/gitlab-org/gitlab-foss/issues/55260 also.
- @clusters = Kaminari.paginate_array(clusters).page(params[:page]).per(20)
+ respond_to do |format|
+ format.html
+ format.json do
+ serializer = ClusterSerializer.new(current_user: current_user)
- @has_ancestor_clusters = finder.has_ancestor_clusters?
+ render json: {
+ clusters: serializer.with_pagination(request, response).represent_list(@clusters),
+ has_ancestor_clusters: @has_ancestor_clusters
+ }
+ end
+ end
end
def new
@@ -158,6 +157,23 @@ class Clusters::ClustersController < Clusters::BaseController
private
+ def cluster_list
+ finder = ClusterAncestorsFinder.new(clusterable.subject, current_user)
+ clusters = finder.execute
+
+ @has_ancestor_clusters = finder.has_ancestor_clusters?
+
+ # Note: We are paginating through an array here but this should OK as:
+ #
+ # In CE, we can have a maximum group nesting depth of 21, so including
+ # project cluster, we can have max 22 clusters for a group hierarchy.
+ # In EE (Premium) we can have any number, as multiple clusters are
+ # supported, but the number of clusters are fairly low currently.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab-foss/issues/55260 also.
+ Kaminari.paginate_array(clusters).page(params[:page]).per(20)
+ end
+
def destroy_params
params.permit(:cleanup)
end
diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb
index eb1080cb3d2..9d40b9e8c88 100644
--- a/app/controllers/concerns/boards_actions.rb
+++ b/app/controllers/concerns/boards_actions.rb
@@ -10,6 +10,9 @@ module BoardsActions
before_action :boards, only: :index
before_action :board, only: :show
before_action :push_wip_limits, only: [:index, :show]
+ before_action do
+ push_frontend_feature_flag(:not_issuable_queries, parent, default_enabled: true)
+ end
end
def index
diff --git a/app/controllers/concerns/impersonation.rb b/app/controllers/concerns/impersonation.rb
new file mode 100644
index 00000000000..a4f2c263eb4
--- /dev/null
+++ b/app/controllers/concerns/impersonation.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Impersonation
+ include Gitlab::Utils::StrongMemoize
+
+ def current_user
+ user = super
+
+ user.impersonator = impersonator if impersonator
+
+ user
+ end
+
+ protected
+
+ def check_impersonation_availability
+ return unless session[:impersonator_id]
+
+ unless Gitlab.config.gitlab.impersonation_enabled
+ stop_impersonation
+ access_denied! _('Impersonation has been disabled')
+ end
+ end
+
+ def stop_impersonation
+ log_impersonation_event
+
+ warden.set_user(impersonator, scope: :user)
+ session[:impersonator_id] = nil
+
+ current_user
+ end
+
+ def log_impersonation_event
+ Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{current_user.username}")
+ end
+
+ def impersonator
+ strong_memoize(:impersonator) do
+ User.find(session[:impersonator_id]) if session[:impersonator_id]
+ end
+ end
+end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index ca43bf42580..0b1b3f2bcba 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -11,6 +11,9 @@ module IssuableActions
before_action only: :show do
push_frontend_feature_flag(:scoped_labels, default_enabled: true)
end
+ before_action do
+ push_frontend_feature_flag(:not_issuable_queries, @project, default_enabled: true)
+ end
end
def permitted_keys
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index 0a6f684a9fc..78b3c6771b3 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -32,6 +32,10 @@ module IssuableCollectionsAction
private
+ def set_not_query_feature_flag(object = nil)
+ push_frontend_feature_flag(:not_issuable_queries, object, default_enabled: true)
+ end
+
def sorting_field
case action_name
when 'issues'
diff --git a/app/controllers/concerns/known_sign_in.rb b/app/controllers/concerns/known_sign_in.rb
new file mode 100644
index 00000000000..97883d8d08c
--- /dev/null
+++ b/app/controllers/concerns/known_sign_in.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module KnownSignIn
+ include Gitlab::Utils::StrongMemoize
+
+ private
+
+ def verify_known_sign_in
+ return unless current_user
+
+ notify_user unless known_remote_ip?
+ end
+
+ def known_remote_ip?
+ known_ip_addresses.include?(request.remote_ip)
+ end
+
+ def sessions
+ strong_memoize(:session) do
+ ActiveSession.list(current_user).reject(&:is_impersonated)
+ end
+ end
+
+ def known_ip_addresses
+ [current_user.last_sign_in_ip, sessions.map(&:ip_address)].flatten
+ end
+
+ def notify_user
+ current_user.notification_service.unknown_sign_in(current_user, request.remote_ip)
+ end
+end
diff --git a/app/controllers/concerns/members_presentation.rb b/app/controllers/concerns/members_presentation.rb
index 0a9d3d86245..ceccef8113f 100644
--- a/app/controllers/concerns/members_presentation.rb
+++ b/app/controllers/concerns/members_presentation.rb
@@ -5,6 +5,7 @@ module MembersPresentation
def present_members(members)
preload_associations(members)
+
Gitlab::View::Presenter::Factory.new(
members,
current_user: current_user,
diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb
index fa79f3bc4e6..1aea0e294a5 100644
--- a/app/controllers/concerns/metrics_dashboard.rb
+++ b/app/controllers/concerns/metrics_dashboard.rb
@@ -18,7 +18,7 @@ module MetricsDashboard
if result
result[:all_dashboards] = all_dashboards if include_all_dashboards?
- result[:metrics_data] = metrics_data(project_for_dashboard, environment_for_dashboard) if project_for_dashboard && environment_for_dashboard
+ result[:metrics_data] = metrics_data(project_for_dashboard, environment_for_dashboard)
end
respond_to do |format|
@@ -35,10 +35,9 @@ module MetricsDashboard
private
def all_dashboards
- dashboards = dashboard_finder.find_all_paths(project_for_dashboard)
- dashboards.map do |dashboard|
- amend_dashboard(dashboard)
- end
+ dashboard_finder
+ .find_all_paths(project_for_dashboard)
+ .map(&method(:amend_dashboard))
end
def amend_dashboard(dashboard)
@@ -46,10 +45,16 @@ module MetricsDashboard
dashboard[:can_edit] = project_dashboard ? can_edit?(dashboard) : false
dashboard[:project_blob_path] = project_dashboard ? dashboard_project_blob_path(dashboard) : nil
+ dashboard[:starred] = starred_dashboards.include?(dashboard[:path])
+ dashboard[:user_starred_path] = project_for_dashboard ? user_starred_path(project_for_dashboard, dashboard[:path]) : nil
dashboard
end
+ def user_starred_path(project, path)
+ expose_path(api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: path }))
+ end
+
def dashboard_project_blob_path(dashboard)
project_blob_path(project_for_dashboard, File.join(project_for_dashboard.default_branch, dashboard.fetch(:path, "")))
end
@@ -73,6 +78,20 @@ module MetricsDashboard
::Gitlab::Metrics::Dashboard::Finder
end
+ def starred_dashboards
+ @starred_dashboards ||= begin
+ if project_for_dashboard.present?
+ ::Metrics::UsersStarredDashboardsFinder
+ .new(user: current_user, project: project_for_dashboard)
+ .execute
+ .map(&:dashboard_path)
+ .to_set
+ else
+ Set.new
+ end
+ end
+ end
+
# Project is not defined for group and admin level clusters.
def project_for_dashboard
defined?(project) ? project : nil
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 7dd2f6e5706..d4b0d3b2674 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -13,7 +13,7 @@ module NotesActions
end
def index
- current_fetched_at = Time.now.to_i
+ current_fetched_at = Time.current.to_i
notes_json = { notes: [], last_fetched_at: current_fetched_at }
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index c7c9f2e9b70..ba15d611c0d 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -37,7 +37,7 @@ module PreviewMarkdown
when 'groups' then { group: group }
when 'projects' then projects_filter_params
else {}
- end.merge(requested_path: params[:path])
+ end.merge(requested_path: params[:path], ref: params[:ref])
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb
index 4013596ba12..29164df4516 100644
--- a/app/controllers/concerns/record_user_last_activity.rb
+++ b/app/controllers/concerns/record_user_last_activity.rb
@@ -17,7 +17,6 @@ module RecordUserLastActivity
def set_user_last_activity
return unless request.get?
- return unless Feature.enabled?(:set_user_last_activity, default_enabled: true)
return if Gitlab::Database.read_only?
if current_user && current_user.last_activity_on != Date.today
diff --git a/app/controllers/concerns/renders_ldap_servers.rb b/app/controllers/concerns/renders_ldap_servers.rb
new file mode 100644
index 00000000000..cc83ff47048
--- /dev/null
+++ b/app/controllers/concerns/renders_ldap_servers.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module RendersLdapServers
+ extend ActiveSupport::Concern
+
+ included do
+ helper_method :ldap_servers
+ end
+
+ def ldap_servers
+ @ldap_servers ||= begin
+ if Gitlab::Auth::Ldap::Config.sign_in_enabled?
+ Gitlab::Auth::Ldap::Config.available_servers
+ else
+ []
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index 3ccf227c431..e2c83f9a069 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -19,6 +19,7 @@ module ServiceParams
:color,
:colorize_messages,
:comment_on_event_enabled,
+ :comment_detail,
:confidential_issues_events,
:default_irc_uri,
:description,
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index 096c6efc0fc..e78723bdda2 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -53,10 +53,10 @@ module SnippetsActions
def blob
return unless snippet
- @blob ||= if Feature.enabled?(:version_snippets, current_user) && !snippet.repository.empty?
- snippet.blobs.first
- else
+ @blob ||= if snippet.empty_repo?
snippet.blob
+ else
+ snippet.blobs.first
end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
@@ -65,11 +65,12 @@ module SnippetsActions
params[:line_ending] == 'raw' ? content : content.gsub(/\r\n/, "\n")
end
- def check_repository_error
- repository_errors = Array(snippet.errors.delete(:repository))
+ def handle_repository_error(action)
+ errors = Array(snippet.errors.delete(:repository))
+
+ flash.now[:alert] = errors.first if errors.present?
- flash.now[:alert] = repository_errors.first if repository_errors.present?
- recaptcha_check_with_fallback(repository_errors.empty?) { render :edit }
+ recaptcha_check_with_fallback(errors.empty?) { render action }
end
def redirect_if_binary
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index 46ba270f328..50c93441dd4 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -82,6 +82,6 @@ module SpammableActions
return false if spammable.errors.count > 1 # re-render "new" template in case there are other errors
return false unless Gitlab::Recaptcha.enabled?
- spammable.spam
+ spammable.needs_recaptcha?
end
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index c173d7d2310..25c48fadf49 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -83,11 +83,13 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
def use_cte_for_finder?
# The starred action loads public projects, which causes the CTE to be less efficient
- action_name == 'index' && Feature.enabled?(:use_cte_for_projects_finder, default_enabled: true)
+ action_name == 'index'
end
def load_events
- projects = load_projects(params.merge(non_public: true))
+ projects = ProjectsFinder
+ .new(params: params.merge(non_public: true), current_user: current_user)
+ .execute
@events = EventCollection
.new(projects, offset: params[:offset].to_i, filter: event_filter)
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 1668cf004f8..dd9e6488bc5 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -10,6 +10,7 @@ class DashboardController < Dashboard::ApplicationController
before_action :projects, only: [:issues, :merge_requests]
before_action :set_show_full_reference, only: [:issues, :merge_requests]
before_action :check_filters_presence!, only: [:issues, :merge_requests]
+ before_action :set_not_query_feature_flag
respond_to :html
diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb
index ed0995e7ffd..5723ccc14a7 100644
--- a/app/controllers/google_api/authorizations_controller.rb
+++ b/app/controllers/google_api/authorizations_controller.rb
@@ -15,6 +15,9 @@ module GoogleApi
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] =
expires_at.to_s
+ rescue ::Faraday::TimeoutError, ::Faraday::ConnectionFailed
+ flash[:alert] = _('Timeout connecting to the Google API. Please try again.')
+ ensure
redirect_to redirect_uri_from_session
end
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 522d171b5bf..a1348e4d858 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -3,7 +3,12 @@
class GraphqlController < ApplicationController
# Unauthenticated users have access to the API for public data
skip_before_action :authenticate_user!
- skip_around_action :set_session_storage
+
+ # If a user is using their session to access GraphQL, we need to have session
+ # storage, since the admin-mode check is session wide.
+ # We can't enable this for anonymous users because that would cause users using
+ # enforced SSO from using an auth token to access the API.
+ skip_around_action :set_session_storage, unless: :current_user
# Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing,
# the user won't be authenticated but can proceed as an anonymous user.
@@ -14,6 +19,7 @@ class GraphqlController < ApplicationController
before_action :authorize_access_api!
before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }
+ before_action :set_user_last_activity
# Since we deactivate authentication from the main ApplicationController and
# defer it to :authorize_access_api!, we need to override the bypass session
@@ -42,6 +48,12 @@ class GraphqlController < ApplicationController
private
+ def set_user_last_activity
+ return unless current_user
+
+ Users::ActivityService.new(current_user).execute
+ end
+
def execute_multiplex
GitlabSchema.multiplex(multiplex_queries, context: context)
end
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index 23daa29ac43..52ee69edaa5 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class Groups::GroupLinksController < Groups::ApplicationController
- before_action :check_feature_flag!
before_action :authorize_admin_group!
before_action :group_link, only: [:update, :destroy]
@@ -51,8 +50,4 @@ class Groups::GroupLinksController < Groups::ApplicationController
def group_link_params
params.require(:group_link).permit(:group_access, :expires_at)
end
-
- def check_feature_flag!
- render_404 unless Feature.enabled?(:share_group_with_group, default_enabled: true)
- end
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 664c58e8b7a..63311ab983b 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -21,19 +21,26 @@ class Groups::GroupMembersController < Groups::ApplicationController
def index
@sort = params[:sort].presence || sort_value_name
+
@project = @group.projects.find(params[:project_id]) if params[:project_id]
- @members = find_members
+
+ @members = GroupMembersFinder
+ .new(@group, current_user, params: filter_params)
+ .execute(include_relations: requested_relations)
if can_manage_members
@skip_groups = @group.related_group_ids
- @invited_members = present_invited_members(@members)
+
+ @invited_members = @members.invite
+ @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present?
+ @invited_members = present_invited_members(@invited_members)
end
- @members = @members.non_invite
- @members = present_group_members(@members)
+ @members = present_group_members(@members.non_invite)
@requesters = present_members(
- AccessRequestsFinder.new(@group).execute(current_user))
+ AccessRequestsFinder.new(@group).execute(current_user)
+ )
@group_member = @group.group_members.new
end
@@ -43,30 +50,24 @@ class Groups::GroupMembersController < Groups::ApplicationController
private
- def present_invited_members(members)
- invited_members = members.invite
-
- if params[:search_invited].present?
- invited_members = invited_members.search_invite_email(params[:search_invited])
- end
-
- present_members(invited_members
- .page(params[:invited_members_page])
- .per(MEMBER_PER_PAGE_LIMIT))
+ def can_manage_members
+ can?(current_user, :admin_group_member, @group)
end
- def find_members
- filter_params = params.slice(:two_factor, :search).merge(sort: @sort)
- GroupMembersFinder.new(@group, current_user, params: filter_params).execute(include_relations: requested_relations)
+ def present_invited_members(invited_members)
+ present_members(invited_members
+ .page(params[:invited_members_page])
+ .per(MEMBER_PER_PAGE_LIMIT))
end
- def can_manage_members
- can?(current_user, :admin_group_member, @group)
+ def present_group_members(members)
+ present_members(members
+ .page(params[:page])
+ .per(MEMBER_PER_PAGE_LIMIT))
end
- def present_group_members(original_members)
- members = original_members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT)
- present_members(members)
+ def filter_params
+ params.permit(:two_factor, :search).merge(sort: @sort)
end
end
diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb
index 16aa6e50320..14651e0794a 100644
--- a/app/controllers/groups/registry/repositories_controller.rb
+++ b/app/controllers/groups/registry/repositories_controller.rb
@@ -9,7 +9,9 @@ module Groups
respond_to do |format|
format.html
format.json do
- @images = ContainerRepositoriesFinder.new(user: current_user, subject: group).execute.with_api_entity_associations
+ @images = ContainerRepositoriesFinder.new(user: current_user, subject: group, params: params.slice(:name))
+ .execute
+ .with_api_entity_associations
track_event(:list_repositories)
diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb
index 6e8c5628d24..4af5e613296 100644
--- a/app/controllers/groups/settings/repository_controller.rb
+++ b/app/controllers/groups/settings/repository_controller.rb
@@ -46,7 +46,7 @@ module Groups
end
def deploy_token_params
- params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :username)
+ params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :read_package_registry, :write_package_registry, :username)
end
end
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 44120fda17c..d5f2239b16a 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -31,6 +31,10 @@ class GroupsController < Groups::ApplicationController
push_frontend_feature_flag(:vue_issuables_list, @group)
end
+ before_action do
+ set_not_query_feature_flag(@group)
+ end
+
before_action :export_rate_limit, only: [:export, :download_export]
skip_cross_project_access_check :index, :new, :create, :edit, :update,
@@ -142,7 +146,7 @@ class GroupsController < Groups::ApplicationController
export_service = Groups::ImportExport::ExportService.new(group: @group, user: current_user)
if export_service.async_execute
- redirect_to edit_group_path(@group), notice: _('Group export started.')
+ redirect_to edit_group_path(@group), notice: _('Group export started. A download link will be sent by email and made available on this page.')
else
redirect_to edit_group_path(@group), alert: _('Group export could not be started.')
end
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index 91bba1eb617..a1bbcf34f69 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -26,7 +26,7 @@ class HelpController < ApplicationController
respond_to do |format|
format.any(:markdown, :md, :html) do
- # Note: We are purposefully NOT using `Rails.root.join`
+ # Note: We are purposefully NOT using `Rails.root.join` because of https://gitlab.com/gitlab-org/gitlab/-/issues/216028.
path = File.join(Rails.root, 'doc', "#{@path}.md")
if File.exist?(path)
@@ -42,7 +42,7 @@ class HelpController < ApplicationController
# Allow access to specific media files in the doc folder
format.any(:png, :gif, :jpeg, :mp4, :mp3) do
- # Note: We are purposefully NOT using `Rails.root.join`
+ # Note: We are purposefully NOT using `Rails.root.join` because of https://gitlab.com/gitlab-org/gitlab/-/issues/216028.
path = File.join(Rails.root, 'doc', "#{@path}.#{params[:format]}")
if File.exist?(path)
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index bffbdf01f8f..8a838db04f9 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -6,10 +6,6 @@ class IdeController < ApplicationController
include ClientsidePreviewCSP
include StaticObjectExternalStorageCSP
- before_action do
- push_frontend_feature_flag(:webide_dark_theme)
- end
-
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 34af1ecd6a5..4e8ceae75bd 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -144,7 +144,7 @@ class Import::GithubController < Import::BaseController
end
def provider_rate_limit(exception)
- reset_time = Time.at(exception.response_headers['x-ratelimit-reset'].to_i)
+ reset_time = Time.zone.at(exception.response_headers['x-ratelimit-reset'].to_i)
session[access_token_key] = nil
redirect_to new_import_url,
alert: _("GitHub API rate limit exceeded. Try again after %{reset_time}") % { reset_time: reset_time }
diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb
index 4dddfbcd20d..03bde0345e3 100644
--- a/app/controllers/import/google_code_controller.rb
+++ b/app/controllers/import/google_code_controller.rb
@@ -15,7 +15,7 @@ class Import::GoogleCodeController < Import::BaseController
end
begin
- dump = JSON.parse(dump_file.read)
+ dump = Gitlab::Json.parse(dump_file.read)
rescue
return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") })
end
@@ -42,7 +42,7 @@ class Import::GoogleCodeController < Import::BaseController
user_map_json = "{}" if user_map_json.blank?
begin
- user_map = JSON.parse(user_map_json)
+ user_map = Gitlab::Json.parse(user_map_json)
rescue
flash.now[:alert] = _("The entered user map is not a valid JSON user map.")
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index da39d64c93d..3e7755046cd 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -4,7 +4,9 @@ class JwtController < ApplicationController
skip_around_action :set_session_storage
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
- before_action :authenticate_project_or_user
+
+ # Add this before other actions, since we want to have the user or project
+ prepend_before_action :auth_user, :authenticate_project_or_user
SERVICES = {
Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService
@@ -75,4 +77,11 @@ class JwtController < ApplicationController
Array(Rack::Utils.parse_query(request.query_string)['scope'])
end
+
+ def auth_user
+ strong_memoize(:auth_user) do
+ actor = @authentication_result&.actor
+ actor.is_a?(User) ? actor : nil
+ end
+ end
end
diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb
index 8e4d8f3d21b..4b6339c21cd 100644
--- a/app/controllers/ldap/omniauth_callbacks_controller.rb
+++ b/app/controllers/ldap/omniauth_callbacks_controller.rb
@@ -16,6 +16,10 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
def ldap
return unless Gitlab::Auth::Ldap::Config.sign_in_enabled?
+ if Feature.enabled?(:user_mode_in_session)
+ return admin_mode_flow(Gitlab::Auth::Ldap::User) if current_user_mode.admin_mode_requested?
+ end
+
sign_in_user_flow(Gitlab::Auth::Ldap::User)
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index d82a46e57ea..4c595313cb6 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -6,6 +6,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include Devise::Controllers::Rememberable
include AuthHelper
include InitializesCurrentUserMode
+ include KnownSignIn
+
+ after_action :verify_known_sign_in
protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
@@ -87,6 +90,14 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
private
+ def after_omniauth_failure_path_for(scope)
+ if Feature.enabled?(:user_mode_in_session)
+ return new_admin_session_path if current_user_mode.admin_mode_requested?
+ end
+
+ super
+ end
+
def omniauth_flow(auth_module, identity_linker: nil)
if fragment = request.env.dig('omniauth.params', 'redirect_fragment').presence
store_redirect_fragment(fragment)
diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb
new file mode 100644
index 00000000000..0c0a91e136f
--- /dev/null
+++ b/app/controllers/projects/alert_management_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+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
+
+ def details
+ @alert_id = params[:id]
+ end
+end
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 50399a8cfbb..b8663bc59f2 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -10,7 +10,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
before_action :authorize_update_build!, only: [:keep]
before_action :authorize_destroy_artifacts!, only: [:destroy]
before_action :extract_ref_name_and_path
- before_action :validate_artifacts!, except: [:index, :download, :destroy]
+ before_action :validate_artifacts!, except: [:index, :download, :raw, :destroy]
before_action :entry, only: [:file]
MAX_PER_PAGE = 20
@@ -22,7 +22,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
# issues: https://gitlab.com/gitlab-org/gitlab/issues/32281
return head :no_content unless Feature.enabled?(:artifacts_management_page, @project)
- finder = ArtifactsFinder.new(@project, artifacts_params)
+ finder = Ci::JobArtifactsFinder.new(@project, artifacts_params)
all_artifacts = finder.execute
@artifacts = all_artifacts.page(params[:page]).per(MAX_PER_PAGE)
@@ -73,9 +73,11 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def raw
+ return render_404 unless zip_artifact?
+
path = Gitlab::Ci::Build::Artifacts::Path.new(params[:path])
- send_artifacts_entry(build, path)
+ send_artifacts_entry(artifacts_file, path)
end
def keep
@@ -138,6 +140,13 @@ class Projects::ArtifactsController < Projects::ApplicationController
@artifacts_file ||= build&.artifacts_file_for_type(params[:file_type] || :archive)
end
+ def zip_artifact?
+ types = HashWithIndifferentAccess.new(Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS)
+ file_type = params[:file_type] || :archive
+
+ types[file_type] == :zip
+ end
+
def entry
@entry = build.artifacts_metadata_entry(params[:path])
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 09754409104..cc595740696 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -186,7 +186,6 @@ class Projects::BranchesController < Projects::ApplicationController
end
def confidential_issue_project
- return unless helpers.create_confidential_merge_request_enabled?
return if params[:confidential_issue_project_id].blank?
confidential_issue_project = Project.find(params[:confidential_issue_project_id])
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
new file mode 100644
index 00000000000..dfda5fca310
--- /dev/null
+++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+class Projects::Ci::DailyBuildGroupReportResultsController < Projects::ApplicationController
+ include Gitlab::Utils::StrongMemoize
+
+ MAX_ITEMS = 1000
+ 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 :validate_param_type!
+
+ def index
+ respond_to do |format|
+ format.csv { send_data(render_csv(results), type: 'text/csv; charset=utf-8') }
+ end
+ end
+
+ private
+
+ def validate_feature_flag!
+ render_404 unless Feature.enabled?(:ci_download_daily_code_coverage, project, default_enabled: true)
+ end
+
+ def validate_param_type!
+ respond_422 unless allowed_param_types.include?(param_type)
+ end
+
+ def render_csv(collection)
+ CsvBuilders::SingleBatch.new(
+ collection,
+ {
+ date: 'date',
+ group_name: 'group_name',
+ param_type => -> (record) { record.data[param_type] }
+ }
+ ).render
+ end
+
+ def results
+ Ci::DailyBuildGroupReportResultsFinder.new(finder_params).execute
+ end
+
+ def finder_params
+ {
+ current_user: current_user,
+ project: project,
+ ref_path: params.require(:ref_path),
+ start_date: start_date,
+ end_date: end_date,
+ limit: MAX_ITEMS
+ }
+ end
+
+ def start_date
+ strong_memoize(:start_date) do
+ start_date = Date.parse(params.require(:start_date))
+
+ # The start_date cannot be older than `end_date - 90 days`
+ [start_date, end_date - REPORT_WINDOW].max
+ end
+ end
+
+ def end_date
+ strong_memoize(:end_date) do
+ Date.parse(params.require(:end_date))
+ end
+ end
+
+ def allowed_param_types
+ Ci::DailyBuildGroupReportResult::PARAM_TYPES
+ end
+
+ def param_type
+ params.require(:param_type)
+ end
+end
diff --git a/app/controllers/projects/design_management/designs/raw_images_controller.rb b/app/controllers/projects/design_management/designs/raw_images_controller.rb
new file mode 100644
index 00000000000..beb7e9d294b
--- /dev/null
+++ b/app/controllers/projects/design_management/designs/raw_images_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+# Returns full-size design images
+module Projects
+ module DesignManagement
+ module Designs
+ class RawImagesController < Projects::DesignManagement::DesignsController
+ include SendsBlob
+
+ skip_before_action :default_cache_headers, only: :show
+
+ def show
+ blob = design_repository.blob_at(ref, design.full_path)
+
+ send_blob(design_repository, blob, inline: false, allow_caching: project.public?)
+ end
+
+ private
+
+ def design_repository
+ @design_repository ||= project.design_repository
+ end
+
+ def ref
+ sha || design_repository.root_ref
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/design_management/designs/resized_image_controller.rb b/app/controllers/projects/design_management/designs/resized_image_controller.rb
new file mode 100644
index 00000000000..50a997f32db
--- /dev/null
+++ b/app/controllers/projects/design_management/designs/resized_image_controller.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+# Returns smaller sized design images
+module Projects
+ module DesignManagement
+ module Designs
+ class ResizedImageController < Projects::DesignManagement::DesignsController
+ include SendFileUpload
+
+ before_action :validate_size!
+
+ skip_before_action :default_cache_headers, only: :show
+
+ def show
+ relation = design.actions
+ relation = relation.up_to_version(sha) if sha
+ action = relation.most_recent.first
+
+ return render_404 unless action
+
+ # This controller returns a 404 if the the `size` param
+ # is not one of our specific sizes, so using `send` here is safe.
+ uploader = action.public_send(:"image_#{size}") # rubocop:disable GitlabSecurity/PublicSend
+
+ return render_404 unless uploader.file # The image has not been processed
+
+ if stale?(etag: action.cache_key)
+ workhorse_set_content_type!
+
+ send_upload(uploader, attachment: design.filename)
+ end
+ end
+
+ private
+
+ def validate_size!
+ render_404 unless ::DesignManagement::DESIGN_IMAGE_SIZES.include?(size)
+ end
+
+ def size
+ params[:id]
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/design_management/designs_controller.rb b/app/controllers/projects/design_management/designs_controller.rb
new file mode 100644
index 00000000000..fec09fa9515
--- /dev/null
+++ b/app/controllers/projects/design_management/designs_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Projects::DesignManagement::DesignsController < Projects::ApplicationController
+ before_action :authorize_read_design!
+
+ private
+
+ def authorize_read_design!
+ unless can?(current_user, :read_design, design)
+ access_denied!
+ end
+ end
+
+ def design
+ @design ||= project.designs.find(params[:design_id])
+ end
+
+ def sha
+ params[:sha].presence
+ end
+end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 09dc4d118a1..5f4d88c57e9 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -4,6 +4,12 @@ class Projects::EnvironmentsController < Projects::ApplicationController
include MetricsDashboard
layout 'project'
+
+ before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
+ authorize_metrics_dashboard!
+
+ push_frontend_feature_flag(:prometheus_computed_alerts)
+ end
before_action :authorize_read_environment!
before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_stop_environment!, only: [:stop]
@@ -12,10 +18,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
- before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
- push_frontend_feature_flag(:prometheus_computed_alerts)
- push_frontend_feature_flag(:metrics_dashboard_annotations)
- end
after_action :expire_etag_cache, only: [:cancel_auto_stop]
def index
@@ -27,12 +29,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController
format.html
format.json do
Gitlab::PollingInterval.set_header(response, interval: 3_000)
+ environments_count_by_state = project.environments.count_by_state
render json: {
environments: serialize_environments(request, response, params[:nested]),
review_app: serialize_review_app,
- available_count: project.environments.available.count,
- stopped_count: project.environments.stopped.count
+ available_count: environments_count_by_state[:available],
+ stopped_count: environments_count_by_state[:stopped]
}
end
end
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 889dcefb65a..34246f27241 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -28,6 +28,7 @@ class Projects::GraphsController < Projects::ApplicationController
def charts
get_commits
get_languages
+ get_daily_coverage_options
end
def ci
@@ -52,6 +53,27 @@ class Projects::GraphsController < Projects::ApplicationController
end
end
+ def get_daily_coverage_options
+ return unless Feature.enabled?(:ci_download_daily_code_coverage, default_enabled: true)
+
+ date_today = Date.current
+ report_window = Projects::Ci::DailyBuildGroupReportResultsController::REPORT_WINDOW
+
+ @daily_coverage_options = {
+ base_params: {
+ start_date: date_today - report_window,
+ end_date: date_today,
+ ref_path: @project.repository.expand_ref(@ref),
+ param_type: 'coverage'
+ },
+ download_path: namespace_project_ci_daily_build_group_report_results_path(
+ namespace_id: @project.namespace,
+ project_id: @project,
+ format: :csv
+ )
+ }
+ end
+
def fetch_graph
@commits = @project.repository.commits(@ref, limit: 6000, skip_merges: true)
@log = []
diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb
index 4a70ed45404..711e23dc3ce 100644
--- a/app/controllers/projects/import/jira_controller.rb
+++ b/app/controllers/projects/import/jira_controller.rb
@@ -36,7 +36,7 @@ module Projects
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.'
+ flash[:alert] = 'No Jira project key has been provided.'
end
redirect_to project_import_jira_path(@project)
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 3aae8990f07..3e9d956f7b1 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -21,7 +21,6 @@ class Projects::IssuesController < Projects::ApplicationController
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
prepend_before_action :authenticate_user!, only: [:new, :export_csv]
- # designs is only applicable to EE, but defining a prepend_before_action in EE code would overwrite this
prepend_before_action :store_uri, only: [:new, :show, :designs]
before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
@@ -50,6 +49,10 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:save_issuable_health_status, project.group, default_enabled: true)
end
+ before_action only: :show do
+ push_frontend_feature_flag(:real_time_issue_sidebar, @project)
+ end
+
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
respond_to :html
@@ -81,11 +84,13 @@ class Projects::IssuesController < Projects::ApplicationController
)
build_params = issue_params.merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
- discussion_to_resolve: params[:discussion_to_resolve]
+ discussion_to_resolve: params[:discussion_to_resolve],
+ confidential: !!Gitlab::Utils.to_boolean(params[:issue][:confidential])
)
service = Issues::BuildService.new(project, current_user, build_params)
@issue = @noteable = service.execute
+
@merge_request_to_resolve_discussions_of = service.merge_request_to_resolve_discussions_of
@discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve]
@@ -154,7 +159,10 @@ class Projects::IssuesController < Projects::ApplicationController
end
def related_branches
- @related_branches = Issues::RelatedBranchesService.new(project, current_user).execute(issue)
+ @related_branches = Issues::RelatedBranchesService
+ .new(project, current_user)
+ .execute(issue)
+ .map { |branch| branch.merge(link: branch_link(branch)) }
respond_to do |format|
format.json do
@@ -179,7 +187,7 @@ class Projects::IssuesController < Projects::ApplicationController
def create_merge_request
create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid)
- create_params[:target_project_id] = params[:target_project_id] if helpers.create_confidential_merge_request_enabled?
+ create_params[:target_project_id] = params[:target_project_id]
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute
if result[:status] == :success
@@ -193,7 +201,8 @@ class Projects::IssuesController < Projects::ApplicationController
ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
index_path = project_issues_path(project)
- redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.")
+ message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email }
+ redirect_to(index_path, notice: message)
end
def import_csv
@@ -305,6 +314,10 @@ class Projects::IssuesController < Projects::ApplicationController
private
+ def branch_link(branch)
+ project_compare_path(project, from: project.default_branch, to: branch[:name])
+ end
+
def create_rate_limit
key = :issues_create
diff --git a/app/controllers/projects/mattermosts_controller.rb b/app/controllers/projects/mattermosts_controller.rb
index 085b1bc1498..cfaeddf711a 100644
--- a/app/controllers/projects/mattermosts_controller.rb
+++ b/app/controllers/projects/mattermosts_controller.rb
@@ -30,7 +30,7 @@ class Projects::MattermostsController < Projects::ApplicationController
def configure_params
params.require(:mattermost).permit(:trigger, :team_id).merge(
url: service_trigger_url(@service),
- icon_url: asset_url('slash-command-logo.png'))
+ icon_url: asset_url('slash-command-logo.png', skip_pipeline: true))
end
def teams
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 23222cbd37c..28aa1b300aa 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -16,7 +16,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
end
def create
- @target_branches ||= []
@merge_request = ::MergeRequests::CreateService.new(project, current_user, merge_request_params).execute
if @merge_request.valid?
@@ -97,13 +96,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def define_new_vars
@noteable = @merge_request
-
- @target_branches = if @merge_request.target_project
- @merge_request.target_project.repository.branch_names
- else
- []
- end
-
@target_project = @merge_request.target_project
@source_project = @merge_request.source_project
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 79598c0aaff..2331674f42c 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -9,6 +9,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
before_action :define_diff_vars
before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata]
+ around_action :allow_gitaly_ref_name_caching
+
def show
render_diffs
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 8c37d70d4c9..5613b5b9589 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -14,7 +14,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
skip_before_action :merge_request, only: [:index, :bulk_update]
before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
- before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts, :coverage_reports]
+ before_action :authorize_read_actual_head_pipeline!, only: [
+ :test_reports,
+ :exposed_artifacts,
+ :coverage_reports,
+ :terraform_reports,
+ :accessibility_reports
+ ]
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
@@ -26,7 +32,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:code_navigation, @project)
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
push_frontend_feature_flag(:merge_ref_head_comments, @project)
- push_frontend_feature_flag(:diff_compare_with_head, @project)
+ push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true)
end
before_action do
@@ -136,6 +142,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
reports_response(@merge_request.compare_test_reports)
end
+ def accessibility_reports
+ if @merge_request.has_accessibility_reports?
+ reports_response(@merge_request.compare_accessibility_reports)
+ else
+ head :no_content
+ end
+ end
+
def coverage_reports
if @merge_request.has_coverage_reports?
reports_response(@merge_request.find_coverage_reports)
@@ -144,6 +158,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
end
+ def terraform_reports
+ reports_response(@merge_request.find_terraform_reports)
+ end
+
def exposed_artifacts
if @merge_request.has_exposed_artifacts?
reports_response(@merge_request.find_exposed_artifacts)
@@ -353,7 +371,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def define_edit_vars
@source_project = @merge_request.source_project
@target_project = @merge_request.target_project
- @target_branches = @merge_request.target_project.repository.branch_names
@noteable = @merge_request
# FIXME: We have to assign a presenter to another instance variable
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 726ce8974c7..678d0862f48 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -11,7 +11,9 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
- push_frontend_feature_flag(:junit_pipeline_view)
+ 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)
end
before_action :ensure_pipeline, only: [:show]
@@ -22,9 +24,8 @@ class Projects::PipelinesController < Projects::ApplicationController
POLLING_INTERVAL = 10_000
def index
- @scope = params[:scope]
@pipelines = Ci::PipelinesFinder
- .new(project, current_user, scope: @scope)
+ .new(project, current_user, index_params)
.execute
.page(params[:page])
.per(30)
@@ -69,6 +70,8 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def show
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/26657')
+
respond_to do |format|
format.html
format.json do
@@ -91,6 +94,10 @@ class Projects::PipelinesController < Projects::ApplicationController
render_show
end
+ def dag
+ render_show
+ end
+
def failures
if @pipeline.failed_builds.present?
render_show
@@ -169,19 +176,9 @@ class Projects::PipelinesController < Projects::ApplicationController
end
format.json do
- if pipeline_test_report == :error
- render json: { status: :error_parsing_report }
- else
- test_reports = if params[:scope] == "with_attachment"
- pipeline_test_report.with_attachment!
- else
- pipeline_test_report
- end
-
- render json: TestReportSerializer
- .new(current_user: @current_user)
- .represent(test_reports, project: project)
- end
+ render json: TestReportSerializer
+ .new(current_user: @current_user)
+ .represent(pipeline_test_report, project: project)
end
end
end
@@ -189,11 +186,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def test_reports_count
return unless Feature.enabled?(:junit_pipeline_view, project)
- begin
- render json: { total_count: pipeline.test_reports_count }.to_json
- rescue Gitlab::Ci::Parsers::ParserError
- render json: { total_count: 0 }.to_json
- end
+ render json: { total_count: pipeline.test_reports_count }.to_json
end
private
@@ -262,18 +255,22 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def limited_pipelines_count(project, scope = nil)
- finder = Ci::PipelinesFinder.new(project, current_user, scope: scope)
+ finder = Ci::PipelinesFinder.new(project, current_user, index_params.merge(scope: scope))
view_context.limited_counter_with_delimiter(finder.execute)
end
def pipeline_test_report
strong_memoize(:pipeline_test_report) do
- @pipeline.test_reports
- rescue Gitlab::Ci::Parsers::ParserError
- :error
+ @pipeline.test_reports.tap do |reports|
+ reports.with_attachment! if params[:scope] == 'with_attachment'
+ end
end
end
+
+ def index_params
+ params.permit(:scope, :username, :ref)
+ end
end
Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController')
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index 109c8b7005f..3e52248f292 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -17,8 +17,9 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@group_links = @project.project_group_links
@group_links = @group_links.search(params[:search]) if params[:search].present?
- @project_members = MembersFinder.new(@project, current_user)
- .execute(include_relations: requested_relations, params: params.merge(sort: @sort))
+ @project_members = MembersFinder
+ .new(@project, current_user, params: filter_params)
+ .execute(include_relations: requested_relations)
@project_members = present_members(@project_members.page(params[:page]))
@@ -43,12 +44,17 @@ class Projects::ProjectMembersController < Projects::ApplicationController
return render_404
end
- redirect_to(project_project_members_path(project),
- notice: notice)
+ redirect_to(project_project_members_path(project), notice: notice)
end
# MembershipActions concern
alias_method :membershipable, :project
+
+ private
+
+ def filter_params
+ params.permit(:search).merge(sort: @sort)
+ end
end
Projects::ProjectMembersController.prepend_if_ee('EE::Projects::ProjectMembersController')
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index 7c606bd8c45..fcbeb5c840c 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -12,7 +12,7 @@ class Projects::RefsController < Projects::ApplicationController
before_action :authorize_download_code!
before_action only: [:logs_tree] do
- push_frontend_feature_flag(:vue_file_list_lfs_badge)
+ push_frontend_feature_flag(:vue_file_list_lfs_badge, default_enabled: true)
end
def switch
@@ -44,30 +44,25 @@ class Projects::RefsController < Projects::ApplicationController
end
def logs_tree
- summary = ::Gitlab::TreeSummary.new(
- @commit,
- @project,
- path: @path,
- offset: params[:offset],
- limit: 25
- )
-
- @logs, commits = summary.summarize
- @more_log_url = more_url(summary.next_offset) if summary.more?
+ tree_summary = ::Gitlab::TreeSummary.new(
+ @commit, @project, current_user,
+ path: @path, offset: params[:offset], limit: 25)
respond_to do |format|
format.html { render_404 }
format.json do
- response.headers["More-Logs-Url"] = @more_log_url if summary.more?
- response.headers["More-Logs-Offset"] = summary.next_offset if summary.more?
- render json: @logs
+ logs, next_offset = tree_summary.fetch_logs
+
+ response.headers["More-Logs-Offset"] = next_offset if next_offset
+
+ render json: logs
end
- # The commit titles must be rendered and redacted before being shown.
- # Doing it here allows us to apply performance optimizations that avoid
- # N+1 problems
+ # Deprecated due to https://gitlab.com/gitlab-org/gitlab/-/issues/36863
+ # Will be removed soon https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29895
format.js do
- prerender_commit_full_titles!(commits)
+ @logs, _ = tree_summary.summarize
+ @more_log_url = more_url(tree_summary.next_offset) if tree_summary.more?
end
end
end
@@ -78,14 +73,6 @@ class Projects::RefsController < Projects::ApplicationController
logs_file_project_ref_path(@project, @ref, @path, offset: offset)
end
- def prerender_commit_full_titles!(commits)
- # Preload commit authors as they are used in rendering
- commits.each(&:lazy_author)
-
- renderer = Banzai::ObjectRenderer.new(user: current_user, default_project: @project)
- renderer.render(commits, :full_title)
- end
-
def validate_ref_id
return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 2418ea97409..19d0cb9acdc 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -10,7 +10,8 @@ module Projects
respond_to do |format|
format.html
format.json do
- @images = project.container_repositories
+ @images = ContainerRepositoriesFinder.new(user: current_user, subject: project, params: params.slice(:name))
+ .execute
track_event(:list_repositories)
diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb
new file mode 100644
index 00000000000..d6b4c4dd5dc
--- /dev/null
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Projects
+ module Settings
+ class AccessTokensController < Projects::ApplicationController
+ include ProjectsHelper
+
+ before_action :check_feature_availability
+
+ def index
+ @project_access_token = PersonalAccessToken.new
+ set_index_vars
+ end
+
+ def create
+ token_response = ResourceAccessTokens::CreateService.new(current_user, @project, create_params).execute
+
+ if token_response.success?
+ @project_access_token = token_response.payload[:access_token]
+ PersonalAccessToken.redis_store!(key_identity, @project_access_token.token)
+
+ redirect_to namespace_project_settings_access_tokens_path, notice: _("Your new project access token has been created.")
+ else
+ render :index
+ end
+ end
+
+ def revoke
+ @project_access_token = finder.find(params[:id])
+ revoked_response = ResourceAccessTokens::RevokeService.new(current_user, @project, @project_access_token).execute
+
+ if revoked_response.success?
+ flash[:notice] = _("Revoked project access token %{project_access_token_name}!") % { project_access_token_name: @project_access_token.name }
+ else
+ flash[:alert] = _("Could not revoke project access token %{project_access_token_name}.") % { project_access_token_name: @project_access_token.name }
+ end
+
+ redirect_to namespace_project_settings_access_tokens_path
+ end
+
+ private
+
+ def check_feature_availability
+ render_404 unless project_access_token_available?(@project)
+ end
+
+ def create_params
+ params.require(:project_access_token).permit(:name, :expires_at, scopes: [])
+ end
+
+ def set_index_vars
+ @scopes = Gitlab::Auth.resource_bot_scopes
+ @active_project_access_tokens = finder(state: 'active').execute
+ @inactive_project_access_tokens = finder(state: 'inactive', sort: 'expires_at_asc').execute
+ @new_project_access_token = PersonalAccessToken.redis_getdel(key_identity)
+ end
+
+ def finder(options = {})
+ PersonalAccessTokensFinder.new({ user: bot_users, impersonation: false }.merge(options))
+ end
+
+ def bot_users
+ @project.bots
+ end
+
+ def key_identity
+ "#{current_user.id}:#{@project.id}"
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 0aa55dcc5b9..35ca9336613 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -89,7 +89,7 @@ module Projects
end
def deploy_token_params
- params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :username)
+ params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :read_package_registry, :write_package_registry, :username)
end
def access_levels_options
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index da0e3a44f05..9233f063f55 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -52,15 +52,8 @@ class Projects::SnippetsController < Projects::ApplicationController
create_params = snippet_params.merge(spammable_params)
service_response = Snippets::CreateService.new(project, current_user, create_params).execute
@snippet = service_response.payload[:snippet]
- repository_operation_error = service_response.error? && !@snippet.persisted? && @snippet.valid?
- if repository_operation_error
- flash.now[:alert] = service_response.message
-
- render :new
- else
- recaptcha_check_with_fallback { render :new }
- end
+ handle_repository_error(:new)
end
def update
@@ -69,7 +62,7 @@ class Projects::SnippetsController < Projects::ApplicationController
service_response = Snippets::UpdateService.new(project, current_user, update_params).execute(@snippet)
@snippet = service_response.payload[:snippet]
- check_repository_error
+ handle_repository_error(:edit)
end
def show
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index b8fe2a47b30..9cb345724cc 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -16,7 +16,7 @@ class Projects::TreeController < Projects::ApplicationController
before_action :authorize_edit_tree!, only: [:create_dir]
before_action only: [:show] do
- push_frontend_feature_flag(:vue_file_list_lfs_badge)
+ push_frontend_feature_flag(:vue_file_list_lfs_badge, default_enabled: true)
end
def show
diff --git a/app/controllers/projects/usage_ping_controller.rb b/app/controllers/projects/usage_ping_controller.rb
index ebdf28bd59c..0e2c8f879b1 100644
--- a/app/controllers/projects/usage_ping_controller.rb
+++ b/app/controllers/projects/usage_ping_controller.rb
@@ -10,4 +10,10 @@ class Projects::UsagePingController < Projects::ApplicationController
head(200)
end
+
+ def web_ide_pipelines_count
+ Gitlab::UsageDataCounters::WebIdeCounter.increment_pipelines_count
+
+ head(200)
+ end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 90ff798077a..508b1f5bd0a 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -10,8 +10,9 @@ class Projects::WikisController < Projects::ApplicationController
before_action :authorize_admin_wiki!, only: :destroy
before_action :load_project_wiki
before_action :load_page, only: [:show, :edit, :update, :history, :destroy]
- before_action :valid_encoding?,
- if: -> { %w[show edit update].include?(action_name) && load_page }
+ 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
@@ -64,7 +65,7 @@ class Projects::WikisController < Projects::ApplicationController
def update
return render('empty') unless can?(current_user, :create_wiki, @project)
- @page = WikiPages::UpdateService.new(@project, current_user, wiki_params).execute(@page)
+ @page = WikiPages::UpdateService.new(container: @project, current_user: current_user, params: wiki_params).execute(@page)
if @page.valid?
redirect_to(
@@ -80,7 +81,7 @@ class Projects::WikisController < Projects::ApplicationController
end
def create
- @page = WikiPages::CreateService.new(@project, current_user, wiki_params).execute
+ @page = WikiPages::CreateService.new(container: @project, current_user: current_user, params: wiki_params).execute
if @page.persisted?
redirect_to(
@@ -111,7 +112,7 @@ class Projects::WikisController < Projects::ApplicationController
end
def destroy
- WikiPages::DestroyService.new(@project, current_user).execute(@page)
+ WikiPages::DestroyService.new(container: @project, current_user: current_user).execute(@page)
redirect_to project_wiki_path(@project, :home),
status: :found,
@@ -144,7 +145,7 @@ class Projects::WikisController < Projects::ApplicationController
@sidebar_page = @project_wiki.find_sidebar(params[:version_id])
unless @sidebar_page # Fallback to default sidebar
- @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.list_pages(limit: 15))
+ @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.")
@@ -167,7 +168,11 @@ class Projects::WikisController < Projects::ApplicationController
end
def load_page
- @page ||= @project_wiki.find_page(*page_params)
+ @page ||= find_page
+ end
+
+ def find_page
+ @project_wiki.find_page(*page_params)
end
def page_params
@@ -178,9 +183,11 @@ class Projects::WikisController < Projects::ApplicationController
end
def valid_encoding?
- strong_memoize(:valid_encoding) do
- @page.content.encoding == Encoding::UTF_8
- end
+ page_encoding == Encoding::UTF_8
+ end
+
+ def page_encoding
+ strong_memoize(:page_encoding) { @page&.content&.encoding }
end
def set_encoding_error
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index bb20ea1de49..2f86b945b06 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -36,10 +36,6 @@ class ProjectsController < Projects::ApplicationController
layout :determine_layout
- before_action do
- push_frontend_feature_flag(:metrics_dashboard_visibility_switching_available)
- end
-
def index
redirect_to(current_user ? root_path : explore_root_path)
end
@@ -62,7 +58,7 @@ class ProjectsController < Projects::ApplicationController
@project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
- cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.at(0) }
+ cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.zone.at(0) }
redirect_to(
project_path(@project, custom_import_params),
@@ -205,7 +201,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
edit_project_path(@project, anchor: 'js-export-project'),
- notice: _("Project export started. A download link will be sent by email.")
+ notice: _("Project export started. A download link will be sent by email and made available on this page.")
)
end
@@ -403,6 +399,10 @@ class ProjectsController < Projects::ApplicationController
snippets_access_level
wiki_access_level
pages_access_level
+ metrics_dashboard_access_level
+ ],
+ project_setting_attributes: %i[
+ show_default_award_emojis
]
]
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index a6c5a6d8526..ffbccbb01f2 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -8,7 +8,7 @@ class RegistrationsController < Devise::RegistrationsController
layout :choose_layout
- skip_before_action :required_signup_info, only: [:welcome, :update_registration]
+ skip_before_action :required_signup_info, :check_two_factor_requirement, only: [:welcome, :update_registration]
prepend_before_action :check_captcha, only: :create
before_action :whitelist_query_limiting, only: [:destroy]
before_action :ensure_terms_accepted,
@@ -137,7 +137,6 @@ class RegistrationsController < Devise::RegistrationsController
def check_captcha
ensure_correct_params!
- return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) # reCAPTCHA on the UI will still display however
return unless show_recaptcha_sign_up?
return unless Gitlab::Recaptcha.load_configurations!
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index 118036de230..e3dbe6fcbdf 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -4,7 +4,6 @@ module Repositories
class GitHttpController < Repositories::GitHttpClientController
include WorkhorseRequest
- before_action :snippet_request_allowed?
before_action :access_check
prepend_before_action :deny_head_requests, only: [:info_refs]
@@ -121,13 +120,6 @@ module Repositories
def log_user_activity
Users::ActivityService.new(user).execute
end
-
- def snippet_request_allowed?
- if repo_type.snippet? && Feature.disabled?(:version_snippets, user)
- Gitlab::AppLogger.info('Snippet access attempt with feature disabled')
- render plain: 'Snippet git access is disabled.', status: :forbidden
- end
- end
end
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index d1e15a72350..04d2b3068da 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -5,7 +5,6 @@ class SearchController < ApplicationController
include SearchHelper
include RendersCommits
- before_action :override_snippet_scope, only: :show
around_action :allow_gitaly_ref_name_caching
skip_before_action :authenticate_user!
@@ -104,14 +103,4 @@ class SearchController < ApplicationController
Gitlab::UsageDataCounters::SearchCounter.increment_navbar_searches_count
end
-
- # Disallow web snippet_blobs search as we migrate snippet
- # from database-backed storage to git repository-based,
- # and searching across multiple git repositories is not feasible.
- #
- # TODO: after 13.0 refactor this into Search::SnippetService
- # See https://gitlab.com/gitlab-org/gitlab/issues/208882
- def override_snippet_scope
- params[:scope] = 'snippet_titles' if params[:snippets] == 'true'
- end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 2c87c3c890f..9e8075d4bcc 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -6,6 +6,8 @@ class SessionsController < Devise::SessionsController
include Devise::Controllers::Rememberable
include Recaptcha::ClientHelper
include Recaptcha::Verify
+ include RendersLdapServers
+ include KnownSignIn
skip_before_action :check_two_factor_requirement, only: [:destroy]
# replaced with :require_no_authentication_without_flash
@@ -16,7 +18,6 @@ class SessionsController < Devise::SessionsController
if: -> { action_name == 'create' && two_factor_enabled? }
prepend_before_action :check_captcha, only: [:create]
prepend_before_action :store_redirect_uri, only: [:new]
- prepend_before_action :ldap_servers, only: [:new, :create]
prepend_before_action :require_no_authentication_without_flash, only: [:new, :create]
prepend_before_action :ensure_password_authentication_enabled!, if: -> { action_name == 'create' && password_based_login? }
@@ -27,6 +28,7 @@ class SessionsController < Devise::SessionsController
before_action :frontend_tracking_data, only: [:new]
after_action :log_failed_login, if: :action_new_and_failed_login?
+ after_action :verify_known_sign_in, only: [:create]
helper_method :captcha_enabled?, :captcha_on_login_required?
@@ -269,16 +271,6 @@ class SessionsController < Devise::SessionsController
Gitlab::Recaptcha.load_configurations!
end
- def ldap_servers
- @ldap_servers ||= begin
- if Gitlab::Auth::Ldap::Config.sign_in_enabled?
- Gitlab::Auth::Ldap::Config.available_servers
- else
- []
- end
- end
- end
-
def unverified_anonymous_user?
exceeded_failed_login_attempts? || exceeded_anonymous_sessions?
end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index a07baa1a045..425e0458b41 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -49,29 +49,22 @@ class SnippetsController < ApplicationController
end
def create
- create_params = snippet_params.merge(spammable_params)
+ create_params = snippet_params.merge(files: params.delete(:files))
service_response = Snippets::CreateService.new(nil, current_user, create_params).execute
@snippet = service_response.payload[:snippet]
- repository_operation_error = service_response.error? && !@snippet.persisted? && @snippet.valid?
- if repository_operation_error
- flash.now[:alert] = service_response.message
-
- render :new
+ if service_response.error? && @snippet.errors[:repository].present?
+ handle_repository_error(:new)
else
- move_temporary_files if @snippet.valid? && params[:files]
-
recaptcha_check_with_fallback { render :new }
end
end
def update
- update_params = snippet_params.merge(spammable_params)
-
- service_response = Snippets::UpdateService.new(nil, current_user, update_params).execute(@snippet)
+ service_response = Snippets::UpdateService.new(nil, current_user, snippet_params).execute(@snippet)
@snippet = service_response.payload[:snippet]
- check_repository_error
+ handle_repository_error(:edit)
end
def show
@@ -153,12 +146,6 @@ class SnippetsController < ApplicationController
end
def snippet_params
- params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
- end
-
- def move_temporary_files
- params[:files].each do |file|
- FileMover.new(file, from_model: current_user, to_model: @snippet).execute
- end
+ params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description).merge(spammable_params)
end
end
diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb
index 4ee75218db1..06f422b9d90 100644
--- a/app/controllers/user_callouts_controller.rb
+++ b/app/controllers/user_callouts_controller.rb
@@ -5,7 +5,7 @@ class UserCalloutsController < ApplicationController
callout = ensure_callout
if callout.persisted?
- callout.update(dismissed_at: Time.now)
+ callout.update(dismissed_at: Time.current)
respond_to do |format|
format.json { head :ok }
end
diff --git a/app/finders/alert_management/alerts_finder.rb b/app/finders/alert_management/alerts_finder.rb
new file mode 100644
index 00000000000..cb35be43c15
--- /dev/null
+++ b/app/finders/alert_management/alerts_finder.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class AlertsFinder
+ # @return [Hash<Integer,Integer>] Mapping of status id to count
+ # ex) { 0: 6, ...etc }
+ def self.counts_by_status(current_user, project, params = {})
+ new(current_user, project, params).execute.counts_by_status
+ end
+
+ def initialize(current_user, project, params)
+ @current_user = current_user
+ @project = project
+ @params = params
+ end
+
+ def execute
+ return AlertManagement::Alert.none unless authorized?
+
+ collection = project.alert_management_alerts
+ collection = by_status(collection)
+ collection = by_search(collection)
+ collection = by_iid(collection)
+ sort(collection)
+ end
+
+ private
+
+ attr_reader :current_user, :project, :params
+
+ def by_iid(collection)
+ return collection unless params[:iid]
+
+ collection.for_iid(params[:iid])
+ end
+
+ def by_status(collection)
+ values = AlertManagement::Alert::STATUSES.values & Array(params[:status])
+
+ values.present? ? collection.for_status(values) : collection
+ end
+
+ def by_search(collection)
+ params[:search].present? ? collection.search(params[:search]) : collection
+ end
+
+ def sort(collection)
+ params[:sort] ? collection.sort_by_attribute(params[:sort]) : collection
+ end
+
+ def authorized?
+ Ability.allowed?(current_user, :read_alert_management_alert, project)
+ end
+ end
+end
diff --git a/app/finders/artifacts_finder.rb b/app/finders/artifacts_finder.rb
deleted file mode 100644
index 81c5168d782..00000000000
--- a/app/finders/artifacts_finder.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-class ArtifactsFinder
- def initialize(project, params = {})
- @project = project
- @params = params
- end
-
- def execute
- artifacts = @project.job_artifacts
-
- sort(artifacts)
- end
-
- private
-
- def sort_key
- @params[:sort] || 'created_desc'
- end
-
- def sort(artifacts)
- artifacts.order_by(sort_key)
- 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
new file mode 100644
index 00000000000..3c3c24c1479
--- /dev/null
+++ b/app/finders/ci/daily_build_group_report_results_finder.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Ci
+ class DailyBuildGroupReportResultsFinder
+ include Gitlab::Allowable
+
+ def initialize(current_user:, project:, ref_path:, start_date:, end_date:, limit: nil)
+ @current_user = current_user
+ @project = project
+ @ref_path = ref_path
+ @start_date = start_date
+ @end_date = end_date
+ @limit = limit
+ end
+
+ def execute
+ return none unless can?(current_user, :download_code, project)
+
+ Ci::DailyBuildGroupReportResult.recent_results(
+ {
+ project_id: project,
+ ref_path: ref_path,
+ date: start_date..end_date
+ },
+ limit: @limit
+ )
+ end
+
+ private
+
+ attr_reader :current_user, :project, :ref_path, :start_date, :end_date
+
+ def none
+ Ci::DailyBuildGroupReportResult.none
+ end
+ end
+end
diff --git a/app/finders/ci/job_artifacts_finder.rb b/app/finders/ci/job_artifacts_finder.rb
new file mode 100644
index 00000000000..808c159ced1
--- /dev/null
+++ b/app/finders/ci/job_artifacts_finder.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Ci
+ class JobArtifactsFinder
+ def initialize(project, params = {})
+ @project = project
+ @params = params
+ end
+
+ def execute
+ artifacts = @project.job_artifacts
+
+ sort(artifacts)
+ end
+
+ private
+
+ def sort_key
+ @params[:sort] || 'created_desc'
+ end
+
+ def sort(artifacts)
+ artifacts.order_by(sort_key)
+ end
+ end
+end
diff --git a/app/finders/clusters/knative_services_finder.rb b/app/finders/clusters/knative_services_finder.rb
index 71cebe4495e..af8c42f672f 100644
--- a/app/finders/clusters/knative_services_finder.rb
+++ b/app/finders/clusters/knative_services_finder.rb
@@ -11,6 +11,7 @@ module Clusters
}.freeze
self.reactive_cache_key = ->(finder) { finder.model_name }
+ self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) }
attr_reader :cluster, :environment
diff --git a/app/finders/container_repositories_finder.rb b/app/finders/container_repositories_finder.rb
index 34921df840b..5109efb361b 100644
--- a/app/finders/container_repositories_finder.rb
+++ b/app/finders/container_repositories_finder.rb
@@ -3,17 +3,18 @@
class ContainerRepositoriesFinder
VALID_SUBJECTS = [Group, Project].freeze
- def initialize(user:, subject:)
+ def initialize(user:, subject:, params: {})
@user = user
@subject = subject
+ @params = params
end
def execute
raise ArgumentError, "invalid subject_type" unless valid_subject_type?
return unless authorized?
- return project_repositories if @subject.is_a?(Project)
- return group_repositories if @subject.is_a?(Group)
+ repositories = @subject.is_a?(Project) ? project_repositories : group_repositories
+ filter_by_image_name(repositories)
end
private
@@ -32,6 +33,12 @@ class ContainerRepositoriesFinder
ContainerRepository.for_group_and_its_subgroups(@subject)
end
+ def filter_by_image_name(repositories)
+ return repositories unless @params[:name]
+
+ repositories.search_by_name(@params[:name])
+ end
+
def authorized?
Ability.allowed?(@user, :read_container_image, @subject)
end
diff --git a/app/finders/design_management/designs_finder.rb b/app/finders/design_management/designs_finder.rb
new file mode 100644
index 00000000000..10f95520d1e
--- /dev/null
+++ b/app/finders/design_management/designs_finder.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignsFinder
+ include Gitlab::Allowable
+
+ # Params:
+ # ids: integer[]
+ # filenames: string[]
+ # visible_at_version: ?version
+ # filenames: String[]
+ def initialize(issue, current_user, params = {})
+ @issue = issue
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ items = init_collection
+
+ items = by_visible_at_version(items)
+ items = by_filename(items)
+ items = by_id(items)
+
+ items
+ end
+
+ private
+
+ attr_reader :issue, :current_user, :params
+
+ def init_collection
+ return ::DesignManagement::Design.none unless can?(current_user, :read_design, issue)
+
+ issue.designs
+ end
+
+ # Returns all designs that existed at a particular design version
+ def by_visible_at_version(items)
+ items.visible_at_version(params[:visible_at_version])
+ end
+
+ def by_filename(items)
+ return items if params[:filenames].nil?
+ return ::DesignManagement::Design.none if params[:filenames].empty?
+
+ items.with_filename(params[:filenames])
+ end
+
+ def by_id(items)
+ return items if params[:ids].nil?
+ return ::DesignManagement::Design.none if params[:ids].empty?
+
+ items.id_in(params[:ids])
+ end
+ end
+end
diff --git a/app/finders/design_management/versions_finder.rb b/app/finders/design_management/versions_finder.rb
new file mode 100644
index 00000000000..c4aefd3078e
--- /dev/null
+++ b/app/finders/design_management/versions_finder.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class VersionsFinder
+ attr_reader :design_or_collection, :current_user, :params
+
+ # The `design_or_collection` argument should be either a:
+ #
+ # - DesignManagement::Design, or
+ # - DesignManagement::DesignCollection
+ #
+ # The object will have `#versions` called on it to set up the
+ # initial scope of the versions.
+ #
+ # valid params:
+ # - earlier_or_equal_to: Version
+ # - sha: String
+ # - version_id: Integer
+ #
+ def initialize(design_or_collection, current_user, params = {})
+ @design_or_collection = design_or_collection
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ unless Ability.allowed?(current_user, :read_design, design_or_collection)
+ return ::DesignManagement::Version.none
+ end
+
+ items = design_or_collection.versions
+ items = by_earlier_or_equal_to(items)
+ items = by_sha(items)
+ items = by_version_id(items)
+ items.ordered
+ end
+
+ private
+
+ def by_earlier_or_equal_to(items)
+ return items unless params[:earlier_or_equal_to]
+
+ items.earlier_or_equal_to(params[:earlier_or_equal_to])
+ end
+
+ def by_version_id(items)
+ return items unless params[:version_id]
+
+ items.id_in(params[:version_id])
+ end
+
+ def by_sha(items)
+ return items unless params[:sha]
+
+ items.by_sha(params[:sha])
+ end
+ end
+end
diff --git a/app/finders/freeze_periods_finder.rb b/app/finders/freeze_periods_finder.rb
new file mode 100644
index 00000000000..2a9bfbe12ba
--- /dev/null
+++ b/app/finders/freeze_periods_finder.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class FreezePeriodsFinder
+ def initialize(project, current_user = nil)
+ @project = project
+ @current_user = current_user
+ end
+
+ def execute
+ return Ci::FreezePeriod.none unless Ability.allowed?(@current_user, :read_freeze_period, @project)
+
+ @project.freeze_periods
+ end
+end
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index a56d4ebb368..949af103eb3 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -9,7 +9,6 @@ class GroupMembersFinder < UnionFinder
# search: string
# created_after: datetime
# created_before: datetime
-
attr_reader :params
def initialize(group, user = nil, params: {})
@@ -22,7 +21,6 @@ class GroupMembersFinder < UnionFinder
def execute(include_relations: [:inherited, :direct])
group_members = group.members
relations = []
- @params = params
return group_members if include_relations == [:direct]
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 5687b375cf0..7014f2ec205 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -40,7 +40,7 @@ class IssuableFinder
requires_cross_project_access unless: -> { params.project? }
- NEGATABLE_PARAMS_HELPER_KEYS = %i[include_subgroups in].freeze
+ NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze
attr_accessor :current_user, :params
@@ -68,7 +68,7 @@ class IssuableFinder
# This should not be used in controller strong params!
def negatable_scalar_params
- @negatable_scalar_params ||= scalar_params + %i[project_id group_id]
+ @negatable_scalar_params ||= scalar_params - %i[search in]
end
# This should not be used in controller strong params!
@@ -100,7 +100,7 @@ class IssuableFinder
items = filter_items(items)
# Let's see if we have to negate anything
- items = by_negation(items)
+ items = filter_negated_items(items)
# This has to be last as we use a CTE as an optimization fence
# for counts by passing the force_cte param and enabling the
@@ -132,6 +132,22 @@ class IssuableFinder
by_my_reaction_emoji(items)
end
+ # Negates all params found in `negatable_params`
+ def filter_negated_items(items)
+ return items unless Feature.enabled?(:not_issuable_queries, params.group || params.project, default_enabled: true)
+
+ # API endpoints send in `nil` values so we test if there are any non-nil
+ return items unless not_params.present? && not_params.values.any?
+
+ items = by_negated_author(items)
+ items = by_negated_assignee(items)
+ items = by_negated_label(items)
+ items = by_negated_milestone(items)
+ items = by_negated_release(items)
+ items = by_negated_my_reaction_emoji(items)
+ by_negated_iids(items)
+ end
+
def row_count
Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state])
end
@@ -189,6 +205,21 @@ class IssuableFinder
private
+ def not_params
+ strong_memoize(:not_params) do
+ params_class.new(params[:not].dup, current_user, klass).tap do |not_params|
+ next unless not_params.present?
+
+ # These are "helper" params that modify the results, like :in and :search. They usually come in at the top-level
+ # params, but if they do come in inside the `:not` params, the inner ones should take precedence.
+ not_helpers = params.slice(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].slice(*NEGATABLE_PARAMS_HELPER_KEYS))
+ not_helpers.each do |key, value|
+ not_params[key] = value unless not_params[key].present?
+ end
+ end
+ end
+ end
+
def force_cte?
!!params[:force_cte]
end
@@ -215,33 +246,6 @@ class IssuableFinder
klass.available_states.key(value)
end
- # Negates all params found in `negatable_params`
- # rubocop: disable CodeReuse/ActiveRecord
- def by_negation(items)
- not_params = params[:not].dup
- # API endpoints send in `nil` values so we test if there are any non-nil
- return items unless not_params.present? && not_params.values.any?
-
- not_params.keep_if { |_k, v| v.present? }.each do |(key, value)|
- # These aren't negatable params themselves, but rather help other searches, so we skip them.
- # They will be added into all the NOT searches.
- next if NEGATABLE_PARAMS_HELPER_KEYS.include?(key.to_sym)
- next unless self.class.negatable_params.include?(key.to_sym)
-
- # These are "helper" params that are required inside the NOT to get the right results. They usually come in
- # at the top-level params, but if they do come in inside the `:not` params, they should take precedence.
- not_helpers = params.slice(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].slice(*NEGATABLE_PARAMS_HELPER_KEYS))
- not_param = { key => value }.with_indifferent_access.merge(not_helpers).merge(not_query: true)
-
- items_to_negate = self.class.new(current_user, not_param).execute
-
- items = items.where.not(id: items_to_negate)
- end
-
- items
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
# rubocop: disable CodeReuse/ActiveRecord
def by_scope(items)
return items.none if params.current_user_related? && !current_user
@@ -326,6 +330,12 @@ class IssuableFinder
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
+ def by_negated_iids(items)
+ not_params[:iids].present? ? items.where.not(iid: not_params[:iids]) : items
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
def sort(items)
# Ensure we always have an explicit sort order (instead of inheriting
# multiple orders when combining ActiveRecord::Relation objects).
@@ -347,9 +357,19 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- def by_assignee(items)
- return items.assigned_to(params.assignees) if not_query? && params.assignees.any?
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_negated_author(items)
+ if not_params.author
+ items.where.not(author_id: not_params.author.id)
+ elsif not_params.author_id? || not_params.author_username? # author not found
+ items.none
+ else
+ items
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ def by_assignee(items)
if params.filter_by_no_assignee?
items.unassigned
elsif params.filter_by_any_assignee?
@@ -363,6 +383,17 @@ class IssuableFinder
end
end
+ def by_negated_assignee(items)
+ # We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB"
+ if not_params.assignees.present?
+ items.not_assigned_to(not_params.assignees)
+ elsif not_params.assignee_id? || not_params.assignee_username? # assignee not found
+ items.none
+ else
+ items
+ end
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_milestone(items)
return items unless params.milestones?
@@ -382,6 +413,20 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_negated_milestone(items)
+ return items unless not_params.milestones?
+
+ if not_params.filter_by_upcoming_milestone?
+ items.joins(:milestone).merge(Milestone.not_upcoming)
+ elsif not_params.filter_by_started_milestone?
+ items.joins(:milestone).merge(Milestone.not_started)
+ else
+ items.without_particular_milestone(not_params[:milestone_title])
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def by_release(items)
return items unless params.releases?
@@ -394,6 +439,12 @@ class IssuableFinder
end
end
+ def by_negated_release(items)
+ return items unless not_params.releases?
+
+ items.without_particular_release(not_params[:release_tag], not_params[:project_id])
+ end
+
def by_label(items)
return items unless params.labels?
@@ -402,10 +453,16 @@ class IssuableFinder
elsif params.filter_by_any_label?
items.any_label
else
- items.with_label(params.label_names, params[:sort], not_query: not_query?)
+ items.with_label(params.label_names, params[:sort])
end
end
+ def by_negated_label(items)
+ return items unless not_params.labels?
+
+ items.without_particular_labels(not_params.label_names)
+ end
+
def by_my_reaction_emoji(items)
return items unless params[:my_reaction_emoji] && current_user
@@ -418,11 +475,13 @@ class IssuableFinder
end
end
- def by_non_archived(items)
- params[:non_archived].present? ? items.non_archived : items
+ def by_negated_my_reaction_emoji(items)
+ return items unless not_params[:my_reaction_emoji] && current_user
+
+ items.not_awarded(current_user, not_params[:my_reaction_emoji])
end
- def not_query?
- !!params[:not_query]
+ def by_non_archived(items)
+ params[:non_archived].present? ? items.non_archived : items
end
end
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index 120ef364368..adf9f1ca9d8 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -132,6 +132,8 @@ class IssuableFinder
def project
strong_memoize(:project) do
+ next nil unless params[:project_id].present?
+
project = Project.find(params[:project_id])
project = nil unless Ability.allowed?(current_user, :"read_#{klass.to_ability_name}", project)
diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb
index aaeead7c709..cd92b79265d 100644
--- a/app/finders/issues_finder/params.rb
+++ b/app/finders/issues_finder/params.rb
@@ -50,4 +50,4 @@ class IssuesFinder
end
end
-IssuableFinder::Params.prepend_if_ee('EE::IssuesFinder::Params')
+IssuesFinder::Params.prepend_if_ee('EE::IssuesFinder::Params')
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index 0617f34dc8c..e08ed737ca6 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -4,17 +4,19 @@ class MembersFinder
# Params can be any of the following:
# sort: string
# search: string
+ attr_reader :params
- def initialize(project, current_user)
+ def initialize(project, current_user, params: {})
@project = project
- @current_user = current_user
@group = project.group
+ @current_user = current_user
+ @params = params
end
- def execute(include_relations: [:inherited, :direct], params: {})
- members = find_members(include_relations, params)
+ def execute(include_relations: [:inherited, :direct])
+ members = find_members(include_relations)
- filter_members(members, params)
+ filter_members(members)
end
def can?(*args)
@@ -25,7 +27,7 @@ class MembersFinder
attr_reader :project, :current_user, :group
- def find_members(include_relations, params)
+ def find_members(include_relations)
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
@@ -39,7 +41,7 @@ class MembersFinder
distinct_union_of_members(union_members)
end
- def filter_members(members, params)
+ def filter_members(members)
members = members.search(params[:search]) if params[:search].present?
members = members.sort_by_attribute(params[:sort]) if params[:sort].present?
members
diff --git a/app/finders/metrics/users_starred_dashboards_finder.rb b/app/finders/metrics/users_starred_dashboards_finder.rb
new file mode 100644
index 00000000000..7244c51f9a7
--- /dev/null
+++ b/app/finders/metrics/users_starred_dashboards_finder.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Metrics
+ class UsersStarredDashboardsFinder
+ def initialize(user:, project:, params: {})
+ @user, @project, @params = user, project, params
+ end
+
+ def execute
+ return ::Metrics::UsersStarredDashboard.none unless Ability.allowed?(user, :read_metrics_user_starred_dashboard, project)
+
+ items = starred_dashboards
+ items = by_project(items)
+ by_dashboard(items)
+ end
+
+ private
+
+ attr_reader :user, :project, :params
+
+ def by_project(items)
+ items.for_project(project)
+ end
+
+ def by_dashboard(items)
+ return items unless params[:dashboard_path]
+
+ items.merge(starred_dashboards.for_project_dashboard(project, params[:dashboard_path]))
+ end
+
+ def starred_dashboards
+ @starred_dashboards ||= user.metrics_users_starred_dashboards
+ end
+ end
+end
diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb
index 3b4ecbb5387..13f84e0e3a5 100644
--- a/app/finders/projects/serverless/functions_finder.rb
+++ b/app/finders/projects/serverless/functions_finder.rb
@@ -9,6 +9,7 @@ module Projects
attr_reader :project
self.reactive_cache_key = ->(finder) { finder.cache_key }
+ self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
MAX_CLUSTERS = 10
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 3a84600b09f..8846ff54eb2 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -151,11 +151,11 @@ class ProjectsFinder < UnionFinder
end
def by_personal(items)
- (params[:personal].present? && current_user) ? items.personal(current_user) : items
+ params[:personal].present? && current_user ? items.personal(current_user) : items
end
def by_starred(items)
- (params[:starred].present? && current_user) ? items.starred_by(current_user) : items
+ params[:starred].present? && current_user ? items.starred_by(current_user) : items
end
def by_trending(items)
diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb
index e58a90922a5..6a754fdb5a1 100644
--- a/app/finders/releases_finder.rb
+++ b/app/finders/releases_finder.rb
@@ -1,17 +1,31 @@
# frozen_string_literal: true
class ReleasesFinder
- def initialize(project, current_user = nil)
+ attr_reader :project, :current_user, :params
+
+ def initialize(project, current_user = nil, params = {})
@project = project
@current_user = current_user
+ @params = params
end
def execute(preload: true)
- return Release.none unless Ability.allowed?(@current_user, :read_release, @project)
+ return Release.none unless Ability.allowed?(current_user, :read_release, project)
# See https://gitlab.com/gitlab-org/gitlab/-/issues/211988
- releases = @project.releases.where.not(tag: nil) # rubocop:disable CodeReuse/ActiveRecord
+ releases = project.releases.where.not(tag: nil) # rubocop:disable CodeReuse/ActiveRecord
+ releases = by_tag(releases)
releases = releases.preloaded if preload
releases.sorted
end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_tag(releases)
+ return releases unless params[:tag].present?
+
+ releases.where(tag: params[:tag])
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index e56009be33d..672bbd52b07 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -23,7 +23,7 @@ class TodosFinder
NONE = '0'
- TODO_TYPES = Set.new(%w(Issue MergeRequest)).freeze
+ TODO_TYPES = Set.new(%w(Issue MergeRequest DesignManagement::Design)).freeze
attr_accessor :current_user, :params
diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb
new file mode 100644
index 00000000000..ca2057d4845
--- /dev/null
+++ b/app/graphql/mutations/alert_management/base.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ class Base < BaseMutation
+ include Mutations::ResolvesProject
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: "The project the alert to mutate is in"
+
+ argument :iid, GraphQL::STRING_TYPE,
+ required: true,
+ description: "The iid of the alert to mutate"
+
+ field :alert,
+ Types::AlertManagement::AlertType,
+ null: true,
+ description: "The alert after mutation"
+
+ field :issue,
+ Types::IssueType,
+ null: true,
+ description: "The issue created after mutation"
+
+ authorize :update_alert_management_alert
+
+ private
+
+ def find_object(project_path:, iid:)
+ project = resolve_project(full_path: project_path)
+
+ return unless project
+
+ resolver = Resolvers::AlertManagementAlertResolver.single.new(object: project, context: context, field: nil)
+ resolver.resolve(iid: iid)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/create_alert_issue.rb b/app/graphql/mutations/alert_management/create_alert_issue.rb
new file mode 100644
index 00000000000..adb048a4479
--- /dev/null
+++ b/app/graphql/mutations/alert_management/create_alert_issue.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ class CreateAlertIssue < Base
+ graphql_name 'CreateAlertIssue'
+
+ def resolve(args)
+ alert = authorized_find!(project_path: args[:project_path], iid: args[:iid])
+ result = create_alert_issue(alert, current_user)
+
+ prepare_response(alert, result)
+ end
+
+ private
+
+ def create_alert_issue(alert, user)
+ ::AlertManagement::CreateAlertIssueService.new(alert, user).execute
+ end
+
+ def prepare_response(alert, result)
+ {
+ alert: alert,
+ issue: result.payload[:issue],
+ errors: Array(result.message)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb
new file mode 100644
index 00000000000..e73a591378a
--- /dev/null
+++ b/app/graphql/mutations/alert_management/update_alert_status.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ class UpdateAlertStatus < Base
+ graphql_name 'UpdateAlertStatus'
+
+ argument :status, Types::AlertManagement::StatusEnum,
+ required: true,
+ description: 'The status to set the alert'
+
+ def resolve(args)
+ alert = authorized_find!(project_path: args[:project_path], iid: args[:iid])
+ result = update_status(alert, args[:status])
+
+ prepare_response(result)
+ end
+
+ private
+
+ def update_status(alert, status)
+ ::AlertManagement::UpdateAlertStatusService
+ .new(alert, current_user, status)
+ .execute
+ end
+
+ def prepare_response(result)
+ {
+ alert: result.payload[:alert],
+ errors: result.error? ? [result.message] : []
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index 623f7c27584..30510cfab50 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: "Reasons why the mutation failed."
+ 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
new file mode 100644
index 00000000000..127d5447d0a
--- /dev/null
+++ b/app/graphql/mutations/branches/create.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Branches
+ class Create < BaseMutation
+ include Mutations::ResolvesProject
+
+ graphql_name 'CreateBranch'
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Project full path the branch is associated with'
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Name of the branch'
+
+ argument :ref,
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Branch name or commit SHA to create branch from'
+
+ field :branch,
+ Types::BranchType,
+ null: true,
+ description: 'Branch after mutation'
+
+ authorize :push_code
+
+ def resolve(project_path:, name:, ref:)
+ project = authorized_find!(full_path: project_path)
+
+ context.scoped_set!(:branch_project, project)
+
+ result = ::Branches::CreateService.new(project, current_user)
+ .execute(name, ref)
+
+ {
+ branch: (result[:branch] 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/design_management/base.rb b/app/graphql/mutations/design_management/base.rb
new file mode 100644
index 00000000000..918e5709b94
--- /dev/null
+++ b/app/graphql/mutations/design_management/base.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DesignManagement
+ class Base < ::Mutations::BaseMutation
+ include Mutations::ResolvesIssuable
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: "The project where the issue is to upload designs for"
+
+ argument :iid, GraphQL::ID_TYPE,
+ required: true,
+ description: "The iid of the issue to modify designs for"
+
+ private
+
+ def find_object(project_path:, iid:)
+ resolve_issuable(type: :issue, parent_path: project_path, iid: iid)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/design_management/delete.rb b/app/graphql/mutations/design_management/delete.rb
new file mode 100644
index 00000000000..d2ef2c9bcca
--- /dev/null
+++ b/app/graphql/mutations/design_management/delete.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DesignManagement
+ class Delete < Base
+ Errors = ::Gitlab::Graphql::Errors
+
+ graphql_name "DesignManagementDelete"
+
+ argument :filenames, [GraphQL::STRING_TYPE],
+ required: true,
+ description: "The filenames of the designs to delete",
+ prepare: ->(names, _ctx) do
+ names.presence || (raise Errors::ArgumentError, 'no filenames')
+ end
+
+ field :version, Types::DesignManagement::VersionType,
+ null: true, # null on error
+ description: 'The new version in which the designs are deleted'
+
+ authorize :destroy_design
+
+ def resolve(project_path:, iid:, filenames:)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+ project = issue.project
+ designs = resolve_designs(issue, filenames)
+
+ result = ::DesignManagement::DeleteDesignsService
+ .new(project, current_user, issue: issue, designs: designs)
+ .execute
+
+ {
+ version: result[:version],
+ errors: Array.wrap(result[:message])
+ }
+ end
+
+ private
+
+ # Here we check that:
+ # * we find exactly as many designs as filenames
+ def resolve_designs(issue, filenames)
+ designs = issue.design_collection.designs_by_filename(filenames)
+
+ validate_all_were_found!(designs, filenames)
+
+ designs
+ end
+
+ def validate_all_were_found!(designs, filenames)
+ found_filenames = designs.map(&:filename)
+ missing = filenames.difference(found_filenames)
+
+ if missing.present?
+ raise Errors::ArgumentError, <<~MSG
+ Not all the designs you named currently exist.
+ The following filenames were not found:
+ #{missing.join(', ')}
+
+ They may have already been deleted.
+ MSG
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/design_management/upload.rb b/app/graphql/mutations/design_management/upload.rb
new file mode 100644
index 00000000000..1ed7f8e49e6
--- /dev/null
+++ b/app/graphql/mutations/design_management/upload.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DesignManagement
+ class Upload < Base
+ graphql_name "DesignManagementUpload"
+
+ argument :files, [ApolloUploadServer::Upload],
+ required: true,
+ description: "The files to upload"
+
+ authorize :create_design
+
+ field :designs, [Types::DesignManagement::DesignType],
+ null: false,
+ description: "The designs that were uploaded by the mutation"
+
+ field :skipped_designs, [Types::DesignManagement::DesignType],
+ null: false,
+ description: "Any designs that were skipped from the upload due to there " \
+ "being no change to their content since their last version"
+
+ def resolve(project_path:, iid:, files:)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+ project = issue.project
+
+ result = ::DesignManagement::SaveDesignsService.new(project, current_user, issue: issue, files: files)
+ .execute
+
+ {
+ designs: Array.wrap(result[:designs]),
+ skipped_designs: Array.wrap(result[:skipped_designs]),
+ errors: Array.wrap(result[:message])
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
new file mode 100644
index 00000000000..f99688aeac6
--- /dev/null
+++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Metrics
+ module Dashboard
+ module Annotations
+ class Create < BaseMutation
+ graphql_name 'CreateAnnotation'
+
+ ANNOTATION_SOURCE_ARGUMENT_ERROR = 'Either a cluster or environment global id is required'
+ INVALID_ANNOTATION_SOURCE_ERROR = 'Invalid cluster or environment id'
+
+ authorize :create_metrics_dashboard_annotation
+
+ field :annotation,
+ Types::Metrics::Dashboards::AnnotationType,
+ null: true,
+ description: 'The created annotation'
+
+ argument :environment_id,
+ GraphQL::ID_TYPE,
+ required: false,
+ description: 'The global id of the environment to add an annotation to'
+
+ argument :cluster_id,
+ GraphQL::ID_TYPE,
+ required: false,
+ description: 'The global id of the cluster to add an annotation to'
+
+ argument :starting_at, Types::TimeType,
+ required: true,
+ description: 'Timestamp indicating starting moment to which the annotation relates'
+
+ argument :ending_at, Types::TimeType,
+ required: false,
+ description: 'Timestamp indicating ending moment to which the annotation relates'
+
+ argument :dashboard_path,
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: 'The path to a file defining the dashboard on which the annotation should be added'
+
+ argument :description,
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: 'The description of the annotation'
+
+ AnnotationSource = Struct.new(:object, keyword_init: true) do
+ def type_keys
+ { 'Clusters::Cluster' => :cluster, 'Environment' => :environment }
+ end
+
+ def klass
+ object.class.name
+ end
+
+ def type
+ raise Gitlab::Graphql::Errors::ArgumentError, INVALID_ANNOTATION_SOURCE_ERROR unless type_keys[klass]
+
+ type_keys[klass]
+ end
+ end
+
+ def resolve(args)
+ annotation_response = ::Metrics::Dashboard::Annotations::CreateService.new(context[:current_user], annotation_create_params(args)).execute
+
+ annotation = annotation_response[:annotation]
+
+ {
+ annotation: annotation.valid? ? annotation : nil,
+ errors: errors_on_object(annotation)
+ }
+ end
+
+ private
+
+ def ready?(**args)
+ # Raise error if both cluster_id and environment_id are present or neither is present
+ unless args[:cluster_id].present? ^ args[:environment_id].present?
+ raise Gitlab::Graphql::Errors::ArgumentError, ANNOTATION_SOURCE_ARGUMENT_ERROR
+ end
+
+ super(args)
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id)
+ end
+
+ def annotation_create_params(args)
+ annotation_source = AnnotationSource.new(object: annotation_source(args))
+
+ args[annotation_source.type] = annotation_source.object
+
+ args
+ end
+
+ def annotation_source(args)
+ annotation_source_id = args[:cluster_id] || args[:environment_id]
+ authorized_find!(id: annotation_source_id)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/base.rb b/app/graphql/mutations/snippets/base.rb
index 9dc6d49774e..c8cc721b2e0 100644
--- a/app/graphql/mutations/snippets/base.rb
+++ b/app/graphql/mutations/snippets/base.rb
@@ -15,6 +15,8 @@ module Mutations
end
def authorized_resource?(snippet)
+ return false if snippet.nil?
+
Ability.allowed?(context[:current_user], ability_for(snippet), snippet)
end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 266a123de82..6fc223fbee7 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -36,6 +36,10 @@ module Mutations
required: false,
description: 'The project full path the snippet is associated with'
+ argument :uploaded_files, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'The paths to files uploaded in the snippet description'
+
def resolve(args)
project_path = args.delete(:project_path)
@@ -45,9 +49,14 @@ module Mutations
raise_resource_not_available_error!
end
+ # We need to rename `uploaded_files` into `files` because
+ # it's the expected key param
+ args[:files] = args.delete(:uploaded_files)
+
service_response = ::Snippets::CreateService.new(project,
context[:current_user],
args).execute
+
snippet = service_response.payload[:snippet]
{
diff --git a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
new file mode 100644
index 00000000000..7f4346632ca
--- /dev/null
+++ b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module AlertManagement
+ class AlertStatusCountsResolver < BaseResolver
+ type Types::AlertManagement::AlertStatusCountsType, null: true
+
+ def resolve(**args)
+ ::Gitlab::AlertManagement::AlertStatusCounts.new(context[:current_user], object, args)
+ end
+ 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..51ebbb96476
--- /dev/null
+++ b/app/graphql/resolvers/alert_management_alert_resolver.rb
@@ -0,0 +1,31 @@
+# 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/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
new file mode 100644
index 00000000000..f8d62ba86af
--- /dev/null
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class BoardListsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::BoardListType, null: true
+
+ alias_method :board, :object
+
+ def resolve(lookahead: nil)
+ authorize!(board)
+
+ lists = board_lists
+
+ if load_preferences?(lookahead)
+ List.preload_preferences_for_user(lists, context[:current_user])
+ end
+
+ Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(lists)
+ end
+
+ private
+
+ def board_lists
+ service = Boards::Lists::ListService.new(board.resource_parent, context[:current_user])
+ service.execute(board, create_default_lists: false)
+ end
+
+ def authorized_resource?(board)
+ Ability.allowed?(context[:current_user], :read_list, board)
+ end
+
+ def load_preferences?(lookahead)
+ lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/branch_commit_resolver.rb b/app/graphql/resolvers/branch_commit_resolver.rb
new file mode 100644
index 00000000000..11c49e17bc5
--- /dev/null
+++ b/app/graphql/resolvers/branch_commit_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class BranchCommitResolver < BaseResolver
+ type Types::CommitType, null: true
+
+ alias_method :branch, :object
+
+ def resolve(**args)
+ return unless branch
+
+ commit = branch.dereferenced_target
+
+ ::Commit.new(commit, context[:branch_project]) if commit
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/design_at_version_resolver.rb
new file mode 100644
index 00000000000..fd9b349f974
--- /dev/null
+++ b/app/graphql/resolvers/design_management/design_at_version_resolver.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class DesignAtVersionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::DesignAtVersionType, null: false
+
+ authorize :read_design
+
+ argument :id, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The Global ID of the design at this version'
+
+ def resolve(id:)
+ authorized_find!(id: id)
+ end
+
+ def find_object(id:)
+ dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
+ return unless consistent?(dav)
+
+ dav
+ end
+
+ def self.single
+ self
+ end
+
+ private
+
+ # If this resolver is mounted on something that has an issue
+ # (such as design collection for instance), then we should check
+ # that the DesignAtVersion as found by its ID does in fact belong
+ # to this issue.
+ def consistent?(dav)
+ issue.nil? || (dav&.design&.issue_id == issue.id)
+ end
+
+ def issue
+ object&.issue
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/design_resolver.rb b/app/graphql/resolvers/design_management/design_resolver.rb
new file mode 100644
index 00000000000..05bdbbbe407
--- /dev/null
+++ b/app/graphql/resolvers/design_management/design_resolver.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class DesignResolver < BaseResolver
+ argument :id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'Find a design by its ID'
+
+ argument :filename, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Find a design by its filename'
+
+ def resolve(filename: nil, id: nil)
+ params = parse_args(filename, id)
+
+ build_finder(params).execute.first
+ end
+
+ def self.single
+ self
+ end
+
+ private
+
+ def issue
+ object.issue
+ end
+
+ def build_finder(params)
+ ::DesignManagement::DesignsFinder.new(issue, current_user, params)
+ end
+
+ def error(msg)
+ raise ::Gitlab::Graphql::Errors::ArgumentError, msg
+ end
+
+ def parse_args(filename, id)
+ provided = [filename, id].map(&:present?)
+
+ if provided.none?
+ error('one of id or filename must be passed')
+ elsif provided.all?
+ error('only one of id or filename may be passed')
+ elsif filename.present?
+ { filenames: [filename] }
+ else
+ { ids: [parse_gid(id)] }
+ end
+ end
+
+ def parse_gid(gid)
+ GitlabSchema.parse_gid(gid, expected_type: ::DesignManagement::Design).model_id
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/designs_resolver.rb b/app/graphql/resolvers/design_management/designs_resolver.rb
new file mode 100644
index 00000000000..81f94d5cb30
--- /dev/null
+++ b/app/graphql/resolvers/design_management/designs_resolver.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class DesignsResolver < BaseResolver
+ argument :ids,
+ [GraphQL::ID_TYPE],
+ required: false,
+ description: 'Filters designs by their ID'
+ argument :filenames,
+ [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Filters designs by their filename'
+ argument :at_version,
+ GraphQL::ID_TYPE,
+ required: false,
+ description: 'Filters designs to only those that existed at the version. ' \
+ 'If argument is omitted or nil then all designs will reflect the latest version'
+
+ def self.single
+ ::Resolvers::DesignManagement::DesignResolver
+ end
+
+ def resolve(ids: nil, filenames: nil, at_version: nil)
+ ::DesignManagement::DesignsFinder.new(
+ issue,
+ current_user,
+ ids: design_ids(ids),
+ filenames: filenames,
+ visible_at_version: version(at_version),
+ order: :id
+ ).execute
+ end
+
+ private
+
+ def version(at_version)
+ GitlabSchema.object_from_id(at_version)&.sync if at_version
+ end
+
+ def design_ids(ids)
+ ids&.map { |id| GlobalID.parse(id).model_id }
+ end
+
+ def issue
+ object.issue
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
new file mode 100644
index 00000000000..03f7908780c
--- /dev/null
+++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ module Version
+ # Resolver for a DesignAtVersion object given an implicit version context
+ class DesignAtVersionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::DesignAtVersionType, null: true
+
+ authorize :read_design
+
+ argument :id, GraphQL::ID_TYPE,
+ required: false,
+ as: :design_at_version_id,
+ description: 'The ID of the DesignAtVersion'
+ argument :design_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The ID of a specific design'
+ argument :filename, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The filename of a specific design'
+
+ def self.single
+ self
+ end
+
+ def resolve(design_id: nil, filename: nil, design_at_version_id: nil)
+ validate_arguments(design_id, filename, design_at_version_id)
+
+ return unless Ability.allowed?(current_user, :read_design, issue)
+ return specific_design_at_version(design_at_version_id) if design_at_version_id
+
+ find(design_id, filename).map { |d| make(d) }.first
+ end
+
+ private
+
+ def validate_arguments(design_id, filename, design_at_version_id)
+ args = { filename: filename, id: design_at_version_id, design_id: design_id }
+ passed = args.compact.keys
+
+ return if passed.size == 1
+
+ msg = "Exactly one of #{args.keys.join(', ')} expected, got #{passed}"
+
+ raise Gitlab::Graphql::Errors::ArgumentError, msg
+ end
+
+ def specific_design_at_version(id)
+ dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
+ return unless consistent?(dav)
+
+ dav
+ end
+
+ # Test that the DAV found by ID actually belongs on this version, and
+ # that it is visible at this version.
+ def consistent?(dav)
+ return false unless dav.present?
+
+ dav.design.issue_id == issue.id &&
+ dav.version.id == version.id &&
+ dav.design.visible_in?(version)
+ end
+
+ def find(id, filename)
+ ids = [parse_design_id(id).model_id] if id
+ filenames = [filename] if filename
+
+ ::DesignManagement::DesignsFinder
+ .new(issue, current_user, ids: ids, filenames: filenames, visible_at_version: version)
+ .execute
+ end
+
+ def parse_design_id(id)
+ GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
+ end
+
+ def issue
+ version.issue
+ end
+
+ def version
+ object
+ end
+
+ def make(design)
+ ::DesignManagement::DesignAtVersion.new(design: design, version: version)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb
new file mode 100644
index 00000000000..5ccb2f3e311
--- /dev/null
+++ b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ module Version
+ # Resolver for DesignAtVersion objects given an implicit version context
+ class DesignsAtVersionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::DesignAtVersionType, null: true
+
+ authorize :read_design
+
+ argument :ids,
+ [GraphQL::ID_TYPE],
+ required: false,
+ description: 'Filters designs by their ID'
+ argument :filenames,
+ [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Filters designs by their filename'
+
+ def self.single
+ ::Resolvers::DesignManagement::Version::DesignAtVersionResolver
+ end
+
+ def resolve(ids: nil, filenames: nil)
+ find(ids, filenames).execute.map { |d| make(d) }
+ end
+
+ private
+
+ def find(ids, filenames)
+ ids = ids&.map { |id| parse_design_id(id).model_id }
+
+ ::DesignManagement::DesignsFinder.new(issue, current_user,
+ ids: ids,
+ filenames: filenames,
+ visible_at_version: version)
+ end
+
+ def parse_design_id(id)
+ GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
+ end
+
+ def issue
+ version.issue
+ end
+
+ def version
+ object
+ end
+
+ def make(design)
+ ::DesignManagement::DesignAtVersion.new(design: design, version: version)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
new file mode 100644
index 00000000000..9e729172881
--- /dev/null
+++ b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class VersionInCollectionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::VersionType, null: true
+
+ authorize :read_design
+
+ alias_method :collection, :object
+
+ argument :sha, GraphQL::STRING_TYPE,
+ required: false,
+ description: "The SHA256 of a specific version"
+ argument :id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The Global ID of the version'
+
+ def resolve(id: nil, sha: nil)
+ check_args(id, sha)
+
+ gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
+
+ ::DesignManagement::VersionsFinder
+ .new(collection, current_user, sha: sha, version_id: gid&.model_id)
+ .execute
+ .first
+ end
+
+ def self.single
+ self
+ end
+
+ private
+
+ def check_args(id, sha)
+ return if id.present? || sha.present?
+
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'one of id or sha is required'
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/version_resolver.rb b/app/graphql/resolvers/design_management/version_resolver.rb
new file mode 100644
index 00000000000..b0e0843e6c8
--- /dev/null
+++ b/app/graphql/resolvers/design_management/version_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class VersionResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::DesignManagement::VersionType, null: true
+
+ authorize :read_design
+
+ argument :id, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The Global ID of the version'
+
+ def resolve(id:)
+ authorized_find!(id: id)
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/versions_resolver.rb b/app/graphql/resolvers/design_management/versions_resolver.rb
new file mode 100644
index 00000000000..a62258dad5c
--- /dev/null
+++ b/app/graphql/resolvers/design_management/versions_resolver.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DesignManagement
+ class VersionsResolver < BaseResolver
+ type Types::DesignManagement::VersionType.connection_type, null: false
+
+ alias_method :design_or_collection, :object
+
+ argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE,
+ as: :sha,
+ required: false,
+ description: 'The SHA256 of the most recent acceptable version'
+
+ argument :earlier_or_equal_to_id, GraphQL::ID_TYPE,
+ as: :id,
+ required: false,
+ description: 'The Global ID of the most recent acceptable version'
+
+ # This resolver has a custom singular resolver
+ def self.single
+ ::Resolvers::DesignManagement::VersionInCollectionResolver
+ end
+
+ def resolve(parent: nil, id: nil, sha: nil)
+ version = cutoff(parent, id, sha)
+
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'cutoff not found' unless version.present?
+
+ if version == :unconstrained
+ find
+ else
+ find(earlier_or_equal_to: version)
+ end
+ end
+
+ private
+
+ # Find the most recent version that the client will accept
+ def cutoff(parent, id, sha)
+ if sha.present? || id.present?
+ specific_version(id, sha)
+ elsif at_version = at_version_arg(parent)
+ by_id(at_version)
+ else
+ :unconstrained
+ end
+ end
+
+ def specific_version(id, sha)
+ gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
+ find(sha: sha, version_id: gid&.model_id).first
+ end
+
+ def find(**params)
+ ::DesignManagement::VersionsFinder
+ .new(design_or_collection, current_user, params)
+ .execute
+ end
+
+ def by_id(id)
+ GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version).sync
+ end
+
+ # Find an `at_version` argument passed to a parent node.
+ #
+ # If one is found, then a design collection further up the AST
+ # has been filtered to reflect designs at that version, and so
+ # for consistency we should only present versions up to the given
+ # version here.
+ def at_version_arg(parent)
+ ::Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 04da54a6bb6..f103da07666 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -52,6 +52,10 @@ module Resolvers
type Types::IssueType, null: true
+ NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc
+ label_priority_asc label_priority_desc
+ milestone_due_asc milestone_due_desc].freeze
+
def resolve(**args)
# The project could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project to query for issues, so
@@ -70,7 +74,15 @@ module Resolvers
args[:iids] ||= [args[:iid]].compact
args[:attempt_project_search_optimizations] = args[:search].present?
- IssuesFinder.new(context[:current_user], args).execute
+ issues = IssuesFinder.new(context[:current_user], args).execute
+
+ if non_stable_cursor_sort?(args[:sort])
+ # Certain complex sorts are not supported by the stable cursor pagination yet.
+ # In these cases, we use offset pagination, so we return the correct connection.
+ Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(issues)
+ else
+ issues
+ end
end
def self.resolver_complexity(args, child_complexity:)
@@ -79,5 +91,9 @@ module Resolvers
complexity
end
+
+ def non_stable_cursor_sort?(sort)
+ NON_STABLE_CURSOR_SORTS.include?(sort)
+ end
end
end
diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
index 068323a3073..2dd224bb17b 100644
--- a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
+++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
@@ -18,7 +18,6 @@ module Resolvers
def resolve(**args)
return [] unless dashboard
- return [] unless Feature.enabled?(:metrics_dashboard_annotations, dashboard.environment&.project)
::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute
end
diff --git a/app/graphql/resolvers/milestone_resolver.rb b/app/graphql/resolvers/milestone_resolver.rb
index 2e7b6fdfd5f..6c6513e0ee4 100644
--- a/app/graphql/resolvers/milestone_resolver.rb
+++ b/app/graphql/resolvers/milestone_resolver.rb
@@ -9,6 +9,10 @@ module Resolvers
required: false,
description: 'Filter milestones by state'
+ argument :include_descendants, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'Return also milestones in all subgroups and subprojects'
+
type Types::MilestoneType, null: true
def resolve(**args)
@@ -26,16 +30,16 @@ module Resolvers
state: args[:state] || 'all',
start_date: args[:start_date],
end_date: args[:end_date]
- }.merge(parent_id_parameter)
+ }.merge(parent_id_parameter(args))
end
def parent
@parent ||= object.respond_to?(:sync) ? object.sync : object
end
- def parent_id_parameter
+ def parent_id_parameter(args)
if parent.is_a?(Group)
- { group_ids: parent.id }
+ group_parameters(args)
elsif parent.is_a?(Project)
{ project_ids: parent.id }
end
@@ -46,5 +50,26 @@ module Resolvers
def authorize!
Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error!
end
+
+ def group_parameters(args)
+ return { group_ids: parent.id } unless include_descendants?(args)
+
+ {
+ group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id),
+ project_ids: group_projects.with_issues_or_mrs_available_for_user(current_user)
+ }
+ end
+
+ def include_descendants?(args)
+ args[:include_descendants].present? && Feature.enabled?(:group_milestone_descendants, parent)
+ end
+
+ def group_projects
+ GroupProjectsFinder.new(
+ group: parent,
+ current_user: current_user,
+ options: { include_subgroups: true }
+ ).execute
+ end
end
end
diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb
index f5b60f91be6..e841132eea7 100644
--- a/app/graphql/resolvers/namespace_projects_resolver.rb
+++ b/app/graphql/resolvers/namespace_projects_resolver.rb
@@ -29,3 +29,5 @@ module Resolvers
end
end
end
+
+Resolvers::NamespaceProjectsResolver.prepend_if_ee('::EE::Resolvers::NamespaceProjectsResolver')
diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb
new file mode 100644
index 00000000000..068546cd39f
--- /dev/null
+++ b/app/graphql/resolvers/projects_resolver.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ProjectsResolver < BaseResolver
+ type Types::ProjectType, null: true
+
+ argument :membership, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'Limit projects that the current user is a member of'
+
+ argument :search, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Search criteria'
+
+ def resolve(**args)
+ ProjectsFinder
+ .new(current_user: current_user, params: project_finder_params(args))
+ .execute
+ end
+
+ private
+
+ def project_finder_params(params)
+ {
+ without_deleted: true,
+ non_public: params[:membership],
+ search: params[:search]
+ }.compact
+ end
+ end
+end
diff --git a/app/graphql/resolvers/release_resolver.rb b/app/graphql/resolvers/release_resolver.rb
new file mode 100644
index 00000000000..9bae8b8cd13
--- /dev/null
+++ b/app/graphql/resolvers/release_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ReleaseResolver < BaseResolver
+ type Types::ReleaseType, null: true
+
+ argument :tag_name, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'The name of the tag associated to the release'
+
+ alias_method :project, :object
+
+ def self.single
+ self
+ end
+
+ def resolve(tag_name:)
+ ReleasesFinder.new(
+ project,
+ current_user,
+ { tag: tag_name }
+ ).execute.first
+ end
+ end
+end
diff --git a/app/graphql/resolvers/releases_resolver.rb b/app/graphql/resolvers/releases_resolver.rb
new file mode 100644
index 00000000000..b2afbb92684
--- /dev/null
+++ b/app/graphql/resolvers/releases_resolver.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ReleasesResolver < BaseResolver
+ type Types::ReleaseType.connection_type, null: true
+
+ alias_method :project, :object
+
+ # This resolver has a custom singular resolver
+ def self.single
+ Resolvers::ReleaseResolver
+ end
+
+ def resolve(**args)
+ ReleasesFinder.new(
+ project,
+ current_user
+ ).execute
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/alert_sort_enum.rb b/app/graphql/types/alert_management/alert_sort_enum.rb
new file mode 100644
index 00000000000..e6d38af8170
--- /dev/null
+++ b/app/graphql/types/alert_management/alert_sort_enum.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ class AlertSortEnum < SortEnum
+ 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 '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 '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
+ value 'STATUS_DESC', 'Status by descending order', value: :status_desc
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/alert_status_counts_type.rb b/app/graphql/types/alert_management/alert_status_counts_type.rb
new file mode 100644
index 00000000000..f80b289eabc
--- /dev/null
+++ b/app/graphql/types/alert_management/alert_status_counts_type.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+# Service for managing alert counts and cache updates.
+module Types
+ module AlertManagement
+ class AlertStatusCountsType < BaseObject
+ graphql_name 'AlertManagementAlertStatusCountsType'
+ description "Represents total number of alerts for the represented categories"
+
+ authorize :read_alert_management_alert
+
+ ::Gitlab::AlertManagement::AlertStatusCounts::STATUSES.each_key do |status|
+ field status,
+ GraphQL::INT_TYPE,
+ null: true,
+ description: "Number of alerts with status #{status.upcase} for the project"
+ end
+
+ field :open,
+ GraphQL::INT_TYPE,
+ null: true,
+ description: 'Number of alerts with status TRIGGERED or ACKNOWLEDGED for the project'
+
+ field :all,
+ GraphQL::INT_TYPE,
+ null: true,
+ description: 'Total number of alerts for the project'
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
new file mode 100644
index 00000000000..a766fb3236d
--- /dev/null
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ class AlertType < BaseObject
+ graphql_name 'AlertManagementAlert'
+ description "Describes an alert from the project's Alert Management"
+
+ authorize :read_alert_management_alert
+
+ field :iid,
+ GraphQL::ID_TYPE,
+ null: false,
+ description: 'Internal ID of the alert'
+
+ field :issue_iid,
+ GraphQL::ID_TYPE,
+ null: true,
+ description: 'Internal ID of the GitLab issue attached to the alert'
+
+ field :title,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Title of the alert'
+
+ field :description,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Description of the alert'
+
+ field :severity,
+ AlertManagement::SeverityEnum,
+ null: true,
+ description: 'Severity of the alert'
+
+ field :status,
+ AlertManagement::StatusEnum,
+ null: true,
+ description: 'Status of the alert'
+
+ field :service,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Service the alert came from'
+
+ field :monitoring_tool,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Monitoring tool the alert came from'
+
+ field :hosts,
+ [GraphQL::STRING_TYPE],
+ null: true,
+ description: 'List of hosts the alert came from'
+
+ field :started_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the alert was raised'
+
+ field :ended_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the alert ended'
+
+ field :event_count,
+ GraphQL::INT_TYPE,
+ null: true,
+ description: 'Number of events of this alert',
+ method: :events
+
+ field :details,
+ GraphQL::Types::JSON,
+ null: true,
+ description: 'Alert details'
+
+ field :created_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the alert was created'
+
+ field :updated_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the alert was last updated'
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/severity_enum.rb b/app/graphql/types/alert_management/severity_enum.rb
new file mode 100644
index 00000000000..99ea56da02c
--- /dev/null
+++ b/app/graphql/types/alert_management/severity_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ class SeverityEnum < BaseEnum
+ graphql_name 'AlertManagementSeverity'
+ description 'Alert severity values'
+
+ ::AlertManagement::Alert.severities.keys.each do |severity|
+ value severity.upcase, value: severity, description: "#{severity.titleize} severity"
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/status_enum.rb b/app/graphql/types/alert_management/status_enum.rb
new file mode 100644
index 00000000000..4ff6c4a9505
--- /dev/null
+++ b/app/graphql/types/alert_management/status_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ class StatusEnum < BaseEnum
+ graphql_name 'AlertManagementStatus'
+ description 'Alert status values'
+
+ ::AlertManagement::Alert::STATUSES.each do |name, value|
+ value name.upcase, value: value, description: "#{name.to_s.titleize} status"
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
new file mode 100644
index 00000000000..e94ff898807
--- /dev/null
+++ b/app/graphql/types/board_list_type.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class BoardListType < BaseObject
+ graphql_name 'BoardList'
+ description 'Represents a list for an issue board'
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID (global ID) of the list'
+ field :title, GraphQL::STRING_TYPE, null: false,
+ description: 'Title of the list'
+ field :list_type, GraphQL::STRING_TYPE, null: false,
+ description: 'Type of the list'
+ field :position, GraphQL::INT_TYPE, null: true,
+ description: 'Position of list within the board'
+ field :label, Types::LabelType, null: true,
+ description: 'Label of the list'
+ field :collapsed, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if list is collapsed for this user',
+ resolve: -> (list, _args, ctx) { list.collapsed?(ctx[:current_user]) }
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
+
+Types::BoardListType.prepend_if_ee('::EE::Types::BoardListType')
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
index 9c95a987fe4..c0be782ed1e 100644
--- a/app/graphql/types/board_type.rb
+++ b/app/graphql/types/board_type.rb
@@ -11,6 +11,13 @@ module Types
description: 'ID (global ID) of the board'
field :name, type: GraphQL::STRING_TYPE, null: true,
description: 'Name of the board'
+
+ field :lists,
+ Types::BoardListType.connection_type,
+ null: true,
+ description: 'Lists of the project board',
+ resolver: Resolvers::BoardListsResolver,
+ extras: [:lookahead]
end
end
diff --git a/app/graphql/types/branch_type.rb b/app/graphql/types/branch_type.rb
new file mode 100644
index 00000000000..b15038a46de
--- /dev/null
+++ b/app/graphql/types/branch_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class BranchType < BaseObject
+ graphql_name 'Branch'
+
+ field :name,
+ GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Name of the branch'
+
+ field :commit, Types::CommitType,
+ null: true, resolver: Resolvers::BranchCommitResolver,
+ description: 'Commit for the branch'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index aaf2dfd8488..be5165da545 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -14,6 +14,7 @@ module Types
description: 'SHA1 ID of the commit'
field :title, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true,
description: 'Title of the commit message'
+ markdown_field :title_html, null: true
field :description, type: GraphQL::STRING_TYPE, null: true,
description: 'Description of the commit message'
field :message, type: GraphQL::STRING_TYPE, null: true,
diff --git a/app/graphql/types/design_management/design_at_version_type.rb b/app/graphql/types/design_management/design_at_version_type.rb
new file mode 100644
index 00000000000..343d4cf4ff4
--- /dev/null
+++ b/app/graphql/types/design_management/design_at_version_type.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignAtVersionType < BaseObject
+ graphql_name 'DesignAtVersion'
+
+ description 'A design pinned to a specific version. ' \
+ 'The image field reflects the design as of the associated version.'
+
+ authorize :read_design
+
+ delegate :design, :version, to: :object
+ delegate :issue, :filename, :full_path, :diff_refs, to: :design
+
+ implements ::Types::DesignManagement::DesignFields
+
+ field :version,
+ Types::DesignManagement::VersionType,
+ null: false,
+ description: 'The version this design-at-versions is pinned to'
+
+ field :design,
+ Types::DesignManagement::DesignType,
+ null: false,
+ description: 'The underlying design.'
+
+ def cached_stateful_version(_parent)
+ version
+ end
+
+ def notes_count
+ design.user_notes_count
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb
new file mode 100644
index 00000000000..194910831c6
--- /dev/null
+++ b/app/graphql/types/design_management/design_collection_type.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignCollectionType < BaseObject
+ graphql_name 'DesignCollection'
+ description 'A collection of designs.'
+
+ authorize :read_design
+
+ field :project, Types::ProjectType, null: false,
+ description: 'Project associated with the design collection'
+ field :issue, Types::IssueType, null: false,
+ description: 'Issue associated with the design collection'
+
+ field :designs,
+ Types::DesignManagement::DesignType.connection_type,
+ null: false,
+ resolver: Resolvers::DesignManagement::DesignsResolver,
+ description: 'All designs for the design collection',
+ complexity: 5
+
+ field :versions,
+ Types::DesignManagement::VersionType.connection_type,
+ resolver: Resolvers::DesignManagement::VersionsResolver,
+ description: 'All versions related to all designs, ordered newest first'
+
+ field :version,
+ Types::DesignManagement::VersionType,
+ resolver: Resolvers::DesignManagement::VersionsResolver.single,
+ description: 'A specific version'
+
+ field :design_at_version, ::Types::DesignManagement::DesignAtVersionType,
+ null: true,
+ resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver,
+ description: 'Find a design as of a version'
+
+ field :design, ::Types::DesignManagement::DesignType,
+ null: true,
+ resolver: ::Resolvers::DesignManagement::DesignResolver,
+ description: 'Find a specific design'
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb
new file mode 100644
index 00000000000..b03b3927392
--- /dev/null
+++ b/app/graphql/types/design_management/design_fields.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ module DesignFields
+ include BaseInterface
+
+ field_class Types::BaseField
+
+ field :id, GraphQL::ID_TYPE, description: 'The ID of this design', null: false
+ field :project, Types::ProjectType, null: false, description: 'The project the design belongs to'
+ field :issue, Types::IssueType, null: false, description: 'The issue the design belongs to'
+ field :filename, GraphQL::STRING_TYPE, null: false, description: 'The filename of the design'
+ field :full_path, GraphQL::STRING_TYPE, null: false, description: 'The full path to the design file'
+ field :image, GraphQL::STRING_TYPE, null: false, extras: [:parent], description: 'The URL of the full-sized image'
+ field :image_v432x230, GraphQL::STRING_TYPE, null: true, extras: [:parent],
+ description: 'The URL of the design resized to fit within the bounds of 432x230. ' \
+ 'This will be `null` if the image has not been generated'
+ field :diff_refs, Types::DiffRefsType,
+ null: false,
+ calls_gitaly: true,
+ extras: [:parent],
+ description: 'The diff refs for this design'
+ field :event, Types::DesignManagement::DesignVersionEventEnum,
+ null: false,
+ extras: [:parent],
+ description: 'How this design was changed in the current version'
+ field :notes_count,
+ GraphQL::INT_TYPE,
+ null: false,
+ method: :user_notes_count,
+ description: 'The total count of user-created notes for this design'
+
+ def diff_refs(parent:)
+ version = cached_stateful_version(parent)
+ version.diff_refs
+ end
+
+ def image(parent:)
+ sha = cached_stateful_version(parent).sha
+
+ Gitlab::UrlBuilder.build(design, ref: sha)
+ end
+
+ def image_v432x230(parent:)
+ version = cached_stateful_version(parent)
+ action = design.actions.up_to_version(version).most_recent.first
+
+ # A `nil` return value indicates that the image has not been processed
+ return unless action.image_v432x230.file
+
+ Gitlab::UrlBuilder.build(design, ref: version.sha, size: :v432x230)
+ end
+
+ def event(parent:)
+ version = cached_stateful_version(parent)
+
+ action = cached_actions_for_version(version)[design.id]
+
+ action&.event || ::Types::DesignManagement::DesignVersionEventEnum::NONE
+ end
+
+ def cached_actions_for_version(version)
+ Gitlab::SafeRequestStore.fetch(['DesignFields', 'actions_for_version', version.id]) do
+ version.actions.to_h { |dv| [dv.design_id, dv] }
+ end
+ end
+
+ def project
+ ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Project, design.project_id).find
+ end
+
+ def issue
+ ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Issue, design.issue_id).find
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb
new file mode 100644
index 00000000000..3c84dc151bd
--- /dev/null
+++ b/app/graphql/types/design_management/design_type.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignType < BaseObject
+ graphql_name 'Design'
+ description 'A single design'
+
+ authorize :read_design
+
+ alias_method :design, :object
+
+ implements(Types::Notes::NoteableType)
+ implements(Types::DesignManagement::DesignFields)
+
+ field :versions,
+ Types::DesignManagement::VersionType.connection_type,
+ resolver: Resolvers::DesignManagement::VersionsResolver,
+ description: "All versions related to this design ordered newest first",
+ extras: [:parent]
+
+ # Returns a `DesignManagement::Version` for this query based on the
+ # `atVersion` argument passed to a parent node if present, or otherwise
+ # the most recent `Version` for the issue.
+ def cached_stateful_version(parent_node)
+ version_gid = Gitlab::Graphql::FindArgumentInParent.find(parent_node, :at_version)
+
+ # Caching is scoped to an `issue_id` to allow us to cache the
+ # most recent `Version` for an issue
+ Gitlab::SafeRequestStore.fetch([request_cache_base_key, 'stateful_version', object.issue_id, version_gid]) do
+ if version_gid
+ GitlabSchema.object_from_id(version_gid)&.sync
+ else
+ object.issue.design_versions.most_recent
+ end
+ end
+ end
+
+ def request_cache_base_key
+ self.class.name
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_version_event_enum.rb b/app/graphql/types/design_management/design_version_event_enum.rb
new file mode 100644
index 00000000000..ea4bc1ffbfa
--- /dev/null
+++ b/app/graphql/types/design_management/design_version_event_enum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignVersionEventEnum < BaseEnum
+ graphql_name 'DesignVersionEvent'
+ description 'Mutation event of a design within a version'
+
+ NONE = 'NONE'
+
+ value NONE, 'No change'
+
+ ::DesignManagement::Action.events.keys.each do |event_name|
+ value event_name.upcase, value: event_name, description: "A #{event_name} event"
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/version_type.rb b/app/graphql/types/design_management/version_type.rb
new file mode 100644
index 00000000000..c774f5d1bdf
--- /dev/null
+++ b/app/graphql/types/design_management/version_type.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class VersionType < ::Types::BaseObject
+ # Just `Version` might be a bit to general to expose globally so adding
+ # a `Design` prefix to specify the class exposed in GraphQL
+ graphql_name 'DesignVersion'
+
+ description 'A specific version in which designs were added, modified or deleted'
+
+ authorize :read_design
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the design version'
+ field :sha, GraphQL::ID_TYPE, null: false,
+ description: 'SHA of the design version'
+
+ field :designs,
+ ::Types::DesignManagement::DesignType.connection_type,
+ null: false,
+ description: 'All designs that were changed in the version'
+
+ field :designs_at_version,
+ ::Types::DesignManagement::DesignAtVersionType.connection_type,
+ null: false,
+ description: 'All designs that are visible at this version, as of this version',
+ resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver
+
+ field :design_at_version,
+ ::Types::DesignManagement::DesignAtVersionType,
+ null: false,
+ description: 'A particular design as of this version, provided it is visible at this version',
+ resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver.single
+ end
+ end
+end
diff --git a/app/graphql/types/design_management_type.rb b/app/graphql/types/design_management_type.rb
new file mode 100644
index 00000000000..ec85b8a0c1f
--- /dev/null
+++ b/app/graphql/types/design_management_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# rubocop: disable Graphql/AuthorizeTypes
+module Types
+ class DesignManagementType < BaseObject
+ graphql_name 'DesignManagement'
+
+ field :version, ::Types::DesignManagement::VersionType,
+ null: true,
+ resolver: ::Resolvers::DesignManagement::VersionResolver,
+ description: 'Find a version'
+
+ field :design_at_version, ::Types::DesignManagement::DesignAtVersionType,
+ null: true,
+ resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver,
+ description: 'Find a design as of a version'
+ end
+end
diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb
index c0582b266ab..7db733fc62a 100644
--- a/app/graphql/types/grafana_integration_type.rb
+++ b/app/graphql/types/grafana_integration_type.rb
@@ -9,7 +9,7 @@ module Types
field :id, GraphQL::ID_TYPE, null: false,
description: 'Internal ID of the Grafana integration'
field :grafana_url, GraphQL::STRING_TYPE, null: false,
- description: 'Url for the Grafana host for the Grafana integration'
+ description: 'URL for the Grafana host for the Grafana integration'
field :enabled, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates whether Grafana integration is enabled'
field :created_at, Types::TimeType, null: false,
diff --git a/app/graphql/types/issuable_sort_enum.rb b/app/graphql/types/issuable_sort_enum.rb
index 9fb1249d582..a6d52124d99 100644
--- a/app/graphql/types/issuable_sort_enum.rb
+++ b/app/graphql/types/issuable_sort_enum.rb
@@ -4,5 +4,12 @@ module Types
class IssuableSortEnum < SortEnum
graphql_name 'IssuableSort'
description 'Values for sorting issuables'
+
+ value 'PRIORITY_ASC', 'Priority by ascending order', value: :priority_asc
+ value 'PRIORITY_DESC', 'Priority by descending order', value: :priority_desc
+ value 'LABEL_PRIORITY_ASC', 'Label priority by ascending order', value: :label_priority_asc
+ value 'LABEL_PRIORITY_DESC', 'Label priority by descending order', value: :label_priority_desc
+ value 'MILESTONE_DUE_ASC', 'Milestone due date by ascending order', value: :milestone_due_asc
+ value 'MILESTONE_DUE_DESC', 'Milestone due date by descending order', value: :milestone_due_desc
end
end
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
index c8d8f3ef079..e458d6e02c5 100644
--- a/app/graphql/types/issue_sort_enum.rb
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -5,9 +5,9 @@ module Types
graphql_name 'IssueSort'
description 'Values for sorting issues'
- value 'DUE_DATE_ASC', 'Due date by ascending order', value: 'due_date_asc'
- value 'DUE_DATE_DESC', 'Due date by descending order', value: 'due_date_desc'
- value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: 'relative_position_asc'
+ value 'DUE_DATE_ASC', 'Due date by ascending order', value: :due_date_asc
+ value 'DUE_DATE_DESC', 'Due date by descending order', value: :due_date_desc
+ value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: :relative_position_asc
end
end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 11850e5865f..73219ca9e1e 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -85,6 +85,14 @@ module Types
field :task_completion_status, Types::TaskCompletionStatus, null: false,
description: 'Task completion status of the issue'
+
+ field :designs, Types::DesignManagement::DesignCollectionType, null: true,
+ method: :design_collection,
+ deprecated: { reason: 'Use `designCollection`', milestone: '12.2' },
+ description: 'The designs associated with this issue'
+
+ field :design_collection, Types::DesignManagement::DesignCollectionType, null: true,
+ description: 'Collection of design images associated with this issue'
end
end
diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb
index ccd463370b6..4a124566ffb 100644
--- a/app/graphql/types/jira_import_type.rb
+++ b/app/graphql/types/jira_import_type.rb
@@ -7,9 +7,10 @@ module Types
class JiraImportType < BaseObject
graphql_name 'JiraImport'
- field :scheduled_at, Types::TimeType, null: true,
- method: :created_at,
+ field :created_at, Types::TimeType, null: true,
description: 'Timestamp of when the Jira import was created'
+ field :scheduled_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the Jira import was scheduled'
field :scheduled_by, Types::UserType, null: true,
description: 'User that started the Jira import'
field :jira_project_key, GraphQL::STRING_TYPE, null: false,
diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb
index e7d09866bb5..d684533ff94 100644
--- a/app/graphql/types/metrics/dashboard_type.rb
+++ b/app/graphql/types/metrics/dashboard_type.rb
@@ -11,8 +11,7 @@ module Types
description: 'Path to a file with the dashboard definition'
field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true,
- description: 'Annotations added to the dashboard. Will always return `null` ' \
- 'if `metrics_dashboard_annotations` feature flag is disabled',
+ description: 'Annotations added to the dashboard',
resolver: Resolvers::Metrics::Dashboards::AnnotationResolver
end
# rubocop: enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb
index 055d2544eff..0f8f95c187b 100644
--- a/app/graphql/types/metrics/dashboards/annotation_type.rb
+++ b/app/graphql/types/metrics/dashboards/annotation_type.rb
@@ -16,10 +16,10 @@ module Types
field :panel_id, GraphQL::STRING_TYPE, null: true,
description: 'ID of a dashboard panel to which the annotation should be scoped'
- field :starting_at, GraphQL::STRING_TYPE, null: true,
+ field :starting_at, Types::TimeType, null: true,
description: 'Timestamp marking start of annotated time span'
- field :ending_at, GraphQL::STRING_TYPE, null: true,
+ field :ending_at, Types::TimeType, null: true,
description: 'Timestamp marking end of annotated time span'
def panel_id
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index ab25d5baf71..aeff84b83b8 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -7,9 +7,12 @@ module Types
graphql_name 'Mutation'
mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs
+ mount_mutation Mutations::AlertManagement::CreateAlertIssue
+ mount_mutation Mutations::AlertManagement::UpdateAlertStatus
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::Issues::SetConfidential
mount_mutation Mutations::Issues::SetDueDate
mount_mutation Mutations::Issues::Update
@@ -19,6 +22,7 @@ module Types
mount_mutation Mutations::MergeRequests::SetSubscription
mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true
mount_mutation Mutations::MergeRequests::SetAssignees
+ mount_mutation Mutations::Metrics::Dashboard::Annotations::Create
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
@@ -40,6 +44,8 @@ module Types
mount_mutation Mutations::Snippets::Create
mount_mutation Mutations::Snippets::MarkAsSpam
mount_mutation Mutations::JiraImport::Start
+ mount_mutation Mutations::DesignManagement::Upload, calls_gitaly: true
+ mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true
end
end
diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb
index 2ac66452841..187c9109f8c 100644
--- a/app/graphql/types/notes/noteable_type.rb
+++ b/app/graphql/types/notes/noteable_type.rb
@@ -17,6 +17,8 @@ module Types
Types::MergeRequestType
when Snippet
Types::SnippetType
+ when ::DesignManagement::Design
+ Types::DesignManagement::DesignType
else
raise "Unknown GraphQL type for #{object}"
end
@@ -25,5 +27,3 @@ module Types
end
end
end
-
-Types::Notes::NoteableType.extend_if_ee('::EE::Types::Notes::NoteableType')
diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb
index e26c5950e73..94e1bffd685 100644
--- a/app/graphql/types/permission_types/issue.rb
+++ b/app/graphql/types/permission_types/issue.rb
@@ -6,11 +6,9 @@ module Types
description 'Check permissions for the current user on a issue'
graphql_name 'IssuePermissions'
- abilities :read_issue, :admin_issue,
- :update_issue, :create_note,
- :reopen_issue
+ abilities :read_issue, :admin_issue, :update_issue, :reopen_issue,
+ :read_design, :create_design, :destroy_design,
+ :create_note
end
end
end
-
-Types::PermissionTypes::Issue.prepend_if_ee('::EE::Types::PermissionTypes::Issue')
diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb
index f773fce0c63..5747e63d195 100644
--- a/app/graphql/types/permission_types/project.rb
+++ b/app/graphql/types/permission_types/project.rb
@@ -17,7 +17,7 @@ module Types
:admin_wiki, :admin_project, :update_pages,
:admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki,
:create_pages, :destroy_pages, :read_pages_content, :admin_operations,
- :read_merge_request
+ :read_merge_request, :read_design, :create_design, :destroy_design
permission_field :create_snippet
@@ -27,5 +27,3 @@ module Types
end
end
end
-
-Types::PermissionTypes::Project.prepend_if_ee('EE::Types::PermissionTypes::Project')
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 8356e763be9..4e438ed2576 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -205,6 +205,38 @@ module Types
null: true,
description: 'Project services',
resolver: Resolvers::Projects::ServicesResolver
+
+ field :alert_management_alerts,
+ Types::AlertManagement::AlertType.connection_type,
+ null: true,
+ description: 'Alert Management alerts of the project',
+ resolver: Resolvers::AlertManagementAlertResolver
+
+ field :alert_management_alert,
+ Types::AlertManagement::AlertType,
+ null: true,
+ description: 'A single Alert Management alert of the project',
+ resolver: Resolvers::AlertManagementAlertResolver.single
+
+ field :alert_management_alert_status_counts,
+ Types::AlertManagement::AlertStatusCountsType,
+ null: true,
+ description: 'Counts of alerts by status for the project',
+ resolver: Resolvers::AlertManagement::AlertStatusCountsResolver
+
+ field :releases,
+ Types::ReleaseType.connection_type,
+ null: true,
+ description: 'Releases of the project',
+ resolver: Resolvers::ReleasesResolver,
+ feature_flag: :graphql_release_data
+
+ field :release,
+ Types::ReleaseType,
+ null: true,
+ description: 'A single release of the project',
+ resolver: Resolvers::ReleasesResolver.single,
+ feature_flag: :graphql_release_data
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index e8f6eeff3e9..70cdcb62bc6 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -4,11 +4,19 @@ module Types
class QueryType < ::Types::BaseObject
graphql_name 'Query'
+ # The design management context object needs to implement #issue
+ DesignManagementObject = Struct.new(:issue)
+
field :project, Types::ProjectType,
null: true,
resolver: Resolvers::ProjectResolver,
description: "Find a project"
+ field :projects, Types::ProjectType.connection_type,
+ null: true,
+ resolver: Resolvers::ProjectsResolver,
+ description: "Find projects visible to the current user"
+
field :group, Types::GroupType,
null: true,
resolver: Resolvers::GroupResolver,
@@ -35,9 +43,17 @@ module Types
resolver: Resolvers::SnippetsResolver,
description: 'Find Snippets visible to the current user'
+ field :design_management, Types::DesignManagementType,
+ null: false,
+ description: 'Fields related to design management'
+
field :echo, GraphQL::STRING_TYPE, null: false,
description: 'Text to echo back',
resolver: Resolvers::EchoResolver
+
+ def design_management
+ DesignManagementObject.new(nil)
+ end
end
end
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
new file mode 100644
index 00000000000..632351be5d3
--- /dev/null
+++ b/app/graphql/types/release_type.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Types
+ class ReleaseType < BaseObject
+ graphql_name 'Release'
+
+ authorize :read_release
+
+ alias_method :release, :object
+
+ present_using ReleasePresenter
+
+ field :tag_name, GraphQL::STRING_TYPE, null: false, method: :tag,
+ description: 'Name of the tag associated with the release'
+ field :tag_path, GraphQL::STRING_TYPE, null: true,
+ description: 'Relative web path to the tag associated with the release'
+ field :description, GraphQL::STRING_TYPE, null: true,
+ description: 'Description (also known as "release notes") of the release'
+ markdown_field :description_html, null: true
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the release'
+ field :created_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the release was created'
+ field :released_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the release was released'
+ field :milestones, Types::MilestoneType.connection_type, null: true,
+ description: 'Milestones associated to the release'
+
+ field :author, Types::UserType, null: true,
+ description: 'User that created the release'
+
+ def author
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, release.author_id).find
+ end
+
+ field :commit, Types::CommitType, null: true,
+ complexity: 10, calls_gitaly: true,
+ description: 'The commit associated with the release',
+ authorize: :reporter_access
+
+ def commit
+ return if release.sha.nil?
+
+ release.project.commit_by(oid: release.sha)
+ end
+ end
+end
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index 4ebdbd5766c..b23c4f71ffa 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -14,7 +14,7 @@ module Types
expose_permissions Types::PermissionTypes::Snippet
field :id, GraphQL::ID_TYPE,
- description: 'Id of the snippet',
+ description: 'ID of the snippet',
null: false
field :title, GraphQL::STRING_TYPE,
diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb
index feff5d20874..dcde1e5a73b 100644
--- a/app/graphql/types/snippets/blob_type.rb
+++ b/app/graphql/types/snippets/blob_type.rb
@@ -14,6 +14,7 @@ module Types
field :plain_data, GraphQL::STRING_TYPE,
description: 'Blob plain highlighted data',
+ calls_gitaly: true,
null: true
field :raw_path, GraphQL::STRING_TYPE,
@@ -48,6 +49,15 @@ module Types
field :mode, type: GraphQL::STRING_TYPE,
description: 'Blob mode',
null: true
+
+ field :external_storage, type: GraphQL::STRING_TYPE,
+ description: 'Blob external storage',
+ null: true
+
+ field :rendered_as_text, type: GraphQL::BOOLEAN_TYPE,
+ description: 'Shows whether the blob is rendered as text',
+ method: :rendered_as_text?,
+ null: false
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/snippets/blob_viewer_type.rb b/app/graphql/types/snippets/blob_viewer_type.rb
index 3e653576d07..50d0b0522d6 100644
--- a/app/graphql/types/snippets/blob_viewer_type.rb
+++ b/app/graphql/types/snippets/blob_viewer_type.rb
@@ -17,12 +17,14 @@ module Types
field :collapsed, GraphQL::BOOLEAN_TYPE,
description: 'Shows whether the blob should be displayed collapsed',
method: :collapsed?,
- null: false
+ null: false,
+ resolve: -> (viewer, _args, _ctx) { !!viewer&.collapsed? }
field :too_large, GraphQL::BOOLEAN_TYPE,
description: 'Shows whether the blob too large to be displayed',
method: :too_large?,
- null: false
+ null: false,
+ resolve: -> (viewer, _args, _ctx) { !!viewer&.too_large? }
field :render_error, GraphQL::STRING_TYPE,
description: 'Error rendering the blob content',
diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb
index 8358a86b35c..a377c3aafdc 100644
--- a/app/graphql/types/todo_target_enum.rb
+++ b/app/graphql/types/todo_target_enum.rb
@@ -5,6 +5,7 @@ module Types
value 'COMMIT', value: 'Commit', description: 'A Commit'
value 'ISSUE', value: 'Issue', description: 'An Issue'
value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest'
+ value 'DESIGN', value: 'DesignManagement::Design', description: 'A Design'
end
end
diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb
index 5ce5093c55e..08e7fabeb74 100644
--- a/app/graphql/types/todo_type.rb
+++ b/app/graphql/types/todo_type.rb
@@ -10,7 +10,7 @@ module Types
authorize :read_todo
field :id, GraphQL::ID_TYPE,
- description: 'Id of the todo',
+ description: 'ID of the todo',
null: false
field :project, Types::ProjectType,
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index e530641d6ae..29a3f5d452f 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -10,8 +10,12 @@ module Types
expose_permissions Types::PermissionTypes::User
+ 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 :avatar_url, GraphQL::STRING_TYPE, null: true,
diff --git a/app/helpers/access_tokens_helper.rb b/app/helpers/access_tokens_helper.rb
new file mode 100644
index 00000000000..877ad6db576
--- /dev/null
+++ b/app/helpers/access_tokens_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module AccessTokensHelper
+ def scope_description(prefix)
+ prefix == :project_access_token ? [:doorkeeper, :project_access_token_scope_desc] : [:doorkeeper, :scope_desc]
+ end
+end
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 0c1b2c7d093..3ae9f93a27a 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -25,6 +25,10 @@ module AppearancesHelper
markdown_field(current_appearance, :new_project_guidelines)
end
+ def brand_profile_image_guidelines
+ markdown_field(current_appearance, :profile_image_guidelines)
+ end
+
def current_appearance
strong_memoize(:current_appearance) do
Appearance.current
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index a815b378f8b..2df33073a89 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -54,6 +54,10 @@ module ApplicationHelper
args.any? { |v| v.to_s.downcase == action_name }
end
+ def admin_section?
+ controller.class.ancestors.include?(Admin::ApplicationController)
+ end
+
def last_commit(project)
if project.repo_exists?
time_ago_with_tooltip(project.repository.commit.committed_date)
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 443451cd394..b9f0e3582df 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -229,14 +229,7 @@ module ApplicationSettingsHelper
:max_artifacts_size,
:max_attachment_size,
:max_pages_size,
- :metrics_enabled,
- :metrics_host,
:metrics_method_call_threshold,
- :metrics_packet_size,
- :metrics_pool_size,
- :metrics_port,
- :metrics_sample_interval,
- :metrics_timeout,
:minimum_password_length,
:mirror_available,
:pages_domain_verification_enabled,
@@ -310,7 +303,9 @@ module ApplicationSettingsHelper
:custom_http_clone_url_root,
:snippet_size_limit,
:email_restrictions_enabled,
- :email_restrictions
+ :email_restrictions,
+ :issues_create_limit,
+ :raw_blob_request_limit
]
end
@@ -365,7 +360,7 @@ module ApplicationSettingsHelper
end
end
-ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
+ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper')
# The methods in `EE::ApplicationSettingsHelper` should be available as both
# instance and class methods.
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 1f1ff75359d..a57e27d23c8 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -145,10 +145,14 @@ module AuthHelper
IdentityProviderPolicy.new(current_user, provider).can?(:link)
end
+ def allow_admin_mode_password_authentication_for_web?
+ current_user.allow_password_authentication_for_web? && !current_user.password_automatically_set?
+ end
+
extend self
end
-AuthHelper.prepend_if_ee('EE::AuthHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
+AuthHelper.prepend_if_ee('EE::AuthHelper')
# The methods added in EE should be available as both class and instance
# methods, just like the methods provided by `AuthHelper` itself.
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 4debf66db64..69fe3303840 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -17,7 +17,7 @@ module BlobHelper
options[:link_opts])
end
- def ide_edit_path(project = @project, ref = @ref, path = @path, options = {})
+ def ide_edit_path(project = @project, ref = @ref, path = @path)
project_path =
if !current_user || can?(current_user, :push_code, project)
project.full_path
@@ -52,28 +52,25 @@ module BlobHelper
edit_button_tag(blob,
common_classes,
_('Edit'),
- Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path, options) : edit_blob_path(project, ref, path, options),
+ Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path) : edit_blob_path(project, ref, path, options),
project,
ref)
end
- def ide_edit_button(project = @project, ref = @ref, path = @path, options = {})
+ def ide_edit_button(project = @project, ref = @ref, path = @path, blob:)
return if Feature.enabled?(:web_ide_default)
- return unless blob = readable_blob(options, path, project, ref)
+ return unless blob
edit_button_tag(blob,
'btn btn-inverted btn-primary ide-edit-button ml-2',
_('Web IDE'),
- ide_edit_path(project, ref, path, options),
+ ide_edit_path(project, ref, path),
project,
ref)
end
- def modify_file_button(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:)
+ def modify_file_button(project = @project, ref = @ref, path = @path, blob:, label:, action:, btn_class:, modal_type:)
return unless current_user
-
- blob = project.repository.blob_at(ref, path) rescue nil
-
return unless blob
common_classes = "btn btn-#{btn_class}"
@@ -89,11 +86,12 @@ module BlobHelper
end
end
- def replace_blob_link(project = @project, ref = @ref, path = @path)
+ def replace_blob_link(project = @project, ref = @ref, path = @path, blob:)
modify_file_button(
project,
ref,
path,
+ blob: blob,
label: _("Replace"),
action: "replace",
btn_class: "default",
@@ -101,11 +99,12 @@ module BlobHelper
)
end
- def delete_blob_link(project = @project, ref = @ref, path = @path)
+ def delete_blob_link(project = @project, ref = @ref, path = @path, blob:)
modify_file_button(
project,
ref,
path,
+ blob: blob,
label: _("Delete"),
action: "delete",
btn_class: "default",
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index c14bc454bb9..f8c00f3a4cd 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -16,7 +16,8 @@ module BoardsHelper
full_path: full_path,
bulk_update_path: @bulk_issues_path,
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
- recent_boards_endpoint: recent_boards_path
+ recent_boards_endpoint: recent_boards_path,
+ parent: current_board_parent.model_name.param_key
}
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index e1aed5393ea..c999d1f94ad 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -93,8 +93,8 @@ module ButtonHelper
content_tag (href ? :a : :span),
(href ? button_content : title),
class: "#{title.downcase}-selector #{active_class}",
- href: (href if href),
- data: (data if data)
+ href: href,
+ data: data
end
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index a97216f8a22..39aaf242231 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -17,6 +17,17 @@ module ClustersHelper
end
end
+ 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
+ end
+
def render_gcp_signup_offer
return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
return unless show_gcp_signup_offer?
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index ace8bae03ac..2a0c2e73dd6 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -215,6 +215,8 @@ module CommitsHelper
def commit_path(project, commit, merge_request: nil)
if merge_request&.persisted?
diffs_project_merge_request_path(project, merge_request, commit_id: commit.id)
+ elsif merge_request
+ project_commit_path(merge_request&.source_project, commit)
else
project_commit_path(project, commit)
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index 52f189b122f..bd400009c96 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -25,7 +25,7 @@ module EnvironmentHelper
def deployment_link(deployment, text: nil)
return unless deployment
- link_label = text ? text : "##{deployment.iid}"
+ link_label = text || "##{deployment.iid}"
link_to link_label, deployment_path(deployment)
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 5b640ea6538..e7b561af3da 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -2,7 +2,6 @@
module EnvironmentsHelper
include ActionView::Helpers::AssetUrlHelper
- prepend_if_ee('::EE::EnvironmentsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
def environments_list_data
{
@@ -23,31 +22,13 @@ module EnvironmentsHelper
end
def metrics_data(project, environment)
- {
- "settings-path" => edit_project_service_path(project, 'prometheus'),
- "clusters-path" => project_clusters_path(project),
- "current-environment-name" => environment.name,
- "documentation-path" => help_page_path('administration/monitoring/prometheus/index.md'),
- "empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'),
- "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'),
- "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json),
- "dashboards-endpoint" => project_performance_monitoring_dashboards_path(project, format: :json),
- "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json),
- "deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json),
- "default-branch" => project.default_branch,
- "project-path" => project_path(project),
- "tags-path" => project_tags_path(project),
- "has-metrics" => "#{environment.has_metrics?}",
- "prometheus-status" => "#{environment.prometheus_status}",
- "external-dashboard-url" => project.metrics_setting_external_dashboard_url,
- "environment-state" => "#{environment.state}",
- "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)}"
- }
+ metrics_data = {}
+ metrics_data.merge!(project_metrics_data(project)) if project
+ metrics_data.merge!(environment_metrics_data(environment)) if environment
+ metrics_data.merge!(project_and_environment_metrics_data(project, environment)) if project && environment
+ metrics_data.merge!(static_metrics_data)
+
+ metrics_data
end
def environment_logs_data(project, environment)
@@ -62,4 +43,60 @@ module EnvironmentsHelper
def can_destroy_environment?(environment)
can?(current_user, :destroy_environment, environment)
end
+
+ private
+
+ def project_metrics_data(project)
+ return {} unless project
+
+ {
+ 'settings-path' => edit_project_service_path(project, 'prometheus'),
+ 'clusters-path' => project_clusters_path(project),
+ 'dashboards-endpoint' => project_performance_monitoring_dashboards_path(project, format: :json),
+ 'default-branch' => project.default_branch,
+ 'project-path' => project_path(project),
+ 'tags-path' => project_tags_path(project),
+ 'external-dashboard-url' => project.metrics_setting_external_dashboard_url,
+ '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)}"
+ }
+ end
+
+ def environment_metrics_data(environment)
+ return {} unless environment
+
+ {
+ 'current-environment-name' => environment.name,
+ 'has-metrics' => "#{environment.has_metrics?}",
+ 'prometheus-status' => "#{environment.prometheus_status}",
+ 'environment-state' => "#{environment.state}"
+ }
+ end
+
+ def project_and_environment_metrics_data(project, environment)
+ return {} unless project && environment
+
+ {
+ 'metrics-endpoint' => additional_metrics_project_environment_path(project, environment, format: :json),
+ 'dashboard-endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json),
+ 'deployments-endpoint' => project_environment_deployments_path(project, environment, format: :json),
+ 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json)
+
+ }
+ end
+
+ def static_metrics_data
+ {
+ 'documentation-path' => help_page_path('administration/monitoring/prometheus/index.md'),
+ 'empty-getting-started-svg-path' => image_path('illustrations/monitoring/getting_started.svg'),
+ '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')
+ }
+ end
end
+
+EnvironmentsHelper.prepend_if_ee('::EE::EnvironmentsHelper')
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index ba8e046f504..e93aeba6dfd 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -4,12 +4,14 @@ module EventsHelper
ICON_NAMES_BY_EVENT_TYPE = {
'pushed to' => 'commit',
'pushed new' => 'commit',
+ 'updated' => 'commit',
'created' => 'status_open',
'opened' => 'status_open',
'closed' => 'status_closed',
'accepted' => 'fork',
'commented on' => 'comment',
'deleted' => 'remove',
+ 'destroyed' => 'remove',
'imported' => 'import',
'joined' => 'users'
}.freeze
@@ -167,6 +169,8 @@ module EventsHelper
project_issue_url(event.project, id: event.note_target, anchor: dom_id(event.target))
elsif event.merge_request_note?
project_merge_request_url(event.project, id: event.note_target, anchor: dom_id(event.target))
+ elsif event.design_note?
+ design_url(event.note_target, anchor: dom_id(event.note))
else
polymorphic_url([event.project.namespace.becomes(Namespace),
event.project, event.note_target],
@@ -237,6 +241,16 @@ module EventsHelper
concat content_tag(:span, event.author.to_reference, class: "username")
end
end
+
+ private
+
+ def design_url(design, opts)
+ designs_project_issue_url(
+ design.project,
+ design.issue,
+ opts.merge(vueroute: design.filename)
+ )
+ end
end
EventsHelper.prepend_if_ee('EE::EventsHelper')
diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb
index d03fa6eadb2..483b350b99b 100644
--- a/app/helpers/export_helper.rb
+++ b/app/helpers/export_helper.rb
@@ -9,7 +9,18 @@ module ExportHelper
_('Project configuration, including services'),
_('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities'),
_('LFS objects'),
- _('Issue Boards')
+ _('Issue Boards'),
+ _('Design Management files and data')
+ ]
+ end
+
+ def group_export_descriptions
+ [
+ _('Milestones'),
+ _('Labels'),
+ _('Boards and Board Lists'),
+ _('Badges'),
+ _('Subgroups')
]
end
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index b611f700d21..ecacde65c10 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
module FormHelper
- prepend_if_ee('::EE::FormHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
def form_errors(model, type: 'form', truncate: [])
return unless model.errors.any?
@@ -79,3 +77,5 @@ module FormHelper
new_options
end
end
+
+FormHelper.prepend_if_ee('::EE::FormHelper')
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 91f8bc33e3e..a6c3c97a873 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -49,6 +49,10 @@ module GroupsHelper
can?(current_user, :change_visibility_level, group)
end
+ def can_update_default_branch_protection?(group)
+ can?(current_user, :update_default_branch_protection, group)
+ end
+
def can_change_share_with_group_lock?(group)
can?(current_user, :change_share_with_group_lock, group)
end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 876789e0d4a..8a32d3c8a3f 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -158,6 +158,6 @@ module IconsHelper
def known_sprites
return if Rails.env.production?
- @known_sprites ||= JSON.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/icons.json')))['icons']
+ @known_sprites ||= Gitlab::Json.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/icons.json')))['icons']
end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 7e0cc591308..1ce99652463 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -196,7 +196,7 @@ module IssuablesHelper
author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline")
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none")
- author_output << gitlab_team_member_badge(issuable.author, css_class: 'ml-1')
+ author_output << issuable_meta_author_slot(issuable.author, css_class: 'ml-1')
if status = user_status(issuable.author)
author_output << "#{status}".html_safe
@@ -213,6 +213,11 @@ module IssuablesHelper
output.join.html_safe
end
+ # This is a dummy method, and has an override defined in ee
+ def issuable_meta_author_slot(author, css_class: nil)
+ nil
+ end
+
def issuable_labels_tooltip(labels, limit: 5)
first, last = labels.partition.with_index { |_, i| i < limit }
@@ -242,27 +247,6 @@ module IssuablesHelper
html.html_safe
end
- def gitlab_team_member_badge(author, css_class: nil)
- return unless author.gitlab_employee?
-
- default_css_class = 'd-inline-block align-middle'
- gitlab_team_member = _('GitLab Team Member')
-
- content_tag(
- :span,
- class: css_class ? "#{default_css_class} #{css_class}" : default_css_class,
- data: { toggle: 'tooltip', title: gitlab_team_member, container: 'body' },
- role: 'img',
- aria: { label: gitlab_team_member }
- ) do
- sprite_icon(
- 'tanuki-verified',
- size: 16,
- css_class: 'gl-text-purple d-block'
- )
- end
- end
-
def issuable_first_contribution_icon
content_tag(:span, class: 'fa-stack') do
concat(icon('certificate', class: "fa-stack-2x"))
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 34b6ba05a62..39edfeea81e 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -9,13 +9,6 @@ module IssuesHelper
classes.join(' ')
end
- # Returns an OpenStruct object suitable for use by <tt>options_from_collection_for_select</tt>
- # to allow filtering issues by an unassigned User or Milestone
- def unassigned_filter
- # Milestone uses :title, Issue uses :name
- OpenStruct.new(id: 0, title: 'None (backlog)', name: 'Unassigned')
- end
-
def url_for_issue(issue_iid, project = @project, options = {})
return '' if project.nil?
@@ -145,17 +138,12 @@ module IssuesHelper
can?(current_user, :create_issue, project)
end
- def create_confidential_merge_request_enabled?
- Feature.enabled?(:create_confidential_merge_request, @project, default_enabled: true)
- end
-
def show_new_branch_button?
can_create_confidential_merge_request? || !@issue.confidential?
end
def can_create_confidential_merge_request?
@issue.confidential? && !@project.private? &&
- create_confidential_merge_request_enabled? &&
can?(current_user, :create_merge_request_in, @project)
end
@@ -177,6 +165,10 @@ module IssuesHelper
end
end
+ 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
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
index 11d5591d509..31995c27fac 100644
--- a/app/helpers/members_helper.rb
+++ b/app/helpers/members_helper.rb
@@ -15,7 +15,18 @@ module MembersHelper
elsif member.invite?
"revoke the invitation for #{member.invite_email} to join"
else
- "remove #{member.user.name} from"
+ if member.user
+ "remove #{member.user.name} from"
+ else
+ e = RuntimeError.new("Data integrity error: no associated user for member ID #{member.id}")
+ Gitlab::ErrorTracking.track_exception(e,
+ member_id: member.id,
+ invite_email: member.invite_email,
+ invite_accepted_at: member.invite_accepted_at,
+ source_id: member.source_id,
+ source_type: member.source_type)
+ "remove this orphaned member from"
+ end
end
"#{text} #{action} the #{member.source.human_name} #{source_text(member)}?"
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index 2f5aac892ab..df1ee54c5ac 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -249,7 +249,7 @@ module MilestonesHelper
if milestone.legacy_group_milestone?
group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params)
else
- group_milestone_path(@group, milestone.iid, milestone: params)
+ group_milestone_path(milestone.group, milestone.iid, milestone: params)
end
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 9de28fb3ed9..228dc2cc27f 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -80,8 +80,8 @@ module NamespacesHelper
visibility_level: n.visibility_level_value,
visibility: n.visibility,
name: n.name,
- show_path: (type == 'group') ? group_path(n) : user_path(n),
- edit_path: (type == 'group') ? edit_group_path(n) : nil
+ show_path: type == 'group' ? group_path(n) : user_path(n),
+ edit_path: type == 'group' ? edit_group_path(n) : nil
}]
end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 6013475acb1..9ea0b9cb584 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -54,11 +54,12 @@ module NavHelper
current_path?('merge_requests#show') ||
current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') ||
- current_path?('milestones#show')
+ current_path?('milestones#show') ||
+ current_path?('issues#designs')
end
def admin_monitoring_nav_links
- %w(system_info background_jobs logs health_check requests_profiles)
+ %w(system_info background_jobs health_check requests_profiles)
end
def group_issues_sub_menu_items
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 070089d6ef8..7a0462e1b2c 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -70,7 +70,7 @@ module PreferencesHelper
end
def language_choices
- Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }
+ Gitlab::I18n::AVAILABLE_LANGUAGES.map(&:reverse).sort
end
private
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
new file mode 100644
index 00000000000..af86ef715c2
--- /dev/null
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Projects::AlertManagementHelper
+ def alert_management_data(current_user, project)
+ {
+ 'project-path' => project.full_path,
+ 'enable-alert-management-path' => edit_project_service_path(project, AlertsService),
+ 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'),
+ 'user-can-enable-alert-management' => can?(current_user, :admin_project, project).to_s,
+ 'alert-management-enabled' => (!!project.alerts_service_activated?).to_s
+ }
+ end
+
+ def alert_management_detail_data(project, alert_id)
+ {
+ 'alert-id' => alert_id,
+ 'project-path' => project.full_path,
+ 'new-issue-path' => new_project_issue_path(project)
+ }
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 8bec7599158..d743ea6aeea 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
module ProjectsHelper
- prepend_if_ee('::EE::ProjectsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
def project_incident_management_setting
@project_incident_management_setting ||= @project.incident_management_setting ||
@project.build_incident_management_setting
@@ -297,11 +295,11 @@ module ProjectsHelper
end
def show_merge_request_count?(disabled: false, compact_mode: false)
- !disabled && !compact_mode && Feature.enabled?(:project_list_show_mr_count, default_enabled: true)
+ !disabled && !compact_mode
end
def show_issue_count?(disabled: false, compact_mode: false)
- !disabled && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true)
+ !disabled && !compact_mode
end
# overridden in EE
@@ -448,6 +446,7 @@ module ProjectsHelper
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,
@@ -588,7 +587,9 @@ module ProjectsHelper
pagesAccessLevel: feature.pages_access_level,
containerRegistryEnabled: !!project.container_registry_enabled,
lfsEnabled: !!project.lfs_enabled,
- emailsDisabled: project.emails_disabled?
+ emailsDisabled: project.emails_disabled?,
+ metricsDashboardAccessLevel: feature.metrics_dashboard_access_level,
+ showDefaultAwardEmojis: project.show_default_award_emojis?
}
end
@@ -674,6 +675,7 @@ module ProjectsHelper
services#edit
hooks#index
hooks#edit
+ access_tokens#index
hook_logs#show
repository#show
ci_cd#show
@@ -708,6 +710,7 @@ module ProjectsHelper
clusters
functions
error_tracking
+ alert_management
user
gcp
logs
@@ -737,6 +740,12 @@ module ProjectsHelper
Gitlab.config.registry.enabled &&
can?(current_user, :destroy_container_image, project)
end
+
+ def project_access_token_available?(project)
+ return false if ::Gitlab.com?
+
+ ::Feature.enabled?(:resource_access_token, project)
+ end
end
ProjectsHelper.prepend_if_ee('EE::ProjectsHelper')
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index af51427dc91..1238567a4ed 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -30,7 +30,9 @@ module ReleasesHelper
markdown_docs_path: help_page_path('user/markdown'),
releases_page_path: project_releases_path(@project, anchor: @release.tag),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'),
- release_assets_docs_path: help_page(anchor: 'release-assets')
+ release_assets_docs_path: help_page(anchor: 'release-assets'),
+ manage_milestones_path: project_milestones_path(@project),
+ new_milestone_path: new_project_milestone_url(@project)
}
end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index e478f76818f..5ad65c59a2e 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -58,8 +58,6 @@ module SearchHelper
ns_('SearchResults|comment', 'SearchResults|comments', count)
when 'projects'
ns_('SearchResults|project', 'SearchResults|projects', count)
- when 'snippet_blobs'
- ns_('SearchResults|snippet result', 'SearchResults|snippet results', count)
when 'snippet_titles'
ns_('SearchResults|snippet', 'SearchResults|snippets', count)
when 'users'
@@ -209,11 +207,11 @@ module SearchHelper
end
end
- def search_filter_input_options(type)
+ def search_filter_input_options(type, placeholder = _('Search or filter results...'))
opts =
{
id: "filtered-search-#{type}",
- placeholder: _('Search or filter results...'),
+ placeholder: placeholder,
data: {
'username-params' => UserSerializer.new.represent(@users)
},
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index f3f4cdc857f..b13cc93436f 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -51,17 +51,13 @@ module ServicesHelper
end
end
- def service_save_button(service)
- button_tag(class: 'btn btn-success', type: 'submit', disabled: service.deprecated?, data: { qa_selector: 'save_changes_button' }) do
+ def service_save_button
+ button_tag(class: 'btn btn-success', type: 'submit', data: { qa_selector: 'save_changes_button' }) do
icon('spinner spin', class: 'hidden js-btn-spinner') +
content_tag(:span, 'Save changes', class: 'js-btn-label')
end
end
- def disable_fields_service?(service)
- !current_controller?("admin/services") && service.deprecated?
- end
-
def scoped_integrations_path
if @project.present?
project_settings_integrations_path(@project)
@@ -84,7 +80,7 @@ module ServicesHelper
def scoped_edit_integration_path(integration)
if @project.present?
- edit_project_settings_integration_path(@project, integration)
+ edit_project_service_path(@project, integration)
elsif @group.present?
edit_group_settings_integration_path(@group, integration)
else
@@ -105,7 +101,7 @@ module ServicesHelper
extend self
end
-ServicesHelper.prepend_if_ee('EE::ServicesHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
+ServicesHelper.prepend_if_ee('EE::ServicesHelper')
# The methods in `EE::ServicesHelper` should be available as both instance and
# class methods.
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index a9f90a8f5e4..d6a9e447fbc 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -42,72 +42,6 @@ module SnippetsHelper
(lower..upper).to_a
end
- # Returns a sorted set of lines to be included in a snippet preview.
- # This ensures matching adjacent lines do not display duplicated
- # surrounding code.
- #
- # @returns Array, unique and sorted.
- def matching_lines(lined_content, surrounding_lines, query)
- used_lines = []
- lined_content.each_with_index do |line, line_number|
- used_lines.concat bounded_line_numbers(
- line_number,
- 0,
- lined_content.size,
- surrounding_lines
- ) if line.downcase.include?(query.downcase)
- end
-
- used_lines.uniq.sort
- end
-
- # 'Chunkify' entire snippet. Splits the snippet data into matching lines +
- # surrounding_lines() worth of unmatching lines.
- #
- # @returns a hash with {snippet_object, snippet_chunks:{data,start_line}}
- def chunk_snippet(snippet, query, surrounding_lines = 3)
- lined_content = snippet.content.split("\n")
- used_lines = matching_lines(lined_content, surrounding_lines, query)
-
- snippet_chunk = []
- snippet_chunks = []
- snippet_start_line = 0
- last_line = -1
-
- # Go through each used line, and add consecutive lines as a single chunk
- # to the snippet chunk array.
- used_lines.each do |line_number|
- if last_line < 0
- # Start a new chunk.
- snippet_start_line = line_number
- snippet_chunk << lined_content[line_number]
- elsif last_line == line_number - 1
- # Consecutive line, continue chunk.
- snippet_chunk << lined_content[line_number]
- else
- # Non-consecutive line, add chunk to chunk array.
- snippet_chunks << {
- data: snippet_chunk.join("\n"),
- start_line: snippet_start_line + 1
- }
-
- # Start a new chunk.
- snippet_chunk = [lined_content[line_number]]
- snippet_start_line = line_number
- end
-
- last_line = line_number
- end
- # Add final chunk to chunk array
- snippet_chunks << {
- data: snippet_chunk.join("\n"),
- start_line: snippet_start_line + 1
- }
-
- # Return snippet with chunk array
- { snippet_object: snippet, snippet_chunks: snippet_chunks }
- end
-
def snippet_embed_tag(snippet)
content_tag(:script, nil, src: gitlab_snippet_url(snippet, format: :js))
end
@@ -160,14 +94,4 @@ module SnippetsHelper
title: 'Download',
rel: 'noopener noreferrer')
end
-
- def snippet_file_name(snippet)
- blob = if Feature.enabled?(:version_snippets, current_user) && !snippet.repository.empty?
- snippet.blobs.first
- else
- snippet.blob
- end
-
- blob.name
- end
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 3e448087db0..ed1b35338ae 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
module SortingHelper
- prepend_if_ee('::EE::SortingHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
def sort_options_hash
{
sort_value_created_date => sort_title_created_date,
@@ -584,3 +582,5 @@ module SortingHelper
'expired_asc'
end
end
+
+SortingHelper.prepend_if_ee('::EE::SortingHelper')
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index d3b6ecf2bd7..7baa615d36f 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -27,7 +27,11 @@ module SystemNoteHelper
'locked' => 'lock',
'unlocked' => 'lock-open',
'due_date' => 'calendar',
- 'health_status' => 'status-health'
+ 'health_status' => 'status-health',
+ 'designs_added' => 'doc-image',
+ 'designs_modified' => 'doc-image',
+ 'designs_removed' => 'doc-image',
+ 'designs_discussion_added' => 'doc-image'
}.freeze
def system_note_icon_name(note)
@@ -42,7 +46,7 @@ module SystemNoteHelper
extend self
end
-SystemNoteHelper.prepend_if_ee('EE::SystemNoteHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
+SystemNoteHelper.prepend_if_ee('EE::SystemNoteHelper')
# The methods in `EE::SystemNoteHelper` should be available as both instance and
# class methods.
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 0211a22a8c4..41f39c7e798 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -53,6 +53,8 @@ module TodosHelper
end
def todo_target_type_name(todo)
+ return _('design') if todo.for_design?
+
todo.target_type.titleize.downcase
end
@@ -63,6 +65,8 @@ module TodosHelper
if todo.for_commit?
project_commit_path(todo.project, todo.target, path_options)
+ elsif todo.for_design?
+ todos_design_path(todo, path_options)
else
path = [todo.resource_parent, todo.target]
@@ -151,7 +155,8 @@ module TodosHelper
[
{ id: '', text: 'Any Type' },
{ id: 'Issue', text: 'Issue' },
- { id: 'MergeRequest', text: 'Merge Request' }
+ { id: 'MergeRequest', text: 'Merge Request' },
+ { id: 'DesignManagement::Design', text: 'Design' }
]
end
@@ -188,6 +193,18 @@ module TodosHelper
private
+ def todos_design_path(todo, path_options)
+ design = todo.target
+
+ designs_project_issue_path(
+ todo.resource_parent,
+ design.issue,
+ path_options.merge(
+ vueroute: design.filename
+ )
+ )
+ end
+
def todo_action_subject(todo)
todo.self_added? ? 'yourself' : 'you'
end
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index bb5b1555dc4..f74b53d68a1 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -36,8 +36,8 @@ module WorkhorseHelper
end
# Send an entry from artifacts through Workhorse
- def send_artifacts_entry(build, entry)
- headers.store(*Gitlab::Workhorse.send_artifacts_entry(build, entry))
+ def send_artifacts_entry(file, entry)
+ headers.store(*Gitlab::Workhorse.send_artifacts_entry(file, entry))
head :ok
end
diff --git a/app/helpers/x509_helper.rb b/app/helpers/x509_helper.rb
index c330b599d74..009635fb629 100644
--- a/app/helpers/x509_helper.rb
+++ b/app/helpers/x509_helper.rb
@@ -16,4 +16,8 @@ module X509Helper
rescue
{}
end
+
+ def x509_signature?(sig)
+ sig.is_a?(X509CommitSignature) || sig.is_a?(Gitlab::X509::Signature)
+ end
end
diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb
new file mode 100644
index 00000000000..07812a01202
--- /dev/null
+++ b/app/mailers/emails/groups.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Emails
+ module Groups
+ def group_was_exported_email(current_user, group)
+ group_email(current_user, group, _('Group was exported'))
+ end
+
+ def group_was_not_exported_email(current_user, group, errors)
+ group_email(current_user, group, _('Group export error'), errors: errors)
+ end
+
+ def group_email(current_user, group, subj, errors: nil)
+ @group = group
+ @errors = errors
+ mail(to: current_user.notification_email_for(@group), subject: subject(subj))
+ end
+ end
+end
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 6dd4ccb510a..4b56ff60f09 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -40,6 +40,18 @@ module Emails
mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason))
end
+ def note_design_email(recipient_id, note_id, reason = nil)
+ setup_note_mail(note_id, recipient_id)
+
+ design = @note.noteable
+ @target_url = ::Gitlab::Routing.url_helpers.designs_project_issue_url(
+ @note.resource_parent,
+ design.issue,
+ note_target_url_query_params.merge(vueroute: design.filename)
+ )
+ mail_answer_note_thread(design, @note, note_thread_options(recipient_id, reason))
+ end
+
private
def note_target_url_options
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 441439444d5..4b19149a833 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -44,6 +44,16 @@ module Emails
mail(to: @user.notification_email, subject: subject(_("Your Personal Access Tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
end
end
+
+ def unknown_sign_in_email(user, ip)
+ @user = user
+ @ip = ip
+ @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")))
+ end
+ end
end
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 49eacc44519..d9483bab543 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -17,6 +17,7 @@ class Notify < ApplicationMailer
include Emails::AutoDevops
include Emails::RemoteMirrors
include Emails::Releases
+ include Emails::Groups
helper MilestonesHelper
helper MergeRequestsHelper
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 38e1d9532a6..c931b5a848f 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -161,6 +161,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.remote_mirror_update_failed_email(remote_mirror.id, user.id).message
end
+ def unknown_sign_in_email
+ Notify.unknown_sign_in_email(user, '127.0.0.1').message
+ end
+
private
def project
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 050155398ab..065bd5507be 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -124,7 +124,7 @@ class ActiveSession
end
end
- # Lists the ActiveSession objects for the given session IDs.
+ # Lists the session Hash objects for the given session IDs.
#
# session_ids - An array of Rack::Session::SessionId objects
#
@@ -143,7 +143,7 @@ class ActiveSession
end
end
- # Deserializes an ActiveSession object from Redis.
+ # Deserializes a session Hash object from Redis.
#
# raw_session - Raw bytes from Redis
#
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
new file mode 100644
index 00000000000..acaf474ecc2
--- /dev/null
+++ b/app/models/alert_management/alert.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class Alert < ApplicationRecord
+ include AtomicInternalId
+ include ShaAttribute
+ include Sortable
+ include Gitlab::SQL::Pattern
+
+ STATUSES = {
+ triggered: 0,
+ acknowledged: 1,
+ resolved: 2,
+ ignored: 3
+ }.freeze
+
+ STATUS_EVENTS = {
+ triggered: :trigger,
+ acknowledged: :acknowledge,
+ resolved: :resolve,
+ ignored: :ignore
+ }.freeze
+
+ 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'
+
+ sha_attribute :fingerprint
+
+ HOSTS_MAX_LENGTH = 255
+
+ validates :title, length: { maximum: 200 }, presence: true
+ validates :description, length: { maximum: 1_000 }
+ validates :service, length: { maximum: 100 }
+ validates :monitoring_tool, length: { maximum: 100 }
+ validates :project, presence: true
+ validates :events, presence: true
+ validates :severity, presence: true
+ validates :status, presence: true
+ validates :started_at, presence: true
+ validates :fingerprint, uniqueness: { scope: :project }, allow_blank: true
+ validate :hosts_length
+
+ enum severity: {
+ critical: 0,
+ high: 1,
+ medium: 2,
+ low: 3,
+ info: 4,
+ unknown: 5
+ }
+
+ state_machine :status, initial: :triggered do
+ state :triggered, value: STATUSES[:triggered]
+
+ state :acknowledged, value: STATUSES[:acknowledged]
+
+ state :resolved, value: STATUSES[:resolved] do
+ validates :ended_at, presence: true
+ end
+
+ state :ignored, value: STATUSES[:ignored]
+
+ state :triggered, :acknowledged, :ignored do
+ validates :ended_at, absence: true
+ end
+
+ event :trigger do
+ transition any => :triggered
+ end
+
+ event :acknowledge do
+ transition any => :acknowledged
+ end
+
+ event :resolve do
+ transition any => :resolved
+ end
+
+ event :ignore do
+ transition any => :ignored
+ end
+
+ before_transition to: [:triggered, :acknowledged, :ignored] do |alert, _transition|
+ alert.ended_at = nil
+ end
+
+ before_transition to: :resolved do |alert, transition|
+ ended_at = transition.args.first
+ alert.ended_at = ended_at || Time.current
+ end
+ end
+
+ delegate :iid, to: :issue, prefix: true, allow_nil: true
+
+ scope :for_iid, -> (iid) { where(iid: iid) }
+ scope :for_status, -> (status) { where(status: status) }
+ scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) }
+ scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) }
+
+ 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_severity, -> (sort_order) { order(severity: sort_order) }
+ scope :order_status, -> (sort_order) { order(status: sort_order) }
+
+ scope :counts_by_status, -> { group(:status).count }
+
+ 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 'severity_asc' then order_severity(:asc)
+ when 'severity_desc' then order_severity(:desc)
+ when 'status_asc' then order_status(:asc)
+ when 'status_desc' then order_status(:desc)
+ else
+ order_by(method)
+ end
+ end
+
+ def details
+ details_payload = payload.except(*attributes.keys)
+
+ Gitlab::Utils::InlineHash.merge_keys(details_payload)
+ end
+
+ def prometheus?
+ monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus]
+ end
+
+ private
+
+ def hosts_length
+ return unless hosts
+
+ errors.add(:hosts, "hosts array is over #{HOSTS_MAX_LENGTH} chars") if hosts.join.length > HOSTS_MAX_LENGTH
+ end
+ end
+end
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index 9da4dfd43b5..00a95070691 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -8,6 +8,7 @@ class Appearance < ApplicationRecord
cache_markdown_field :description
cache_markdown_field :new_project_guidelines
+ cache_markdown_field :profile_image_guidelines
cache_markdown_field :header_message, pipeline: :broadcast_message
cache_markdown_field :footer_message, pipeline: :broadcast_message
@@ -15,12 +16,14 @@ class Appearance < ApplicationRecord
validates :header_logo, file_size: { maximum: 1.megabyte }
validates :message_background_color, allow_blank: true, color: true
validates :message_font_color, allow_blank: true, color: true
+ validates :profile_image_guidelines, length: { maximum: 4096 }
validate :single_appearance_row, on: :create
default_value_for :title, ''
default_value_for :description, ''
default_value_for :new_project_guidelines, ''
+ default_value_for :profile_image_guidelines, ''
default_value_for :header_message, ''
default_value_for :footer_message, ''
default_value_for :message_background_color, '#E75E40'
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 0aa0216558f..b29d6731b08 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -144,7 +144,7 @@ class ApplicationSetting < ApplicationRecord
validates :default_artifacts_expire_in, presence: true, duration: true
validates :container_expiration_policies_enable_historic_entries,
- inclusion: { in: [true, false], message: 'must be a boolean value' }
+ inclusion: { in: [true, false], message: 'must be a boolean value' }
validates :container_registry_token_expire_delay,
presence: true,
@@ -263,6 +263,8 @@ class ApplicationSetting < ApplicationRecord
validates :email_restrictions, untrusted_regexp: true
+ validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -345,6 +347,12 @@ class ApplicationSetting < ApplicationRecord
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :issues_create_limit,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :raw_blob_request_limit,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -412,7 +420,7 @@ class ApplicationSetting < ApplicationRecord
# can cause a significant amount of load on Redis, let's cache it in
# memory.
def self.cache_backend
- Gitlab::ThreadMemoryCache.cache_backend
+ Gitlab::ProcessMemoryCache.cache_backend
end
def recaptcha_or_login_protection_enabled
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index c96f086684f..221e4d5e0c6 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -43,7 +43,10 @@ module ApplicationSettingImplementation
authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
commit_email_hostname: default_commit_email_hostname,
container_expiration_policies_enable_historic_entries: false,
+ container_registry_features: [],
container_registry_token_expire_delay: 5,
+ container_registry_vendor: '',
+ container_registry_version: '',
default_artifacts_expire_in: '30 days',
default_branch_protection: Settings.gitlab['default_branch_protection'],
default_ci_config_path: nil,
@@ -93,7 +96,7 @@ module ApplicationSettingImplementation
plantuml_url: nil,
polling_interval_multiplier: 1,
project_export_enabled: true,
- protected_ci_variables: false,
+ protected_ci_variables: true,
push_event_hooks_limit: 3,
push_event_activities_limit: 3,
raw_blob_request_limit: 300,
diff --git a/app/models/blob.rb b/app/models/blob.rb
index cdc5838797b..c8df6c7732a 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -86,8 +86,8 @@ class Blob < SimpleDelegator
new(blob, container)
end
- def self.lazy(container, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
- BatchLoader.for([commit_id, path]).batch(key: container.repository) do |items, loader, args|
+ def self.lazy(repository, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
+ BatchLoader.for([commit_id, path]).batch(key: repository) do |items, loader, args|
args[:key].blobs_at(items, blob_size_limit: blob_size_limit).each do |blob|
loader.call([blob.commit_id, blob.path], blob) if blob
end
@@ -129,7 +129,7 @@ class Blob < SimpleDelegator
def external_storage_error?
if external_storage == :lfs
- !project&.lfs_enabled?
+ !repository.lfs_enabled?
else
false
end
diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb
index 711465c7c79..a851f22bfcd 100644
--- a/app/models/blob_viewer/dependency_manager.rb
+++ b/app/models/blob_viewer/dependency_manager.rb
@@ -32,7 +32,7 @@ module BlobViewer
def json_data
@json_data ||= begin
prepare!
- JSON.parse(blob.data)
+ Gitlab::Json.parse(blob.data)
rescue
{}
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 0a536a01f72..856f86201ec 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -105,7 +105,10 @@ class BroadcastMessage < ApplicationRecord
def matches_current_path(current_path)
return true if current_path.blank? || target_path.blank?
- current_path.match(Regexp.escape(target_path).gsub('\\*', '.*'))
+ escaped = Regexp.escape(target_path).gsub('\\*', '.*')
+ regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE
+
+ regexp.match(current_path)
end
def flush_redis_cache
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 76882dfcb0d..1e92a47ab49 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -166,6 +166,10 @@ module Ci
end
end
+ def dependency_variables
+ []
+ end
+
private
def cross_project_params
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index e515447e394..7f64ea7dd97 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -25,13 +25,16 @@ module Ci
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
- refspecs: -> (build) { build.merge_request_ref? }
+ refspecs: -> (build) { build.merge_request_ref? },
+ artifacts_exclude: -> (build) { build.supports_artifacts_exclude? }
}.freeze
DEFAULT_RETRIES = {
scheduler_failure: 2
}.freeze
+ DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD'
+
has_one :deployment, as: :deployable, class_name: 'Deployment'
has_one :resource, class_name: 'Ci::Resource', inverse_of: :build
has_many :trace_sections, class_name: 'Ci::BuildTraceSection'
@@ -87,8 +90,12 @@ module Ci
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
- scope :with_artifacts_archive, ->() do
- where('EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ scope :with_downloadable_artifacts, ->() do
+ where('EXISTS (?)',
+ Ci::JobArtifact.select(1)
+ .where('ci_builds.id = ci_job_artifacts.job_id')
+ .where(file_type: Ci::JobArtifact::DOWNLOADABLE_TYPES)
+ )
end
scope :with_existing_job_artifacts, ->(query) do
@@ -130,8 +137,8 @@ module Ci
.includes(:metadata, :job_artifacts_metadata)
end
- scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
- scope :with_expired_artifacts, ->() { with_artifacts_archive.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.now) }
+ scope :with_expired_artifacts, ->() { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.now) }
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]) }
@@ -486,8 +493,7 @@ module Ci
end
def requires_resource?
- Feature.enabled?(:ci_resource_group, project, default_enabled: true) &&
- self.resource_group_id.present?
+ self.resource_group_id.present?
end
def has_environment?
@@ -530,6 +536,7 @@ module Ci
.concat(job_variables)
.concat(environment_changed_page_variables)
.concat(persisted_environment_variables)
+ .concat(deploy_freeze_variables)
.to_runner_variables
end
end
@@ -585,6 +592,26 @@ module Ci
end
end
+ def deploy_freeze_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless freeze_period?
+
+ variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true')
+ end
+ end
+
+ def freeze_period?
+ Ci::FreezePeriodStatus.new(project: project).execute
+ end
+
+ def dependency_variables
+ return [] if all_dependencies.empty?
+
+ Gitlab::Ci::Variables::Collection.new.concat(
+ Ci::JobVariable.where(job: all_dependencies).dotenv_source
+ )
+ end
+
def features
{ trace_sections: true }
end
@@ -870,6 +897,14 @@ module Ci
end
end
+ def collect_accessibility_reports!(accessibility_report)
+ each_report(Ci::JobArtifact::ACCESSIBILITY_REPORT_FILE_TYPES) do |file_type, blob|
+ Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, accessibility_report)
+ end
+
+ accessibility_report
+ end
+
def collect_coverage_reports!(coverage_report)
each_report(Ci::JobArtifact::COVERAGE_REPORT_FILE_TYPES) do |file_type, blob|
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, coverage_report)
@@ -878,6 +913,14 @@ module Ci
coverage_report
end
+ def collect_terraform_reports!(terraform_reports)
+ each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact|
+ ::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact)
+ end
+
+ terraform_reports
+ end
+
def report_artifacts
job_artifacts.with_reports
end
@@ -902,6 +945,16 @@ 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
+ end
+
private
def dependencies
diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb
new file mode 100644
index 00000000000..3506b27e974
--- /dev/null
+++ b/app/models/ci/daily_build_group_report_result.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Ci
+ class DailyBuildGroupReportResult < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ PARAM_TYPES = %w[coverage].freeze
+
+ belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
+ belongs_to :project
+
+ def self.upsert_reports(data)
+ upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any?
+ end
+
+ def self.recent_results(attrs, limit: nil)
+ where(attrs).order(date: :desc, group_name: :asc).limit(limit)
+ end
+ end
+end
diff --git a/app/models/ci/daily_report_result.rb b/app/models/ci/daily_report_result.rb
deleted file mode 100644
index 3c1c5f11ed4..00000000000
--- a/app/models/ci/daily_report_result.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class DailyReportResult < ApplicationRecord
- extend Gitlab::Ci::Model
-
- belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
- belongs_to :project
-
- # TODO: Refactor this out when BuildReportResult is implemented.
- # They both need to share the same enum values for param.
- REPORT_PARAMS = {
- coverage: 0
- }.freeze
-
- enum param_type: REPORT_PARAMS
-
- def self.upsert_reports(data)
- upsert_all(data, unique_by: :index_daily_report_results_unique_columns) if data.any?
- end
- end
-end
diff --git a/app/models/ci/freeze_period.rb b/app/models/ci/freeze_period.rb
new file mode 100644
index 00000000000..bf03b92259a
--- /dev/null
+++ b/app/models/ci/freeze_period.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Ci
+ class FreezePeriod < ApplicationRecord
+ include StripAttribute
+ self.table_name = 'ci_freeze_periods'
+
+ default_scope { order(created_at: :asc) }
+
+ belongs_to :project, inverse_of: :freeze_periods
+
+ strip_attributes :freeze_start, :freeze_end
+
+ validates :freeze_start, cron: true, presence: true
+ validates :freeze_end, cron: true, presence: true
+ validates :cron_timezone, cron_freeze_period_timezone: true, presence: true
+ end
+end
diff --git a/app/models/ci/freeze_period_status.rb b/app/models/ci/freeze_period_status.rb
new file mode 100644
index 00000000000..befa935e750
--- /dev/null
+++ b/app/models/ci/freeze_period_status.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Ci
+ class FreezePeriodStatus
+ attr_reader :project
+
+ def initialize(project:)
+ @project = project
+ end
+
+ def execute
+ project.freeze_periods.any? { |period| within_freeze_period?(period) }
+ end
+
+ def within_freeze_period?(period)
+ # previous_freeze_end, ..., previous_freeze_start, ..., NOW, ..., next_freeze_end, ..., next_freeze_start
+ # Current time is within a freeze period if
+ # it falls between a previous freeze start and next freeze end
+ start_freeze = Gitlab::Ci::CronParser.new(period.freeze_start, period.cron_timezone)
+ end_freeze = Gitlab::Ci::CronParser.new(period.freeze_end, period.cron_timezone)
+
+ previous_freeze_start = previous_time(start_freeze)
+ previous_freeze_end = previous_time(end_freeze)
+ next_freeze_start = next_time(start_freeze)
+ next_freeze_end = next_time(end_freeze)
+
+ previous_freeze_end < previous_freeze_start &&
+ previous_freeze_start <= time_zone_now &&
+ time_zone_now <= next_freeze_end &&
+ next_freeze_end < next_freeze_start
+ end
+
+ private
+
+ def previous_time(cron_parser)
+ cron_parser.previous_time_from(time_zone_now)
+ end
+
+ def next_time(cron_parser)
+ cron_parser.next_time_from(time_zone_now)
+ end
+
+ def time_zone_now
+ @time_zone_now ||= Time.zone.now
+ end
+ end
+end
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
index 15dc1ca8954..4b2081f2977 100644
--- a/app/models/ci/group.rb
+++ b/app/models/ci/group.rb
@@ -46,7 +46,7 @@ module Ci
end
def self.fabricate(project, stage)
- stage.statuses.ordered.latest
+ stage.latest_statuses
.sort_by(&:sortable_name).group_by(&:group_name)
.map do |group_name, grouped_statuses|
self.new(project, stage, name: group_name, jobs: grouped_statuses)
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
new file mode 100644
index 00000000000..c674f76d229
--- /dev/null
+++ b/app/models/ci/instance_variable.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Ci
+ class InstanceVariable < ApplicationRecord
+ extend Gitlab::Ci::Model
+ include Ci::NewHasVariable
+ include Ci::Maskable
+
+ alias_attribute :secret_value, :value
+
+ validates :key, uniqueness: {
+ message: "(%{value}) has already been taken"
+ }
+
+ scope :unprotected, -> { where(protected: false) }
+ after_commit { self.class.touch_redis_cache_timestamp }
+
+ class << self
+ def all_cached
+ cached_data[:all]
+ end
+
+ def unprotected_cached
+ 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
+ fetch_memory_cache(:ci_instance_variable_data) do
+ all_records = unscoped.all.to_a
+
+ { all: all_records, unprotected: all_records.reject(&:protected?) }
+ 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
+
+ def process_backend
+ Gitlab::ProcessMemoryCache.cache_backend
+ end
+ end
+ end
+end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index ef0701b3874..d931428dccd 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -12,7 +12,10 @@ module Ci
TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
+ ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze
NON_ERASABLE_FILE_TYPES = %w[trace].freeze
+ TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze
+ UNSUPPORTED_FILE_TYPES = %i[license_management].freeze
DEFAULT_FILE_NAMES = {
archive: nil,
metadata: nil,
@@ -20,6 +23,7 @@ module Ci
metrics_referee: nil,
network_referee: nil,
junit: 'junit.xml',
+ accessibility: 'gl-accessibility.json',
codequality: 'gl-code-quality-report.json',
sast: 'gl-sast-report.json',
dependency_scanning: 'gl-dependency-scanning-report.json',
@@ -32,7 +36,8 @@ module Ci
lsif: 'lsif.json',
dotenv: '.env',
cobertura: 'cobertura-coverage.xml',
- terraform: 'tfplan.json'
+ terraform: 'tfplan.json',
+ cluster_applications: 'gl-cluster-applications.json'
}.freeze
INTERNAL_TYPES = {
@@ -46,13 +51,15 @@ module Ci
metrics: :gzip,
metrics_referee: :gzip,
network_referee: :gzip,
- lsif: :gzip,
dotenv: :gzip,
cobertura: :gzip,
+ cluster_applications: :gzip,
+ lsif: :zip,
# All these file formats use `raw` as we need to store them uncompressed
# for Frontend to fetch the files and do analysis
# When they will be only used by backend, they can be `gzipped`.
+ accessibility: :raw,
codequality: :raw,
sast: :raw,
dependency_scanning: :raw,
@@ -64,15 +71,38 @@ module Ci
terraform: :raw
}.freeze
+ DOWNLOADABLE_TYPES = %w[
+ accessibility
+ archive
+ cobertura
+ codequality
+ container_scanning
+ dast
+ dependency_scanning
+ dotenv
+ junit
+ license_management
+ license_scanning
+ lsif
+ metrics
+ performance
+ sast
+ ].freeze
+
TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
+ # This is required since we cannot add a default to the database
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/215418
+ attribute :locked, :boolean, default: false
+
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
mount_uploader :file, JobArtifactUploader
validates :file_format, presence: true, unless: :trace?, on: :create
- validate :valid_file_format?, unless: :trace?, on: :create
+ validate :validate_supported_file_format!, on: :create
+ validate :validate_file_format!, unless: :trace?, on: :create
before_save :set_size, if: :file_changed?
update_project_statistics project_statistics_name: :build_artifacts_size
@@ -82,6 +112,7 @@ module Ci
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 }) }
+ scope :for_ref, ->(ref, project_id) { joins(job: :pipeline).where(ci_pipelines: { ref: ref, project_id: project_id }) }
scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
scope :with_file_types, -> (file_types) do
@@ -98,10 +129,18 @@ module Ci
with_file_types(TEST_REPORT_FILE_TYPES)
end
+ scope :accessibility_reports, -> do
+ with_file_types(ACCESSIBILITY_REPORT_FILE_TYPES)
+ end
+
scope :coverage_reports, -> do
with_file_types(COVERAGE_REPORT_FILE_TYPES)
end
+ scope :terraform_reports, -> do
+ with_file_types(TERRAFORM_REPORT_FILE_TYPES)
+ end
+
scope :erasable, -> do
types = self.file_types.reject { |file_type| NON_ERASABLE_FILE_TYPES.include?(file_type) }.values
@@ -109,6 +148,8 @@ module Ci
end
scope :expired, -> (limit) { where('expire_at < ?', Time.now).limit(limit) }
+ scope :locked, -> { where(locked: true) }
+ scope :unlocked, -> { where(locked: [false, nil]) }
scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') }
@@ -133,7 +174,9 @@ module Ci
lsif: 15, # LSIF data for code navigation
dotenv: 16,
cobertura: 17,
- terraform: 18 # Transformed json
+ terraform: 18, # Transformed json
+ accessibility: 19,
+ cluster_applications: 20
}
enum file_format: {
@@ -161,7 +204,15 @@ module Ci
raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream
}.freeze
- def valid_file_format?
+ def validate_supported_file_format!
+ return if Feature.disabled?(:drop_license_management_artifact, project, default_enabled: true)
+
+ if UNSUPPORTED_FILE_TYPES.include?(self.file_type&.to_sym)
+ errors.add(:base, _("File format is no longer supported"))
+ end
+ end
+
+ def validate_file_format!
unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym
errors.add(:base, _('Invalid file format with specified file type'))
end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
index f156219ea81..250306e2be4 100644
--- a/app/models/ci/legacy_stage.rb
+++ b/app/models/ci/legacy_stage.rb
@@ -41,6 +41,10 @@ module Ci
.fabricate!
end
+ def latest_statuses
+ statuses.ordered.latest
+ end
+
def statuses
@statuses ||= pipeline.statuses.where(stage: name)
end
diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb
index 76139f5d676..91163c85a9e 100644
--- a/app/models/ci/persistent_ref.rb
+++ b/app/models/ci/persistent_ref.rb
@@ -14,16 +14,12 @@ module Ci
delegate :ref_exists?, :create_ref, :delete_refs, to: :repository
def exist?
- return unless enabled?
-
ref_exists?(path)
rescue
false
end
def create
- return unless enabled?
-
create_ref(sha, path)
rescue => e
Gitlab::ErrorTracking
@@ -31,8 +27,6 @@ module Ci
end
def delete
- return unless enabled?
-
delete_refs(path)
rescue Gitlab::Git::Repository::NoRepository
# no-op
@@ -44,11 +38,5 @@ module Ci
def path
"refs/#{Repository::REF_PIPELINES}/#{pipeline.id}"
end
-
- private
-
- def enabled?
- Feature.enabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true)
- end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 8a3ca2e758c..5db1635f64d 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -82,7 +82,7 @@ module Ci
has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline
- has_many :daily_report_results, class_name: 'Ci::DailyReportResult', foreign_key: :last_pipeline_id
+ has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult', foreign_key: :last_pipeline_id
accepts_nested_attributes_for :variables, reject_if: :persisted?
@@ -115,8 +115,11 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition [:created, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending
+ transition [:created, :manual, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending
transition [:success, :failed, :canceled] => :running
+
+ # this is needed to ensure tests to be covered
+ transition [:running] => :running
end
event :request_resource do
@@ -194,7 +197,7 @@ module Ci
# We wait a little bit to ensure that all BuildFinishedWorkers finish first
# because this is where some metrics like code coverage is parsed and stored
# in CI build records which the daily build metrics worker relies on.
- pipeline.run_after_commit { Ci::DailyReportResultsWorker.perform_in(10.minutes, pipeline.id) }
+ pipeline.run_after_commit { Ci::DailyBuildGroupReportResultsWorker.perform_in(10.minutes, pipeline.id) }
end
after_transition do |pipeline, transition|
@@ -393,16 +396,18 @@ module Ci
false
end
- ##
- # TODO We do not completely switch to persisted stages because of
- # race conditions with setting statuses gitlab-foss#23257.
- #
def ordered_stages
- return legacy_stages unless complete?
-
- if Feature.enabled?('ci_pipeline_persisted_stages', default_enabled: true)
+ if Feature.enabled?(:ci_atomic_processing, project, default_enabled: false)
+ # 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?
+ # 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
stages
else
+ # In other cases, we need to calculate stages dynamically
legacy_stages
end
end
@@ -440,7 +445,7 @@ module Ci
end
def legacy_stages
- if Feature.enabled?(:ci_composite_status, default_enabled: false)
+ if Feature.enabled?(:ci_composite_status, project, default_enabled: false)
legacy_stages_using_composite_status
else
legacy_stages_using_sql
@@ -681,6 +686,8 @@ module Ci
variables.concat(merge_request.predefined_variables)
end
+ variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active?
+
if external_pull_request_event? && external_pull_request
variables.concat(external_pull_request.predefined_variables)
end
@@ -781,7 +788,7 @@ module Ci
end
def find_job_with_archive_artifacts(name)
- builds.latest.with_artifacts_archive.find_by_name(name)
+ builds.latest.with_downloadable_artifacts.find_by_name(name)
end
def latest_builds_with_artifacts
@@ -809,6 +816,14 @@ module Ci
end
end
+ def accessibility_reports
+ Gitlab::Ci::Reports::AccessibilityReports.new.tap do |accessibility_reports|
+ builds.latest.with_reports(Ci::JobArtifact.accessibility_reports).each do |build|
+ build.collect_accessibility_reports!(accessibility_reports)
+ end
+ end
+ end
+
def coverage_reports
Gitlab::Ci::Reports::CoverageReports.new.tap do |coverage_reports|
builds.latest.with_reports(Ci::JobArtifact.coverage_reports).each do |build|
@@ -817,6 +832,14 @@ module Ci
end
end
+ def terraform_reports
+ ::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports|
+ builds.latest.with_reports(::Ci::JobArtifact.terraform_reports).each do |build|
+ build.collect_terraform_reports!(terraform_reports)
+ end
+ end
+ end
+
def has_exposed_artifacts?
complete? && builds.latest.with_exposed_artifacts.exists?
end
@@ -938,6 +961,14 @@ module Ci
end
end
+ # Set scheduling type of processables if they were created before scheduling_type
+ # data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246).
+ def ensure_scheduling_type!
+ return unless ::Gitlab::Ci::Features.ensure_scheduling_type_enabled?
+
+ processables.populate_scheduling_type!
+ end
+
private
def pipeline_data
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index f5785000062..8c9ad343f32 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -6,6 +6,10 @@ module Ci
include Importable
include StripAttribute
include Schedulable
+ include Limitable
+
+ self.limit_name = 'ci_pipeline_schedules'
+ self.limit_scope = :project
belongs_to :project
belongs_to :owner, class_name: 'User'
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index c123bd7c33b..cc00500662d 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -49,7 +49,7 @@ module Ci
end
validates :type, presence: true
- validates :scheduling_type, presence: true, on: :create, if: :validate_scheduling_type?
+ validates :scheduling_type, presence: true, on: :create, unless: :importing?
delegate :merge_request?,
:merge_request_ref?,
@@ -83,7 +83,7 @@ module Ci
# Overriding scheduling_type enum's method for nil `scheduling_type`s
def scheduling_type_dag?
- super || find_legacy_scheduling_type == :dag
+ scheduling_type.nil? ? find_legacy_scheduling_type == :dag : super
end
# scheduling_type column of previous builds/bridges have not been populated,
@@ -100,10 +100,12 @@ module Ci
end
end
- private
+ def ensure_scheduling_type!
+ # If this has a scheduling_type, it means all processables in the pipeline already have.
+ return if scheduling_type
- def validate_scheduling_type?
- !importing? && Feature.enabled?(:validate_scheduling_type_of_processables, project)
+ pipeline.ensure_scheduling_type!
+ reset
end
end
end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 93bd42f8734..a316b4718e0 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -13,6 +13,7 @@ module Ci
belongs_to :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id
+ has_many :latest_statuses, -> { ordered.latest }, class_name: 'CommitStatus', foreign_key: :stage_id
has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id
has_many :builds, foreign_key: :stage_id
has_many :bridges, foreign_key: :stage_id
@@ -42,8 +43,7 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition [:created, :waiting_for_resource, :preparing] => :pending
- transition [:success, :failed, :canceled, :skipped] => :running
+ transition any - [:pending] => :pending
end
event :request_resource do
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
index afdc1c91c69..0d029aabc3b 100644
--- a/app/models/clusters/applications/elastic_stack.rb
+++ b/app/models/clusters/applications/elastic_stack.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class ElasticStack < ApplicationRecord
- VERSION = '1.9.0'
+ VERSION = '3.0.0'
ELASTICSEARCH_PORT = 9200
@@ -18,7 +18,11 @@ module Clusters
default_value_for :version, VERSION
def chart
- 'stable/elastic-stack'
+ 'elastic-stack/elastic-stack'
+ end
+
+ def repository
+ 'https://charts.gitlab.io'
end
def install_command
@@ -27,7 +31,9 @@ module Clusters
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
+ repository: repository,
files: files,
+ preinstall: migrate_to_3_script,
postinstall: post_install_script
)
end
@@ -49,7 +55,7 @@ module Clusters
strong_memoize(:elasticsearch_client) do
next unless kube_client
- proxy_url = kube_client.proxy_url('service', 'elastic-stack-elasticsearch-client', ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE)
+ proxy_url = kube_client.proxy_url('service', service_name, ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE)
Elasticsearch::Client.new(url: proxy_url) do |faraday|
# ensures headers containing auth data are appended to original client options
@@ -69,23 +75,54 @@ module Clusters
end
end
+ def chart_above_v2?
+ Gem::Version.new(version) >= Gem::Version.new('2.0.0')
+ end
+
+ def chart_above_v3?
+ Gem::Version.new(version) >= Gem::Version.new('3.0.0')
+ end
+
private
+ def service_name
+ chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client'
+ end
+
+ def pvc_selector
+ chart_above_v3? ? "app=elastic-stack-elasticsearch-master" : "release=elastic-stack"
+ end
+
def post_install_script
[
- "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-client:9200"
+ "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-master:9200"
]
end
def post_delete_script
[
- Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack")
+ Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", pvc_selector, "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE)
]
end
def kube_client
cluster&.kubeclient&.core_client
end
+
+ def migrate_to_3_script
+ return [] if !updating? || chart_above_v3?
+
+ # Chart version 3.0.0 moves to our own chart at https://gitlab.com/gitlab-org/charts/elastic-stack
+ # and is not compatible with pre-existing resources. We first remove them.
+ [
+ Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ name: 'elastic-stack',
+ rbac: cluster.platform_kubernetes_rbac?,
+ files: files
+ ).delete_command,
+ Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack", "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE)
+ ]
+ end
end
end
end
diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb
index a33b1e39ace..3fd6e870edc 100644
--- a/app/models/clusters/applications/fluentd.rb
+++ b/app/models/clusters/applications/fluentd.rb
@@ -4,6 +4,7 @@ module Clusters
module Applications
class Fluentd < ApplicationRecord
VERSION = '2.4.0'
+ CILIUM_CONTAINER_NAME = 'cilium-monitor'
self.table_name = 'clusters_applications_fluentd'
@@ -18,6 +19,8 @@ module Clusters
enum protocol: { tcp: 0, udp: 1 }
+ validate :has_at_least_one_log_enabled?
+
def chart
'stable/fluentd'
end
@@ -39,6 +42,12 @@ module Clusters
private
+ def has_at_least_one_log_enabled?
+ if !waf_log_enabled && !cilium_log_enabled
+ errors.add(:base, _("At least one logging option is required to be enabled"))
+ end
+ end
+
def content_values
YAML.load_file(chart_values_file).deep_merge!(specification)
end
@@ -62,7 +71,7 @@ module Clusters
program fluentd
hostname ${kubernetes_host}
protocol #{protocol}
- packet_size 65535
+ packet_size 131072
<buffer kubernetes_host>
</buffer>
<format>
@@ -85,7 +94,7 @@ module Clusters
<source>
@type tail
@id in_tail_container_logs
- path /var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log
+ path #{path_to_logs}
pos_file /var/log/fluentd-containers.log.pos
tag kubernetes.*
read_from_head true
@@ -96,6 +105,13 @@ module Clusters
</source>
EOF
end
+
+ def path_to_logs
+ path = []
+ path << "/var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log" if waf_log_enabled
+ path << "/var/log/containers/*#{CILIUM_CONTAINER_NAME}*.log" if cilium_log_enabled
+ path.join(',')
+ end
end
end
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 5985e08d73e..dd354198910 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -17,6 +17,7 @@ module Clusters
include ::Clusters::Concerns::ApplicationVersion
include ::Clusters::Concerns::ApplicationData
include AfterCommitQueue
+ include UsageStatistics
default_value_for :ingress_type, :nginx
default_value_for :modsecurity_enabled, true
@@ -29,6 +30,10 @@ module Clusters
enum modsecurity_mode: { logging: 0, blocking: 1 }
+ scope :modsecurity_not_installed, -> { where(modsecurity_enabled: nil) }
+ scope :modsecurity_enabled, -> { where(modsecurity_enabled: true) }
+ scope :modsecurity_disabled, -> { where(modsecurity_enabled: false) }
+
FETCH_IP_ADDRESS_DELAY = 30.seconds
state_machine :status do
@@ -98,7 +103,7 @@ module Clusters
"args" => [
"/bin/sh",
"-c",
- "tail -f /var/log/modsec/audit.log"
+ "tail -F /var/log/modsec/audit.log"
],
"volumeMounts" => [
{
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 42fa4a6f179..056ea355de6 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -5,7 +5,7 @@ require 'securerandom'
module Clusters
module Applications
class Jupyter < ApplicationRecord
- VERSION = '0.9.0-beta.2'
+ VERSION = '0.9.0'
self.table_name = 'clusters_applications_jupyter'
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 1f90318f845..3047da12dd9 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -4,8 +4,8 @@ module Clusters
module Applications
class Knative < ApplicationRecord
VERSION = '0.9.0'
- REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'
- METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'
+ REPOSITORY = 'https://charts.gitlab.io'
+ METRICS_CONFIG = 'https://gitlab.com/gitlab-org/charts/knative/-/raw/v0.9.0/vendor/istio-metrics.yml'
FETCH_IP_ADDRESS_DELAY = 30.seconds
API_GROUPS_PATH = 'config/knative/api_groups.yml'
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 7d67e258991..a861126908f 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.15.0'
+ VERSION = '0.16.1'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 430a9b3c43e..83f558af1a1 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -26,6 +26,8 @@ module Clusters
KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'
APPLICATIONS_ASSOCIATIONS = APPLICATIONS.values.map(&:association_name).freeze
+ self.reactive_cache_work_type = :external_dependency
+
belongs_to :user
belongs_to :management_project, class_name: '::Project', optional: true
@@ -33,6 +35,7 @@ module Clusters
has_many :projects, through: :cluster_projects, class_name: '::Project'
has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project'
has_many :deployment_clusters
+ has_many :deployments, inverse_of: :cluster
has_many :cluster_groups, class_name: 'Clusters::Group'
has_many :groups, through: :cluster_groups, class_name: '::Group'
@@ -203,10 +206,16 @@ module Clusters
end
end
+ def nodes
+ with_reactive_cache do |data|
+ data[:nodes]
+ end
+ end
+
def calculate_reactive_cache
return unless enabled?
- { connection_status: retrieve_connection_status }
+ { connection_status: retrieve_connection_status, nodes: retrieve_nodes }
end
def persisted_applications
@@ -214,11 +223,19 @@ module Clusters
end
def applications
- APPLICATIONS_ASSOCIATIONS.map do |association_name|
- public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend
+ APPLICATIONS.each_value.map do |application_class|
+ find_or_build_application(application_class)
end
end
+ def find_or_build_application(application_class)
+ raise ArgumentError, "#{application_class} is not in APPLICATIONS" unless APPLICATIONS.value?(application_class)
+
+ association_name = application_class.association_name
+
+ public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+
def provider
if gcp?
provider_gcp
@@ -345,32 +362,55 @@ module Clusters
end
def retrieve_connection_status
- kubeclient.core_client.discover
- rescue *Gitlab::Kubernetes::Errors::CONNECTION
- :unreachable
- rescue *Gitlab::Kubernetes::Errors::AUTHENTICATION
- :authentication_failure
- rescue Kubeclient::HttpError => e
- kubeclient_error_status(e.message)
- rescue => e
- Gitlab::ErrorTracking.track_exception(e, cluster_id: id)
-
- :unknown_failure
- else
- :connected
- end
-
- # KubeClient uses the same error class
- # For connection errors (eg. timeout) and
- # for Kubernetes errors.
- def kubeclient_error_status(message)
- if message&.match?(/timed out|timeout/i)
- :unreachable
- else
- :authentication_failure
+ result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.core_client.discover }
+ result[:status]
+ end
+
+ def retrieve_nodes
+ result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.get_nodes }
+ cluster_nodes = result[:response].to_a
+
+ result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.metrics_client.get_nodes }
+ nodes_metrics = result[:response].to_a
+
+ cluster_nodes.inject([]) do |memo, node|
+ sliced_node = filter_relevant_node_attributes(node)
+
+ matched_node_metric = nodes_metrics.find { |node_metric| node_metric.metadata.name == node.metadata.name }
+
+ sliced_node_metrics = matched_node_metric ? filter_relevant_node_metrics_attributes(matched_node_metric) : {}
+
+ memo << sliced_node.merge(sliced_node_metrics)
end
end
+ def filter_relevant_node_attributes(node)
+ {
+ 'metadata' => {
+ 'name' => node.metadata.name
+ },
+ 'status' => {
+ 'capacity' => {
+ 'cpu' => node.status.capacity.cpu,
+ 'memory' => node.status.capacity.memory
+ },
+ 'allocatable' => {
+ 'cpu' => node.status.allocatable.cpu,
+ 'memory' => node.status.allocatable.memory
+ }
+ }
+ }
+ end
+
+ def filter_relevant_node_metrics_attributes(node_metrics)
+ {
+ 'usage' => {
+ 'cpu' => node_metrics.usage.cpu,
+ 'memory' => node_metrics.usage.memory
+ }
+ }
+ end
+
# To keep backward compatibility with AUTO_DEVOPS_DOMAIN
# environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN
# is set if AUTO_DEVOPS_DOMAIN is set on any of the following options:
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index 14237439a8d..0b915126f8a 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -27,6 +27,7 @@ module Clusters
state :update_errored, value: 6
state :uninstalling, value: 7
state :uninstall_errored, value: 8
+ state :uninstalled, value: 10
# Used for applications that are pre-installed by the cluster,
# e.g. Knative in GCP Cloud Run enabled clusters
@@ -35,6 +36,14 @@ module Clusters
# and no exit transitions.
state :pre_installed, value: 9
+ event :make_externally_installed do
+ transition any => :installed
+ end
+
+ event :make_externally_uninstalled do
+ transition any => :uninstalled
+ end
+
event :make_scheduled do
transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 046f131b041..7e99f128dad 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -7,8 +7,6 @@ class CommitStatus < ApplicationRecord
include Presentable
include EnumWithNil
- prepend_if_ee('::EE::CommitStatus') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
self.table_name = 'ci_builds'
belongs_to :user
@@ -267,8 +265,16 @@ class CommitStatus < ApplicationRecord
end
end
+ def recoverable?
+ failed? && !unrecoverable_failure?
+ end
+
private
+ def unrecoverable_failure?
+ script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure?
+ end
+
def schedule_stage_and_pipeline_update
if Feature.enabled?(:ci_atomic_processing, project)
# Atomic Processing requires only single Worker
@@ -284,3 +290,5 @@ class CommitStatus < ApplicationRecord
end
end
end
+
+CommitStatus.prepend_if_ee('::EE::CommitStatus')
diff --git a/app/models/concerns/async_devise_email.rb b/app/models/concerns/async_devise_email.rb
new file mode 100644
index 00000000000..38c99dc7e71
--- /dev/null
+++ b/app/models/concerns/async_devise_email.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module AsyncDeviseEmail
+ extend ActiveSupport::Concern
+
+ private
+
+ # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
+ def send_devise_notification(notification, *args)
+ return true unless can?(:receive_notifications)
+
+ devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index 0f2a389f0a3..896f0916d8c 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -14,32 +14,29 @@ module Awardable
class_methods do
def awarded(user, name = nil)
- sql = <<~EOL
- EXISTS (
- SELECT TRUE
- FROM award_emoji
- WHERE user_id = :user_id AND
- #{"name = :name AND" if name.present?}
- awardable_type = :awardable_type AND
- awardable_id = #{self.arel_table.name}.id
- )
- EOL
+ award_emoji_table = Arel::Table.new('award_emoji')
+ inner_query = award_emoji_table
+ .project('true')
+ .where(award_emoji_table[:user_id].eq(user.id))
+ .where(award_emoji_table[:awardable_type].eq(self.name))
+ .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id]))
+
+ inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present?
- where(sql, user_id: user.id, name: name, awardable_type: self.name)
+ where(inner_query.exists)
end
- def not_awarded(user)
- sql = <<~EOL
- NOT EXISTS (
- SELECT TRUE
- FROM award_emoji
- WHERE user_id = :user_id AND
- awardable_type = :awardable_type AND
- awardable_id = #{self.arel_table.name}.id
- )
- EOL
+ def not_awarded(user, name = nil)
+ award_emoji_table = Arel::Table.new('award_emoji')
+ inner_query = award_emoji_table
+ .project('true')
+ .where(award_emoji_table[:user_id].eq(user.id))
+ .where(award_emoji_table[:awardable_type].eq(self.name))
+ .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id]))
+
+ inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present?
- where(sql, user_id: user.id, awardable_type: self.name)
+ where(inner_query.exists.not)
end
def order_upvotes_desc
@@ -77,7 +74,7 @@ module Awardable
# By default we always load award_emoji user association
awards = award_emoji.group_by(&:name)
- if with_thumbs
+ if with_thumbs && (!project || project.show_default_award_emojis?)
awards[AwardEmoji::UPVOTE_NAME] ||= []
awards[AwardEmoji::DOWNVOTE_NAME] ||= []
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index cc13f279c4d..e4e0f55d5f4 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -161,7 +161,6 @@ module CacheMarkdownField
define_method(invalidation_method) do
changed_fields = changed_attributes.keys
invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
- invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end
end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index 5ff537a7837..ccd90ea5900 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -18,6 +18,8 @@ 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(secret_instance_variables)
variables.concat(secret_group_variables)
variables.concat(secret_project_variables(environment: environment))
variables.concat(trigger_request.user_variables) if trigger_request
@@ -81,6 +83,12 @@ module Ci
)
end
+ def secret_instance_variables
+ return [] unless ::Feature.enabled?(:ci_instance_level_variables, project, default_enabled: true)
+
+ project.ci_instance_variables_for(ref: git_ref)
+ end
+
def secret_group_variables
return [] unless project.group
diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb
index 6484a3157b1..cea3c7d119c 100644
--- a/app/models/concerns/diff_positionable_note.rb
+++ b/app/models/concerns/diff_positionable_note.rb
@@ -17,12 +17,14 @@ module DiffPositionableNote
%i(original_position position change_position).each do |meth|
define_method "#{meth}=" do |new_position|
if new_position.is_a?(String)
- new_position = JSON.parse(new_position) rescue nil
+ new_position = Gitlab::Json.parse(new_position) rescue nil
end
if new_position.is_a?(Hash)
new_position = new_position.with_indifferent_access
new_position = Gitlab::Diff::Position.new(new_position)
+ elsif !new_position.is_a?(Gitlab::Diff::Position)
+ new_position = nil
end
return if new_position == read_attribute(meth)
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index af7afd6604a..29d31b8bb4f 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -9,7 +9,6 @@
# needs any special behavior.
module HasRepository
extend ActiveSupport::Concern
- include AfterCommitQueue
include Referable
include Gitlab::ShellAdapter
include Gitlab::Utils::StrongMemoize
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
new file mode 100644
index 00000000000..8a238dc736c
--- /dev/null
+++ b/app/models/concerns/has_user_type.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module HasUserType
+ extend ActiveSupport::Concern
+
+ USER_TYPES = {
+ human: nil,
+ support_bot: 1,
+ alert_bot: 2,
+ visual_review_bot: 3,
+ service_user: 4,
+ ghost: 5,
+ project_bot: 6,
+ migration_bot: 7
+ }.with_indifferent_access.freeze
+
+ BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot].freeze
+ NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze
+ INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
+
+ included do
+ scope :humans, -> { where(user_type: :human) }
+ scope :bots, -> { where(user_type: BOT_USER_TYPES) }
+ scope :bots_without_project_bot, -> { where(user_type: BOT_USER_TYPES - ['project_bot']) }
+ scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) }
+ scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) }
+ scope :without_project_bot, -> { humans.or(where.not(user_type: :project_bot)) }
+
+ enum user_type: USER_TYPES
+
+ def human?
+ super || user_type.nil?
+ end
+ end
+
+ def bot?
+ BOT_USER_TYPES.include?(user_type)
+ end
+
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
+ def internal?
+ ghost? || (bot? && !project_bot?)
+ end
+end
diff --git a/app/models/concerns/has_wiki.rb b/app/models/concerns/has_wiki.rb
new file mode 100644
index 00000000000..4dd72216e77
--- /dev/null
+++ b/app/models/concerns/has_wiki.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module HasWiki
+ extend ActiveSupport::Concern
+
+ included do
+ validate :check_wiki_path_conflict
+ end
+
+ def create_wiki
+ wiki.wiki
+ true
+ rescue Wiki::CouldNotCreateWikiError
+ errors.add(:base, _('Failed to create wiki'))
+ false
+ end
+
+ def wiki
+ strong_memoize(:wiki) do
+ Wiki.for_container(self, self.owner)
+ end
+ end
+
+ def wiki_repository_exists?
+ wiki.repository_exists?
+ end
+
+ def after_wiki_activity
+ true
+ end
+
+ private
+
+ def check_wiki_path_conflict
+ return if path.blank?
+
+ path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki"
+
+ if Project.in_namespace(parent_id).where(path: path_to_check).exists? ||
+ GroupsFinder.new(nil, parent: parent_id).execute.where(path: path_to_check).exists?
+ errors.add(:name, _('has already been taken'))
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 37f2209b9d2..a1b14dca4ac 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -115,9 +115,31 @@ module Issuable
end
# rubocop:enable GitlabSecurity/SqlInjection
+ scope :not_assigned_to, ->(users) do
+ assignees_table = Arel::Table.new("#{to_ability_name}_assignees")
+ sql = assignees_table.project('true')
+ .where(assignees_table[:user_id].in(users))
+ .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id"))
+ where(sql.exists.not)
+ end
+
+ scope :without_particular_labels, ->(label_names) do
+ labels_table = Label.arel_table
+ label_links_table = LabelLink.arel_table
+ issuables_table = klass.arel_table
+ inner_query = label_links_table.project('true')
+ .join(labels_table, Arel::Nodes::InnerJoin).on(labels_table[:id].eq(label_links_table[:label_id]))
+ .where(label_links_table[:target_type].eq(name)
+ .and(label_links_table[:target_id].eq(issuables_table[:id]))
+ .and(labels_table[:title].in(label_names)))
+ .exists.not
+
+ where(inner_query)
+ end
+
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) }
- scope :any_label, -> { joins(:label_links).group(:id) }
+ 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) }
@@ -286,9 +308,8 @@ module Issuable
.reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
end
- def with_label(title, sort = nil, not_query: false)
- multiple_labels = title.is_a?(Array) && title.size > 1
- if multiple_labels && !not_query
+ def with_label(title, sort = nil)
+ if title.is_a?(Array) && title.size > 1
joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}")
else
joins(:labels).where(labels: { title: title })
diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb
new file mode 100644
index 00000000000..1c24032dbbb
--- /dev/null
+++ b/app/models/concerns/issue_resource_event.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module IssueResourceEvent
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :issue
+
+ scope :by_issue, ->(issue) { where(issue_id: issue.id) }
+
+ scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) }
+ end
+end
diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb
new file mode 100644
index 00000000000..f320f54bb82
--- /dev/null
+++ b/app/models/concerns/limitable.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Limitable
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :limit_scope
+ class_attribute :limit_name
+ self.limit_name = self.name.demodulize.tableize
+
+ validate :validate_plan_limit_not_exceeded, on: :create
+ end
+
+ private
+
+ def validate_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)
+
+ 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
+ end
+end
diff --git a/app/models/concerns/merge_request_resource_event.rb b/app/models/concerns/merge_request_resource_event.rb
new file mode 100644
index 00000000000..7fb7fb4ec62
--- /dev/null
+++ b/app/models/concerns/merge_request_resource_event.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module MergeRequestResourceEvent
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :merge_request
+
+ scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
+ end
+end
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
index 3ffb32f94fc..8f8494a9678 100644
--- a/app/models/concerns/milestoneable.rb
+++ b/app/models/concerns/milestoneable.rb
@@ -17,8 +17,10 @@ module Milestoneable
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
+ scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) }
scope :any_release, -> { joins_milestone_releases }
scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
+ scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index a7f1fb66a88..933a0b167e2 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -17,7 +17,7 @@ module Noteable
# `Noteable` class names that support resolvable notes.
def resolvable_types
- %w(MergeRequest)
+ %w(MergeRequest DesignManagement::Design)
end
end
@@ -138,15 +138,25 @@ module Noteable
end
def note_etag_key
+ return Gitlab::Routing.url_helpers.designs_project_issue_path(project, issue, { vueroute: filename }) if self.is_a?(DesignManagement::Design)
+
Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
target_type: self.class.name.underscore,
target_id: id
)
end
+
+ def after_note_created(_note)
+ # no-op
+ end
+
+ def after_note_destroyed(_note)
+ # no-op
+ end
end
Noteable.extend(Noteable::ClassMethods)
-Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') # rubocop: disable Cop/InjectEnterpriseEditionModule
+Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods')
Noteable.prepend_if_ee('EE::Noteable')
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index abc41a1c476..761a151a474 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -9,6 +9,7 @@ module PrometheusAdapter
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds
self.reactive_cache_lifetime = 1.minute
+ self.reactive_cache_work_type = :external_dependency
def prometheus_client
raise NotImplementedError
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index 7373f006d64..d1e3d9b2aff 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -50,8 +50,8 @@ module ProtectedRefAccess
end
end
-ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') # rubocop: disable Cop/InjectEnterpriseEditionModule
-ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') # rubocop: disable Cop/InjectEnterpriseEditionModule
+ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes')
+ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess')
# When using `prepend` (or `include` for that matter), the `ClassMethods`
# constants are not merged. This means that `class_methods` in
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 4b472cfdf45..d294563139c 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -8,6 +8,11 @@ module ReactiveCaching
InvalidateReactiveCache = Class.new(StandardError)
ExceededReactiveCacheLimit = Class.new(StandardError)
+ WORK_TYPE = {
+ default: ReactiveCachingWorker,
+ external_dependency: ExternalServiceReactiveCachingWorker
+ }.freeze
+
included do
extend ActiveModel::Naming
@@ -16,6 +21,7 @@ module ReactiveCaching
class_attribute :reactive_cache_refresh_interval
class_attribute :reactive_cache_lifetime
class_attribute :reactive_cache_hard_limit
+ class_attribute :reactive_cache_work_type
class_attribute :reactive_cache_worker_finder
# defaults
@@ -24,6 +30,7 @@ module ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
self.reactive_cache_hard_limit = 1.megabyte
+ self.reactive_cache_work_type = :default
self.reactive_cache_worker_finder = ->(id, *_args) do
find_by(primary_key => id)
end
@@ -112,7 +119,7 @@ module ReactiveCaching
def refresh_reactive_cache!(*args)
clear_reactive_cache!(*args)
keep_alive_reactive_cache!(*args)
- ReactiveCachingWorker.perform_async(self.class, id, *args)
+ worker_class.perform_async(self.class, id, *args)
end
def keep_alive_reactive_cache!(*args)
@@ -145,7 +152,11 @@ module ReactiveCaching
def enqueuing_update(*args)
yield
- ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
+ worker_class.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
+ end
+
+ def worker_class
+ WORK_TYPE.fetch(self.class.reactive_cache_work_type.to_sym)
end
def check_exceeded_reactive_cache_limit!(data)
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
index 4bb4ffe2a8e..2d4ed51ce3b 100644
--- a/app/models/concerns/redis_cacheable.rb
+++ b/app/models/concerns/redis_cacheable.rb
@@ -26,7 +26,7 @@ module RedisCacheable
end
def cache_attributes(values)
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME)
end
@@ -41,9 +41,9 @@ module RedisCacheable
def cached_attributes
strong_memoize(:cached_attributes) do
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
data = redis.get(cache_attribute_key)
- JSON.parse(data, symbolize_names: true) if data
+ Gitlab::Json.parse(data, symbolize_names: true) if data
end
end
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 4fbb5dcb649..9cd1a22b203 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -13,9 +13,13 @@ module Spammable
has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
attr_accessor :spam
+ attr_accessor :needs_recaptcha
attr_accessor :spam_log
+
alias_method :spam?, :spam
+ alias_method :needs_recaptcha?, :needs_recaptcha
+ # if spam errors are added before validation, they will be wiped
after_validation :invalidate_if_spam, on: [:create, :update]
cattr_accessor :spammable_attrs, instance_accessor: false do
@@ -38,24 +42,35 @@ module Spammable
end
def needs_recaptcha!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
- "Please, change the content or solve the reCAPTCHA to proceed.")
+ self.needs_recaptcha = true
end
- def unrecoverable_spam_error!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ def spam!
+ self.spam = true
end
- def invalidate_if_spam
- return unless spam?
+ def clear_spam_flags!
+ self.spam = false
+ self.needs_recaptcha = false
+ end
- if Gitlab::Recaptcha.enabled?
- needs_recaptcha!
- else
+ def invalidate_if_spam
+ if needs_recaptcha? && Gitlab::Recaptcha.enabled?
+ recaptcha_error!
+ elsif needs_recaptcha? || spam?
unrecoverable_spam_error!
end
end
+ def recaptcha_error!
+ self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
+ "Please, change the content or solve the reCAPTCHA to proceed.")
+ end
+
+ def unrecoverable_spam_error!
+ self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ end
+
def spammable_entity_type
self.class.name.underscore
end
diff --git a/app/models/concerns/state_eventable.rb b/app/models/concerns/state_eventable.rb
new file mode 100644
index 00000000000..68129798543
--- /dev/null
+++ b/app/models/concerns/state_eventable.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module StateEventable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :resource_state_events
+ end
+end
diff --git a/app/models/concerns/storage/legacy_project_wiki.rb b/app/models/concerns/storage/legacy_project_wiki.rb
deleted file mode 100644
index a377fa1e5de..00000000000
--- a/app/models/concerns/storage/legacy_project_wiki.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module Storage
- module LegacyProjectWiki
- extend ActiveSupport::Concern
-
- def disk_path
- project.disk_path + '.wiki'
- end
- end
-end
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
new file mode 100644
index 00000000000..d29e6a01c56
--- /dev/null
+++ b/app/models/concerns/timebox.rb
@@ -0,0 +1,204 @@
+# frozen_string_literal: true
+
+module Timebox
+ extend ActiveSupport::Concern
+
+ include AtomicInternalId
+ include CacheMarkdownField
+ include Gitlab::SQL::Pattern
+ include IidRoutes
+ include StripAttribute
+
+ TimeboxStruct = Struct.new(:title, :name, :id) do
+ # Ensure these models match the interface required for exporting
+ def serializable_hash(_opts = {})
+ { title: title, name: name, id: id }
+ end
+ end
+
+ # Represents a "No Timebox" state used for filtering Issues and Merge
+ # Requests that have no timeboxes assigned.
+ None = TimeboxStruct.new('No Timebox', 'No Timebox', 0)
+ Any = TimeboxStruct.new('Any Timebox', '', -1)
+ Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2)
+ Started = TimeboxStruct.new('Started', '#started', -3)
+
+ included do
+ # Defines the same constants above, but inside the including class.
+ const_set :None, TimeboxStruct.new("No #{self.name}", "No #{self.name}", 0)
+ const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1)
+ const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2)
+ const_set :Started, TimeboxStruct.new('Started', '#started', -3)
+
+ alias_method :timebox_id, :id
+
+ validates :group, presence: true, unless: :project
+ validates :project, presence: true, unless: :group
+ validates :title, presence: true
+
+ validate :uniqueness_of_title, if: :title_changed?
+ validate :timebox_type_check
+ validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
+ validate :dates_within_4_digits
+
+ cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :description
+
+ belongs_to :project
+ belongs_to :group
+
+ has_many :issues
+ has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
+ has_many :merge_requests
+
+ scope :of_projects, ->(ids) { where(project_id: ids) }
+ scope :of_groups, ->(ids) { where(group_id: ids) }
+ scope :closed, -> { with_state(:closed) }
+ scope :for_projects, -> { where(group: nil).includes(:project) }
+ scope :with_title, -> (title) { where(title: title) }
+
+ scope :for_projects_and_groups, -> (projects, groups) do
+ projects = projects.compact if projects.is_a? Array
+ projects = [] if projects.nil?
+
+ groups = groups.compact if groups.is_a? Array
+ groups = [] if groups.nil?
+
+ where(project_id: projects).or(where(group_id: groups))
+ end
+
+ 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
+
+ strip_attributes :title
+
+ alias_attribute :name, :title
+ end
+
+ class_methods do
+ # Searches for timeboxes with a matching title or description.
+ #
+ # This method uses ILIKE on PostgreSQL
+ #
+ # query - The search query as a String
+ #
+ # Returns an ActiveRecord::Relation.
+ def search(query)
+ fuzzy_search(query, [:title, :description])
+ end
+
+ # Searches for timeboxes with a matching title.
+ #
+ # This method uses ILIKE on PostgreSQL
+ #
+ # query - The search query as a String
+ #
+ # Returns an ActiveRecord::Relation.
+ def search_title(query)
+ fuzzy_search(query, [:title])
+ end
+
+ def filter_by_state(timeboxes, state)
+ case state
+ when 'closed' then timeboxes.closed
+ when 'all' then timeboxes
+ else timeboxes.active
+ end
+ end
+
+ def count_by_state
+ reorder(nil).group(:state).count
+ end
+
+ def predefined_id?(id)
+ [Any.id, None.id, Upcoming.id, Started.id].include?(id)
+ end
+
+ def predefined?(timebox)
+ predefined_id?(timebox&.id)
+ end
+ end
+
+ def title=(value)
+ write_attribute(:title, sanitize_title(value)) if value.present?
+ end
+
+ def timebox_name
+ model_name.singular
+ end
+
+ def group_timebox?
+ group_id.present?
+ end
+
+ def project_timebox?
+ project_id.present?
+ end
+
+ def safe_title
+ title.to_slug.normalize.to_s
+ end
+
+ def resource_parent
+ group || project
+ end
+
+ def to_ability_name
+ model_name.singular
+ end
+
+ def merge_requests_enabled?
+ if group_timebox?
+ # Assume that groups have at least one project with merge requests enabled.
+ # Otherwise, we would need to load all of the projects from the database.
+ true
+ elsif project_timebox?
+ project&.merge_requests_enabled?
+ end
+ end
+
+ private
+
+ # Timebox titles must be unique across project and group timeboxes
+ def uniqueness_of_title
+ if project
+ relation = self.class.for_projects_and_groups([project_id], [project.group&.id])
+ elsif group
+ relation = self.class.for_projects_and_groups(group.projects.select(:id), [group.id])
+ end
+
+ title_exists = relation.find_by_title(title)
+ errors.add(:title, _("already being used for another group or project %{timebox_name}.") % { timebox_name: timebox_name }) if title_exists
+ end
+
+ # Timebox should be either a project timebox or a group timebox
+ def timebox_type_check
+ if group_id && project_id
+ field = project_id_changed? ? :project_id : :group_id
+ errors.add(field, _("%{timebox_name} should belong either to a project or a group.") % { timebox_name: timebox_name })
+ end
+ end
+
+ def start_date_should_be_less_than_due_date
+ if due_date <= start_date
+ errors.add(:due_date, _("must be greater than start date"))
+ end
+ end
+
+ def dates_within_4_digits
+ if start_date && start_date > Date.new(9999, 12, 31)
+ errors.add(:start_date, _("date must not be after 9999-12-31"))
+ end
+
+ if due_date && due_date > Date.new(9999, 12, 31)
+ errors.add(:due_date, _("date must not be after 9999-12-31"))
+ end
+ end
+
+ def sanitize_title(value)
+ CGI.unescape_html(Sanitize.clean(value.to_s))
+ end
+end
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index a84fb1cf56d..6cf012680d8 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -68,21 +68,11 @@ module UpdateProjectStatistics
def schedule_update_project_statistic(delta)
return if delta.zero?
+ return if project.nil?
- if Feature.enabled?(:update_project_statistics_after_commit, default_enabled: true)
- # Update ProjectStatistics after the transaction
- run_after_commit do
- ProjectStatistics.increment_statistic(
- project_id, self.class.project_statistics_name, delta)
- end
- else
- # Use legacy-way to update within transaction
+ run_after_commit do
ProjectStatistics.increment_statistic(
project_id, self.class.project_statistics_name, delta)
- end
-
- run_after_commit do
- next if project.nil?
Namespaces::ScheduleAggregationWorker.perform_async(
project.namespace_id)
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 3bff7cb06c1..455c672cea3 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -2,6 +2,7 @@
class ContainerRepository < ApplicationRecord
include Gitlab::Utils::StrongMemoize
+ include Gitlab::SQL::Pattern
belongs_to :project
@@ -17,6 +18,7 @@ class ContainerRepository < ApplicationRecord
scope :for_group_and_its_subgroups, ->(group) do
where(project_id: Project.for_group_and_its_subgroups(group).with_container_registry.select(:id))
end
+ scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
def self.exists_by_path?(path)
where(
diff --git a/app/models/cycle_analytics/group_level.rb b/app/models/cycle_analytics/group_level.rb
deleted file mode 100644
index a41e1375484..00000000000
--- a/app/models/cycle_analytics/group_level.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module CycleAnalytics
- class GroupLevel
- include LevelBase
- attr_reader :options, :group
-
- def initialize(group:, options:)
- @group = group
- @options = options.merge(group: group)
- end
-
- def summary
- @summary ||= ::Gitlab::CycleAnalytics::GroupStageSummary.new(group, options: options).data
- end
-
- def permissions(*)
- STAGES.each_with_object({}) do |stage, obj|
- obj[stage] = true
- end
- end
-
- def stats
- @stats ||= STAGES.map do |stage_name|
- self[stage_name].as_json(serializer: GroupAnalyticsStageSerializer)
- end
- end
- end
-end
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index 69245710f01..395260b5201 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -7,7 +7,8 @@ class DeployToken < ApplicationRecord
include Gitlab::Utils::StrongMemoize
add_authentication_token_field :token, encrypted: :optional
- AVAILABLE_SCOPES = %i(read_repository read_registry write_registry).freeze
+ AVAILABLE_SCOPES = %i(read_repository read_registry write_registry
+ read_package_registry write_package_registry).freeze
GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token'
default_value_for(:expires_at) { Forever.date }
@@ -105,7 +106,7 @@ class DeployToken < ApplicationRecord
end
def ensure_at_least_one_scope
- errors.add(:base, _("Scopes can't be blank")) unless read_repository || read_registry || write_registry
+ errors.add(:base, _("Scopes can't be blank")) unless scopes.any?
end
def default_username
diff --git a/app/models/design_management.rb b/app/models/design_management.rb
new file mode 100644
index 00000000000..81e170f7e59
--- /dev/null
+++ b/app/models/design_management.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ DESIGN_IMAGE_SIZES = %w(v432x230).freeze
+
+ def self.designs_directory
+ 'designs'
+ end
+
+ def self.table_name_prefix
+ 'design_management_'
+ end
+end
diff --git a/app/models/design_management/action.rb b/app/models/design_management/action.rb
new file mode 100644
index 00000000000..ecd7973a523
--- /dev/null
+++ b/app/models/design_management/action.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require_dependency 'design_management'
+
+module DesignManagement
+ class Action < ApplicationRecord
+ include WithUploads
+
+ self.table_name = "#{DesignManagement.table_name_prefix}designs_versions"
+
+ mount_uploader :image_v432x230, DesignManagement::DesignV432x230Uploader
+
+ belongs_to :design, class_name: "DesignManagement::Design", inverse_of: :actions
+ belongs_to :version, class_name: "DesignManagement::Version", inverse_of: :actions
+
+ enum event: { creation: 0, modification: 1, deletion: 2 }
+
+ # we assume sequential ordering.
+ scope :ordered, -> { order(version_id: :asc) }
+
+ # For each design, only select the most recent action
+ scope :most_recent, -> do
+ selection = Arel.sql("DISTINCT ON (#{table_name}.design_id) #{table_name}.*")
+
+ order(arel_table[:design_id].asc, arel_table[:version_id].desc).select(selection)
+ end
+
+ # Find all records created before or at the given version, or all if nil
+ scope :up_to_version, ->(version = nil) do
+ case version
+ when nil
+ all
+ when DesignManagement::Version
+ where(arel_table[:version_id].lteq(version.id))
+ when ::Gitlab::Git::COMMIT_ID
+ versions = DesignManagement::Version.arel_table
+ subquery = versions.project(versions[:id]).where(versions[:sha].eq(version))
+ where(arel_table[:version_id].lteq(subquery))
+ else
+ raise ArgumentError, "Expected a DesignManagement::Version, got #{version}"
+ end
+ end
+ end
+end
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
new file mode 100644
index 00000000000..e9b69eab7a7
--- /dev/null
+++ b/app/models/design_management/design.rb
@@ -0,0 +1,266 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Design < ApplicationRecord
+ include Importable
+ include Noteable
+ include Gitlab::FileTypeDetection
+ include Gitlab::Utils::StrongMemoize
+ include Referable
+ include Mentionable
+ include WhereComposite
+
+ belongs_to :project, inverse_of: :designs
+ belongs_to :issue
+
+ has_many :actions
+ has_many :versions, through: :actions, class_name: 'DesignManagement::Version', inverse_of: :designs
+ # This is a polymorphic association, so we can't count on FK's to delete the
+ # data
+ 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
+
+ validates :project, :filename, presence: true
+ validates :issue, presence: true, unless: :importing?
+ validates :filename, uniqueness: { scope: :issue_id }
+ validate :validate_file_is_image
+
+ alias_attribute :title, :filename
+
+ # Pre-fetching scope to include the data necessary to construct a
+ # reference using `to_reference`.
+ scope :for_reference, -> { includes(issue: [{ project: [:route, :namespace] }]) }
+
+ # A design can be uniquely identified by issue_id and filename
+ # Takes one or more sets of composite IDs of the form:
+ # `{issue_id: Integer, filename: String}`.
+ #
+ # @see WhereComposite::where_composite
+ #
+ # e.g:
+ #
+ # by_issue_id_and_filename(issue_id: 1, filename: 'homescreen.jpg')
+ # by_issue_id_and_filename([]) # returns ActiveRecord::NullRelation
+ # by_issue_id_and_filename([
+ # { issue_id: 1, filename: 'homescreen.jpg' },
+ # { issue_id: 2, filename: 'homescreen.jpg' },
+ # { issue_id: 1, filename: 'menu.png' }
+ # ])
+ #
+ scope :by_issue_id_and_filename, ->(composites) do
+ where_composite(%i[issue_id filename], composites)
+ end
+
+ # Find designs visible at the given version
+ #
+ # @param version [nil, DesignManagement::Version]:
+ # the version at which the designs must be visible
+ # Passing `nil` is the same as passing the most current version
+ #
+ # Restricts to designs
+ # - created at least *before* the given version
+ # - not deleted as of the given version.
+ #
+ # As a query, we ascertain this by finding the last event prior to
+ # (or equal to) the cut-off, and seeing whether that version was a deletion.
+ scope :visible_at_version, -> (version) do
+ deletion = ::DesignManagement::Action.events[:deletion]
+ designs = arel_table
+ actions = ::DesignManagement::Action
+ .most_recent.up_to_version(version)
+ .arel.as('most_recent_actions')
+
+ join = designs.join(actions)
+ .on(actions[:design_id].eq(designs[:id]))
+
+ joins(join.join_sources).where(actions[:event].not_eq(deletion)).order(:id)
+ end
+
+ scope :with_filename, -> (filenames) { where(filename: filenames) }
+ scope :on_issue, ->(issue) { where(issue_id: issue) }
+
+ # Scope called by our REST API to avoid N+1 problems
+ scope :with_api_entity_associations, -> { preload(:issue) }
+
+ # A design is current if the most recent event is not a deletion
+ scope :current, -> { visible_at_version(nil) }
+
+ def status
+ if new_design?
+ :new
+ elsif deleted?
+ :deleted
+ else
+ :current
+ end
+ end
+
+ def deleted?
+ most_recent_action&.deletion?
+ end
+
+ # A design is visible_in? a version if:
+ # * it was created before that version
+ # * the most recent action before the version was not a deletion
+ def visible_in?(version)
+ map = strong_memoize(:visible_in) do
+ Hash.new do |h, k|
+ h[k] = self.class.visible_at_version(k).where(id: id).exists?
+ end
+ end
+
+ map[version]
+ end
+
+ def most_recent_action
+ strong_memoize(:most_recent_action) { actions.ordered.last }
+ end
+
+ # A reference for a design is the issue reference, indexed by the filename
+ # with an optional infix when full.
+ #
+ # e.g.
+ # #123[homescreen.png]
+ # other-project#72[sidebar.jpg]
+ # #38/designs[transition.gif]
+ # #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
+
+ "#{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
+ 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
+
+ super(path_segment, filename_pattern)
+ end
+ end
+
+ def to_ability_name
+ 'design'
+ end
+
+ def description
+ ''
+ end
+
+ def new_design?
+ strong_memoize(:new_design) { actions.none? }
+ end
+
+ def full_path
+ @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename)
+ end
+
+ def diff_refs
+ strong_memoize(:diff_refs) { head_version&.diff_refs }
+ end
+
+ def clear_version_cache
+ [versions, actions].each(&:reset)
+ %i[new_design diff_refs head_sha visible_in most_recent_action].each do |key|
+ clear_memoization(key)
+ end
+ end
+
+ def repository
+ project.design_repository
+ end
+
+ def user_notes_count
+ user_notes_count_service.count
+ end
+
+ def after_note_changed(note)
+ user_notes_count_service.delete_cache unless note.system?
+ end
+ alias_method :after_note_created, :after_note_changed
+ alias_method :after_note_destroyed, :after_note_changed
+
+ private
+
+ def head_version
+ strong_memoize(:head_sha) { versions.ordered.first }
+ end
+
+ def allow_dangerous_images?
+ Feature.enabled?(:design_management_allow_dangerous_images, project)
+ end
+
+ def valid_file_extensions
+ allow_dangerous_images? ? (SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT) : SAFE_IMAGE_EXT
+ end
+
+ def validate_file_is_image
+ unless image? || (dangerous_image? && allow_dangerous_images?)
+ message = _('does not have a supported extension. Only %{extension_list} are supported') % {
+ extension_list: valid_file_extensions.to_sentence
+ }
+ errors.add(:filename, message)
+ end
+ end
+
+ def user_notes_count_service
+ strong_memoize(:user_notes_count_service) do
+ ::DesignManagement::DesignUserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
+ end
+ end
+ end
+end
diff --git a/app/models/design_management/design_action.rb b/app/models/design_management/design_action.rb
new file mode 100644
index 00000000000..22baa916296
--- /dev/null
+++ b/app/models/design_management/design_action.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # Parameter object which is a tuple of the database record and the
+ # last gitaly call made to change it. This serves to perform the
+ # logical mapping from git action to database representation.
+ class DesignAction
+ include ActiveModel::Validations
+
+ EVENT_FOR_GITALY_ACTION = {
+ create: DesignManagement::Action.events[:creation],
+ update: DesignManagement::Action.events[:modification],
+ delete: DesignManagement::Action.events[:deletion]
+ }.freeze
+
+ attr_reader :design, :action, :content
+
+ delegate :issue_id, to: :design
+
+ validates :design, presence: true
+ validates :action, presence: true, inclusion: { in: EVENT_FOR_GITALY_ACTION.keys }
+ validates :content,
+ absence: { if: :forbids_content?,
+ message: 'this action forbids content' },
+ presence: { if: :needs_content?,
+ message: 'this action needs content' }
+
+ # Parameters:
+ # - design [DesignManagement::Design]: the design that was changed
+ # - action [Symbol]: the action that gitaly performed
+ def initialize(design, action, content = nil)
+ @design, @action, @content = design, action, content
+ validate!
+ end
+
+ def row_attrs(version)
+ { design_id: design.id, version_id: version.id, event: event }
+ end
+
+ def gitaly_action
+ { action: action, file_path: design.full_path, content: content }.compact
+ end
+
+ # This action has been performed - do any post-creation actions
+ # such as clearing method caches.
+ def performed
+ design.clear_version_cache
+ end
+
+ private
+
+ def needs_content?
+ action != :delete
+ end
+
+ def forbids_content?
+ action == :delete
+ end
+
+ def event
+ EVENT_FOR_GITALY_ACTION[action]
+ end
+ end
+end
diff --git a/app/models/design_management/design_at_version.rb b/app/models/design_management/design_at_version.rb
new file mode 100644
index 00000000000..b4cafb93c2c
--- /dev/null
+++ b/app/models/design_management/design_at_version.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+# Tuple of design and version
+# * has a composite ID, with lazy_find
+module DesignManagement
+ class DesignAtVersion
+ include ActiveModel::Validations
+ include GlobalID::Identification
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :version
+ attr_reader :design
+
+ validates :version, presence: true
+ validates :design, presence: true
+
+ validate :design_and_version_belong_to_the_same_issue
+ validate :design_and_version_have_issue_id
+
+ def initialize(design: nil, version: nil)
+ @design, @version = design, version
+ end
+
+ def self.instantiate(attrs)
+ new(attrs).tap { |obj| obj.validate! }
+ end
+
+ # The ID, needed by GraphQL types and as part of the Lazy-fetch
+ # protocol, includes information about both the design and the version.
+ #
+ # The particular format is not interesting, and should be treated as opaque
+ # by all callers.
+ def id
+ "#{design.id}.#{version.id}"
+ end
+
+ def ==(other)
+ return false unless other && self.class == other.class
+
+ other.id == id
+ end
+
+ alias_method :eql?, :==
+
+ def self.lazy_find(id)
+ BatchLoader.for(id).batch do |ids, callback|
+ find(ids).each do |record|
+ callback.call(record.id, record)
+ end
+ end
+ end
+
+ def self.find(ids)
+ pairs = ids.map { |id| id.split('.').map(&:to_i) }
+
+ design_ids = pairs.map(&:first).uniq
+ version_ids = pairs.map(&:second).uniq
+
+ designs = ::DesignManagement::Design
+ .where(id: design_ids)
+ .index_by(&:id)
+
+ versions = ::DesignManagement::Version
+ .where(id: version_ids)
+ .index_by(&:id)
+
+ pairs.map do |(design_id, version_id)|
+ design = designs[design_id]
+ version = versions[version_id]
+
+ obj = new(design: design, version: version)
+
+ obj if obj.valid?
+ end.compact
+ end
+
+ def status
+ if not_created_yet?
+ :not_created_yet
+ elsif deleted?
+ :deleted
+ else
+ :current
+ end
+ end
+
+ def deleted?
+ action&.deletion?
+ end
+
+ def not_created_yet?
+ action.nil?
+ end
+
+ private
+
+ def action
+ strong_memoize(:most_recent_action) do
+ ::DesignManagement::Action
+ .most_recent.up_to_version(version)
+ .find_by(design: design)
+ end
+ end
+
+ def design_and_version_belong_to_the_same_issue
+ id_a, id_b = [design, version].map { |obj| obj&.issue_id }
+
+ return if id_a == id_b
+
+ errors.add(:issue, 'must be the same on design and version')
+ end
+
+ def design_and_version_have_issue_id
+ return if [design, version].all? { |obj| obj.try(:issue_id).present? }
+
+ errors.add(:issue, 'must be present on both design and version')
+ end
+ end
+end
diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb
new file mode 100644
index 00000000000..18d1541e9c7
--- /dev/null
+++ b/app/models/design_management/design_collection.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignCollection
+ attr_reader :issue
+
+ delegate :designs, :project, to: :issue
+
+ def initialize(issue)
+ @issue = issue
+ end
+
+ def find_or_create_design!(filename:)
+ designs.find { |design| design.filename == filename } ||
+ designs.safe_find_or_create_by!(project: project, filename: filename)
+ end
+
+ def versions
+ @versions ||= DesignManagement::Version.for_designs(designs)
+ end
+
+ def repository
+ project.design_repository
+ end
+
+ def designs_by_filename(filenames)
+ designs.current.where(filename: filenames)
+ end
+ end
+end
diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb
new file mode 100644
index 00000000000..985d6317d5d
--- /dev/null
+++ b/app/models/design_management/repository.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Repository < ::Repository
+ extend ::Gitlab::Utils::Override
+
+ # We define static git attributes for the design repository as this
+ # repository is entirely GitLab-managed rather than user-facing.
+ #
+ # Enable all uploaded files to be stored in LFS.
+ MANAGED_GIT_ATTRIBUTES = <<~GA.freeze
+ /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text
+ GA
+
+ def initialize(project)
+ full_path = project.full_path + Gitlab::GlRepository::DESIGN.path_suffix
+ disk_path = project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix
+
+ super(full_path, project, shard: project.repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::DESIGN)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def info_attributes
+ @info_attributes ||= Gitlab::Git::AttributesParser.new(MANAGED_GIT_ATTRIBUTES)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def attributes(path)
+ info_attributes.attributes(path)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def gitattribute(path, name)
+ attributes(path)[name]
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def attributes_at(_ref = nil)
+ info_attributes
+ end
+
+ override :copy_gitattributes
+ def copy_gitattributes(_ref = nil)
+ true
+ end
+ end
+end
diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb
new file mode 100644
index 00000000000..6be98fe3d44
--- /dev/null
+++ b/app/models/design_management/version.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Version < ApplicationRecord
+ include Importable
+ include ShaAttribute
+ include AfterCommitQueue
+ include Gitlab::Utils::StrongMemoize
+ extend Gitlab::ExclusiveLeaseHelpers
+
+ NotSameIssue = Class.new(StandardError)
+
+ class CouldNotCreateVersion < StandardError
+ attr_reader :sha, :issue_id, :actions
+
+ def initialize(sha, issue_id, actions)
+ @sha, @issue_id, @actions = sha, issue_id, actions
+ end
+
+ def message
+ "could not create version from commit: #{sha}"
+ end
+
+ def sentry_extra_data
+ {
+ sha: sha,
+ issue_id: issue_id,
+ design_ids: actions.map { |a| a.design.id }
+ }
+ end
+ end
+
+ belongs_to :issue
+ belongs_to :author, class_name: 'User'
+ has_many :actions
+ has_many :designs,
+ through: :actions,
+ class_name: "DesignManagement::Design",
+ source: :design,
+ inverse_of: :versions
+
+ validates :designs, presence: true, unless: :importing?
+ validates :sha, presence: true
+ validates :sha, uniqueness: { case_sensitive: false, scope: :issue_id }
+ validates :author, presence: true
+ # We are not validating the issue object as it incurs an extra query to fetch
+ # the record from the DB. Instead, we rely on the foreign key constraint to
+ # ensure referential integrity.
+ validates :issue_id, presence: true, unless: :importing?
+
+ sha_attribute :sha
+
+ delegate :project, to: :issue
+
+ scope :for_designs, -> (designs) do
+ where(id: ::DesignManagement::Action.where(design_id: designs).select(:version_id)).distinct
+ end
+ scope :earlier_or_equal_to, -> (version) { where("(#{table_name}.id) <= ?", version) } # rubocop:disable GitlabSecurity/SqlInjection
+ scope :ordered, -> { order(id: :desc) }
+ scope :for_issue, -> (issue) { where(issue: issue) }
+ scope :by_sha, -> (sha) { where(sha: sha) }
+
+ # This is the one true way to create a Version.
+ #
+ # This method means you can avoid the paradox of versions being invalid without
+ # designs, and not being able to add designs without a saved version. Also this
+ # method inserts designs in bulk, rather than one by one.
+ #
+ # Before calling this method, callers must guard against concurrent
+ # modification by obtaining the lock on the design repository. See:
+ # `DesignManagement::Version.with_lock`.
+ #
+ # Parameters:
+ # - design_actions [DesignManagement::DesignAction]:
+ # the actions that have been performed in the repository.
+ # - sha [String]:
+ # the SHA of the commit that performed them
+ # - author [User]:
+ # the user who performed the commit
+ # returns [DesignManagement::Version]
+ def self.create_for_designs(design_actions, sha, author)
+ issue_id, not_uniq = design_actions.map(&:issue_id).compact.uniq
+ raise NotSameIssue, 'All designs must belong to the same issue!' if not_uniq
+
+ transaction do
+ version = new(sha: sha, issue_id: issue_id, author: author)
+ version.save(validate: false) # We need it to have an ID. Validate later when designs are present
+
+ rows = design_actions.map { |action| action.row_attrs(version) }
+
+ Gitlab::Database.bulk_insert(::DesignManagement::Action.table_name, rows)
+ version.designs.reset
+ version.validate!
+ design_actions.each(&:performed)
+
+ version
+ end
+ rescue
+ raise CouldNotCreateVersion.new(sha, issue_id, design_actions)
+ end
+
+ CREATION_TTL = 5.seconds
+ RETRY_DELAY = ->(num) { 0.2.seconds * num**2 }
+
+ def self.with_lock(project_id, repository, &block)
+ key = "with_lock:#{name}:{#{project_id}}"
+
+ in_lock(key, ttl: CREATION_TTL, retries: 5, sleep_sec: RETRY_DELAY) do |_retried|
+ repository.create_if_not_exists
+ yield
+ end
+ end
+
+ def designs_by_event
+ actions
+ .includes(:design)
+ .group_by(&:event)
+ .transform_values { |group| group.map(&:design) }
+ end
+
+ def author
+ super || (commit_author if persisted?)
+ end
+
+ def diff_refs
+ strong_memoize(:diff_refs) { commit&.diff_refs }
+ end
+
+ def reset
+ %i[diff_refs commit].each { |k| clear_memoization(k) }
+ super
+ end
+
+ private
+
+ def commit_author
+ commit&.author
+ end
+
+ def commit
+ strong_memoize(:commit) { issue.project.design_repository.commit(sha) }
+ end
+ end
+end
diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb
new file mode 100644
index 00000000000..baf4db29a0f
--- /dev/null
+++ b/app/models/design_user_mention.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class DesignUserMention < UserMention
+ belongs_to :design, class_name: 'DesignManagement::Design'
+ belongs_to :note
+end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index e3df61dadae..ff39dbb59f3 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -9,7 +9,7 @@ class DiffNote < Note
include Gitlab::Utils::StrongMemoize
def self.noteable_types
- %w(MergeRequest Commit)
+ %w(MergeRequest Commit DesignManagement::Design)
end
validates :original_position, presence: true
@@ -60,6 +60,8 @@ class DiffNote < Note
# Returns the diff file from `position`
def latest_diff_file
strong_memoize(:latest_diff_file) do
+ next if for_design?
+
position.diff_file(repository)
end
end
@@ -67,6 +69,8 @@ class DiffNote < Note
# Returns the diff file from `original_position`
def diff_file
strong_memoize(:diff_file) do
+ next if for_design?
+
enqueue_diff_file_creation_job if should_create_diff_file?
fetch_diff_file
@@ -145,7 +149,7 @@ class DiffNote < Note
end
def supported?
- for_commit? || self.noteable.has_complete_diff_refs?
+ for_commit? || for_design? || self.noteable.has_complete_diff_refs?
end
def set_line_code
@@ -184,5 +188,3 @@ class DiffNote < Note
noteable.respond_to?(:repository) ? noteable.repository : project.repository
end
end
-
-DiffNote.prepend_if_ee('::EE::DiffNote')
diff --git a/app/models/email.rb b/app/models/email.rb
index 580633d3232..c5154267ff0 100644
--- a/app/models/email.rb
+++ b/app/models/email.rb
@@ -6,7 +6,8 @@ class Email < ApplicationRecord
belongs_to :user, optional: false
- validates :email, presence: true, uniqueness: true, devise_email: true
+ validates :email, presence: true, uniqueness: true
+ validate :validate_email_format
validate :unique_email, if: ->(email) { email.email_changed? }
scope :confirmed, -> { where.not(confirmed_at: nil) }
@@ -14,9 +15,14 @@ class Email < ApplicationRecord
after_commit :update_invalid_gpg_signatures, if: -> { previous_changes.key?('confirmed_at') }
devise :confirmable
+
+ # This module adds async behaviour to Devise emails
+ # and should be added after Devise modules are initialized.
+ include AsyncDeviseEmail
+
self.reconfirmable = false # currently email can't be changed, no need to reconfirm
- delegate :username, to: :user
+ delegate :username, :can?, to: :user
def email=(value)
write_attribute(:email, value.downcase.strip)
@@ -30,6 +36,10 @@ class Email < ApplicationRecord
user.accept_pending_invitations!
end
+ def validate_email_format
+ self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
+ end
+
# once email is confirmed, update the gpg signatures
def update_invalid_gpg_signatures
user.update_invalid_gpg_signatures if confirmed?
diff --git a/app/models/environment.rb b/app/models/environment.rb
index b2391f33aca..21044771bbb 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -8,6 +8,7 @@ class Environment < ApplicationRecord
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 55.seconds
self.reactive_cache_hard_limit = 10.megabytes
+ self.reactive_cache_work_type = :external_dependency
belongs_to :project, required: true
@@ -151,6 +152,14 @@ class Environment < ApplicationRecord
.preload(:user, :metadata, :deployment)
end
+ def count_by_state
+ environments_count_by_state = group(:state).count
+
+ valid_states.each_with_object({}) do |state, count_hash|
+ count_hash[state] = environments_count_by_state[state.to_s] || 0
+ end
+ end
+
private
def cte_for_deployments_with_stop_action
diff --git a/app/models/epic.rb b/app/models/epic.rb
index 04e19c17e18..e09dc1080e6 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
-# It reserves '&' as a reference prefix, but the table does not exists in CE
+# It reserves '&' as a reference prefix, but the table does not exist in FOSS
class Epic < ApplicationRecord
include IgnorableColumns
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 133850b6ab6..fa32c8a5450 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -22,6 +22,7 @@ module ErrorTracking
}x.freeze
self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] }
+ self.reactive_cache_work_type = :external_dependency
belongs_to :project
diff --git a/app/models/event.rb b/app/models/event.rb
index 447ab753421..12b85697690 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -96,6 +96,8 @@ class Event < ApplicationRecord
end
scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) }
+ scope :for_wiki_meta, ->(meta) { where(target_type: 'WikiPage::Meta', target_id: meta.id) }
+ scope :created_at, ->(time) { where(created_at: time) }
# Authors are required as they're used to display who pushed data.
#
@@ -313,6 +315,10 @@ class Event < ApplicationRecord
note? && target && target.for_personal_snippet?
end
+ def design_note?
+ note? && note.for_design?
+ end
+
def note_target
target.noteable
end
@@ -380,6 +386,11 @@ 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?
@@ -396,9 +407,13 @@ class Event < ApplicationRecord
:read_milestone
elsif wiki_page?
:read_wiki
+ elsif design_note?
+ :read_design
end
end
end
+ # rubocop:enable Metrics/CyclomaticComplexity
+ # rubocop:enable Metrics/PerceivedComplexity
private
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index d0cec0e9fc6..43de7454cb7 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -2,7 +2,6 @@
# Global Milestones are milestones that can be shared across multiple projects
class GlobalMilestone
include Milestoneish
- include_if_ee('::EE::GlobalMilestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
@@ -11,7 +10,7 @@ class GlobalMilestone
delegate :title, :state, :due_date, :start_date, :participants, :project,
:group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title,
- :milestoneish_id, :resource_parent, :releases, to: :milestone
+ :timebox_id, :milestoneish_id, :resource_parent, :releases, to: :milestone
def to_hash
{
@@ -105,3 +104,5 @@ class GlobalMilestone
true
end
end
+
+GlobalMilestone.include_if_ee('::EE::GlobalMilestone')
diff --git a/app/models/group.rb b/app/models/group.rb
index 55a2c4ba9a9..04cb6b8b4da 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -30,6 +30,7 @@ class Group < Namespace
has_many :members_and_requesters, as: :source, class_name: 'GroupMember'
has_many :milestones
+ has_many :iterations
has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink'
has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink'
has_many :shared_groups, through: :shared_group_links, source: :shared_group
@@ -59,6 +60,8 @@ class Group < Namespace
has_many :import_failures, inverse_of: :group
+ has_one :import_state, class_name: 'GroupImportState', inverse_of: :group
+
has_many :group_deploy_tokens
has_many :deploy_tokens, through: :group_deploy_tokens
@@ -168,7 +171,7 @@ class Group < Namespace
notification_settings.find { |n| n.notification_email.present? }&.notification_email
end
- def to_reference(_from = nil, full: nil)
+ def to_reference(_from = nil, target_project: nil, full: nil)
"#{self.class.reference_prefix}#{full_path}"
end
@@ -302,9 +305,10 @@ class Group < Namespace
# rubocop: enable CodeReuse/ServiceClass
# rubocop: disable CodeReuse/ServiceClass
- def refresh_members_authorized_projects(blocking: true)
- UserProjectAccessChangedService.new(user_ids_for_project_authorizations)
- .execute(blocking: blocking)
+ def refresh_members_authorized_projects(blocking: true, priority: UserProjectAccessChangedService::HIGH_PRIORITY)
+ UserProjectAccessChangedService
+ .new(user_ids_for_project_authorizations)
+ .execute(blocking: blocking, priority: priority)
end
# rubocop: enable CodeReuse/ServiceClass
@@ -332,6 +336,11 @@ class Group < Namespace
.where(source_id: source_ids)
end
+ def members_from_self_and_ancestors_with_effective_access_level
+ members_with_parents.select([:user_id, 'MAX(access_level) AS access_level'])
+ .group(:user_id)
+ end
+
def members_with_descendants
GroupMember
.active_without_invites_and_requests
@@ -475,14 +484,14 @@ class Group < Namespace
false
end
- def wiki_access_level
- # TODO: Remove this method once we implement group-level features.
- # https://gitlab.com/gitlab-org/gitlab/-/issues/208412
- if Feature.enabled?(:group_wiki, self)
- ProjectFeature::ENABLED
- else
- ProjectFeature::DISABLED
- end
+ def execute_hooks(data, hooks_scope)
+ # NOOP
+ # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
+ end
+
+ def execute_services(data, hooks_scope)
+ # NOOP
+ # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
end
private
@@ -516,8 +525,6 @@ class Group < Namespace
end
def max_member_access_for_user_from_shared_groups(user)
- return unless Feature.enabled?(:share_group_with_group, default_enabled: true)
-
group_group_link_table = GroupGroupLink.arel_table
group_member_table = GroupMember.arel_table
diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb
new file mode 100644
index 00000000000..7773b887249
--- /dev/null
+++ b/app/models/group_import_state.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class GroupImportState < ApplicationRecord
+ self.primary_key = :group_id
+
+ belongs_to :group, inverse_of: :import_state
+
+ validates :group, :status, :jid, presence: true
+
+ state_machine :status, initial: :created do
+ state :created, value: 0
+ state :started, value: 1
+ state :finished, value: 2
+ state :failed, value: -1
+
+ event :start do
+ transition created: :started
+ end
+
+ event :finish do
+ transition started: :finished
+ end
+
+ event :fail_op do
+ transition any => :failed
+ end
+
+ after_transition any => :failed do |state, transition|
+ last_error = transition.args.first
+
+ state.update_column(:last_error, last_error) if last_error
+ end
+ end
+end
diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb
index 87338512d99..60e97174e50 100644
--- a/app/models/group_milestone.rb
+++ b/app/models/group_milestone.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
# Group Milestones are milestones that can be shared among many projects within the same group
class GroupMilestone < GlobalMilestone
- include_if_ee('::EE::GroupMilestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
attr_reader :group, :milestones
def self.build_collection(group, projects, params)
@@ -46,3 +45,5 @@ class GroupMilestone < GlobalMilestone
true
end
end
+
+GroupMilestone.include_if_ee('::EE::GroupMilestone')
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index bc480b14e67..71494b6de4d 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -3,6 +3,9 @@
class ProjectHook < WebHook
include TriggerableHooks
include Presentable
+ include Limitable
+
+ self.limit_scope = :project
triggerable_hooks [
:push_hooks,
diff --git a/app/models/internal_id_enums.rb b/app/models/internal_id_enums.rb
index 2f7d7aeff2f..125ae7573b6 100644
--- a/app/models/internal_id_enums.rb
+++ b/app/models/internal_id_enums.rb
@@ -3,7 +3,18 @@
module InternalIdEnums
def self.usage_resources
# when adding new resource, make sure it doesn't conflict with EE usage_resources
- { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6 }
+ {
+ issues: 0,
+ merge_requests: 1,
+ deployments: 2,
+ milestones: 3,
+ epics: 4,
+ ci_pipelines: 5,
+ operations_feature_flags: 6,
+ operations_user_lists: 7,
+ alert_management_alerts: 8,
+ sprints: 9 # iterations
+ }
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index cdd7429bc58..a04ac412940 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -17,6 +17,7 @@ class Issue < ApplicationRecord
include IgnorableColumns
include MilestoneEventable
include WhereComposite
+ include StateEventable
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -29,9 +30,12 @@ class Issue < ApplicationRecord
SORTING_PREFERENCE_FIELD = :issues_sort
belongs_to :project
- belongs_to :moved_to, class_name: 'Issue'
belongs_to :duplicated_to, class_name: 'Issue'
belongs_to :closed_by, class_name: 'User'
+ belongs_to :iteration, foreign_key: 'sprint_id'
+
+ belongs_to :moved_to, class_name: 'Issue'
+ has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) }
@@ -46,8 +50,15 @@ class Issue < ApplicationRecord
has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :sent_notifications, as: :noteable
+ has_many :designs, class_name: 'DesignManagement::Design', inverse_of: :issue
+ has_many :design_versions, class_name: 'DesignManagement::Version', inverse_of: :issue do
+ def most_recent
+ ordered.first
+ end
+ end
has_one :sentry_issue
+ has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
accepts_nested_attributes_for :sentry_issue
@@ -63,6 +74,7 @@ class Issue < ApplicationRecord
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
scope :due_tomorrow, -> { where(due_date: Date.tomorrow) }
+ scope :not_authored_by, ->(user) { where.not(author_id: user) }
scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) }
@@ -73,11 +85,13 @@ 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 :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
@@ -330,6 +344,10 @@ class Issue < ApplicationRecord
previous_changes['updated_at']&.first || updated_at
end
+ def design_collection
+ @design_collection ||= ::DesignManagement::DesignCollection.new(self)
+ end
+
private
def ensure_metrics
@@ -343,7 +361,7 @@ class Issue < ApplicationRecord
# for performance reasons, check commit: 002ad215818450d2cbbc5fa065850a953dc7ada8
# Make sure to sync this method with issue_policy.rb
def readable_by?(user)
- if user.admin?
+ if user.can_read_all_resources?
true
elsif project.owner == user
true
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
new file mode 100644
index 00000000000..1acd08f2063
--- /dev/null
+++ b/app/models/iteration.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+class Iteration < ApplicationRecord
+ include Timebox
+
+ self.table_name = 'sprints'
+
+ attr_accessor :skip_future_date_validation
+
+ STATE_ENUM_MAP = {
+ upcoming: 1,
+ started: 2,
+ closed: 3
+ }.with_indifferent_access.freeze
+
+ include AtomicInternalId
+
+ has_many :issues, foreign_key: 'sprint_id'
+ has_many :merge_requests, foreign_key: 'sprint_id'
+
+ belongs_to :project
+ belongs_to :group
+
+ has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.iterations&.maximum(:iid) }
+ has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.iterations&.maximum(:iid) }
+
+ validates :start_date, presence: true
+ validates :due_date, presence: true
+
+ validate :dates_do_not_overlap, if: :start_or_due_dates_changed?
+ validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
+
+ scope :upcoming, -> { with_state(:upcoming) }
+ scope :started, -> { with_state(:started) }
+
+ state_machine :state_enum, initial: :upcoming do
+ event :start do
+ transition upcoming: :started
+ end
+
+ event :close do
+ transition [:upcoming, :started] => :closed
+ end
+
+ state :upcoming, value: Iteration::STATE_ENUM_MAP[:upcoming]
+ state :started, value: Iteration::STATE_ENUM_MAP[:started]
+ state :closed, value: Iteration::STATE_ENUM_MAP[:closed]
+ end
+
+ # Alias to state machine .with_state_enum method
+ # This needs to be defined after the state machine block to avoid errors
+ class << self
+ alias_method :with_state, :with_state_enum
+ alias_method :with_states, :with_state_enums
+
+ def filter_by_state(iterations, state)
+ case state
+ when 'closed' then iterations.closed
+ when 'started' then iterations.started
+ when 'opened' then iterations.started.or(iterations.upcoming)
+ when 'all' then iterations
+ else iterations.upcoming
+ end
+ end
+ end
+
+ def state
+ STATE_ENUM_MAP.key(state_enum)
+ end
+
+ def state=(value)
+ self.state_enum = STATE_ENUM_MAP[value]
+ end
+
+ private
+
+ def start_or_due_dates_changed?
+ start_date_changed? || due_date_changed?
+ end
+
+ # ensure dates do not overlap with other Iterations in the same group/project
+ def dates_do_not_overlap
+ return unless resource_parent.iterations.within_timeframe(start_date, due_date).exists?
+
+ errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
+ end
+
+ # ensure dates are in the future
+ def future_date
+ if start_date_changed?
+ errors.add(:start_date, s_("Iteration|cannot be in the past")) if start_date < Date.current
+ errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
+ end
+
+ if due_date_changed?
+ errors.add(:due_date, s_("Iteration|cannot be in the past")) if due_date < Date.current
+ errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
+ end
+ end
+end
diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb
index bde2795e7b8..92147794e88 100644
--- a/app/models/jira_import_state.rb
+++ b/app/models/jira_import_state.rb
@@ -3,6 +3,7 @@
class JiraImportState < ApplicationRecord
include AfterCommitQueue
include ImportState::SidekiqJobTracker
+ include UsageStatistics
self.table_name = 'jira_imports'
@@ -46,7 +47,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) if job_id
+ state.update(jid: job_id, scheduled_at: Time.now) if job_id
end
end
@@ -97,4 +98,8 @@ class JiraImportState < ApplicationRecord
}
)
end
+
+ def self.finished_imports_count
+ finished.sum(:imported_issues_count)
+ end
end
diff --git a/app/models/list.rb b/app/models/list.rb
index 64247fdb983..ec211dfd497 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -3,8 +3,6 @@
class List < ApplicationRecord
include Importable
- prepend_if_ee('::EE::List') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
belongs_to :board
belongs_to :label
has_many :list_user_preferences
@@ -74,14 +72,18 @@ class List < ApplicationRecord
label? ? label.name : list_type.humanize
end
+ def collapsed?(user)
+ preferences = preferences_for(user)
+
+ preferences.collapsed?
+ end
+
def as_json(options = {})
super(options).tap do |json|
json[:collapsed] = false
if options.key?(:collapsed)
- preferences = preferences_for(options[:current_user])
-
- json[:collapsed] = preferences.collapsed?
+ json[:collapsed] = collapsed?(options[:current_user])
end
if options.key?(:label)
@@ -100,3 +102,5 @@ class List < ApplicationRecord
throw(:abort) unless destroyable? # rubocop:disable Cop/BanCatchThrow
end
end
+
+List.prepend_if_ee('::EE::List')
diff --git a/app/models/member.rb b/app/models/member.rb
index 5b33333aa23..791073da095 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Member < ApplicationRecord
+ include EachBatch
include AfterCommitQueue
include Sortable
include Importable
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 68c51860c47..fa2e0cb8198 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -17,6 +17,11 @@ class ProjectMember < Member
.where('projects.namespace_id in (?)', groups.select(:id))
end
+ scope :without_project_bots, -> do
+ left_join_users
+ .merge(User.without_project_bot)
+ end
+
class << self
# Add users to projects with passed access option
#
diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb
index 1ed0434eacf..6da8d5f3161 100644
--- a/app/models/members_preloader.rb
+++ b/app/models/members_preloader.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class MembersPreloader
- prepend_if_ee('EE::MembersPreloader') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
attr_reader :members
def initialize(members)
@@ -16,3 +14,5 @@ class MembersPreloader
ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations)
end
end
+
+MembersPreloader.prepend_if_ee('EE::MembersPreloader')
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index a28e054e13c..b4d0b729454 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -19,6 +19,7 @@ class MergeRequest < ApplicationRecord
include ShaAttribute
include IgnorableColumns
include MilestoneEventable
+ include StateEventable
sha_attribute :squash_commit_sha
@@ -32,6 +33,7 @@ class MergeRequest < ApplicationRecord
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
+ belongs_to :iteration, foreign_key: 'sprint_id'
has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
@@ -864,7 +866,7 @@ class MergeRequest < ApplicationRecord
check_service = MergeRequests::MergeabilityCheckService.new(self)
- if async && Feature.enabled?(:async_merge_request_check_mergeability, project)
+ if async && Feature.enabled?(:async_merge_request_check_mergeability, project, default_enabled: true)
check_service.async_execute
else
check_service.execute(retry_lease: false)
@@ -873,7 +875,7 @@ class MergeRequest < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def diffable_merge_ref?
- Feature.enabled?(:diff_compare_with_head, target_project) && can_be_merged? && merge_ref_head.present?
+ can_be_merged? && merge_ref_head.present?
end
# Returns boolean indicating the merge_status should be rechecked in order to
@@ -1129,26 +1131,6 @@ class MergeRequest < ApplicationRecord
end
end
- # Return array of possible target branches
- # depends on target project of MR
- def target_branches
- if target_project.nil?
- []
- else
- target_project.repository.branch_names
- end
- end
-
- # Return array of possible source branches
- # depends on source project of MR
- def source_branches
- if source_project.nil?
- []
- else
- source_project.repository.branch_names
- end
- end
-
def has_ci?
return false if has_no_commits?
@@ -1319,12 +1301,30 @@ class MergeRequest < ApplicationRecord
compare_reports(Ci::CompareTestReportsService)
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
+
def has_coverage_reports?
return false unless Feature.enabled?(:coverage_report_view, project)
actual_head_pipeline&.has_reports?(Ci::JobArtifact.coverage_reports)
end
+ def has_terraform_reports?
+ actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports)
+ end
+
+ def compare_accessibility_reports
+ unless has_accessibility_reports?
+ return { status: :error, status_reason: _('This merge request does not have accessibility reports') }
+ end
+
+ compare_reports(Ci::CompareAccessibilityReportsService)
+ end
+
# TODO: this method and compare_test_reports use the same
# result type, which is handled by the controller's #reports_response.
# we should minimize mistakes by isolating the common parts.
@@ -1337,9 +1337,15 @@ class MergeRequest < ApplicationRecord
compare_reports(Ci::GenerateCoverageReportsService)
end
- def has_exposed_artifacts?
- return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true)
+ def find_terraform_reports
+ unless has_terraform_reports?
+ return { status: :error, status_reason: 'This merge request does not have terraform reports' }
+ end
+ compare_reports(Ci::GenerateTerraformReportsService)
+ end
+
+ def has_exposed_artifacts?
actual_head_pipeline&.has_exposed_artifacts?
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 7b15d21c095..f793bd3d76f 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -141,7 +141,7 @@ class MergeRequestDiff < ApplicationRecord
after_create :save_git_content, unless: :importing?
after_create_commit :set_as_latest_diff, unless: :importing?
- after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? }
+ after_save :update_external_diff_store
def self.find_by_diff_refs(diff_refs)
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
@@ -385,34 +385,11 @@ class MergeRequestDiff < ApplicationRecord
end
end
- # Carrierwave defines `write_uploader` dynamically on this class, so `super`
- # does not work. Alias the carrierwave method so we can call it when needed
- alias_method :carrierwave_write_uploader, :write_uploader
-
- # The `external_diff`, `external_diff_store`, and `stored_externally`
- # columns were introduced in GitLab 11.8, but some background migration specs
- # use factories that rely on current code with an old schema. Without these
- # `has_attribute?` guards, they fail with a `MissingAttributeError`.
- #
- # For more details, see: https://gitlab.com/gitlab-org/gitlab-foss/issues/44990
-
- def write_uploader(column, identifier)
- carrierwave_write_uploader(column, identifier) if has_attribute?(column)
- end
-
def update_external_diff_store
- update_column(:external_diff_store, external_diff.object_store) if
- has_attribute?(:external_diff_store)
- end
-
- def saved_change_to_external_diff?
- super if has_attribute?(:external_diff)
- end
+ return unless saved_change_to_external_diff? || saved_change_to_stored_externally?
- def stored_externally
- super if has_attribute?(:stored_externally)
+ update_column(:external_diff_store, external_diff.object_store)
end
- alias_method :stored_externally?, :stored_externally
# If enabled, yields the external file containing the diff. Otherwise, yields
# nil. This method is not thread-safe, but it *is* re-entrant, which allows
@@ -575,7 +552,6 @@ class MergeRequestDiff < ApplicationRecord
end
def use_external_diff?
- return false unless has_attribute?(:external_diff)
return false unless Gitlab.config.external_diffs.enabled
case Gitlab.config.external_diffs.when
diff --git a/app/models/metrics/users_starred_dashboard.rb b/app/models/metrics/users_starred_dashboard.rb
new file mode 100644
index 00000000000..07748eb1431
--- /dev/null
+++ b/app/models/metrics/users_starred_dashboard.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Metrics
+ class UsersStarredDashboard < ApplicationRecord
+ self.table_name = 'metrics_users_starred_dashboards'
+
+ belongs_to :user, inverse_of: :metrics_users_starred_dashboards
+ belongs_to :project, inverse_of: :metrics_users_starred_dashboards
+
+ validates :user_id, presence: true
+ validates :project_id, presence: true
+ validates :dashboard_path, presence: true, length: { maximum: 255 }
+ validates :dashboard_path, uniqueness: { scope: %i[user_id project_id] }
+
+ scope :for_project, ->(project) { where(project: project) }
+ scope :for_project_dashboard, ->(project, path) { for_project(project).where(dashboard_path: path) }
+ end
+end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 4ccfe314526..b5e4f62792e 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -1,88 +1,37 @@
# frozen_string_literal: true
class Milestone < ApplicationRecord
- # Represents a "No Milestone" state used for filtering Issues and Merge
- # Requests that have no milestone assigned.
- MilestoneStruct = Struct.new(:title, :name, :id) do
- # Ensure these models match the interface required for exporting
- def serializable_hash(_opts = {})
- { title: title, name: name, id: id }
- end
- end
-
- None = MilestoneStruct.new('No Milestone', 'No Milestone', 0)
- Any = MilestoneStruct.new('Any Milestone', '', -1)
- Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2)
- Started = MilestoneStruct.new('Started', '#started', -3)
-
- include CacheMarkdownField
- include AtomicInternalId
- include IidRoutes
include Sortable
include Referable
- include StripAttribute
+ include Timebox
include Milestoneish
include FromUnion
include Importable
- include Gitlab::SQL::Pattern
prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
- cache_markdown_field :title, pipeline: :single_line
- cache_markdown_field :description
-
- belongs_to :project
- belongs_to :group
-
has_many :milestone_releases
has_many :releases, through: :milestone_releases
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
has_internal_id :iid, scope: :group, track_if: -> { !importing? }, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
- has_many :issues
- has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
- has_many :merge_requests
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- scope :of_projects, ->(ids) { where(project_id: ids) }
- scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) }
- scope :closed, -> { with_state(:closed) }
- scope :for_projects, -> { where(group: nil).includes(:project) }
scope :started, -> { active.where('milestones.start_date <= CURRENT_DATE') }
-
- scope :for_projects_and_groups, -> (projects, groups) do
- projects = projects.compact if projects.is_a? Array
- projects = [] if projects.nil?
-
- groups = groups.compact if groups.is_a? Array
- groups = [] if groups.nil?
-
- where(project_id: projects).or(where(group_id: groups))
- end
-
- 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)
+ scope :not_started, -> { active.where('milestones.start_date > CURRENT_DATE') }
+ scope :not_upcoming, -> do
+ active
+ .where('milestones.due_date <= CURRENT_DATE')
+ .order(:project_id, :group_id, :due_date)
end
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')) }
- validates :group, presence: true, unless: :project
- validates :project, presence: true, unless: :group
- validates :title, presence: true
-
- validate :uniqueness_of_title, if: :title_changed?
- validate :milestone_type_check
- validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
- validate :dates_within_4_digits
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
- strip_attributes :title
-
state_machine :state, initial: :active do
event :close do
transition active: :closed
@@ -97,52 +46,6 @@ class Milestone < ApplicationRecord
state :active
end
- alias_attribute :name, :title
-
- class << self
- # Searches for milestones with a matching title or description.
- #
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
- #
- # query - The search query as a String
- #
- # Returns an ActiveRecord::Relation.
- def search(query)
- fuzzy_search(query, [:title, :description])
- end
-
- # Searches for milestones with a matching title.
- #
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
- #
- # query - The search query as a String
- #
- # Returns an ActiveRecord::Relation.
- def search_title(query)
- fuzzy_search(query, [:title])
- end
-
- def filter_by_state(milestones, state)
- case state
- when 'closed' then milestones.closed
- when 'all' then milestones
- else milestones.active
- end
- end
-
- def count_by_state
- reorder(nil).group(:state).count
- end
-
- def predefined_id?(id)
- [Any.id, None.id, Upcoming.id, Started.id].include?(id)
- end
-
- def predefined?(milestone)
- predefined_id?(milestone&.id)
- end
- end
-
def self.reference_prefix
'%'
end
@@ -220,7 +123,7 @@ class Milestone < ApplicationRecord
end
##
- # Returns the String necessary to reference this Milestone in Markdown. Group
+ # Returns the String necessary to reference a Milestone in Markdown. Group
# milestones only support name references, and do not support cross-project
# references.
#
@@ -248,10 +151,6 @@ class Milestone < ApplicationRecord
self.class.reference_prefix + self.title
end
- def milestoneish_id
- id
- end
-
def for_display
self
end
@@ -264,62 +163,24 @@ class Milestone < ApplicationRecord
nil
end
- def title=(value)
- write_attribute(:title, sanitize_title(value)) if value.present?
- end
+ # TODO: remove after all code paths use `timebox_id`
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/215688
+ alias_method :milestoneish_id, :timebox_id
+ # TODO: remove after all code paths use (group|project)_timebox?
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/215690
+ alias_method :group_milestone?, :group_timebox?
+ alias_method :project_milestone?, :project_timebox?
- def safe_title
- title.to_slug.normalize.to_s
- end
-
- def resource_parent
- group || project
- end
-
- def to_ability_name
- model_name.singular
- end
-
- def group_milestone?
- group_id.present?
- end
-
- def project_milestone?
- project_id.present?
- end
-
- def merge_requests_enabled?
+ def parent
if group_milestone?
- # Assume that groups have at least one project with merge requests enabled.
- # Otherwise, we would need to load all of the projects from the database.
- true
- elsif project_milestone?
- project&.merge_requests_enabled?
+ group
+ else
+ project
end
end
private
- # Milestone titles must be unique across project milestones and group milestones
- def uniqueness_of_title
- if project
- relation = Milestone.for_projects_and_groups([project_id], [project.group&.id])
- elsif group
- relation = Milestone.for_projects_and_groups(group.projects.select(:id), [group.id])
- end
-
- title_exists = relation.find_by_title(title)
- errors.add(:title, _("already being used for another group or project milestone.")) if title_exists
- end
-
- # Milestone should be either a project milestone or a group milestone
- def milestone_type_check
- if group_id && project_id
- field = project_id_changed? ? :project_id : :group_id
- errors.add(field, _("milestone should belong either to a project or a group."))
- end
- end
-
def milestone_format_reference(format = :iid)
raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
@@ -334,26 +195,6 @@ class Milestone < ApplicationRecord
end
end
- def sanitize_title(value)
- CGI.unescape_html(Sanitize.clean(value.to_s))
- end
-
- def start_date_should_be_less_than_due_date
- if due_date <= start_date
- errors.add(:due_date, _("must be greater than start date"))
- end
- end
-
- def dates_within_4_digits
- if start_date && start_date > Date.new(9999, 12, 31)
- errors.add(:start_date, _("date must not be after 9999-12-31"))
- end
-
- if due_date && due_date > Date.new(9999, 12, 31)
- errors.add(:due_date, _("date must not be after 9999-12-31"))
- end
- end
-
def issues_finder_params
{ project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact
end
diff --git a/app/models/milestone_note.rb b/app/models/milestone_note.rb
index 2ff9791feb0..19171e682b7 100644
--- a/app/models/milestone_note.rb
+++ b/app/models/milestone_note.rb
@@ -17,6 +17,6 @@ class MilestoneNote < SyntheticNote
def note_text(html: false)
format = milestone&.group_milestone? ? :name : :iid
- milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
+ event.remove? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 9e7589a1f18..8116f7a256f 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -14,6 +14,7 @@ class Namespace < ApplicationRecord
include IgnorableColumns
ignore_column :plan_id, remove_with: '13.1', remove_after: '2020-06-22'
+ ignore_column :trial_ends_on, remove_with: '13.2', remove_after: '2020-07-22'
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -135,11 +136,6 @@ class Namespace < ApplicationRecord
name = host.delete_suffix(gitlab_host)
Namespace.where(parent_id: nil).by_path(name)
end
-
- # overridden in ee
- def reset_ci_minutes!(namespace_id)
- false
- end
end
def default_branch_protection
@@ -180,6 +176,10 @@ class Namespace < ApplicationRecord
kind == 'user'
end
+ def group?
+ type == 'Group'
+ end
+
def find_fork_of(project)
return unless project.fork_network
@@ -346,6 +346,21 @@ class Namespace < ApplicationRecord
.try(name)
end
+ def actual_plan
+ Plan.default
+ end
+
+ def actual_limits
+ # We default to PlanLimits.new otherwise a lot of specs would fail
+ # On production each plan should already have associated limits record
+ # https://gitlab.com/gitlab-org/gitlab/issues/36037
+ actual_plan.actual_limits
+ end
+
+ def actual_plan_name
+ actual_plan.name
+ end
+
private
def all_projects_with_pages
diff --git a/app/models/namespace/root_storage_size.rb b/app/models/namespace/root_storage_size.rb
new file mode 100644
index 00000000000..d61917e468e
--- /dev/null
+++ b/app/models/namespace/root_storage_size.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class Namespace::RootStorageSize
+ def initialize(root_namespace)
+ @root_namespace = root_namespace
+ end
+
+ def above_size_limit?
+ return false if limit == 0
+
+ usage_ratio > 1
+ end
+
+ def usage_ratio
+ return 0 if limit == 0
+
+ current_size.to_f / limit.to_f
+ end
+
+ def current_size
+ @current_size ||= root_namespace.root_storage_statistics&.storage_size
+ end
+
+ def limit
+ @limit ||= Gitlab::CurrentSettings.namespace_storage_size_limit.megabytes
+ end
+
+ private
+
+ attr_reader :root_namespace
+end
diff --git a/app/models/note.rb b/app/models/note.rb
index a2a711c987f..d174ba8fe83 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -159,6 +159,8 @@ class Note < ApplicationRecord
after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
+ after_commit :notify_after_create, on: :create
+ after_commit :notify_after_destroy, on: :destroy
class << self
def model_name
@@ -279,6 +281,10 @@ class Note < ApplicationRecord
!for_personal_snippet?
end
+ def for_design?
+ noteable_type == DesignManagement::Design.name
+ end
+
def for_issuable?
for_issue? || for_merge_request?
end
@@ -505,6 +511,14 @@ class Note < ApplicationRecord
noteable_object
end
+ def notify_after_create
+ noteable&.after_note_created(self)
+ end
+
+ def notify_after_destroy
+ noteable&.after_note_destroyed(self)
+ end
+
def banzai_render_context(field)
super.merge(noteable: noteable, system_note: system?)
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 486da2c6b45..da5e4012f05 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -2,6 +2,7 @@
class PagesDomain < ApplicationRecord
include Presentable
+ include FromUnion
VERIFICATION_KEY = 'gitlab-pages-verification-code'
VERIFICATION_THRESHOLD = 3.days.freeze
@@ -58,12 +59,14 @@ class PagesDomain < ApplicationRecord
end
scope :need_auto_ssl_renewal, -> do
- expiring = where(certificate_valid_not_after: nil).or(
- where(arel_table[:certificate_valid_not_after].lt(SSL_RENEWAL_THRESHOLD.from_now)))
+ enabled_and_not_failed = where(auto_ssl_enabled: true, auto_ssl_failed: false)
- user_provided_or_expiring = certificate_user_provided.or(expiring)
+ user_provided = enabled_and_not_failed.certificate_user_provided
+ certificate_not_valid = enabled_and_not_failed.where(certificate_valid_not_after: nil)
+ certificate_expiring = enabled_and_not_failed
+ .where(arel_table[:certificate_valid_not_after].lt(SSL_RENEWAL_THRESHOLD.from_now))
- where(auto_ssl_enabled: true).merge(user_provided_or_expiring)
+ from_union([user_provided, certificate_not_valid, certificate_expiring])
end
scope :for_removal, -> { where("remove_at < ?", Time.now) }
diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb
index 30fb1935a27..57222c61b36 100644
--- a/app/models/performance_monitoring/prometheus_dashboard.rb
+++ b/app/models/performance_monitoring/prometheus_dashboard.rb
@@ -4,7 +4,7 @@ module PerformanceMonitoring
class PrometheusDashboard
include ActiveModel::Model
- attr_accessor :dashboard, :panel_groups, :path, :environment, :priority
+ attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating
validates :dashboard, presence: true
validates :panel_groups, presence: true
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index af079f7ebc4..7afee2a35cb 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -4,6 +4,7 @@ class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include Sortable
+ extend ::Gitlab::Utils::Override
add_authentication_token_field :token, digest: true
@@ -23,6 +24,8 @@ class PersonalAccessToken < ApplicationRecord
scope :without_impersonation, -> { where(impersonation: false) }
scope :for_user, -> (user) { where(user: user) }
scope :preload_users, -> { preload(:user) }
+ scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
+ scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
validates :scopes, presence: true
validate :validate_scopes
@@ -39,12 +42,14 @@ class PersonalAccessToken < ApplicationRecord
def self.redis_getdel(user_id)
Gitlab::Redis::SharedState.with do |redis|
- encrypted_token = redis.get(redis_shared_state_key(user_id))
- redis.del(redis_shared_state_key(user_id))
+ redis_key = redis_shared_state_key(user_id)
+ encrypted_token = redis.get(redis_key)
+ redis.del(redis_key)
+
begin
Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
rescue => ex
- logger.warn "Failed to decrypt PersonalAccessToken value stored in Redis for User ##{user_id}: #{ex.class}"
+ logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{ex.class}"
encrypted_token
end
end
@@ -58,6 +63,16 @@ class PersonalAccessToken < ApplicationRecord
end
end
+ override :simple_sorts
+ def self.simple_sorts
+ super.merge(
+ {
+ 'expires_at_asc' => -> { order_expires_at_asc },
+ 'expires_at_desc' => -> { order_expires_at_desc }
+ }
+ )
+ end
+
protected
def validate_scopes
diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb
index 1b5be8698b1..197795dccfe 100644
--- a/app/models/personal_snippet.rb
+++ b/app/models/personal_snippet.rb
@@ -2,4 +2,8 @@
class PersonalSnippet < Snippet
include WithUploads
+
+ def skip_project_check?
+ true
+ end
end
diff --git a/app/models/plan.rb b/app/models/plan.rb
new file mode 100644
index 00000000000..acac5f9aeae
--- /dev/null
+++ b/app/models/plan.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class Plan < ApplicationRecord
+ DEFAULT = 'default'.freeze
+
+ has_one :limits, class_name: 'PlanLimits'
+
+ ALL_PLANS = [DEFAULT].freeze
+ DEFAULT_PLANS = [DEFAULT].freeze
+ private_constant :ALL_PLANS, :DEFAULT_PLANS
+
+ # This always returns an object
+ def self.default
+ Gitlab::SafeRequestStore.fetch(:plan_default) do
+ # find_by allows us to find object (cheaply) against replica DB
+ # safe_find_or_create_by does stick to primary DB
+ find_by(name: DEFAULT) || safe_find_or_create_by(name: DEFAULT)
+ end
+ end
+
+ def self.all_plans
+ ALL_PLANS
+ end
+
+ def self.default_plans
+ DEFAULT_PLANS
+ end
+
+ def actual_limits
+ self.limits || PlanLimits.new
+ end
+
+ def default?
+ self.class.default_plans.include?(name)
+ end
+
+ def paid?
+ false
+ end
+end
+
+Plan.prepend_if_ee('EE::Plan')
diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb
new file mode 100644
index 00000000000..575105cfd79
--- /dev/null
+++ b/app/models/plan_limits.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class PlanLimits < ApplicationRecord
+ belongs_to :plan
+
+ def exceeded?(limit_name, object)
+ return false unless enabled?(limit_name)
+
+ if object.is_a?(Integer)
+ object >= read_attribute(limit_name)
+ else
+ # object.count >= limit value is slower than checking
+ # if a record exists at the limit value - 1 position.
+ object.offset(read_attribute(limit_name) - 1).exists?
+ end
+ end
+
+ private
+
+ def enabled?(limit_name)
+ read_attribute(limit_name) > 0
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 5db349463d8..c0dd2eb8584 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -3,6 +3,7 @@
require 'carrierwave/orm/activerecord'
class Project < ApplicationRecord
+ extend ::Gitlab::Utils::Override
include Gitlab::ConfigHelper
include Gitlab::VisibilityLevel
include AccessRequestable
@@ -18,6 +19,7 @@ class Project < ApplicationRecord
include SelectForProjectAuthorization
include Presentable
include HasRepository
+ include HasWiki
include Routable
include GroupDescendant
include Gitlab::SQL::Pattern
@@ -175,6 +177,7 @@ class Project < ApplicationRecord
has_one :packagist_service
has_one :hangouts_chat_service
has_one :unify_circuit_service
+ has_one :webex_teams_service
has_one :root_of_fork_network,
foreign_key: 'root_project_id',
@@ -206,12 +209,14 @@ class Project < ApplicationRecord
has_many :services
has_many :events
has_many :milestones
+ has_many :iterations
has_many :notes
has_many :snippets, class_name: 'ProjectSnippet'
has_many :hooks, class_name: 'ProjectHook'
has_many :protected_branches
has_many :protected_tags
has_many :repository_languages, -> { order "share DESC" }
+ has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design'
has_many :project_authorizations
has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
@@ -254,6 +259,9 @@ class Project < ApplicationRecord
has_many :prometheus_alerts, inverse_of: :project
has_many :prometheus_alert_events, inverse_of: :project
has_many :self_managed_prometheus_alert_events, inverse_of: :project
+ has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :project
+
+ has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project
# Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy
@@ -295,6 +303,7 @@ class Project < ApplicationRecord
has_many :project_deploy_tokens
has_many :deploy_tokens, through: :project_deploy_tokens
has_many :resource_groups, class_name: 'Ci::ResourceGroup', inverse_of: :project
+ has_many :freeze_periods, class_name: 'Ci::FreezePeriod', inverse_of: :project
has_one :auto_devops, class_name: 'ProjectAutoDevops', inverse_of: :project, autosave: true
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
@@ -315,10 +324,13 @@ class Project < ApplicationRecord
has_many :import_failures, inverse_of: :project
has_many :jira_imports, -> { order 'jira_imports.created_at' }, class_name: 'JiraImportState', inverse_of: :project
- has_many :daily_report_results, class_name: 'Ci::DailyReportResult'
+ has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult'
+
+ has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove'
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
accepts_nested_attributes_for :import_data
accepts_nested_attributes_for :auto_devops, update_only: true
accepts_nested_attributes_for :ci_cd_settings, update_only: true
@@ -342,6 +354,9 @@ class Project < ApplicationRecord
:wiki_access_level, :snippets_access_level, :builds_access_level,
:repository_access_level, :pages_access_level, :metrics_dashboard_access_level,
to: :project_feature, allow_nil: true
+ delegate :show_default_award_emojis, :show_default_award_emojis=,
+ :show_default_award_emojis?,
+ to: :project_setting, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true
delegate :no_import?, to: :import_state, allow_nil: true
@@ -355,6 +370,7 @@ class Project < ApplicationRecord
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci
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
# Validations
validates :creator, presence: true, on: :create
@@ -386,7 +402,6 @@ class Project < ApplicationRecord
validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? }
validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level?
validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level?
- validate :check_wiki_path_conflict
validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) }
validates :repository_storage,
presence: true,
@@ -515,12 +530,14 @@ class Project < ApplicationRecord
def self.public_or_visible_to_user(user = nil, min_access_level = nil)
min_access_level = nil if user&.admin?
- if user
+ return public_to_user unless user
+
+ if user.is_a?(DeployToken)
+ user.projects
+ else
where('EXISTS (?) OR projects.visibility_level IN (?)',
user.authorizations_for_projects(min_access_level: min_access_level),
Gitlab::VisibilityLevel.levels_for_user(user))
- else
- public_to_user
end
end
@@ -785,6 +802,11 @@ class Project < ApplicationRecord
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)
+ end
+
def team
@team ||= ProjectTeam.new(self)
end
@@ -793,6 +815,12 @@ class Project < ApplicationRecord
@repository ||= Repository.new(full_path, self, shard: repository_storage, disk_path: disk_path)
end
+ def design_repository
+ strong_memoize(:design_repository) do
+ DesignManagement::Repository.new(self)
+ end
+ end
+
def cleanup
@repository = nil
end
@@ -819,7 +847,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_ref(ref)
return unless latest_pipeline
- latest_pipeline.builds.latest.with_artifacts_archive.find_by(name: job_name)
+ latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name)
end
def latest_successful_build_for_sha(job_name, sha)
@@ -828,7 +856,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_sha(sha)
return unless latest_pipeline
- latest_pipeline.builds.latest.with_artifacts_archive.find_by(name: job_name)
+ latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name)
end
def latest_successful_build_for_ref!(job_name, ref = default_branch)
@@ -865,10 +893,12 @@ class Project < ApplicationRecord
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?
- return unless user
+ 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, _('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)
+ 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
@@ -1056,16 +1086,6 @@ class Project < ApplicationRecord
self.errors.add(:visibility_level, _("%{level_name} is not allowed since the fork source project has lower visibility.") % { level_name: level_name })
end
- def check_wiki_path_conflict
- return if path.blank?
-
- path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki"
-
- if Project.where(namespace_id: namespace_id, path: path_to_check).exists?
- errors.add(:name, _('has already been taken'))
- end
- end
-
def pages_https_only
return false unless Gitlab.config.pages.external_https
@@ -1179,11 +1199,7 @@ class Project < ApplicationRecord
end
def issues_tracker
- if external_issue_tracker
- external_issue_tracker
- else
- default_issue_tracker
- end
+ external_issue_tracker || default_issue_tracker
end
def external_issue_reference_pattern
@@ -1328,11 +1344,7 @@ class Project < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def owner
- if group
- group
- else
- namespace.try(:owner)
- end
+ group || namespace.try(:owner)
end
def to_ability_name
@@ -1432,15 +1444,12 @@ class Project < ApplicationRecord
# Expires various caches before a project is renamed.
def expire_caches_before_rename(old_path)
- repo = Repository.new(old_path, self, shard: repository_storage)
- wiki = Repository.new("#{old_path}.wiki", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI)
+ project_repo = Repository.new(old_path, self, shard: repository_storage)
+ wiki_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::WIKI.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI)
+ design_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::DESIGN.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::DESIGN)
- if repo.exists?
- repo.before_delete
- end
-
- if wiki.exists?
- wiki.before_delete
+ [project_repo, wiki_repo, design_repo].each do |repo|
+ repo.before_delete if repo.exists?
end
end
@@ -1517,6 +1526,10 @@ class Project < ApplicationRecord
end
end
+ def bots
+ users.project_bot
+ end
+
# Filters `users` to return only authorized users of the project
def members_among(users)
if users.is_a?(ActiveRecord::Relation) && !users.loaded?
@@ -1565,10 +1578,6 @@ class Project < ApplicationRecord
create_repository(force: true) unless repository_exists?
end
- def wiki_repository_exists?
- wiki.repository_exists?
- end
-
# update visibility_level of forks
def update_forks_visibility_level
return if unlink_forks_upon_visibility_decrease_enabled?
@@ -1582,20 +1591,6 @@ class Project < ApplicationRecord
end
end
- def create_wiki
- ProjectWiki.new(self, self.owner).wiki
- true
- rescue ProjectWiki::CouldNotCreateWikiError
- errors.add(:base, _('Failed create wiki'))
- false
- end
-
- def wiki
- strong_memoize(:wiki) do
- ProjectWiki.new(self, self.owner)
- end
- end
-
def allowed_to_share_with_group?
!namespace.share_with_group_lock
end
@@ -2024,6 +2019,14 @@ class Project < ApplicationRecord
end
end
+ def ci_instance_variables_for(ref:)
+ if protected_for?(ref)
+ Ci::InstanceVariable.all_cached
+ else
+ Ci::InstanceVariable.unprotected_cached
+ end
+ end
+
def protected_for?(ref)
raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref)
@@ -2085,7 +2088,12 @@ class Project < ApplicationRecord
raise ArgumentError unless ::Gitlab.config.repositories.storages.key?(new_repository_storage_key)
- run_after_commit { ProjectUpdateRepositoryStorageWorker.perform_async(id, 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
@@ -2425,6 +2433,11 @@ class Project < ApplicationRecord
jira_imports.last
end
+ override :after_wiki_activity
+ def after_wiki_activity
+ touch(:last_activity_at, :last_repository_updated_at)
+ end
+
private
def find_service(services, name)
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index e81d9d0f5fe..366852d93bf 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -2,7 +2,6 @@
class ProjectAuthorization < ApplicationRecord
include FromUnion
- prepend_if_ee('::EE::ProjectAuthorization') # rubocop: disable Cop/InjectEnterpriseEditionModule
belongs_to :user
belongs_to :project
@@ -30,3 +29,5 @@ class ProjectAuthorization < ApplicationRecord
end
end
end
+
+ProjectAuthorization.prepend_if_ee('::EE::ProjectAuthorization')
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index 39e177e8bd8..c295837002a 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -37,8 +37,6 @@ class ProjectCiCdSetting < ApplicationRecord
private
def set_default_git_depth
- return unless Feature.enabled?(:ci_set_project_default_git_depth, default_enabled: true)
-
self.default_git_depth ||= DEFAULT_GIT_DEPTH
end
end
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 31a3fa12c00..9201cd24d66 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -23,7 +23,7 @@ class ProjectFeature < ApplicationRecord
PUBLIC = 30
FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze
- PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze
+ 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,
diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb
new file mode 100644
index 00000000000..e88cc5cfca6
--- /dev/null
+++ b/app/models/project_repository_storage_move.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+# ProjectRepositoryStorageMove are details of repository storage moves for a
+# project. For example, moving a project to another gitaly node to help
+# balance storage capacity.
+class ProjectRepositoryStorageMove < ApplicationRecord
+ include AfterCommitQueue
+
+ belongs_to :project, inverse_of: :repository_storage_moves
+
+ validates :project, presence: true
+ validates :state, presence: true
+ validates :source_storage_name,
+ on: :create,
+ presence: true,
+ inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
+ validates :destination_storage_name,
+ on: :create,
+ presence: true,
+ inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
+
+ state_machine initial: :initial do
+ event :schedule do
+ transition initial: :scheduled
+ end
+
+ event :start do
+ transition scheduled: :started
+ end
+
+ event :finish do
+ transition started: :finished
+ end
+
+ event :do_fail do
+ transition [:initial, :scheduled, :started] => :failed
+ end
+
+ after_transition initial: :scheduled do |storage_move, _|
+ storage_move.run_after_commit do
+ ProjectUpdateRepositoryStorageWorker.perform_async(
+ storage_move.project_id,
+ storage_move.destination_storage_name,
+ storage_move.id
+ )
+ end
+ end
+
+ state :initial, value: 1
+ state :scheduled, value: 2
+ state :started, value: 3
+ state :finished, value: 4
+ state :failed, value: 5
+ end
+
+ scope :order_created_at_desc, -> { order(created_at: :desc) }
+ scope :with_projects, -> { includes(project: :route) }
+end
diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb
index dc62a4c8908..0a2d9120adc 100644
--- a/app/models/project_services/chat_message/merge_message.rb
+++ b/app/models/project_services/chat_message/merge_message.rb
@@ -2,8 +2,6 @@
module ChatMessage
class MergeMessage < BaseMessage
- prepend_if_ee('::EE::ChatMessage::MergeMessage') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
attr_reader :merge_request_iid
attr_reader :source_branch
attr_reader :target_branch
@@ -71,3 +69,5 @@ module ChatMessage
end
end
end
+
+ChatMessage::MergeMessage.prepend_if_ee('::EE::ChatMessage::MergeMessage')
diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb
index 50b982a803f..1cd3837433f 100644
--- a/app/models/project_services/chat_message/pipeline_message.rb
+++ b/app/models/project_services/chat_message/pipeline_message.rb
@@ -52,8 +52,6 @@ module ChatMessage
def attachments
return message if markdown
- return [{ text: format(message), color: attachment_color }] unless fancy_notifications?
-
[{
fallback: format(message),
color: attachment_color,
@@ -103,10 +101,6 @@ module ChatMessage
failed_jobs.uniq { |job| job[:name] }.reverse
end
- def fancy_notifications?
- Feature.enabled?(:fancy_pipeline_slack_notifications, default_enabled: true)
- end
-
def failed_stages_field
{
title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length),
@@ -166,42 +160,22 @@ module ChatMessage
end
def humanized_status
- if fancy_notifications?
- case status
- when 'success'
- detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed")
- when 'failed'
- s_("ChatMessage|has failed")
- else
- status
- end
+ case status
+ when 'success'
+ detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed")
+ when 'failed'
+ s_("ChatMessage|has failed")
else
- case status
- when 'success'
- s_("ChatMessage|passed")
- when 'failed'
- s_("ChatMessage|failed")
- else
- status
- end
+ status
end
end
def attachment_color
- if fancy_notifications?
- case status
- when 'success'
- detailed_status == 'passed with warnings' ? 'warning' : 'good'
- else
- 'danger'
- end
+ case status
+ when 'success'
+ detailed_status == 'passed with warnings' ? 'warning' : 'good'
else
- case status
- when 'success'
- 'good'
- else
- 'danger'
- end
+ 'danger'
end
end
@@ -230,7 +204,7 @@ module ChatMessage
end
def pipeline_url
- if fancy_notifications? && failed_jobs.any?
+ if failed_jobs.any?
pipeline_failed_jobs_url
else
"#{project_url}/pipelines/#{pipeline_id}"
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index eaddac9cce3..53da874ede8 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -25,6 +25,11 @@ class JiraService < IssueTrackerService
before_update :reset_password
+ enum comment_detail: {
+ standard: 1,
+ all_details: 2
+ }
+
alias_method :project_url, :url
# When these are false GitLab does not create cross reference
@@ -172,6 +177,7 @@ class JiraService < IssueTrackerService
noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id
noteable_type = noteable_name(noteable)
entity_url = build_entity_url(noteable_type, noteable_id)
+ entity_meta = build_entity_meta(noteable)
data = {
user: {
@@ -180,12 +186,15 @@ class JiraService < IssueTrackerService
},
project: {
name: project.full_path,
- url: resource_url(namespace_project_path(project.namespace, project)) # rubocop:disable Cop/ProjectPathHelper
+ url: resource_url(project_path(project))
},
entity: {
+ id: entity_meta[:id],
name: noteable_type.humanize.downcase,
url: entity_url,
- title: noteable.title
+ title: noteable.title,
+ description: entity_meta[:description],
+ branch: entity_meta[:branch]
}
}
@@ -259,14 +268,11 @@ class JiraService < IssueTrackerService
end
def add_comment(data, issue)
- user_name = data[:user][:name]
- user_url = data[:user][:url]
entity_name = data[:entity][:name]
entity_url = data[:entity][:url]
entity_title = data[:entity][:title]
- project_name = data[:project][:name]
- message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title.chomp}'"
+ message = comment_message(data)
link_title = "#{entity_name.capitalize} - #{entity_title}"
link_props = build_remote_link_props(url: entity_url, title: link_title)
@@ -275,6 +281,37 @@ class JiraService < IssueTrackerService
end
end
+ def comment_message(data)
+ user_link = build_jira_link(data[:user][:name], data[:user][:url])
+
+ entity = data[:entity]
+ entity_ref = all_details? ? "#{entity[:name]} #{entity[:id]}" : "a #{entity[:name]}"
+ entity_link = build_jira_link(entity_ref, entity[:url])
+
+ project_link = build_jira_link(project.full_name, Gitlab::Routing.url_helpers.project_url(project))
+ branch =
+ if entity[:branch].present?
+ s_('JiraService| on branch %{branch_link}') % {
+ branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch]))
+ }
+ end
+
+ entity_message = entity[:description].presence if all_details?
+ entity_message ||= entity[:title].chomp
+
+ s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of %{project_link}%{branch}:{quote}%{entity_message}{quote}') % {
+ user_link: user_link,
+ entity_link: entity_link,
+ project_link: project_link,
+ branch: branch,
+ entity_message: entity_message
+ }
+ end
+
+ def build_jira_link(title, url)
+ "[#{title}|#{url}]"
+ end
+
def has_resolution?(issue)
issue.respond_to?(:resolution) && issue.resolution.present?
end
@@ -348,6 +385,23 @@ class JiraService < IssueTrackerService
)
end
+ def build_entity_meta(noteable)
+ if noteable.is_a?(Commit)
+ {
+ id: noteable.short_id,
+ description: noteable.safe_message,
+ branch: noteable.ref_names(project.repository).first
+ }
+ elsif noteable.is_a?(MergeRequest)
+ {
+ id: noteable.to_reference,
+ branch: noteable.source_branch
+ }
+ else
+ {}
+ end
+ end
+
def noteable_name(noteable)
name = noteable.model_name.singular
diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb
index ca324f68d2d..0fd85e3a5a9 100644
--- a/app/models/project_services/mattermost_slash_commands_service.rb
+++ b/app/models/project_services/mattermost_slash_commands_service.rb
@@ -36,6 +36,10 @@ class MattermostSlashCommandsService < SlashCommandsService
[[], e.message]
end
+ def chat_responder
+ ::Gitlab::Chat::Responder::Mattermost
+ end
+
private
def command(params)
diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb
index bcf8f1df5da..25ae0f6b60d 100644
--- a/app/models/project_services/mock_monitoring_service.rb
+++ b/app/models/project_services/mock_monitoring_service.rb
@@ -14,7 +14,7 @@ class MockMonitoringService < MonitoringService
end
def metrics(environment)
- JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
+ Gitlab::Json.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
end
def can_test?
diff --git a/app/models/project_services/webex_teams_service.rb b/app/models/project_services/webex_teams_service.rb
new file mode 100644
index 00000000000..1d791b19486
--- /dev/null
+++ b/app/models/project_services/webex_teams_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class WebexTeamsService < ChatNotificationService
+ def title
+ 'Webex Teams'
+ end
+
+ def description
+ 'Receive event notifications in Webex Teams'
+ end
+
+ def self.to_param
+ 'webex_teams'
+ end
+
+ def help
+ 'This service sends notifications about projects events to a Webex Teams conversation.<br />
+ To set up this service:
+ <ol>
+ <li><a href="https://apphub.webex.com/teams/applications/incoming-webhooks-cisco-systems">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li>
+ <li>Paste the <strong>Webhook URL</strong> into the field below.</li>
+ <li>Select events below to enable notifications.</li>
+ </ol>'
+ end
+
+ def event_field(event)
+ end
+
+ def default_channel_placeholder
+ end
+
+ def self.supported_events
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
+ pipeline wiki_page]
+ end
+
+ def default_fields
+ [
+ { type: 'text', name: 'webhook', placeholder: "e.g. https://api.ciscospark.com/v1/webhooks/incoming/…", required: true },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' },
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
+ ]
+ end
+
+ private
+
+ def notify(message, opts)
+ header = { 'Content-Type' => 'application/json' }
+ response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.pretext }.to_json)
+
+ response if response.success?
+ end
+
+ def custom_data(data)
+ super(data).merge(markdown: true)
+ end
+end
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
index 0815e27850d..40203ad692d 100644
--- a/app/models/project_services/youtrack_service.rb
+++ b/app/models/project_services/youtrack_service.rb
@@ -27,8 +27,8 @@ class YoutrackService < IssueTrackerService
def fields
[
{ type: 'text', name: 'description', placeholder: description },
- { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
- { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }
+ { type: 'text', name: 'project_url', title: 'Project URL', placeholder: 'Project URL', required: true },
+ { type: 'text', name: 'issues_url', title: 'Issue URL', placeholder: 'Issue URL', required: true }
]
end
end
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index b71ed75dde6..6f04a36392d 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -21,6 +21,9 @@ class ProjectStatistics < ApplicationRecord
scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
+ scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) }
+ scope :with_any_ci_minutes_used, -> { where.not(shared_runners_seconds: 0) }
+
def total_repository_size
repository_size + lfs_objects_size
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 708b45cf5f0..5df0a33dc9a 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -1,219 +1,17 @@
# frozen_string_literal: true
-class ProjectWiki
- include Storage::LegacyProjectWiki
- include Gitlab::Utils::StrongMemoize
+class ProjectWiki < Wiki
+ alias_method :project, :container
- MARKUPS = {
- 'Markdown' => :markdown,
- 'RDoc' => :rdoc,
- 'AsciiDoc' => :asciidoc,
- 'Org' => :org
- }.freeze unless defined?(MARKUPS)
+ # Project wikis are tied to the main project storage
+ delegate :storage, :repository_storage, :hashed_storage?, to: :container
- CouldNotCreateWikiError = Class.new(StandardError)
- SIDEBAR = '_sidebar'
-
- TITLE_ORDER = 'title'
- CREATED_AT_ORDER = 'created_at'
- DIRECTION_DESC = 'desc'
- DIRECTION_ASC = 'asc'
-
- attr_reader :project, :user
-
- # Returns a string describing what went wrong after
- # an operation fails.
- attr_reader :error_message
-
- def initialize(project, user = nil)
- @project = project
- @user = user
- end
-
- delegate :repository_storage, :hashed_storage?, to: :project
-
- def path
- @project.path + '.wiki'
- end
-
- def full_path
- @project.full_path + '.wiki'
- end
- alias_method :id, :full_path
-
- # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem
- alias_method :path_with_namespace, :full_path
-
- def web_url(only_path: nil)
- Gitlab::UrlBuilder.build(self, only_path: only_path)
- end
-
- def url_to_repo
- ssh_url_to_repo
- end
-
- def ssh_url_to_repo
- Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :ssh)
- end
-
- def http_url_to_repo
- Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :http)
- end
-
- def wiki_base_path
- [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/-', '/wikis'].join('')
- end
-
- # Returns the Gitlab::Git::Wiki object.
- def wiki
- strong_memoize(:wiki) do
- repository.create_if_not_exists
- raise CouldNotCreateWikiError unless repository_exists?
-
- Gitlab::Git::Wiki.new(repository.raw)
- end
- rescue => err
- Gitlab::ErrorTracking.track_exception(err, project_wiki: { project_id: project.id, full_path: full_path, disk_path: disk_path })
- raise CouldNotCreateWikiError
- end
-
- def repository_exists?
- !!repository.exists?
- end
-
- def has_home_page?
- !!find_page('home')
- end
-
- def empty?
- list_pages(limit: 1).empty?
- end
-
- def exists?
- !empty?
- end
-
- # Lists wiki pages of the repository.
- #
- # limit - max number of pages returned by the method.
- # sort - criterion by which the pages are sorted.
- # direction - order of the sorted pages.
- # load_content - option, which specifies whether the content inside the page
- # will be loaded.
- #
- # Returns an Array of GitLab WikiPage instances or an
- # empty Array if this Wiki has no pages.
- def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false)
- wiki.list_pages(
- limit: limit,
- sort: sort,
- direction_desc: direction == DIRECTION_DESC,
- load_content: load_content
- ).map do |page|
- WikiPage.new(self, page)
- end
- end
-
- # Finds a page within the repository based on a tile
- # or slug.
- #
- # title - The human readable or parameterized title of
- # the page.
- #
- # Returns an initialized WikiPage instance or nil
- def find_page(title, version = nil)
- page_title, page_dir = page_title_and_dir(title)
-
- if page = wiki.page(title: page_title, version: version, dir: page_dir)
- WikiPage.new(self, page)
- end
- end
-
- def find_sidebar(version = nil)
- find_page(SIDEBAR, version)
- end
-
- def find_file(name, version = nil)
- wiki.file(name, version)
- end
-
- def create_page(title, content, format = :markdown, message = nil)
- commit = commit_details(:created, message, title)
-
- wiki.write_page(title, format.to_sym, content, commit)
-
- update_project_activity
- rescue Gitlab::Git::Wiki::DuplicatePageError => e
- @error_message = "Duplicate page: #{e.message}"
- false
- end
-
- def update_page(page, content:, title: nil, format: :markdown, message: nil)
- commit = commit_details(:updated, message, page.title)
-
- wiki.update_page(page.path, title || page.name, format.to_sym, content, commit)
-
- update_project_activity
- end
-
- def delete_page(page, message = nil)
- return unless page
-
- wiki.delete_page(page.path, commit_details(:deleted, message, page.title))
-
- update_project_activity
- end
-
- def page_title_and_dir(title)
- return unless title
-
- title_array = title.split("/")
- title = title_array.pop
- [title, title_array.join("/")]
- end
-
- def repository
- @repository ||= Repository.new(full_path, @project, shard: repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI)
- end
-
- def default_branch
- wiki.class.default_ref
- end
-
- def ensure_repository
- raise CouldNotCreateWikiError unless wiki.repository_exists?
- end
-
- def hook_attrs
- {
- web_url: web_url,
- git_ssh_url: ssh_url_to_repo,
- git_http_url: http_url_to_repo,
- path_with_namespace: full_path,
- default_branch: default_branch
- }
- end
-
- private
-
- def commit_details(action, message = nil, title = nil)
- commit_message = message.presence || default_message(action, title)
- git_user = Gitlab::Git::User.from_gitlab(user)
-
- Gitlab::Git::Wiki::CommitDetails.new(user.id,
- git_user.username,
- git_user.name,
- git_user.email,
- commit_message)
- end
-
- def default_message(action, title)
- "#{user.username} #{action} page: #{title}"
- end
-
- def update_project_activity
- @project.touch(:last_activity_at, :last_repository_updated_at)
+ override :disk_path
+ def disk_path(*args, &block)
+ container.disk_path + '.wiki'
end
end
+# TODO: Remove this once we implement ES support for group wikis.
+# https://gitlab.com/gitlab-org/gitlab/-/issues/207889
ProjectWiki.prepend_if_ee('EE::ProjectWiki')
diff --git a/app/models/release.rb b/app/models/release.rb
index 403087a2cad..a0245105cd9 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -34,8 +34,6 @@ class Release < ApplicationRecord
delegate :repository, to: :project
- after_commit :notify_new_release, on: :create, unless: :importing?
-
MAX_NUMBER_TO_DISPLAY = 3
def to_param
@@ -81,14 +79,6 @@ class Release < ApplicationRecord
self.milestones.map {|m| m.title }.sort.join(", ")
end
- def evidence_sha
- evidences.first&.summary_sha
- end
-
- def evidence_summary
- evidences.first&.summary || {}
- end
-
private
def actual_sha
@@ -100,10 +90,6 @@ class Release < ApplicationRecord
repository.find_tag(tag)
end
end
-
- def notify_new_release
- NewReleaseWorker.perform_async(id)
- end
end
Release.prepend_if_ee('EE::Release')
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index 0334d63dd36..8e7612e63c8 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -106,7 +106,23 @@ class RemoteMirror < ApplicationRecord
update_status == 'started'
end
- def update_repository(options)
+ def update_repository
+ Gitlab::Git::RemoteMirror.new(
+ project.repository.raw,
+ remote_name,
+ **options_for_update
+ ).update
+ end
+
+ def options_for_update
+ options = {
+ keep_divergent_refs: keep_divergent_refs?
+ }
+
+ if only_protected_branches?
+ options[:only_branches_matching] = project.protected_branches.pluck(:name)
+ end
+
if ssh_mirror_url?
if ssh_key_auth? && ssh_private_key.present?
options[:ssh_key] = ssh_private_key
@@ -117,13 +133,7 @@ class RemoteMirror < ApplicationRecord
end
end
- options[:keep_divergent_refs] = keep_divergent_refs?
-
- Gitlab::Git::RemoteMirror.new(
- project.repository.raw,
- remote_name,
- **options
- ).update
+ options
end
def sync?
diff --git a/app/models/repository.rb b/app/models/repository.rb
index a9ef0504a3d..2673033ff1f 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1120,6 +1120,17 @@ class Repository
end
end
+ # TODO: pass this in directly to `Blob` rather than delegating it to here
+ #
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/201886
+ def lfs_enabled?
+ if container.is_a?(Project)
+ container.lfs_enabled?
+ else
+ false # LFS is not supported for snippet or group repositories
+ end
+ end
+
private
# TODO Genericize finder, later split this on finders by Ref or Oid
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index cd47c154eef..845be408d5e 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -2,16 +2,14 @@
class ResourceLabelEvent < ResourceEvent
include CacheMarkdownField
+ include IssueResourceEvent
+ include MergeRequestResourceEvent
cache_markdown_field :reference
- belongs_to :issue
- belongs_to :merge_request
belongs_to :label
scope :inc_relations, -> { includes(:label, :user) }
- scope :by_issue, ->(issue) { where(issue_id: issue.id) }
- scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
validates :label, presence: { unless: :importing? }, on: :create
validate :exactly_one_issuable
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index a40af22061e..039f26d8e3f 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -2,14 +2,11 @@
class ResourceMilestoneEvent < ResourceEvent
include IgnorableColumns
+ include IssueResourceEvent
+ include MergeRequestResourceEvent
- belongs_to :issue
- belongs_to :merge_request
belongs_to :milestone
- scope :by_issue, ->(issue) { where(issue_id: issue.id) }
- scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
-
validate :exactly_one_issuable
enum action: {
@@ -25,4 +22,8 @@ class ResourceMilestoneEvent < ResourceEvent
def self.issuable_attrs
%i(issue merge_request).freeze
end
+
+ def milestone_title
+ milestone&.title
+ end
end
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
new file mode 100644
index 00000000000..1d6573b180f
--- /dev/null
+++ b/app/models/resource_state_event.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ResourceStateEvent < ResourceEvent
+ include IssueResourceEvent
+ include MergeRequestResourceEvent
+
+ validate :exactly_one_issuable
+
+ # state is used for issue and merge request states.
+ enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5)
+
+ def self.issuable_attrs
+ %i(issue merge_request).freeze
+ end
+end
diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb
index e0cc0c87a83..bbabd54325e 100644
--- a/app/models/resource_weight_event.rb
+++ b/app/models/resource_weight_event.rb
@@ -3,7 +3,5 @@
class ResourceWeightEvent < ResourceEvent
validates :issue, presence: true
- belongs_to :issue
-
- scope :by_issue, ->(issue) { where(issue_id: issue.id) }
+ include IssueResourceEvent
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index f3a9293376f..4165d3b753f 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -76,12 +76,14 @@ class SentNotification < ApplicationRecord
def position=(new_position)
if new_position.is_a?(String)
- new_position = JSON.parse(new_position) rescue nil
+ new_position = Gitlab::Json.parse(new_position) rescue nil
end
if new_position.is_a?(Hash)
new_position = new_position.with_indifferent_access
new_position = Gitlab::Diff::Position.new(new_position)
+ else
+ new_position = nil
end
super(new_position)
diff --git a/app/models/service.rb b/app/models/service.rb
index 543869c71d6..fb4d9a77077 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -12,7 +12,7 @@ class Service < ApplicationRecord
alerts asana assembla bamboo bugzilla buildkite campfire custom_issue_tracker discord
drone_ci emails_on_push external_wiki flowdock hangouts_chat hipchat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
- pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit youtrack
+ pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
].freeze
DEV_SERVICE_NAMES = %w[
@@ -81,6 +81,10 @@ class Service < ApplicationRecord
active
end
+ def operating?
+ active && persisted?
+ end
+
def show_active_box?
true
end
@@ -345,14 +349,6 @@ class Service < ApplicationRecord
service
end
- def deprecated?
- false
- end
-
- def deprecation_message
- nil
- end
-
# override if needed
def supports_data_fields?
false
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index dbf600cf0df..72ebdf61787 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -15,9 +15,11 @@ class Snippet < ApplicationRecord
include FromUnion
include IgnorableColumns
include HasRepository
+ include AfterCommitQueue
extend ::Gitlab::Utils::Override
- MAX_FILE_COUNT = 1
+ MAX_FILE_COUNT = 10
+ MAX_SINGLE_FILE_COUNT = 1
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -101,6 +103,10 @@ class Snippet < ApplicationRecord
where(project_id: nil)
end
+ def self.only_project_snippets
+ where.not(project_id: nil)
+ end
+
def self.only_include_projects_visible_to(current_user = nil)
levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
@@ -164,6 +170,10 @@ class Snippet < ApplicationRecord
Snippet.find_by(id: id, project: project)
end
+ def self.max_file_limit(user)
+ Feature.enabled?(:snippet_multiple_files, user) ? MAX_FILE_COUNT : MAX_SINGLE_FILE_COUNT
+ end
+
def initialize(attributes = {})
# We can't use default_value_for because the database has a default
# value of 0 for visibility_level. If someone attempts to create a
@@ -199,7 +209,7 @@ class Snippet < ApplicationRecord
def blobs
return [] unless repository_exists?
- repository.ls_files(repository.root_ref).map { |file| Blob.lazy(self, repository.root_ref, file) }
+ repository.ls_files(repository.root_ref).map { |file| Blob.lazy(repository, repository.root_ref, file) }
end
def hook_attrs
@@ -318,8 +328,10 @@ class Snippet < ApplicationRecord
Digest::SHA256.hexdigest("#{title}#{description}#{created_at}#{updated_at}")
end
- def versioned_enabled_for?(user)
- ::Feature.enabled?(:version_snippets, user) && repository_exists?
+ def file_name_on_repo
+ return if repository.empty?
+
+ repository.ls_files(repository.root_ref).first
end
class << self
@@ -334,17 +346,6 @@ class Snippet < ApplicationRecord
fuzzy_search(query, [:title, :description, :file_name])
end
- # Searches for snippets with matching content.
- #
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
- #
- # query - The search query as a String.
- #
- # Returns an ActiveRecord::Relation.
- def search_code(query)
- fuzzy_search(query, [:content])
- end
-
def parent_class
::Project
end
diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb
index e60dbb4d141..2276851b7a1 100644
--- a/app/models/snippet_repository.rb
+++ b/app/models/snippet_repository.rb
@@ -7,6 +7,8 @@ class SnippetRepository < ApplicationRecord
EMPTY_FILE_PATTERN = /^#{DEFAULT_EMPTY_FILE_NAME}(\d+)\.txt$/.freeze
CommitError = Class.new(StandardError)
+ InvalidPathError = Class.new(CommitError)
+ InvalidSignatureError = Class.new(CommitError)
belongs_to :snippet, inverse_of: :snippet_repository
@@ -40,8 +42,12 @@ class SnippetRepository < ApplicationRecord
rescue Gitlab::Git::Index::IndexError,
Gitlab::Git::CommitError,
Gitlab::Git::PreReceiveError,
- Gitlab::Git::CommandError => e
- raise CommitError, e.message
+ Gitlab::Git::CommandError,
+ ArgumentError => error
+
+ logger.error(message: "Snippet git error. Reason: #{error.message}", snippet: snippet.id)
+
+ raise commit_error_exception(error)
end
def transform_file_entries(files)
@@ -85,4 +91,24 @@ class SnippetRepository < ApplicationRecord
def build_empty_file_name(index)
"#{DEFAULT_EMPTY_FILE_NAME}#{index}.txt"
end
+
+ def commit_error_exception(err)
+ if invalid_path_error?(err)
+ InvalidPathError.new('Invalid file name') # To avoid returning the message with the path included
+ elsif invalid_signature_error?(err)
+ InvalidSignatureError.new(err.message)
+ else
+ CommitError.new(err.message)
+ end
+ end
+
+ def invalid_path_error?(err)
+ err.is_a?(Gitlab::Git::Index::IndexError) &&
+ err.message.downcase.start_with?('invalid path', 'path cannot include directory traversal')
+ end
+
+ def invalid_signature_error?(err)
+ err.is_a?(ArgumentError) &&
+ err.message.downcase.match?(/failed to parse signature/)
+ end
end
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index 9bd35d30845..72690ad7d04 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -24,6 +24,7 @@ class SshHostKey
# This is achieved by making the lifetime shorter than the refresh interval.
self.reactive_cache_refresh_interval = 15.minutes
self.reactive_cache_lifetime = 10.minutes
+ self.reactive_cache_work_type = :external_dependency
def self.find_by(opts = {})
opts = HashWithIndifferentAccess.new(opts)
diff --git a/app/models/state_note.rb b/app/models/state_note.rb
new file mode 100644
index 00000000000..cbcb1c2b49d
--- /dev/null
+++ b/app/models/state_note.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class StateNote < SyntheticNote
+ def self.from_event(event, resource: nil, resource_parent: nil)
+ attrs = note_attributes(event.state, event, resource, resource_parent)
+
+ StateNote.new(attrs)
+ end
+
+ def note_html
+ @note_html ||= "<p dir=\"auto\">#{note_text(html: true)}</p>"
+ end
+
+ private
+
+ def note_text(html: false)
+ event.state
+ end
+end
diff --git a/app/models/storage/hashed.rb b/app/models/storage/hashed.rb
index 3dea50ab98b..c61cd3b6b30 100644
--- a/app/models/storage/hashed.rb
+++ b/app/models/storage/hashed.rb
@@ -6,6 +6,7 @@ module Storage
delegate :gitlab_shell, :repository_storage, to: :container
REPOSITORY_PATH_PREFIX = '@hashed'
+ GROUP_REPOSITORY_PATH_PREFIX = '@groups'
SNIPPET_REPOSITORY_PATH_PREFIX = '@snippets'
POOL_PATH_PREFIX = '@pools'
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index b881a43ad4d..4e14bb4e92c 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -15,6 +15,7 @@ class SystemNoteMetadata < ApplicationRecord
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
+ designs_added designs_modified designs_removed designs_discussion_added
title time_tracking branch milestone discussion task moved
opened closed merged duplicate locked unlocked outdated
tag due_date pinned_embed cherry_pick health_status
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index f52dd74d4c9..c0aac6f27aa 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -16,8 +16,8 @@ class Timelog < ApplicationRecord
)
end
- scope :between_dates, -> (start_date, end_date) do
- where('spent_at BETWEEN ? AND ?', start_date, end_date)
+ scope :between_times, -> (start_time, end_time) do
+ where('spent_at BETWEEN ? AND ?', start_time, end_time)
end
def issuable
diff --git a/app/models/todo.rb b/app/models/todo.rb
index d337ef33051..dc42551f0ab 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -110,7 +110,7 @@ class Todo < ApplicationRecord
base = where.not(state: new_state).except(:order)
ids = base.pluck(:id)
- base.update_all(state: new_state)
+ base.update_all(state: new_state, updated_at: Time.now)
ids
end
@@ -183,6 +183,10 @@ class Todo < ApplicationRecord
target_type == "Commit"
end
+ def for_design?
+ target_type == DesignManagement::Design.name
+ end
+
# override to return commits, which are not active record
def target
if for_commit?
diff --git a/app/models/user.rb b/app/models/user.rb
index 1b087da3a2f..b2d3978551e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -24,6 +24,7 @@ class User < ApplicationRecord
include HasUniqueInternalUsers
include IgnorableColumns
include UpdateHighestRole
+ include HasUserType
DEFAULT_NOTIFICATION_LEVEL = :participating
@@ -57,6 +58,10 @@ class User < ApplicationRecord
devise :lockable, :recoverable, :rememberable, :trackable,
:validatable, :omniauthable, :confirmable, :registerable
+ # This module adds async behaviour to Devise emails
+ # and should be added after Devise modules are initialized.
+ include AsyncDeviseEmail
+
BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \
"administrator if you think this is an error."
LOGIN_FORBIDDEN = "Your account does not have the required permission to login. Please contact your GitLab " \
@@ -64,9 +69,8 @@ class User < ApplicationRecord
MINIMUM_INACTIVE_DAYS = 180
- enum user_type: ::UserTypeEnums.types
-
- ignore_column :bot_type, remove_with: '12.11', remove_after: '2020-04-22'
+ 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!
# to limit database writes to at most once every hour
@@ -88,6 +92,9 @@ class User < ApplicationRecord
# Virtual attribute for authenticating by either username or email
attr_accessor :login
+ # Virtual attribute for impersonator
+ attr_accessor :impersonator
+
#
# Relations
#
@@ -166,6 +173,8 @@ class User < ApplicationRecord
has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
+ has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :user
+
has_one :status, class_name: 'UserStatus'
has_one :user_preference
has_one :user_detail
@@ -246,15 +255,12 @@ class User < ApplicationRecord
enum layout: { fixed: 0, fluid: 1 }
# User's Dashboard preference
- # Note: When adding an option, it MUST go on the end of the array.
enum dashboard: { projects: 0, stars: 1, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8 }
# User's Project preference
- # Note: When adding an option, it MUST go on the end of the array.
enum project_view: { readme: 0, activity: 1, files: 2 }
# User's role
- # Note: When adding an option, it MUST go on the end of the array.
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 :path, to: :namespace, allow_nil: true, prefix: true
@@ -321,32 +327,26 @@ class User < ApplicationRecord
scope :admins, -> { where(admin: true) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) }
+ scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :active, -> { with_state(:active).non_internal }
scope :active_without_ghosts, -> { with_state(:active).without_ghosts }
- scope :without_ghosts, -> { where('ghost IS NOT TRUE') }
scope :deactivated, -> { with_state(:deactivated).non_internal }
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
- scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
- scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
- scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
- scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) }
- scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
- scope :bots, -> { where(user_type: UserTypeEnums.bots.values) }
- scope :bots_without_project_bot, -> { bots.where.not(user_type: UserTypeEnums.bots[:project_bot]) }
- scope :with_project_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.except(:project_bot).values)) }
- scope :humans, -> { where(user_type: nil) }
-
scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do
where('EXISTS (?)',
::PersonalAccessToken
.where('personal_access_tokens.user_id = users.id')
.expiring_and_not_notified(at).select(1))
end
+ scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
+ scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
+ scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
+ scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) }
def active_for_authentication?
super && can?(:log_in)
@@ -624,7 +624,7 @@ class User < ApplicationRecord
# owns records previously belonging to deleted users.
def ghost
email = 'ghost%s@example.com'
- unique_internal(where(ghost: true, user_type: :ghost), 'ghost', email) do |u|
+ unique_internal(where(user_type: :ghost), 'ghost', email) do |u|
u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.')
u.name = 'Ghost User'
end
@@ -639,6 +639,16 @@ class User < ApplicationRecord
end
end
+ def migration_bot
+ email_pattern = "noreply+gitlab-migration-bot%s@#{Settings.gitlab.host}"
+
+ unique_internal(where(user_type: :migration_bot), 'migration-bot', email_pattern) do |u|
+ u.bio = 'The GitLab migration bot'
+ u.name = 'GitLab Migration Bot'
+ u.confirmed_at = Time.zone.now
+ end
+ end
+
# Return true if there is only single non-internal user in the deployment,
# ghost user is ignored.
def single_user?
@@ -650,43 +660,14 @@ class User < ApplicationRecord
end
end
- def full_path
- username
- end
-
- def bot?
- UserTypeEnums.bots.has_key?(user_type)
- end
-
- # The explicit check for project_bot will be removed with Bot Categorization
- # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
- def internal?
- ghost? || (bot? && !project_bot?)
- end
-
- # We are transitioning from ghost boolean column to user_type
- # so we need to read from old column for now
- # @see https://gitlab.com/gitlab-org/gitlab/-/issues/210025
- def ghost?
- ghost
- end
-
- # The explicit check for project_bot will be removed with Bot Categorization
- # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
- def self.internal
- where(ghost: true).or(bots_without_project_bot)
- end
-
- # The explicit check for project_bot will be removed with Bot Categorization
- # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
- def self.non_internal
- without_ghosts.with_project_bots
- end
-
#
# Instance methods
#
+ def full_path
+ username
+ end
+
def to_param
username
end
@@ -1700,16 +1681,6 @@ class User < ApplicationRecord
callouts.any?
end
- def gitlab_employee?
- strong_memoize(:gitlab_employee) do
- if Feature.enabled?(:gitlab_employee_badge) && Gitlab.com?
- Mail::Address.new(email).domain == "gitlab.com" && confirmed?
- else
- false
- end
- end
- end
-
# Load the current highest access by looking directly at the user's memberships
def current_highest_access_level
members.non_request.maximum(:access_level)
@@ -1719,8 +1690,8 @@ class User < ApplicationRecord
!confirmed? && !confirmation_period_valid?
end
- def organization
- gitlab_employee? ? 'GitLab' : super
+ def impersonated?
+ impersonator.present?
end
protected
@@ -1779,13 +1750,6 @@ class User < ApplicationRecord
ApplicationSetting.current_without_cache&.usage_stats_set_by_user_id == self.id
end
- # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
- def send_devise_notification(notification, *args)
- return true unless can?(:receive_notifications)
-
- devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
- end
-
def ensure_user_rights_and_limits
if external?
self.can_create_group = false
@@ -1834,7 +1798,6 @@ class User < ApplicationRecord
end
def check_email_restrictions
- return unless Feature.enabled?(:email_restrictions)
return unless Gitlab::CurrentSettings.email_restrictions_enabled?
restrictions = Gitlab::CurrentSettings.email_restrictions
diff --git a/app/models/user_type_enums.rb b/app/models/user_type_enums.rb
deleted file mode 100644
index cb5aac89ed3..00000000000
--- a/app/models/user_type_enums.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module UserTypeEnums
- def self.types
- @types ||= bots.merge(human: nil, ghost: 5)
- end
-
- def self.bots
- @bots ||= { alert_bot: 2, project_bot: 6 }.with_indifferent_access
- end
-end
-
-UserTypeEnums.prepend_if_ee('EE::UserTypeEnums')
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
new file mode 100644
index 00000000000..54bcec32095
--- /dev/null
+++ b/app/models/wiki.rb
@@ -0,0 +1,233 @@
+# frozen_string_literal: true
+
+class Wiki
+ extend ::Gitlab::Utils::Override
+ include HasRepository
+ include Gitlab::Utils::StrongMemoize
+
+ MARKUPS = { # rubocop:disable Style/MultilineIfModifier
+ 'Markdown' => :markdown,
+ 'RDoc' => :rdoc,
+ 'AsciiDoc' => :asciidoc,
+ 'Org' => :org
+ }.freeze unless defined?(MARKUPS)
+
+ CouldNotCreateWikiError = Class.new(StandardError)
+
+ HOMEPAGE = 'home'
+ SIDEBAR = '_sidebar'
+
+ TITLE_ORDER = 'title'
+ CREATED_AT_ORDER = 'created_at'
+ DIRECTION_DESC = 'desc'
+ DIRECTION_ASC = 'asc'
+
+ attr_reader :container, :user
+
+ # Returns a string describing what went wrong after
+ # an operation fails.
+ attr_reader :error_message
+
+ def self.for_container(container, user = nil)
+ "#{container.class.name}Wiki".constantize.new(container, user)
+ end
+
+ def initialize(container, user = nil)
+ @container = container
+ @user = user
+ end
+
+ def path
+ container.path + '.wiki'
+ end
+
+ # Returns the Gitlab::Git::Wiki object.
+ def wiki
+ strong_memoize(:wiki) do
+ create_wiki_repository
+ Gitlab::Git::Wiki.new(repository.raw)
+ end
+ end
+
+ def create_wiki_repository
+ repository.create_if_not_exists
+
+ raise CouldNotCreateWikiError unless repository_exists?
+ rescue => err
+ Gitlab::ErrorTracking.track_exception(err, wiki: {
+ container_type: container.class.name,
+ container_id: container.id,
+ full_path: full_path,
+ disk_path: disk_path
+ })
+
+ raise CouldNotCreateWikiError
+ end
+
+ def has_home_page?
+ !!find_page(HOMEPAGE)
+ end
+
+ def empty?
+ list_pages(limit: 1).empty?
+ end
+
+ def exists?
+ !empty?
+ end
+
+ # Lists wiki pages of the repository.
+ #
+ # limit - max number of pages returned by the method.
+ # sort - criterion by which the pages are sorted.
+ # direction - order of the sorted pages.
+ # load_content - option, which specifies whether the content inside the page
+ # will be loaded.
+ #
+ # Returns an Array of GitLab WikiPage instances or an
+ # empty Array if this Wiki has no pages.
+ def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false)
+ wiki.list_pages(
+ limit: limit,
+ sort: sort,
+ direction_desc: direction == DIRECTION_DESC,
+ load_content: load_content
+ ).map do |page|
+ WikiPage.new(self, page)
+ end
+ end
+
+ def sidebar_entries(limit: Gitlab::WikiPages::MAX_SIDEBAR_PAGES, **options)
+ pages = list_pages(**options.merge(limit: limit + 1))
+ limited = pages.size > limit
+ pages = pages.first(limit) if limited
+
+ [WikiPage.group_by_directory(pages), limited]
+ end
+
+ # Finds a page within the repository based on a tile
+ # or slug.
+ #
+ # title - The human readable or parameterized title of
+ # the page.
+ #
+ # Returns an initialized WikiPage instance or nil
+ def find_page(title, version = nil)
+ page_title, page_dir = page_title_and_dir(title)
+
+ if page = wiki.page(title: page_title, version: version, dir: page_dir)
+ WikiPage.new(self, page)
+ end
+ end
+
+ def find_sidebar(version = nil)
+ find_page(SIDEBAR, version)
+ end
+
+ def find_file(name, version = nil)
+ wiki.file(name, version)
+ end
+
+ def create_page(title, content, format = :markdown, message = nil)
+ commit = commit_details(:created, message, title)
+
+ wiki.write_page(title, format.to_sym, content, commit)
+
+ update_container_activity
+ rescue Gitlab::Git::Wiki::DuplicatePageError => e
+ @error_message = "Duplicate page: #{e.message}"
+ false
+ end
+
+ def update_page(page, content:, title: nil, format: :markdown, message: nil)
+ commit = commit_details(:updated, message, page.title)
+
+ wiki.update_page(page.path, title || page.name, format.to_sym, content, commit)
+
+ update_container_activity
+ end
+
+ def delete_page(page, message = nil)
+ return unless page
+
+ wiki.delete_page(page.path, commit_details(:deleted, message, page.title))
+
+ update_container_activity
+ end
+
+ def page_title_and_dir(title)
+ return unless title
+
+ title_array = title.split("/")
+ title = title_array.pop
+ [title, title_array.join("/")]
+ end
+
+ def ensure_repository
+ raise CouldNotCreateWikiError unless wiki.repository_exists?
+ end
+
+ def hook_attrs
+ {
+ web_url: web_url,
+ git_ssh_url: ssh_url_to_repo,
+ git_http_url: http_url_to_repo,
+ path_with_namespace: full_path,
+ default_branch: default_branch
+ }
+ end
+
+ override :repository
+ def repository
+ @repository ||= Repository.new(full_path, container, shard: repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI)
+ end
+
+ def repository_storage
+ raise NotImplementedError
+ end
+
+ def hashed_storage?
+ raise NotImplementedError
+ end
+
+ override :full_path
+ def full_path
+ container.full_path + '.wiki'
+ end
+ alias_method :id, :full_path
+
+ # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem
+ alias_method :path_with_namespace, :full_path
+
+ override :default_branch
+ def default_branch
+ wiki.class.default_ref
+ end
+
+ def wiki_base_path
+ Gitlab.config.gitlab.relative_url_root + web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}, '')
+ end
+
+ private
+
+ def commit_details(action, message = nil, title = nil)
+ commit_message = message.presence || default_message(action, title)
+ git_user = Gitlab::Git::User.from_gitlab(user)
+
+ Gitlab::Git::Wiki::CommitDetails.new(user.id,
+ git_user.username,
+ git_user.name,
+ git_user.email,
+ commit_message)
+ end
+
+ def default_message(action, title)
+ "#{user.username} #{action} page: #{title}"
+ end
+
+ def update_container_activity
+ container.after_wiki_activity
+ end
+end
+
+Wiki.prepend_if_ee('EE::Wiki')
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 9c887fc87f3..319cdd38d93 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -26,7 +26,7 @@ class WikiPage
def eql?(other)
return false unless other.present? && other.is_a?(self.class)
- slug == other.slug && wiki.project == other.wiki.project
+ slug == other.slug && wiki.container == other.wiki.container
end
alias_method :==, :eql?
@@ -66,9 +66,9 @@ class WikiPage
validates :content, presence: true
validate :validate_path_limits, if: :title_changed?
- # The GitLab ProjectWiki instance.
+ # The GitLab Wiki instance.
attr_reader :wiki
- delegate :project, to: :wiki
+ delegate :container, to: :wiki
# The raw Gitlab::Git::WikiPage instance.
attr_reader :page
@@ -83,7 +83,7 @@ class WikiPage
# Construct a new WikiPage
#
- # @param [ProjectWiki] wiki
+ # @param [Wiki] wiki
# @param [Gitlab::Git::WikiPage] page
def initialize(wiki, page = nil)
@wiki = wiki
@@ -95,29 +95,29 @@ class WikiPage
# The escaped URL path of this page.
def slug
- @attributes[:slug].presence || wiki.wiki.preview_slug(title, format)
+ attributes[:slug].presence || wiki.wiki.preview_slug(title, format)
end
alias_method :to_param, :slug
def human_title
- return 'Home' if title == 'home'
+ return 'Home' if title == Wiki::HOMEPAGE
title
end
# The formatted title of this page.
def title
- @attributes[:title] || ''
+ attributes[:title] || ''
end
# Sets the title of this page.
def title=(new_title)
- @attributes[:title] = new_title
+ attributes[:title] = new_title
end
def raw_content
- @attributes[:content] ||= @page&.text_data
+ attributes[:content] ||= page&.text_data
end
# The hierarchy of the directory this page is contained in.
@@ -127,7 +127,7 @@ class WikiPage
# The markup format for the page.
def format
- @attributes[:format] || :markdown
+ attributes[:format] || :markdown
end
# The commit message for this page version.
@@ -151,13 +151,13 @@ class WikiPage
def versions(options = {})
return [] unless persisted?
- wiki.wiki.page_versions(@page.path, options)
+ wiki.wiki.page_versions(page.path, options)
end
def count_versions
return [] unless persisted?
- wiki.wiki.count_page_versions(@page.path)
+ wiki.wiki.count_page_versions(page.path)
end
def last_version
@@ -173,7 +173,7 @@ class WikiPage
def historical?
return false unless last_commit_sha && version
- @page.historical? && last_commit_sha != version.sha
+ page.historical? && last_commit_sha != version.sha
end
# Returns boolean True or False if this instance
@@ -185,7 +185,7 @@ class WikiPage
# Returns boolean True or False if this instance
# has been fully created on disk or not.
def persisted?
- @page.present?
+ page.present?
end
# Creates a new Wiki Page.
@@ -195,7 +195,7 @@ class WikiPage
# :content - The raw markup content.
# :format - Optional symbol representing the
# content format. Can be any type
- # listed in the ProjectWiki::MARKUPS
+ # listed in the Wiki::MARKUPS
# Hash.
# :message - Optional commit message to set on
# the new page.
@@ -215,7 +215,7 @@ class WikiPage
# attrs - Hash of attributes to be updated on the page.
# :content - The raw markup content to replace the existing.
# :format - Optional symbol representing the content format.
- # See ProjectWiki::MARKUPS Hash for available formats.
+ # See Wiki::MARKUPS Hash for available formats.
# :message - Optional commit message to set on the new version.
# :last_commit_sha - Optional last commit sha to validate the page unchanged.
# :title - The Title (optionally including dir) to replace existing title
@@ -232,13 +232,13 @@ class WikiPage
update_attributes(attrs)
if title.present? && title_changed? && wiki.find_page(title).present?
- @attributes[:title] = @page.title
+ attributes[:title] = page.title
raise PageRenameError
end
save do
wiki.update_page(
- @page,
+ page,
content: raw_content,
format: format,
message: attrs[:message],
@@ -251,7 +251,7 @@ class WikiPage
#
# Returns boolean True or False.
def delete
- if wiki.delete_page(@page)
+ if wiki.delete_page(page)
true
else
false
@@ -261,6 +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'
end
@@ -270,7 +271,7 @@ class WikiPage
def title_changed?
if persisted?
- old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(@page.url_path))
+ old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(page.url_path))
new_title, new_dir = wiki.page_title_and_dir(self.class.unhyphenize(title))
new_title != old_title || (title.include?('/') && new_dir != old_dir)
@@ -287,13 +288,17 @@ class WikiPage
attrs.slice!(:content, :format, :message, :title)
clear_memoization(:parsed_content) if attrs.has_key?(:content)
- @attributes.merge!(attrs)
+ attributes.merge!(attrs)
end
def to_ability_name
'wiki_page'
end
+ def version_commit_timestamp
+ version&.commit&.committed_date
+ end
+
private
def serialize_front_matter(hash)
@@ -303,7 +308,7 @@ class WikiPage
end
def update_front_matter(attrs)
- return unless Gitlab::WikiPages::FrontMatterParser.enabled?(project)
+ return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container)
return unless attrs.has_key?(:front_matter)
fm_yaml = serialize_front_matter(attrs[:front_matter])
@@ -314,7 +319,7 @@ class WikiPage
def parsed_content
strong_memoize(:parsed_content) do
- Gitlab::WikiPages::FrontMatterParser.new(raw_content, project).parse
+ Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse
end
end
@@ -325,7 +330,7 @@ class WikiPage
title = deep_title_squish(title)
current_dirname = File.dirname(title)
- if @page.present?
+ if persisted?
return title[1..-1] if current_dirname == '/'
return File.join([directory.presence, title].compact) if current_dirname == '.'
end
@@ -362,9 +367,11 @@ class WikiPage
end
def validate_path_limits
- *dirnames, title = @attributes[:title].split('/')
+ return unless title.present?
+
+ *dirnames, filename = title.split('/')
- if title && title.bytesize > Gitlab::WikiPages::MAX_TITLE_BYTES
+ if filename && filename.bytesize > Gitlab::WikiPages::MAX_TITLE_BYTES
errors.add(:title, _("exceeds the limit of %{bytes} bytes") % {
bytes: Gitlab::WikiPages::MAX_TITLE_BYTES
})
diff --git a/app/models/wiki_page/meta.rb b/app/models/wiki_page/meta.rb
index 2af7d86ebcc..474968122b1 100644
--- a/app/models/wiki_page/meta.rb
+++ b/app/models/wiki_page/meta.rb
@@ -5,6 +5,7 @@ class WikiPage
include Gitlab::Utils::StrongMemoize
CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid)
+ WikiPageInvalid = Class.new(ArgumentError)
self.table_name = 'wiki_page_meta'
@@ -23,46 +24,62 @@ class WikiPage
alias_method :resource_parent, :project
- # Return the (updated) WikiPage::Meta record for a given wiki page
- #
- # If none is found, then a new record is created, and its fields are set
- # to reflect the wiki_page passed.
- #
- # @param [String] last_known_slug
- # @param [WikiPage] wiki_page
- #
- # As with all `find_or_create` methods, this one raises errors on
- # validation issues.
- def self.find_or_create(last_known_slug, wiki_page)
- project = wiki_page.wiki.project
- known_slugs = [last_known_slug, wiki_page.slug].compact.uniq
- raise 'no slugs!' if known_slugs.empty?
-
- transaction do
- found = find_by_canonical_slug(known_slugs, project)
- meta = found || create(title: wiki_page.title, project_id: project.id)
-
- meta.update_state(found.nil?, known_slugs, wiki_page)
-
- # We don't need to run validations here, since find_by_canonical_slug
- # guarantees that there is no conflict in canonical_slug, and DB
- # constraints on title and project_id enforce our other invariants
- # This saves us a query.
- meta
+ class << self
+ # Return the (updated) WikiPage::Meta record for a given wiki page
+ #
+ # If none is found, then a new record is created, and its fields are set
+ # to reflect the wiki_page passed.
+ #
+ # @param [String] last_known_slug
+ # @param [WikiPage] wiki_page
+ #
+ # This method raises errors on validation issues.
+ def find_or_create(last_known_slug, wiki_page)
+ raise WikiPageInvalid unless wiki_page.valid?
+
+ project = wiki_page.wiki.project
+ known_slugs = [last_known_slug, wiki_page.slug].compact.uniq
+ raise 'No slugs found! This should not be possible.' if known_slugs.empty?
+
+ transaction do
+ updates = wiki_page_updates(wiki_page)
+ found = find_by_canonical_slug(known_slugs, project)
+ meta = found || create!(updates.merge(project_id: project.id))
+
+ meta.update_state(found.nil?, known_slugs, wiki_page, updates)
+
+ # We don't need to run validations here, since find_by_canonical_slug
+ # guarantees that there is no conflict in canonical_slug, and DB
+ # constraints on title and project_id enforce our other invariants
+ # This saves us a query.
+ meta
+ end
end
- end
- def self.find_by_canonical_slug(canonical_slug, project)
- meta, conflict = with_canonical_slug(canonical_slug)
- .where(project_id: project.id)
- .limit(2)
+ def find_by_canonical_slug(canonical_slug, project)
+ meta, conflict = with_canonical_slug(canonical_slug)
+ .where(project_id: project.id)
+ .limit(2)
- if conflict.present?
- meta.errors.add(:canonical_slug, 'Duplicate value found')
- raise CanonicalSlugConflictError.new(meta)
+ if conflict.present?
+ meta.errors.add(:canonical_slug, 'Duplicate value found')
+ raise CanonicalSlugConflictError.new(meta)
+ end
+
+ meta
end
- meta
+ private
+
+ def wiki_page_updates(wiki_page)
+ last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc
+
+ {
+ title: wiki_page.title,
+ created_at: last_commit_date,
+ updated_at: last_commit_date
+ }
+ end
end
def canonical_slug
@@ -85,24 +102,21 @@ class WikiPage
@canonical_slug = slug
end
- def update_state(created, known_slugs, wiki_page)
- update_wiki_page_attributes(wiki_page)
+ def update_state(created, known_slugs, wiki_page, updates)
+ update_wiki_page_attributes(updates)
insert_slugs(known_slugs, created, wiki_page.slug)
self.canonical_slug = wiki_page.slug
end
- def update_columns(attrs = {})
- super(attrs.reverse_merge(updated_at: Time.now.utc))
- end
-
- def self.update_all(attrs = {})
- super(attrs.reverse_merge(updated_at: Time.now.utc))
- end
-
private
- def update_wiki_page_attributes(page)
- update_columns(title: page.title) unless page.title == title
+ def update_wiki_page_attributes(updates)
+ # Remove all unnecessary updates:
+ updates.delete(:updated_at) if updated_at == updates[:updated_at]
+ updates.delete(:created_at) if created_at <= updates[:created_at]
+ updates.delete(:title) if title == updates[:title]
+
+ update_columns(updates) unless updates.empty?
end
def insert_slugs(strings, is_new, canonical_slug)
diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb
index 75b711eab5b..428fd336a32 100644
--- a/app/models/x509_certificate.rb
+++ b/app/models/x509_certificate.rb
@@ -26,6 +26,8 @@ class X509Certificate < ApplicationRecord
validates :x509_issuer_id, presence: true
+ scope :by_x509_issuer, ->(issuer) { where(x509_issuer_id: issuer.id) }
+
after_commit :mark_commit_signatures_unverified
def self.safe_create!(attributes)
@@ -33,6 +35,10 @@ class X509Certificate < ApplicationRecord
.safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier])
end
+ def self.serial_numbers(issuer)
+ by_x509_issuer(issuer).pluck(:serial_number)
+ end
+
def mark_commit_signatures_unverified
X509CertificateRevokeWorker.perform_async(self.id) if revoked?
end
diff --git a/app/models/x509_commit_signature.rb b/app/models/x509_commit_signature.rb
index ed7c638cecc..57d809f7cfb 100644
--- a/app/models/x509_commit_signature.rb
+++ b/app/models/x509_commit_signature.rb
@@ -41,4 +41,8 @@ class X509CommitSignature < ApplicationRecord
Gitlab::X509::Commit.new(commit)
end
+
+ def user
+ commit.committer
+ end
end
diff --git a/app/policies/alert_management/alert_policy.rb b/app/policies/alert_management/alert_policy.rb
new file mode 100644
index 00000000000..85fafcde2cc
--- /dev/null
+++ b/app/policies/alert_management/alert_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class AlertPolicy < ::BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index ebb99270b9a..12892a69257 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -12,6 +12,14 @@ module Ci
end
end
+ condition(:unprotected_ref) do
+ if @subject.tag?
+ !ProtectedTag.protected?(@subject.project, @subject.ref)
+ else
+ !ProtectedBranch.protected?(@subject.project, @subject.ref)
+ end
+ end
+
condition(:owner_of_job) do
@subject.triggered_by?(@user)
end
@@ -34,7 +42,7 @@ module Ci
prevent :erase_build
end
- rule { can?(:admin_build) | (can?(:update_build) & owner_of_job) }.enable :erase_build
+ rule { can?(:admin_build) | (can?(:update_build) & owner_of_job & unprotected_ref) }.enable :erase_build
rule { can?(:public_access) & branch_allows_collaboration }.policy do
enable :update_build
diff --git a/app/policies/ci/freeze_period_policy.rb b/app/policies/ci/freeze_period_policy.rb
new file mode 100644
index 00000000000..60e53a7b2f9
--- /dev/null
+++ b/app/policies/ci/freeze_period_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class FreezePeriodPolicy < BasePolicy
+ delegate { @subject.resource_parent }
+ end
+end
diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb
index 406677d7b56..f910e04d015 100644
--- a/app/policies/concerns/policy_actor.rb
+++ b/app/policies/concerns/policy_actor.rb
@@ -1,8 +1,15 @@
# frozen_string_literal: true
-# Include this module if we want to pass something else than the user to
-# check policies. This defines several methods which the policy checker
-# would call and check.
+# Include this module to have an object respond to user messages without being
+# a user.
+#
+# Use Case 1:
+# Pass something else than the user to check policies. This defines several
+# methods which the policy checker would call and check.
+#
+# Use Case 2:
+# Access the API with non-user object such as deploy tokens. This defines
+# several methods which the API auth flow would call.
module PolicyActor
extend ActiveSupport::Concern
@@ -37,6 +44,30 @@ module PolicyActor
def alert_bot?
false
end
+
+ def deactivated?
+ false
+ end
+
+ def confirmation_required_on_sign_in?
+ false
+ end
+
+ def can?(action, subject = :global)
+ Ability.allowed?(self, action, subject)
+ end
+
+ def preferred_language
+ nil
+ end
+
+ def requires_ldap_check?
+ false
+ end
+
+ def try_obtain_ldap_lease
+ nil
+ end
end
PolicyActor.prepend_if_ee('EE::PolicyActor')
diff --git a/app/policies/design_management/design_at_version_policy.rb b/app/policies/design_management/design_at_version_policy.rb
new file mode 100644
index 00000000000..9decbc0c4b2
--- /dev/null
+++ b/app/policies/design_management/design_at_version_policy.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignAtVersionPolicy < ::BasePolicy
+ delegate { @subject.version }
+ delegate { @subject.design }
+ end
+end
diff --git a/app/policies/design_management/design_collection_policy.rb b/app/policies/design_management/design_collection_policy.rb
new file mode 100644
index 00000000000..6a833da27cc
--- /dev/null
+++ b/app/policies/design_management/design_collection_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignCollectionPolicy < DesignPolicy
+ # Delegates everything to the `issue` just like the `DesignPolicy`
+ end
+end
diff --git a/app/policies/design_management/design_policy.rb b/app/policies/design_management/design_policy.rb
new file mode 100644
index 00000000000..57846095f80
--- /dev/null
+++ b/app/policies/design_management/design_policy.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignPolicy < ::BasePolicy
+ # The IssuePolicy will delegate to the ProjectPolicy
+ delegate { @subject.issue }
+ end
+end
diff --git a/app/policies/design_management/version_policy.rb b/app/policies/design_management/version_policy.rb
new file mode 100644
index 00000000000..1c59ceaea98
--- /dev/null
+++ b/app/policies/design_management/version_policy.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class VersionPolicy < ::BasePolicy
+ # The IssuePolicy will delegate to the ProjectPolicy
+ delegate { @subject.issue }
+ end
+end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 9353b361c2a..03f5a863421 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -18,6 +18,7 @@ class GlobalPolicy < BasePolicy
condition(:private_instance_statistics, score: 0) { Gitlab::CurrentSettings.instance_statistics_visibility_private? }
condition(:project_bot, scope: :user) { @user&.project_bot? }
+ condition(:migration_bot, scope: :user) { @user&.migration_bot? }
rule { admin | (~private_instance_statistics & ~anonymous) }
.enable :read_instance_statistics
@@ -48,11 +49,14 @@ class GlobalPolicy < BasePolicy
rule { blocked | internal }.policy do
prevent :log_in
prevent :access_api
- prevent :access_git
prevent :receive_notifications
prevent :use_slash_commands
end
+ rule { blocked | (internal & ~migration_bot) }.policy do
+ prevent :access_git
+ end
+
rule { project_bot }.policy do
prevent :log_in
prevent :receive_notifications
@@ -74,6 +78,10 @@ class GlobalPolicy < BasePolicy
enable :create_group
end
+ rule { can?(:create_group) }.policy do
+ enable :create_group_with_default_branch_protection
+ end
+
rule { can_create_fork }.policy do
enable :create_fork
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 728c4b76498..136ac4cce63 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class GroupPolicy < BasePolicy
- include CrudPolicyHelpers
include FindGroupProjects
desc "Group is public"
@@ -43,23 +42,15 @@ class GroupPolicy < BasePolicy
@subject.subgroup_creation_level == ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS
end
- desc "Group has wiki disabled"
- condition(:wiki_disabled, score: 32) { !feature_available?(:wiki) }
-
rule { public_group }.policy do
enable :read_group
enable :read_package
- enable :read_wiki
end
- rule { logged_in_viewable }.policy do
- enable :read_group
- enable :read_wiki
- end
+ rule { logged_in_viewable }.enable :read_group
rule { guest }.policy do
enable :read_group
- enable :read_wiki
enable :upload_file
end
@@ -87,13 +78,11 @@ class GroupPolicy < BasePolicy
enable :create_metrics_dashboard_annotation
enable :delete_metrics_dashboard_annotation
enable :update_metrics_dashboard_annotation
- enable :create_wiki
end
rule { reporter }.policy do
enable :reporter_access
enable :read_container_image
- enable :download_wiki_code
enable :admin_label
enable :admin_list
enable :admin_issue
@@ -112,7 +101,6 @@ class GroupPolicy < BasePolicy
enable :destroy_deploy_token
enable :read_deploy_token
enable :create_deploy_token
- enable :admin_wiki
end
rule { owner }.policy do
@@ -123,6 +111,7 @@ class GroupPolicy < BasePolicy
enable :set_note_created_at
enable :set_emails_disabled
+ enable :update_default_branch_protection
end
rule { can?(:read_nested_project_resources) }.policy do
@@ -158,11 +147,6 @@ class GroupPolicy < BasePolicy
rule { maintainer & can?(:create_projects) }.enable :transfer_projects
- rule { wiki_disabled }.policy do
- prevent(*create_read_update_admin_destroy(:wiki))
- prevent(:download_wiki_code)
- end
-
def access_level
return GroupMember::NO_ACCESS if @user.nil?
@@ -172,21 +156,6 @@ class GroupPolicy < BasePolicy
def lookup_access_level!
@subject.max_member_access_for_user(@user)
end
-
- # TODO: Extract this into a helper shared with ProjectPolicy, once we implement group-level features.
- # https://gitlab.com/gitlab-org/gitlab/-/issues/208412
- def feature_available?(feature)
- return false unless feature == :wiki
-
- case @subject.wiki_access_level
- when ProjectFeature::DISABLED
- false
- when ProjectFeature::PRIVATE
- admin? || access_level >= ProjectFeature.required_minimum_access_level(feature)
- else
- true
- end
- end
end
GroupPolicy.prepend_if_ee('EE::GroupPolicy')
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 20df823c737..28baa0d8338 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -15,6 +15,9 @@ class IssuePolicy < IssuablePolicy
desc "Issue is confidential"
condition(:confidential, scope: :subject) { @subject.confidential? }
+ desc "Issue has moved"
+ condition(:moved) { @subject.moved? }
+
rule { confidential & ~can_read_confidential }.policy do
prevent(*create_read_update_admin_destroy(:issue))
prevent :read_issue_iid
@@ -25,6 +28,15 @@ class IssuePolicy < IssuablePolicy
rule { locked }.policy do
prevent :reopen_issue
end
-end
-IssuePolicy.prepend_if_ee('::EE::IssuePolicy')
+ rule { ~can?(:read_issue) }.policy do
+ prevent :read_design
+ prevent :create_design
+ prevent :destroy_design
+ end
+
+ rule { locked | moved }.policy do
+ prevent :create_design
+ prevent :destroy_design
+ end
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 7454343a357..a24c0471d6c 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -11,6 +11,7 @@ class ProjectPolicy < BasePolicy
milestone
snippet
wiki
+ design
note
pipeline
pipeline_schedule
@@ -83,11 +84,26 @@ class ProjectPolicy < BasePolicy
project.merge_requests_allowing_push_to_user(user).any?
end
+ desc "Deploy token with read_package_registry scope"
+ condition(:read_package_registry_deploy_token) do
+ user.is_a?(DeployToken) && user.has_access_to?(project) && user.read_package_registry
+ end
+
+ desc "Deploy token with write_package_registry scope"
+ condition(:write_package_registry_deploy_token) do
+ user.is_a?(DeployToken) && user.has_access_to?(project) && user.write_package_registry
+ end
+
with_scope :subject
condition(:forking_allowed) do
@subject.feature_available?(:forking, @user)
end
+ with_scope :subject
+ condition(:metrics_dashboard_allowed) do
+ feature_available?(:metrics_dashboard)
+ end
+
with_scope :global
condition(:mirror_available, score: 0) do
::Gitlab::CurrentSettings.current_application_settings.mirror_available
@@ -102,6 +118,11 @@ class ProjectPolicy < BasePolicy
)
end
+ with_scope :subject
+ condition(:design_management_disabled) do
+ !@subject.design_management_enabled?
+ end
+
# We aren't checking `:read_issue` or `:read_merge_request` in this case
# because it could be possible for a user to see an issuable-iid
# (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be
@@ -134,6 +155,7 @@ class ProjectPolicy < BasePolicy
wiki
builds
pages
+ metrics_dashboard
]
features.each do |f|
@@ -174,6 +196,7 @@ class ProjectPolicy < BasePolicy
enable :set_issue_updated_at
enable :set_note_created_at
enable :set_emails_disabled
+ enable :set_show_default_award_emojis
end
rule { can?(:guest_access) }.policy do
@@ -218,6 +241,7 @@ class ProjectPolicy < BasePolicy
enable :read_build
enable :read_container_image
enable :read_pipeline
+ enable :read_pipeline_schedule
enable :read_environment
enable :read_deployment
enable :read_merge_request
@@ -225,6 +249,7 @@ class ProjectPolicy < BasePolicy
enable :update_sentry_issue
enable :read_prometheus
enable :read_metrics_dashboard_annotation
+ enable :metrics_dashboard
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
@@ -247,6 +272,21 @@ class ProjectPolicy < BasePolicy
enable :fork_project
end
+ rule { metrics_dashboard_disabled }.policy do
+ prevent(:metrics_dashboard)
+ end
+
+ rule { can?(:metrics_dashboard) }.policy do
+ enable :read_prometheus
+ enable :read_environment
+ enable :read_deployment
+ end
+
+ rule { ~anonymous & can?(:metrics_dashboard) }.policy do
+ enable :create_metrics_user_starred_dashboard
+ enable :read_metrics_user_starred_dashboard
+ end
+
rule { owner | admin | guest | group_member }.prevent :request_access
rule { ~request_access_enabled }.prevent :request_access
@@ -262,7 +302,6 @@ class ProjectPolicy < BasePolicy
enable :update_commit_status
enable :create_build
enable :update_build
- enable :read_pipeline_schedule
enable :create_merge_request_from
enable :create_wiki
enable :push_code
@@ -277,9 +316,14 @@ class ProjectPolicy < BasePolicy
enable :update_deployment
enable :create_release
enable :update_release
+ enable :daily_statistics
enable :create_metrics_dashboard_annotation
enable :delete_metrics_dashboard_annotation
enable :update_metrics_dashboard_annotation
+ enable :read_alert_management_alert
+ enable :update_alert_management_alert
+ enable :create_design
+ enable :destroy_design
end
rule { can?(:developer_access) & user_confirmed? }.policy do
@@ -315,7 +359,6 @@ class ProjectPolicy < BasePolicy
enable :create_environment_terminal
enable :destroy_release
enable :destroy_artifacts
- enable :daily_statistics
enable :admin_operations
enable :read_deploy_token
enable :create_deploy_token
@@ -323,6 +366,18 @@ class ProjectPolicy < BasePolicy
enable :destroy_deploy_token
enable :read_prometheus_alerts
enable :admin_terraform_state
+ enable :create_freeze_period
+ enable :read_freeze_period
+ enable :update_freeze_period
+ enable :destroy_freeze_period
+ end
+
+ rule { public_project & metrics_dashboard_allowed }.policy do
+ enable :metrics_dashboard
+ end
+
+ rule { internal_access & metrics_dashboard_allowed }.policy do
+ enable :metrics_dashboard
end
rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
@@ -374,11 +429,27 @@ 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
@@ -395,6 +466,7 @@ class ProjectPolicy < BasePolicy
prevent :fork_project
prevent :read_commit_status
prevent :read_pipeline
+ prevent :read_pipeline_schedule
prevent(*create_read_update_admin_destroy(:release))
end
@@ -421,6 +493,7 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
enable :read_note
enable :read_pipeline
+ enable :read_pipeline_schedule
enable :read_commit_status
enable :read_container_image
enable :download_code
@@ -439,6 +512,7 @@ class ProjectPolicy < BasePolicy
rule { public_builds & can?(:guest_access) }.policy do
enable :read_pipeline
+ enable :read_pipeline_schedule
end
# These rules are included to allow maintainers of projects to push to certain
@@ -481,6 +555,27 @@ class ProjectPolicy < BasePolicy
rule { admin }.enable :change_repository_storage
+ rule { can?(:read_issue) }.policy do
+ enable :read_design
+ end
+
+ # Design abilities could also be prevented in the issue policy.
+ rule { design_management_disabled }.policy do
+ prevent :read_design
+ prevent :create_design
+ prevent :destroy_design
+ end
+
+ rule { read_package_registry_deploy_token }.policy do
+ enable :read_package
+ enable :read_project
+ end
+
+ rule { write_package_registry_deploy_token }.policy do
+ enable :create_package
+ enable :read_project
+ end
+
private
def team_member?
diff --git a/app/policies/wiki_page_policy.rb b/app/policies/wiki_page_policy.rb
index 468632c9085..f284fd9f5df 100644
--- a/app/policies/wiki_page_policy.rb
+++ b/app/policies/wiki_page_policy.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class WikiPagePolicy < BasePolicy
- delegate { @subject.wiki.project }
+ delegate { @subject.wiki.container }
rule { can?(:read_wiki) }.enable :read_wiki_page
end
diff --git a/app/presenters/README.md b/app/presenters/README.md
index dc4173a880e..62aec4fc8a2 100644
--- a/app/presenters/README.md
+++ b/app/presenters/README.md
@@ -8,7 +8,7 @@ methods from models to presenters.
### When your view is full of logic
-When your view is full of logic (`if`, `else`, `select` on arrays etc.), it's
+When your view is full of logic (`if`, `else`, `select` on arrays, etc.), it's
time to create a presenter!
### When your model has a lot of view-related logic/data methods
@@ -27,11 +27,11 @@ Presenters should be used for:
https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/7073/diffs.
- Data and logic methods that can be pulled from models.
- Simple text output methods: it's ok if the method returns a string, but not a
- whole DOM element for which we'd need HAML, a view context, helpers etc.
+ whole DOM element for which we'd need HAML, a view context, helpers, etc.
## Why use presenters instead of model concerns?
-We should strive to follow the single-responsibility principle, and view-related
+We should strive to follow the single-responsibility principle and view-related
logic/data methods are definitely not the responsibility of models!
Another reason is as follows:
@@ -52,22 +52,22 @@ we gain the following benefits:
- rules are more explicit and centralized in the presenter => improves security
- testing is easier and faster as presenters are Plain Old Ruby Object (PORO)
- views are more readable and maintainable
-- decreases number of CE -> EE merge conflicts since code is in separate files
+- decreases the number of CE -> EE merge conflicts since code is in separate files
- moves the conflicts from views (not always obvious) to presenters (a lot easier to resolve)
## What not to do with presenters?
- Don't use helpers in presenters. Presenters are not aware of the view context.
-- Don't generate complex DOM elements, forms etc. with presenters. Presenters
- can return simple data as texts, and URLs using URL helpers from
- `Gitlab::Routing` but nothing much more fancy.
+- Don't generate complex DOM elements, forms, etc. with presenters. Presenters
+ can return simple data like texts, and URLs using URL helpers from
+ `Gitlab::Routing` but nothing much fancier.
## Implementation
### Presenter definition
Every presenter should inherit from `Gitlab::View::Presenter::Simple`, which
-provides a `.presents` method which allows you to define an accessor for the
+provides a `.presents` the method which allows you to define an accessor for the
presented object. It also includes common helpers like `Gitlab::Routing` and
`Gitlab::Allowable`.
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 33b7899f912..5e35bfc79ef 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -27,14 +27,13 @@ module Ci
def git_depth
if git_depth_variable
git_depth_variable[:value]
- elsif Feature.enabled?(:ci_project_git_depth, default_enabled: true)
+ else
project.ci_default_git_depth
end.to_i
end
def refspecs
specs = []
- specs << refspec_for_pipeline_ref if should_expose_merge_request_ref?
specs << refspec_for_persistent_ref if persistent_ref_exist?
if git_depth > 0
@@ -50,23 +49,10 @@ module Ci
private
- # We will stop exposing merge request refs when we fully depend on persistent refs
- # (i.e. remove `refspec_for_pipeline_ref` when we remove `depend_on_persistent_pipeline_ref` feature flag.)
- # `ci_force_exposing_merge_request_refs` is an extra feature flag that allows us to
- # forcibly expose MR refs even if the `depend_on_persistent_pipeline_ref` feature flag enabled.
- # This is useful when we see an unexpected behaviors/reports from users.
- # See https://gitlab.com/gitlab-org/gitlab/issues/35140.
- def should_expose_merge_request_ref?
- return false unless merge_request_ref?
- return true if Feature.enabled?(:ci_force_exposing_merge_request_refs, project)
-
- Feature.disabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true)
- end
-
def create_archive(artifacts)
return unless artifacts[:untracked] || artifacts[:paths]
- {
+ archive = {
artifact_type: :archive,
artifact_format: :zip,
name: artifacts[:name],
@@ -75,6 +61,12 @@ module Ci
when: artifacts[:when],
expire_in: artifacts[:expire_in]
}
+
+ if artifacts.dig(:exclude).present? && ::Gitlab::Ci::Features.artifacts_exclude_enabled?
+ archive.merge(exclude: artifacts[:exclude])
+ else
+ archive
+ end
end
def create_reports(reports, expire_in:)
@@ -100,15 +92,18 @@ module Ci
"+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}"
end
- def refspec_for_pipeline_ref
- "+#{ref}:#{ref}"
- end
-
def refspec_for_persistent_ref
"+#{persistent_ref_path}:#{persistent_ref_path}"
end
def persistent_ref_exist?
+ ##
+ # Persistent refs for pipelines definitely exist from GitLab 12.4,
+ # hence, we don't need to check the ref existence before passing it to runners.
+ # Checking refs pressurizes gitaly node and should be avoided.
+ # Issue: https://gitlab.com/gitlab-com/gl-infra/production/-/issues/2143
+ return true if Feature.enabled?(:ci_skip_persistent_ref_existence_check)
+
pipeline.persistent_ref.exist?
end
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index 6b1d82e7557..5e669ff2e50 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -21,8 +21,8 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
can?(current_user, :create_cluster, clusterable)
end
- def index_path
- polymorphic_path([clusterable, :clusters])
+ def index_path(options = {})
+ polymorphic_path([clusterable, :clusters], options)
end
def new_path(options = {})
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 23e688e562e..52811e152a6 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -33,14 +33,6 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
def callout_failure_message
self.class.callout_failure_messages.fetch(failure_reason.to_sym)
end
-
- def recoverable?
- failed? && !unrecoverable?
- end
-
- def unrecoverable?
- script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure?
- end
end
CommitStatusPresenter.prepend_if_ee('::EE::CommitStatusPresenter')
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
index 0c267fd5735..41071bc7bc7 100644
--- a/app/presenters/instance_clusterable_presenter.rb
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -13,8 +13,8 @@ class InstanceClusterablePresenter < ClusterablePresenter
end
override :index_path
- def index_path
- admin_clusters_path
+ def index_path(options = {})
+ admin_clusters_path(options)
end
override :new_path
diff --git a/app/presenters/pages_domain_presenter.rb b/app/presenters/pages_domain_presenter.rb
index 6b74983d932..6ef89760bec 100644
--- a/app/presenters/pages_domain_presenter.rb
+++ b/app/presenters/pages_domain_presenter.rb
@@ -8,8 +8,6 @@ class PagesDomainPresenter < Gitlab::View::Presenter::Delegated
end
def show_auto_ssl_failed_warning?
- return false unless Feature.enabled?(:pages_letsencrypt_errors, pages_domain.project)
-
# validations prevents auto ssl from working, so there is no need to show that warning until
return false if needs_verification?
diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb
index c03925c0871..2114e06a8c5 100644
--- a/app/presenters/projects/prometheus/alert_presenter.rb
+++ b/app/presenters/projects/prometheus/alert_presenter.rb
@@ -7,6 +7,7 @@ module Projects
GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze
MARKDOWN_LINE_BREAK = " \n".freeze
INCIDENT_LABEL_NAME = IncidentManagement::CreateIssueService::INCIDENT_LABEL[:title].freeze
+ METRIC_TIME_WINDOW = 30.minutes
def full_title
[environment_name, alert_title].compact.join(': ')
@@ -119,9 +120,63 @@ module Projects
Array(hosts.value).join(' ')
end
- def metric_embed_for_alert; end
+ def metric_embed_for_alert
+ url = embed_url_for_gitlab_alert || embed_url_for_self_managed_alert
+
+ "\n[](#{url})" if url
+ end
+
+ def embed_url_for_gitlab_alert
+ return unless gitlab_alert
+
+ metrics_dashboard_project_prometheus_alert_url(
+ project,
+ gitlab_alert.prometheus_metric_id,
+ environment_id: environment.id,
+ **alert_embed_window_params(embed_time)
+ )
+ end
+
+ def embed_url_for_self_managed_alert
+ return unless environment && full_query && title
+
+ metrics_dashboard_project_environment_url(
+ project,
+ environment,
+ embed_json: dashboard_for_self_managed_alert.to_json,
+ **alert_embed_window_params(embed_time)
+ )
+ end
+
+ def embed_time
+ starts_at ? Time.rfc3339(starts_at) : Time.current
+ end
+
+ def alert_embed_window_params(time)
+ {
+ start: format_embed_timestamp(time - METRIC_TIME_WINDOW),
+ end: format_embed_timestamp(time + METRIC_TIME_WINDOW)
+ }
+ end
+
+ def format_embed_timestamp(time)
+ time.utc.strftime('%FT%TZ')
+ end
+
+ def dashboard_for_self_managed_alert
+ {
+ panel_groups: [{
+ panels: [{
+ type: 'line-graph',
+ title: title,
+ y_label: y_label,
+ metrics: [{
+ query_range: full_query
+ }]
+ }]
+ }]
+ }
+ end
end
end
end
-
-Projects::Prometheus::AlertPresenter.prepend_if_ee('EE::Projects::Prometheus::AlertPresenter')
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 66211d02696..103c26289bf 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -60,11 +60,11 @@ module Projects
end
def to_partial_path
- 'projects/deploy_keys/index'
+ '../../shared/deploy_keys/index'
end
def form_partial_path
- 'projects/deploy_keys/form'
+ 'shared/deploy_keys/project_group_form'
end
private
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index 3db89df1cc8..ea46f0a234b 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -43,13 +43,6 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
edit_project_release_url(project, release)
end
- def evidence_file_path
- evidence = release.evidences.first
- return unless evidence
-
- project_evidence_url(project, release, evidence, format: :json)
- end
-
private
def can_download_code?
diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb
index ba0b2b42383..faaf7568c72 100644
--- a/app/presenters/snippet_presenter.rb
+++ b/app/presenters/snippet_presenter.rb
@@ -12,11 +12,11 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def ssh_url_to_repo
- snippet.ssh_url_to_repo if snippet.versioned_enabled_for?(current_user)
+ snippet.ssh_url_to_repo if snippet.repository_exists?
end
def http_url_to_repo
- snippet.http_url_to_repo if snippet.versioned_enabled_for?(current_user)
+ snippet.http_url_to_repo if snippet.repository_exists?
end
def can_read_snippet?
@@ -36,10 +36,10 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def blob
- if Feature.enabled?(:version_snippets, current_user) && !snippet.repository.empty?
- snippet.blobs.first
- else
+ if snippet.empty_repo?
snippet.blob
+ else
+ snippet.blobs.first
end
end
diff --git a/app/serializers/accessibility_error_entity.rb b/app/serializers/accessibility_error_entity.rb
new file mode 100644
index 00000000000..540f5384d66
--- /dev/null
+++ b/app/serializers/accessibility_error_entity.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class AccessibilityErrorEntity < Grape::Entity
+ expose :code
+ expose :type
+ expose :typeCode, as: :type_code
+ expose :message
+ expose :context
+ expose :selector
+ expose :runner
+ expose :runnerExtras, as: :runner_extras
+end
diff --git a/app/serializers/accessibility_reports_comparer_entity.rb b/app/serializers/accessibility_reports_comparer_entity.rb
new file mode 100644
index 00000000000..3768607a3fc
--- /dev/null
+++ b/app/serializers/accessibility_reports_comparer_entity.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AccessibilityReportsComparerEntity < Grape::Entity
+ expose :status
+
+ expose :new_errors, using: AccessibilityErrorEntity
+ expose :resolved_errors, using: AccessibilityErrorEntity
+ expose :existing_errors, using: AccessibilityErrorEntity
+
+ expose :summary do
+ expose :total_count, as: :total
+ expose :resolved_count, as: :resolved
+ expose :errors_count, as: :errored
+ end
+end
diff --git a/app/serializers/accessibility_reports_comparer_serializer.rb b/app/serializers/accessibility_reports_comparer_serializer.rb
new file mode 100644
index 00000000000..a6b8162e4ea
--- /dev/null
+++ b/app/serializers/accessibility_reports_comparer_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class AccessibilityReportsComparerSerializer < BaseSerializer
+ entity AccessibilityReportsComparerEntity
+end
diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb
index 57e9225e2da..62828fc1428 100644
--- a/app/serializers/analytics_summary_entity.rb
+++ b/app/serializers/analytics_summary_entity.rb
@@ -8,8 +8,6 @@ class AnalyticsSummaryEntity < Grape::Entity
private
def value
- return object.value if object.value.is_a? String
-
- object.value&.nonzero? ? object.value.to_s : '-'
+ object.value.to_s
end
end
diff --git a/app/serializers/ci/basic_variable_entity.rb b/app/serializers/ci/basic_variable_entity.rb
new file mode 100644
index 00000000000..dad59e8735b
--- /dev/null
+++ b/app/serializers/ci/basic_variable_entity.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Ci
+ class BasicVariableEntity < Grape::Entity
+ expose :id
+ expose :key
+ expose :value
+ expose :variable_type
+
+ expose :protected?, as: :protected
+ expose :masked?, as: :masked
+ end
+end
diff --git a/app/serializers/ci/dag_job_entity.rb b/app/serializers/ci/dag_job_entity.rb
new file mode 100644
index 00000000000..b4947319ed1
--- /dev/null
+++ b/app/serializers/ci/dag_job_entity.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Ci
+ class DagJobEntity < Grape::Entity
+ expose :name
+
+ expose :needs, if: -> (job, _) { job.scheduling_type_dag? } do |job|
+ job.needs.pluck(:name) # rubocop: disable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/serializers/ci/dag_job_group_entity.rb b/app/serializers/ci/dag_job_group_entity.rb
new file mode 100644
index 00000000000..ac1ed89281c
--- /dev/null
+++ b/app/serializers/ci/dag_job_group_entity.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Ci
+ class DagJobGroupEntity < Grape::Entity
+ expose :name
+ expose :size
+ expose :jobs, with: Ci::DagJobEntity
+ end
+end
diff --git a/app/serializers/ci/dag_pipeline_entity.rb b/app/serializers/ci/dag_pipeline_entity.rb
new file mode 100644
index 00000000000..b615dd2b194
--- /dev/null
+++ b/app/serializers/ci/dag_pipeline_entity.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Ci
+ class DagPipelineEntity < Grape::Entity
+ expose :ordered_stages_with_preloads, as: :stages, using: Ci::DagStageEntity
+
+ private
+
+ def ordered_stages_with_preloads
+ object.ordered_stages.preload(preloaded_relations) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def preloaded_relations
+ [
+ :project,
+ { latest_statuses: :needs }
+ ]
+ end
+ end
+end
diff --git a/app/serializers/ci/dag_pipeline_serializer.rb b/app/serializers/ci/dag_pipeline_serializer.rb
new file mode 100644
index 00000000000..0c9e9a9db69
--- /dev/null
+++ b/app/serializers/ci/dag_pipeline_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class DagPipelineSerializer < BaseSerializer
+ entity Ci::DagPipelineEntity
+ end
+end
diff --git a/app/serializers/ci/dag_stage_entity.rb b/app/serializers/ci/dag_stage_entity.rb
new file mode 100644
index 00000000000..c7969da6c3c
--- /dev/null
+++ b/app/serializers/ci/dag_stage_entity.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Ci
+ class DagStageEntity < Grape::Entity
+ expose :name
+
+ expose :groups, with: Ci::DagJobGroupEntity
+ end
+end
diff --git a/app/serializers/ci/instance_variable_serializer.rb b/app/serializers/ci/instance_variable_serializer.rb
new file mode 100644
index 00000000000..b0b49aecdbd
--- /dev/null
+++ b/app/serializers/ci/instance_variable_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class InstanceVariableSerializer < BaseSerializer
+ entity BasicVariableEntity
+ end
+end
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index 85a40f1f5cb..32b759b9628 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -19,4 +19,6 @@ class ClusterApplicationEntity < Grape::Entity
expose :host, if: -> (e, _) { e.respond_to?(:host) }
expose :port, if: -> (e, _) { e.respond_to?(:port) }
expose :protocol, if: -> (e, _) { e.respond_to?(:protocol) }
+ expose :waf_log_enabled, if: -> (e, _) { e.respond_to?(:waf_log_enabled) }
+ expose :cilium_log_enabled, if: -> (e, _) { e.respond_to?(:cilium_log_enabled) }
end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index c59f68bbc49..4f53ea30544 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -3,7 +3,16 @@
class ClusterEntity < Grape::Entity
include RequestAwareEntity
+ expose :cluster_type
+ expose :enabled
+ expose :environment_scope
+ expose :name
+ expose :nodes
expose :status_name, as: :status
expose :status_reason
expose :applications, using: ClusterApplicationEntity
+
+ expose :path do |cluster|
+ Clusters::ClusterPresenter.new(cluster).show_path # rubocop: disable CodeReuse/Presenter
+ end
end
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
index 4bb4d4880d4..f59b6a35a29 100644
--- a/app/serializers/cluster_serializer.rb
+++ b/app/serializers/cluster_serializer.rb
@@ -1,8 +1,23 @@
# frozen_string_literal: true
class ClusterSerializer < BaseSerializer
+ include WithPagination
entity ClusterEntity
+ def represent_list(resource)
+ represent(resource, {
+ only: [
+ :cluster_type,
+ :enabled,
+ :environment_scope,
+ :name,
+ :nodes,
+ :path,
+ :status
+ ]
+ })
+ end
+
def represent_status(resource)
represent(resource, { only: [:status, :status_reason, :applications] })
end
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index 302fe3d7c67..8c2b3a65d57 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -22,16 +22,16 @@ class DiffFileBaseEntity < Grape::Entity
expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
merge_request = options[:merge_request]
- options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {}
+ next unless merge_request.merged? || merge_request.source_branch_exists?
- next unless merge_request.source_project
+ target_project, target_branch = edit_project_branch_options(merge_request)
if Feature.enabled?(:web_ide_default)
- ide_edit_path(merge_request.source_project, merge_request.source_branch, diff_file.new_path)
+ ide_edit_path(target_project, target_branch, diff_file.new_path)
else
- project_edit_blob_path(merge_request.source_project,
- tree_join(merge_request.source_branch, diff_file.new_path),
- options)
+ options = merge_request.persisted? && merge_request.source_branch_exists? && !merge_request.merged? ? { from_merge_request_iid: merge_request.iid } : {}
+
+ project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options)
end
end
@@ -61,7 +61,7 @@ class DiffFileBaseEntity < Grape::Entity
next unless diff_file.blob
if merge_request&.source_project && current_user
- can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch)
+ can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch_exists? ? merge_request.source_branch : merge_request.target_branch)
else
false
end
@@ -88,6 +88,7 @@ class DiffFileBaseEntity < Grape::Entity
expose :b_mode
expose :viewer, using: DiffViewerEntity
+ expose :alternate_viewer, using: DiffViewerEntity
expose :old_size do |diff_file|
diff_file.old_blob&.raw_size
@@ -112,4 +113,12 @@ class DiffFileBaseEntity < Grape::Entity
def current_user
request.current_user
end
+
+ def edit_project_branch_options(merge_request)
+ if merge_request.source_branch_exists? && !merge_request.merged?
+ [merge_request.source_project, merge_request.source_branch]
+ else
+ [merge_request.target_project, merge_request.target_branch]
+ end
+ end
end
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index 568d0f6aa8f..fb4fbe57130 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -11,6 +11,10 @@ class DiffsEntity < Grape::Entity
merge_request&.source_branch
end
+ expose :source_branch_exists do |diffs|
+ merge_request&.source_branch_exists?
+ end
+
expose :target_branch_name do |diffs|
merge_request&.target_branch
end
diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb
index 622106458c3..4f44723fefe 100644
--- a/app/serializers/group_variable_entity.rb
+++ b/app/serializers/group_variable_entity.rb
@@ -1,11 +1,4 @@
# frozen_string_literal: true
-class GroupVariableEntity < Grape::Entity
- expose :id
- expose :key
- expose :value
- expose :variable_type
-
- expose :protected?, as: :protected
- expose :masked?, as: :masked
+class GroupVariableEntity < Ci::BasicVariableEntity
end
diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb
index 498cfe5930d..bbec107544e 100644
--- a/app/serializers/issuable_sidebar_basic_entity.rb
+++ b/app/serializers/issuable_sidebar_basic_entity.rb
@@ -21,7 +21,7 @@ class IssuableSidebarBasicEntity < Grape::Entity
expose :labels, using: LabelEntity
expose :current_user, if: lambda { |_issuable| current_user } do
- expose :current_user, merge: true, using: API::Entities::UserBasic
+ expose :current_user, merge: true, using: ::API::Entities::UserBasic
expose :todo, using: IssuableSidebarTodoEntity do |issuable|
current_user.pending_todo_for(issuable)
diff --git a/app/serializers/issuable_sidebar_extras_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb
index 0e1fcc58d7a..77f2e34fa5d 100644
--- a/app/serializers/issuable_sidebar_extras_entity.rb
+++ b/app/serializers/issuable_sidebar_extras_entity.rb
@@ -21,5 +21,5 @@ class IssuableSidebarExtrasEntity < Grape::Entity
issuable.subscribed?(request.current_user, issuable.project)
end
- expose :assignees, using: API::Entities::UserBasic
+ expose :assignees, using: ::API::Entities::UserBasic
end
diff --git a/app/serializers/merge_request_assignee_entity.rb b/app/serializers/merge_request_assignee_entity.rb
index 6849c62e759..b7ef7449270 100644
--- a/app/serializers/merge_request_assignee_entity.rb
+++ b/app/serializers/merge_request_assignee_entity.rb
@@ -5,3 +5,5 @@ class MergeRequestAssigneeEntity < ::API::Entities::UserBasic
options[:merge_request]&.can_be_merged_by?(assignee)
end
end
+
+MergeRequestAssigneeEntity.prepend_if_ee('EE::MergeRequestAssigneeEntity')
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index 18e8ec0e7d1..aad607f358a 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -71,6 +71,18 @@ class MergeRequestPollWidgetEntity < Grape::Entity
end
end
+ expose :accessibility_report_path do |merge_request|
+ if merge_request.has_accessibility_reports?
+ accessibility_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+ end
+
+ expose :terraform_reports_path do |merge_request|
+ if merge_request.has_terraform_reports?
+ terraform_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+ end
+
expose :exposed_artifacts_path do |merge_request|
if merge_request.has_exposed_artifacts?
exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json)
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index 9fd50c8c51d..508a2510dbd 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -27,3 +27,5 @@ class MergeRequestSerializer < BaseSerializer
super(merge_request, opts, entity)
end
end
+
+MergeRequestSerializer.prepend_if_ee('EE::MergeRequestSerializer')
diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb
index 8d30bbff5e4..38e71528f18 100644
--- a/app/serializers/note_user_entity.rb
+++ b/app/serializers/note_user_entity.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class NoteUserEntity < UserEntity
- expose :gitlab_employee?, as: :is_gitlab_employee, if: ->(user, options) { user.gitlab_employee? }
-
unexpose :web_url
end
+
+NoteUserEntity.prepend_if_ee('EE::NoteUserEntity')
diff --git a/app/serializers/service_event_entity.rb b/app/serializers/service_event_entity.rb
new file mode 100644
index 00000000000..fd655dd1ed3
--- /dev/null
+++ b/app/serializers/service_event_entity.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class ServiceEventEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :title do |event|
+ event
+ end
+
+ expose :event_field_name, as: :name
+
+ expose :value do |event|
+ service[event_field_name]
+ end
+
+ expose :description do |event|
+ service.class.event_description(event)
+ end
+
+ expose :field, if: -> (_, _) { event_field } do
+ expose :name do |event|
+ event_field[:name]
+ end
+ expose :value do |event|
+ service.public_send(event_field[:name]) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ private
+
+ alias_method :event, :object
+
+ def event_field_name
+ ServicesHelper.service_event_field_name(event)
+ end
+
+ def event_field
+ @event_field ||= service.event_field(event)
+ end
+
+ def service
+ request.service
+ end
+end
diff --git a/app/serializers/service_event_serializer.rb b/app/serializers/service_event_serializer.rb
new file mode 100644
index 00000000000..7f5fe36e571
--- /dev/null
+++ b/app/serializers/service_event_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ServiceEventSerializer < BaseSerializer
+ entity ServiceEventEntity
+end
diff --git a/app/serializers/test_suite_comparer_entity.rb b/app/serializers/test_suite_comparer_entity.rb
index 78c243f75b8..a9f19564b60 100644
--- a/app/serializers/test_suite_comparer_entity.rb
+++ b/app/serializers/test_suite_comparer_entity.rb
@@ -46,8 +46,6 @@ class TestSuiteComparerEntity < Grape::Entity
private
def max_tests(*used)
- return Integer::MAX unless Feature.enabled?(:ci_limit_test_reports_size, default_enabled: true)
-
[DEFAULT_MAX_TESTS - used.map(&:count).sum, DEFAULT_MIN_TESTS].max
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb
index 0f88a496c77..53fa830718a 100644
--- a/app/serializers/test_suite_entity.rb
+++ b/app/serializers/test_suite_entity.rb
@@ -9,8 +9,9 @@ class TestSuiteEntity < Grape::Entity
expose :failed_count
expose :skipped_count
expose :error_count
+ expose :suite_error
expose :test_cases, using: TestCaseEntity do |test_suite|
- test_suite.test_cases.values.flat_map(&:values)
+ test_suite.suite_error ? [] : test_suite.test_cases.values.flat_map(&:values)
end
end
diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb
index 017035fa117..9b0db371acb 100644
--- a/app/serializers/variable_entity.rb
+++ b/app/serializers/variable_entity.rb
@@ -1,12 +1,5 @@
# frozen_string_literal: true
-class VariableEntity < Grape::Entity
- expose :id
- expose :key
- expose :value
- expose :variable_type
-
- expose :protected?, as: :protected
- expose :masked?, as: :masked
+class VariableEntity < Ci::BasicVariableEntity
expose :environment_scope
end
diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb
new file mode 100644
index 00000000000..0197f29145d
--- /dev/null
+++ b/app/services/alert_management/create_alert_issue_service.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class CreateAlertIssueService
+ # @param alert [AlertManagement::Alert]
+ # @param user [User]
+ def initialize(alert, user)
+ @alert = alert
+ @user = user
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+ return error_issue_already_exists if alert.issue
+
+ result = create_issue(alert, user, alert_payload)
+ @issue = result[:issue]
+
+ return error(result[:message]) if result[:status] == :error
+ return error(alert.errors.full_messages.to_sentence) unless update_alert_issue_id
+
+ success
+ end
+
+ private
+
+ attr_reader :alert, :user, :issue
+
+ delegate :project, to: :alert
+
+ def allowed?
+ Feature.enabled?(:alert_management_create_alert_issue, project) &&
+ user.can?(:create_issue, project)
+ end
+
+ def create_issue(alert, user, alert_payload)
+ ::IncidentManagement::CreateIssueService
+ .new(project, alert_payload, user)
+ .execute(skip_settings_check: true)
+ end
+
+ def alert_payload
+ if alert.prometheus?
+ alert.payload
+ else
+ Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h)
+ end
+ end
+
+ def update_alert_issue_id
+ alert.update(issue_id: issue.id)
+ end
+
+ def success
+ ServiceResponse.success(payload: { issue: issue })
+ end
+
+ def error(message)
+ ServiceResponse.error(payload: { issue: issue }, message: message)
+ end
+
+ def error_issue_already_exists
+ error(_('An issue already exists'))
+ end
+
+ def error_no_permissions
+ error(_('You have no permissions'))
+ end
+ end
+end
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
new file mode 100644
index 00000000000..af28f1354b3
--- /dev/null
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class ProcessPrometheusAlertService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute
+ return bad_request unless parsed_alert.valid?
+
+ process_alert_management_alert
+
+ ServiceResponse.success
+ end
+
+ private
+
+ delegate :firing?, :resolved?, :gitlab_fingerprint, :ends_at, to: :parsed_alert
+
+ def parsed_alert
+ strong_memoize(:parsed_alert) do
+ Gitlab::Alerting::Alert.new(project: project, payload: params)
+ end
+ end
+
+ def process_alert_management_alert
+ process_firing_alert_management_alert if firing?
+ process_resolved_alert_management_alert if resolved?
+ end
+
+ def process_firing_alert_management_alert
+ if am_alert.present?
+ reset_alert_management_alert_status
+ else
+ create_alert_management_alert
+ end
+ end
+
+ def reset_alert_management_alert_status
+ return if am_alert.trigger
+
+ logger.warn(
+ message: 'Unable to update AlertManagement::Alert status to triggered',
+ project_id: project.id,
+ alert_id: am_alert.id
+ )
+ end
+
+ def create_alert_management_alert
+ am_alert = AlertManagement::Alert.new(am_alert_params.merge(ended_at: nil))
+ return if am_alert.save
+
+ logger.warn(
+ message: 'Unable to create AlertManagement::Alert',
+ project_id: project.id,
+ alert_errors: am_alert.errors.messages
+ )
+ end
+
+ def am_alert_params
+ Gitlab::AlertManagement::AlertParams.from_prometheus_alert(project: project, parsed_alert: parsed_alert)
+ end
+
+ def process_resolved_alert_management_alert
+ return if am_alert.blank?
+ return if am_alert.resolve(ends_at)
+
+ logger.warn(
+ message: 'Unable to update AlertManagement::Alert status to resolved',
+ project_id: project.id,
+ alert_id: am_alert.id
+ )
+ end
+
+ def logger
+ @logger ||= Gitlab::AppLogger
+ end
+
+ def am_alert
+ @am_alert ||= AlertManagement::Alert.for_fingerprint(project, gitlab_fingerprint).first
+ end
+
+ def bad_request
+ ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
+ end
+ end
+end
diff --git a/app/services/alert_management/update_alert_status_service.rb b/app/services/alert_management/update_alert_status_service.rb
new file mode 100644
index 00000000000..a7ebddb82e0
--- /dev/null
+++ b/app/services/alert_management/update_alert_status_service.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class UpdateAlertStatusService
+ include Gitlab::Utils::StrongMemoize
+
+ # @param alert [AlertManagement::Alert]
+ # @param user [User]
+ # @param status [Integer] Must match a value from AlertManagement::Alert::STATUSES
+ def initialize(alert, user, status)
+ @alert = alert
+ @user = user
+ @status = status
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+ return error_invalid_status unless status_key
+
+ if alert.update(status_event: status_event)
+ success
+ else
+ error(alert.errors.full_messages.to_sentence)
+ end
+ end
+
+ private
+
+ attr_reader :alert, :user, :status
+
+ delegate :project, to: :alert
+
+ def allowed?
+ user.can?(:update_alert_management_alert, project)
+ end
+
+ def status_key
+ strong_memoize(:status_key) do
+ AlertManagement::Alert::STATUSES.key(status)
+ end
+ end
+
+ def status_event
+ AlertManagement::Alert::STATUS_EVENTS[status_key]
+ end
+
+ def success
+ ServiceResponse.success(payload: { alert: alert })
+ end
+
+ def error_no_permissions
+ error(_('You have no permissions'))
+ end
+
+ def error_invalid_status
+ error(_('Invalid status'))
+ end
+
+ def error(message)
+ ServiceResponse.error(payload: { alert: alert }, message: message)
+ end
+ end
+end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index d9e40c456aa..fb309aed649 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -50,8 +50,9 @@ class AuditEventService
private
def build_author(author)
- if author.is_a?(User)
- author
+ case author
+ when User
+ author.impersonated? ? Gitlab::Audit::ImpersonatedAuthor.new(author) : author
else
Gitlab::Audit::UnauthenticatedAuthor.new(name: author)
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 4a699fe3213..44a434f4402 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -52,7 +52,7 @@ module Auth
end
def self.token_expire_at
- Time.now + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes
+ Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes
end
private
@@ -103,17 +103,19 @@ module Auth
return unless requested_project
- actions = actions.select do |action|
+ authorized_actions = actions.select do |action|
can_access?(requested_project, action)
end
- return unless actions.present?
+ log_if_actions_denied(type, requested_project, actions, authorized_actions)
+
+ return unless authorized_actions.present?
# At this point user/build is already authenticated.
#
- ensure_container_repository!(path, actions)
+ ensure_container_repository!(path, authorized_actions)
- { type: type, name: path.to_s, actions: actions }
+ { type: type, name: path.to_s, actions: authorized_actions }
end
##
@@ -222,5 +224,22 @@ module Auth
REGISTRY_LOGIN_ABILITIES.include?(ability)
end
end
+
+ def log_if_actions_denied(type, requested_project, requested_actions, authorized_actions)
+ return if requested_actions == authorized_actions
+
+ log_info = {
+ message: "Denied container registry permissions",
+ scope_type: type,
+ requested_project_path: requested_project.full_path,
+ requested_actions: requested_actions,
+ authorized_actions: authorized_actions,
+ username: current_user&.username,
+ user_id: current_user&.id,
+ project_path: project&.full_path
+ }.compact
+
+ Gitlab::AuthLogger.warn(log_info)
+ end
end
end
diff --git a/app/services/authorized_project_update/project_create_service.rb b/app/services/authorized_project_update/project_create_service.rb
new file mode 100644
index 00000000000..c17c0a033fe
--- /dev/null
+++ b/app/services/authorized_project_update/project_create_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module AuthorizedProjectUpdate
+ class ProjectCreateService < BaseService
+ BATCH_SIZE = 1000
+
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ group = project.group
+
+ unless group
+ return ServiceResponse.error(message: 'Project does not have a group')
+ end
+
+ group.members_from_self_and_ancestors_with_effective_access_level
+ .each_batch(of: BATCH_SIZE, column: :user_id) do |members|
+ attributes = members.map do |member|
+ { user_id: member.user_id, project_id: project.id, access_level: member.access_level }
+ end
+
+ ProjectAuthorization.insert_all(attributes)
+ end
+
+ ServiceResponse.success
+ end
+
+ private
+
+ attr_reader :project
+ end
+end
diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb
new file mode 100644
index 00000000000..56e4b8c908c
--- /dev/null
+++ b/app/services/base_container_service.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+# Base class, scoped by container (project or group)
+class BaseContainerService
+ include BaseServiceUtility
+
+ attr_reader :container, :current_user, :params
+
+ def initialize(container:, current_user: nil, params: {})
+ @container, @current_user, @params = container, current_user, params.dup
+ end
+end
diff --git a/app/services/base_service.rb b/app/services/base_service.rb
index bc0b968f516..b4c4b6980a8 100644
--- a/app/services/base_service.rb
+++ b/app/services/base_service.rb
@@ -1,7 +1,16 @@
# frozen_string_literal: true
+# This is the original root class for service related classes,
+# and due to historical reason takes a project as scope.
+# Later separate base classes for different scopes will be created,
+# and existing service will use these one by one.
+# After all are migrated, we can remove this class.
+#
+# TODO: New services should consider inheriting from
+# BaseContainerService, or create new base class:
+# https://gitlab.com/gitlab-org/gitlab/-/issues/216672
class BaseService
- include Gitlab::Allowable
+ include BaseServiceUtility
attr_accessor :project, :current_user, :params
@@ -9,67 +18,5 @@ class BaseService
@project, @current_user, @params = project, user, params.dup
end
- def notification_service
- NotificationService.new
- end
-
- def event_service
- EventCreateService.new
- end
-
- def todo_service
- TodoService.new
- end
-
- def log_info(message)
- Gitlab::AppLogger.info message
- end
-
- def log_error(message)
- Gitlab::AppLogger.error message
- end
-
- def system_hook_service
- SystemHooksService.new
- end
-
delegate :repository, to: :project
-
- # Add an error to the specified model for restricted visibility levels
- def deny_visibility_level(model, denied_visibility_level = nil)
- denied_visibility_level ||= model.visibility_level
-
- level_name = Gitlab::VisibilityLevel.level_name(denied_visibility_level).downcase
-
- model.errors.add(:visibility_level, "#{level_name} has been restricted by your GitLab administrator")
- end
-
- def visibility_level
- params[:visibility].is_a?(String) ? Gitlab::VisibilityLevel.level_value(params[:visibility]) : params[:visibility_level]
- end
-
- private
-
- # Return a Hash with an `error` status
- #
- # message - Error message to include in the Hash
- # http_status - Optional HTTP status code override (default: nil)
- # pass_back - Additional attributes to be included in the resulting Hash
- def error(message, http_status = nil, pass_back: {})
- result = {
- message: message,
- status: :error
- }.reverse_merge(pass_back)
-
- result[:http_status] = http_status if http_status
- result
- end
-
- # Return a Hash with a `success` status
- #
- # pass_back - Additional attributes to be included in the resulting Hash
- def success(pass_back = {})
- pass_back[:status] = :success
- pass_back
- end
end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 9637eb1b918..e08509b84db 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -12,7 +12,7 @@ module Boards
def execute
return fetch_issues.order_closed_date_desc if list&.closed?
- fetch_issues.order_by_position_and_priority(with_cte: can_attempt_search_optimization?)
+ fetch_issues.order_by_position_and_priority(with_cte: params[:search].present?)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -91,7 +91,7 @@ module Boards
end
def set_attempt_search_optimizations
- return unless can_attempt_search_optimization?
+ return unless params[:search].present?
if board.group_board?
params[:attempt_group_search_optimizations] = true
@@ -130,11 +130,6 @@ module Boards
def board_group
board.group_board? ? parent : parent.group
end
-
- def can_attempt_search_optimization?
- params[:search].present? &&
- Feature.enabled?(:board_search_optimization, board_group, default_enabled: true)
- end
end
end
end
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index c96ea970943..07ce58b6851 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -3,8 +3,10 @@
module Boards
module Lists
class ListService < Boards::BaseService
- def execute(board)
- board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
+ def execute(board, create_default_lists: true)
+ if create_default_lists && !board.lists.backlog.exists?
+ board.lists.create(list_type: :backlog)
+ end
board.lists.preload_associated_models
end
diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb
index c8afd97e6bf..958dd5c9965 100644
--- a/app/services/branches/create_service.rb
+++ b/app/services/branches/create_service.rb
@@ -14,7 +14,7 @@ module Branches
if new_branch
success(new_branch)
else
- error("Invalid reference name: #{branch_name}")
+ error("Invalid reference name: #{ref}")
end
rescue Gitlab::Git::PreReceiveError => ex
error(ex.message)
diff --git a/app/services/ci/compare_accessibility_reports_service.rb b/app/services/ci/compare_accessibility_reports_service.rb
new file mode 100644
index 00000000000..efb38d39d98
--- /dev/null
+++ b/app/services/ci/compare_accessibility_reports_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Ci
+ class CompareAccessibilityReportsService < CompareReportsBaseService
+ def comparer_class
+ Gitlab::Ci::Reports::AccessibilityReportsComparer
+ end
+
+ def serializer_class
+ AccessibilityReportsComparerSerializer
+ end
+
+ def get_report(pipeline)
+ pipeline&.accessibility_reports
+ end
+ end
+end
diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb
index 5d7d552dc5a..f0ffe67510b 100644
--- a/app/services/ci/create_job_artifacts_service.rb
+++ b/app/services/ci/create_job_artifacts_service.rb
@@ -46,6 +46,11 @@ module Ci
expire_in: expire_in)
end
+ if Feature.enabled?(:keep_latest_artifact_for_ref, job.project)
+ artifact.locked = true
+ artifact_metadata&.locked = true
+ end
+
[artifact, artifact_metadata]
end
@@ -56,6 +61,7 @@ module Ci
case artifact.file_type
when 'dotenv' then parse_dotenv_artifact(job, artifact)
+ when 'cluster_applications' then parse_cluster_applications_artifact(job, artifact)
else success
end
end
@@ -64,6 +70,7 @@ module Ci
Ci::JobArtifact.transaction do
artifact.save!
artifact_metadata&.save!
+ unlock_previous_artifacts!(artifact)
# NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future.
job.update_column(:artifacts_expire_at, artifact.expire_at)
@@ -81,6 +88,12 @@ module Ci
error(error.message, :bad_request)
end
+ def unlock_previous_artifacts!(artifact)
+ return unless Feature.enabled?(:keep_latest_artifact_for_ref, artifact.job.project)
+
+ Ci::JobArtifact.for_ref(artifact.job.ref, artifact.project_id).locked.update_all(locked: false)
+ end
+
def sha256_matches_existing_artifact?(job, artifact_type, artifacts_file)
existing_artifact = job.job_artifacts.find_by_file_type(artifact_type)
return false unless existing_artifact
@@ -99,5 +112,9 @@ module Ci
def parse_dotenv_artifact(job, artifact)
Ci::ParseDotenvArtifactService.new(job.project, current_user).execute(artifact)
end
+
+ def parse_cluster_applications_artifact(job, artifact)
+ Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact)
+ end
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 347630f865f..922c3556362 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -102,21 +102,12 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def auto_cancelable_pipelines
- # TODO: Introduced by https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/23464
- if Feature.enabled?(:ci_support_interruptible_pipelines, project, default_enabled: true)
- project.ci_pipelines
- .where(ref: pipeline.ref)
- .where.not(id: pipeline.same_family_pipeline_ids)
- .where.not(sha: project.commit(pipeline.ref).try(:id))
- .alive_or_scheduled
- .with_only_interruptible_builds
- else
- project.ci_pipelines
- .where(ref: pipeline.ref)
- .where.not(id: pipeline.same_family_pipeline_ids)
- .where.not(sha: project.commit(pipeline.ref).try(:id))
- .created_or_pending
- end
+ project.ci_pipelines
+ .where(ref: pipeline.ref)
+ .where.not(id: pipeline.same_family_pipeline_ids)
+ .where.not(sha: project.commit(pipeline.ref).try(:id))
+ .alive_or_scheduled
+ .with_only_interruptible_builds
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/ci/daily_report_result_service.rb b/app/services/ci/daily_build_group_report_result_service.rb
index b774a806203..6cdf3c88f8c 100644
--- a/app/services/ci/daily_report_result_service.rb
+++ b/app/services/ci/daily_build_group_report_result_service.rb
@@ -1,11 +1,11 @@
# frozen_string_literal: true
module Ci
- class DailyReportResultService
+ class DailyBuildGroupReportResultService
def execute(pipeline)
return unless Feature.enabled?(:ci_daily_code_coverage, pipeline.project, default_enabled: true)
- DailyReportResult.upsert_reports(coverage_reports(pipeline))
+ DailyBuildGroupReportResult.upsert_reports(coverage_reports(pipeline))
end
private
@@ -14,15 +14,16 @@ module Ci
base_attrs = {
project_id: pipeline.project_id,
ref_path: pipeline.source_ref_path,
- param_type: DailyReportResult.param_types[:coverage],
date: pipeline.created_at.to_date,
last_pipeline_id: pipeline.id
}
aggregate(pipeline.builds.with_coverage).map do |group_name, group|
base_attrs.merge(
- title: group_name,
- value: average_coverage(group)
+ group_name: group_name,
+ data: {
+ 'coverage' => average_coverage(group)
+ }
)
end
end
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
index 7d2f5d33fed..5deb84812ac 100644
--- a/app/services/ci/destroy_expired_job_artifacts_service.rb
+++ b/app/services/ci/destroy_expired_job_artifacts_service.rb
@@ -28,7 +28,13 @@ module Ci
private
def destroy_batch
- artifacts = Ci::JobArtifact.expired(BATCH_SIZE).to_a
+ artifact_batch = if Feature.enabled?(:keep_latest_artifact_for_ref)
+ Ci::JobArtifact.expired(BATCH_SIZE).unlocked
+ else
+ Ci::JobArtifact.expired(BATCH_SIZE)
+ end
+
+ artifacts = artifact_batch.to_a
return false if artifacts.empty?
diff --git a/app/services/ci/generate_terraform_reports_service.rb b/app/services/ci/generate_terraform_reports_service.rb
new file mode 100644
index 00000000000..d768ce777d4
--- /dev/null
+++ b/app/services/ci/generate_terraform_reports_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Ci
+ # TODO: a couple of points with this approach:
+ # + reuses existing architecture and reactive caching
+ # - it's not a report comparison and some comparing features must be turned off.
+ # see CompareReportsBaseService for more notes.
+ # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
+ class GenerateTerraformReportsService < CompareReportsBaseService
+ def execute(base_pipeline, head_pipeline)
+ {
+ status: :parsed,
+ key: key(base_pipeline, head_pipeline),
+ data: head_pipeline.terraform_reports.plans
+ }
+ rescue => e
+ Gitlab::ErrorTracking.track_exception(e, project_id: project.id)
+ {
+ status: :error,
+ key: key(base_pipeline, head_pipeline),
+ status_reason: _('An error occurred while fetching terraform reports.')
+ }
+ end
+
+ def latest?(base_pipeline, head_pipeline, data)
+ data&.fetch(:key, nil) == key(base_pipeline, head_pipeline)
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index 2a1bf15b9a3..b01a9d2e3b8 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -95,7 +95,7 @@ module Ci
def processable_status(processable)
if processable.scheduling_type_dag?
# Processable uses DAG, get status of all dependent needs
- @collection.status_for_names(processable.aggregated_needs_names.to_a)
+ @collection.status_for_names(processable.aggregated_needs_names.to_a, dag: true)
else
# Processable uses Stages, get status of prior stage
@collection.status_for_prior_stage_position(processable.stage_idx.to_i)
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
index 42e38a5c80f..2228328882d 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
@@ -32,14 +32,14 @@ module Ci
# This methods gets composite status of all processables
def status_of_all
- status_for_array(all_statuses)
+ status_for_array(all_statuses, dag: false)
end
# This methods gets composite status for processables with given names
- def status_for_names(names)
+ def status_for_names(names, dag:)
name_statuses = all_statuses_by_name.slice(*names)
- status_for_array(name_statuses.values)
+ status_for_array(name_statuses.values, dag: dag)
end
# This methods gets composite status for processables before given stage
@@ -48,7 +48,7 @@ module Ci
stage_statuses = all_statuses_grouped_by_stage_position
.select { |stage_position, _| stage_position < position }
- status_for_array(stage_statuses.values.flatten)
+ status_for_array(stage_statuses.values.flatten, dag: false)
end
end
@@ -65,7 +65,7 @@ module Ci
strong_memoize("status_for_stage_position_#{current_position}") do
stage_statuses = all_statuses_grouped_by_stage_position[current_position].to_a
- status_for_array(stage_statuses.flatten)
+ status_for_array(stage_statuses.flatten, dag: false)
end
end
@@ -76,7 +76,14 @@ module Ci
private
- def status_for_array(statuses)
+ def status_for_array(statuses, dag:)
+ # TODO: This is hack to support
+ # the same exact behaviour for Atomic and Legacy processing
+ # that DAG is blocked from executing if dependent is not "complete"
+ if dag && statuses.any? { |status| HasStatus::COMPLETED_STATUSES.exclude?(status[:status]) }
+ return 'pending'
+ end
+
result = Gitlab::Ci::Status::Composite
.new(statuses)
.status
diff --git a/app/services/ci/pipeline_schedule_service.rb b/app/services/ci/pipeline_schedule_service.rb
index 6028643489d..596c3b80bda 100644
--- a/app/services/ci/pipeline_schedule_service.rb
+++ b/app/services/ci/pipeline_schedule_service.rb
@@ -6,19 +6,7 @@ module Ci
# Ensure `next_run_at` is set properly before creating a pipeline.
# Otherwise, multiple pipelines could be created in a short interval.
schedule.schedule_next_run!
-
- if Feature.enabled?(:ci_pipeline_schedule_async)
- RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner&.id)
- else
- begin
- RunPipelineScheduleWorker.new.perform(schedule.id, schedule.owner&.id)
- ensure
- ##
- # This is the temporary solution for avoiding the memory bloat.
- # See more https://gitlab.com/gitlab-org/gitlab-foss/issues/61955
- GC.start if Feature.enabled?(:ci_pipeline_schedule_force_gc, default_enabled: true)
- end
- end
+ RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner&.id)
end
end
end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index d1efa19eb0d..3f23e81dcdd 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -10,7 +10,6 @@ module Ci
def execute(trigger_build_ids = nil, initial_process: false)
update_retried
- ensure_scheduling_type_for_processables
if Feature.enabled?(:ci_atomic_processing, pipeline.project)
Ci::PipelineProcessing::AtomicProcessingService
@@ -44,17 +43,5 @@ module Ci
.update_all(retried: true) if latest_statuses.any?
end
# rubocop: enable CodeReuse/ActiveRecord
-
- # Set scheduling type of processables if they were created before scheduling_type
- # data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246).
- # Given that this service runs multiple times during the pipeline
- # life cycle we need to ensure we populate the data once.
- # See more: https://gitlab.com/gitlab-org/gitlab/issues/205426
- def ensure_scheduling_type_for_processables
- lease = Gitlab::ExclusiveLease.new("set-scheduling-types:#{pipeline.id}", timeout: 1.hour.to_i)
- return unless lease.try_obtain
-
- pipeline.processables.populate_scheduling_type!
- end
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index fb59797a8df..17b9e56636b 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -85,8 +85,6 @@ module Ci
# to make sure that this is properly handled by runner.
Result.new(nil, false)
rescue => ex
- raise ex unless Feature.enabled?(:ci_doom_build, default_enabled: true)
-
scheduler_failure!(build)
track_exception_for_build(ex, build)
@@ -203,7 +201,7 @@ module Ci
labels[:shard] = shard.gsub(METRICS_SHARD_TAG_PREFIX, '') if shard
end
- job_queue_duration_seconds.observe(labels, Time.now - job.queued_at) unless job.queued_at.nil?
+ job_queue_duration_seconds.observe(labels, Time.current - job.queued_at) unless job.queued_at.nil?
attempt_counter.increment
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index a65fe2ecb3a..23507a31c72 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -9,6 +9,8 @@ module Ci
resource_group scheduling_type].freeze
def execute(build)
+ build.ensure_scheduling_type!
+
reprocess!(build).tap do |new_build|
build.pipeline.mark_as_processable_after_stage(build.stage_idx)
@@ -31,6 +33,9 @@ module Ci
end.to_h
attributes[:user] = current_user
+
+ # TODO: we can probably remove this logic
+ # see: https://gitlab.com/gitlab-org/gitlab/-/issues/217930
attributes[:scheduling_type] ||= build.find_legacy_scheduling_type
Ci::Build.transaction do
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index 9bb236ac44c..4229be6c7d7 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -11,6 +11,8 @@ module Ci
needs = Set.new
+ pipeline.ensure_scheduling_type!
+
pipeline.retryable_builds.preload_needs.find_each do |build|
next unless can?(current_user, :update_build, build)
diff --git a/app/services/ci/update_instance_variables_service.rb b/app/services/ci/update_instance_variables_service.rb
new file mode 100644
index 00000000000..ee513647d08
--- /dev/null
+++ b/app/services/ci/update_instance_variables_service.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+# This class is a simplified version of assign_nested_attributes_for_collection_association from ActiveRecord
+# https://github.com/rails/rails/blob/v6.0.2.1/activerecord/lib/active_record/nested_attributes.rb#L466
+
+module Ci
+ class UpdateInstanceVariablesService
+ UNASSIGNABLE_KEYS = %w(id _destroy).freeze
+
+ def initialize(params)
+ @params = params[:variables_attributes]
+ end
+
+ def execute
+ instantiate_records
+ persist_records
+ end
+
+ def errors
+ @records.to_a.flat_map { |r| r.errors.full_messages }
+ end
+
+ private
+
+ attr_reader :params
+
+ def existing_records_by_id
+ @existing_records_by_id ||= Ci::InstanceVariable
+ .all
+ .index_by { |var| var.id.to_s }
+ end
+
+ def instantiate_records
+ @records = params.map do |attributes|
+ find_or_initialize_record(attributes).tap do |record|
+ record.assign_attributes(attributes.except(*UNASSIGNABLE_KEYS))
+ record.mark_for_destruction if has_destroy_flag?(attributes)
+ end
+ end
+ end
+
+ def find_or_initialize_record(attributes)
+ id = attributes[:id].to_s
+
+ if id.blank?
+ Ci::InstanceVariable.new
+ else
+ existing_records_by_id.fetch(id) { raise ActiveRecord::RecordNotFound }
+ end
+ end
+
+ def persist_records
+ Ci::InstanceVariable.transaction do
+ success = @records.map do |record|
+ if record.marked_for_destruction?
+ record.destroy
+ else
+ record.save
+ end
+ end.all?
+
+ raise ActiveRecord::Rollback unless success
+
+ success
+ end
+ end
+
+ def has_destroy_flag?(hash)
+ Gitlab::Utils.to_boolean(hash['_destroy'])
+ end
+ end
+end
diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb
index 86b48b5228d..39a2d6bf758 100644
--- a/app/services/clusters/applications/base_service.rb
+++ b/app/services/clusters/applications/base_service.rb
@@ -5,6 +5,8 @@ module Clusters
class BaseService
InvalidApplicationError = Class.new(StandardError)
+ FLUENTD_KNOWN_ATTRS = %i[host protocol port waf_log_enabled cilium_log_enabled].freeze
+
attr_reader :cluster, :current_user, :params
def initialize(cluster, user, params = {})
@@ -35,17 +37,7 @@ module Clusters
application.modsecurity_mode = params[:modsecurity_mode] || 0
end
- if application.has_attribute?(:host)
- application.host = params[:host]
- end
-
- if application.has_attribute?(:protocol)
- application.protocol = params[:protocol]
- end
-
- if application.has_attribute?(:port)
- application.port = params[:port]
- end
+ apply_fluentd_related_attributes(application)
if application.respond_to?(:oauth_application)
application.oauth_application = create_oauth_application(application, request)
@@ -111,6 +103,12 @@ module Clusters
::Applications::CreateService.new(current_user, oauth_application_params).execute(request)
end
+
+ def apply_fluentd_related_attributes(application)
+ FLUENTD_KNOWN_ATTRS.each do |attr|
+ application[attr] = params[attr] if application.has_attribute?(attr)
+ end
+ end
end
end
end
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
index 7d064abfaa3..249abd3ff9d 100644
--- a/app/services/clusters/applications/check_installation_progress_service.rb
+++ b/app/services/clusters/applications/check_installation_progress_service.rb
@@ -33,7 +33,7 @@ module Clusters
end
def timed_out?
- Time.now.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
+ Time.current.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
end
def remove_installation_pod
diff --git a/app/services/clusters/applications/check_uninstall_progress_service.rb b/app/services/clusters/applications/check_uninstall_progress_service.rb
index fe9c488bdfd..cd213c3ebbf 100644
--- a/app/services/clusters/applications/check_uninstall_progress_service.rb
+++ b/app/services/clusters/applications/check_uninstall_progress_service.rb
@@ -31,7 +31,7 @@ module Clusters
end
def timed_out?
- Time.now.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT
+ Time.current.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT
end
def remove_uninstallation_pod
diff --git a/app/services/clusters/applications/check_upgrade_progress_service.rb b/app/services/clusters/applications/check_upgrade_progress_service.rb
index 8502ea69f27..bc161218618 100644
--- a/app/services/clusters/applications/check_upgrade_progress_service.rb
+++ b/app/services/clusters/applications/check_upgrade_progress_service.rb
@@ -46,7 +46,7 @@ module Clusters
end
def timed_out?
- Time.now.utc - app.updated_at.to_time.utc > ::ClusterWaitForAppUpdateWorker::TIMEOUT
+ Time.current.utc - app.updated_at.to_time.utc > ::ClusterWaitForAppUpdateWorker::TIMEOUT
end
def remove_pod
diff --git a/app/services/clusters/applications/ingress_modsecurity_usage_service.rb b/app/services/clusters/applications/ingress_modsecurity_usage_service.rb
deleted file mode 100644
index 4aac8bb3cbd..00000000000
--- a/app/services/clusters/applications/ingress_modsecurity_usage_service.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop: disable CodeReuse/ActiveRecord
-module Clusters
- module Applications
- ##
- # This service measures usage of the Modsecurity Web Application Firewall across the entire
- # instance's deployed environments.
- #
- # The default configuration is`AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE=DetectionOnly` so we
- # measure non-default values via definition of either ci_variables or ci_pipeline_variables.
- # Since both these values are encrypted, we must decrypt and count them in memory.
- #
- # NOTE: this service is an approximation as it does not yet take into account `environment_scope` or `ci_group_variables`.
- ##
- class IngressModsecurityUsageService
- ADO_MODSEC_KEY = "AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE"
-
- def initialize(blocking_count: 0, disabled_count: 0)
- @blocking_count = blocking_count
- @disabled_count = disabled_count
- end
-
- def execute
- conditions = -> { merge(::Environment.available).merge(::Deployment.success).where(key: ADO_MODSEC_KEY) }
-
- ci_pipeline_var_enabled =
- ::Ci::PipelineVariable
- .joins(pipeline: { environments: :last_visible_deployment })
- .merge(conditions)
- .order('deployments.environment_id, deployments.id DESC')
-
- ci_var_enabled =
- ::Ci::Variable
- .joins(project: { environments: :last_visible_deployment })
- .merge(conditions)
- .merge(
- # Give priority to pipeline variables by excluding from dataset
- ::Ci::Variable.joins(project: :environments).where.not(
- environments: { id: ci_pipeline_var_enabled.select('DISTINCT ON (deployments.environment_id) deployments.environment_id') }
- )
- ).select('DISTINCT ON (deployments.environment_id) ci_variables.*')
-
- sum_modsec_config_counts(
- ci_pipeline_var_enabled.select('DISTINCT ON (deployments.environment_id) ci_pipeline_variables.*')
- )
- sum_modsec_config_counts(ci_var_enabled)
-
- {
- ingress_modsecurity_blocking: @blocking_count,
- ingress_modsecurity_disabled: @disabled_count
- }
- end
-
- private
-
- # These are encrypted so we must decrypt and count in memory
- def sum_modsec_config_counts(dataset)
- dataset.each do |var|
- case var.value
- when "On" then @blocking_count += 1
- when "Off" then @disabled_count += 1
- # `else` could be default or any unsupported user input
- end
- end
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/schedule_update_service.rb b/app/services/clusters/applications/schedule_update_service.rb
index b7639c771a8..41718df9a98 100644
--- a/app/services/clusters/applications/schedule_update_service.rb
+++ b/app/services/clusters/applications/schedule_update_service.rb
@@ -16,9 +16,9 @@ module Clusters
return unless application
if recently_scheduled?
- worker_class.perform_in(BACKOFF_DELAY, application.name, application.id, project.id, Time.now)
+ worker_class.perform_in(BACKOFF_DELAY, application.name, application.id, project.id, Time.current)
else
- worker_class.perform_async(application.name, application.id, project.id, Time.now)
+ worker_class.perform_async(application.name, application.id, project.id, Time.current)
end
end
@@ -31,7 +31,7 @@ module Clusters
def recently_scheduled?
return false unless application.last_update_started_at
- application.last_update_started_at.utc >= Time.now.utc - BACKOFF_DELAY
+ application.last_update_started_at.utc >= Time.current.utc - BACKOFF_DELAY
end
end
end
diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb
index b24246f5c4b..ddb2832aae6 100644
--- a/app/services/clusters/gcp/verify_provision_status_service.rb
+++ b/app/services/clusters/gcp/verify_provision_status_service.rb
@@ -35,7 +35,7 @@ module Clusters
end
def elapsed_time_from_creation(operation)
- Time.now.utc - operation.start_time.to_time.utc
+ Time.current.utc - operation.start_time.to_time.utc
end
def finalize_creation
diff --git a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb
index a81014d99ff..53c3c686f07 100644
--- a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb
+++ b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb
@@ -54,8 +54,8 @@ module Clusters
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 0
- cert.not_before = Time.now
- cert.not_after = Time.now + 1000.years
+ cert.not_before = Time.current
+ cert.not_after = Time.current + 1000.years
cert.public_key = key.public_key
cert.subject = name
diff --git a/app/services/clusters/management/create_project_service.rb b/app/services/clusters/management/create_project_service.rb
index 0a33582be98..5a0176edd12 100644
--- a/app/services/clusters/management/create_project_service.rb
+++ b/app/services/clusters/management/create_project_service.rb
@@ -15,11 +15,8 @@ module Clusters
def execute
return unless management_project_required?
- ActiveRecord::Base.transaction do
- project = create_management_project!
-
- update_cluster!(project)
- end
+ project = create_management_project!
+ update_cluster!(project)
end
private
diff --git a/app/services/clusters/parse_cluster_applications_artifact_service.rb b/app/services/clusters/parse_cluster_applications_artifact_service.rb
new file mode 100644
index 00000000000..b8e1c80cfe7
--- /dev/null
+++ b/app/services/clusters/parse_cluster_applications_artifact_service.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module Clusters
+ class ParseClusterApplicationsArtifactService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ MAX_ACCEPTABLE_ARTIFACT_SIZE = 5.kilobytes
+ RELEASE_NAMES = %w[prometheus].freeze
+
+ def initialize(job, current_user)
+ @job = job
+
+ super(job.project, current_user)
+ end
+
+ def execute(artifact)
+ return success unless Feature.enabled?(:cluster_applications_artifact, project)
+
+ 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
+
+ parse!(artifact)
+
+ success
+ rescue Gitlab::Kubernetes::Helm::Parsers::ListV2::ParserError, ActiveRecord::RecordInvalid => error
+ Gitlab::ErrorTracking.track_exception(error, job_id: artifact.job_id)
+ error(error.message, :bad_request)
+ end
+
+ private
+
+ attr_reader :job
+
+ def cluster
+ strong_memoize(:cluster) do
+ deployment_cluster = job.deployment&.cluster
+
+ deployment_cluster if Ability.allowed?(current_user, :admin_cluster, deployment_cluster)
+ end
+ end
+
+ def parse!(artifact)
+ releases = []
+
+ artifact.each_blob do |blob|
+ releases.concat(Gitlab::Kubernetes::Helm::Parsers::ListV2.new(blob).releases)
+ end
+
+ update_cluster_application_statuses!(releases)
+ end
+
+ def update_cluster_application_statuses!(releases)
+ release_by_name = releases.index_by { |release| release['Name'] }
+
+ Clusters::Cluster.transaction do
+ RELEASE_NAMES.each do |release_name|
+ application = find_or_build_application(release_name)
+
+ release = release_by_name[release_name]
+
+ if release
+ case release['Status']
+ when 'DEPLOYED'
+ application.make_externally_installed!
+ when 'FAILED'
+ application.make_errored!(s_('ClusterIntegration|Helm release failed to install'))
+ end
+ else
+ # missing, so by definition, we consider this uninstalled
+ application.make_externally_uninstalled! if application.persisted?
+ end
+ end
+ 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
+ end
+end
diff --git a/app/services/concerns/base_service_utility.rb b/app/services/concerns/base_service_utility.rb
new file mode 100644
index 00000000000..70b223a0289
--- /dev/null
+++ b/app/services/concerns/base_service_utility.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module BaseServiceUtility
+ extend ActiveSupport::Concern
+ include Gitlab::Allowable
+
+ ### Convenience service methods
+
+ def notification_service
+ NotificationService.new
+ end
+
+ def event_service
+ EventCreateService.new
+ end
+
+ def todo_service
+ TodoService.new
+ end
+
+ def system_hook_service
+ SystemHooksService.new
+ end
+
+ # Logging
+
+ def log_info(message)
+ Gitlab::AppLogger.info message
+ end
+
+ def log_error(message)
+ Gitlab::AppLogger.error message
+ end
+
+ # Add an error to the specified model for restricted visibility levels
+ def deny_visibility_level(model, denied_visibility_level = nil)
+ denied_visibility_level ||= model.visibility_level
+
+ level_name = Gitlab::VisibilityLevel.level_name(denied_visibility_level).downcase
+
+ model.errors.add(:visibility_level, "#{level_name} has been restricted by your GitLab administrator")
+ end
+
+ def visibility_level
+ params[:visibility].is_a?(String) ? Gitlab::VisibilityLevel.level_value(params[:visibility]) : params[:visibility_level]
+ end
+
+ private
+
+ # Return a Hash with an `error` status
+ #
+ # message - Error message to include in the Hash
+ # http_status - Optional HTTP status code override (default: nil)
+ # pass_back - Additional attributes to be included in the resulting Hash
+ def error(message, http_status = nil, pass_back: {})
+ result = {
+ message: message,
+ status: :error
+ }.reverse_merge(pass_back)
+
+ result[:http_status] = http_status if http_status
+ result
+ end
+
+ # Return a Hash with a `success` status
+ #
+ # pass_back - Additional attributes to be included in the resulting Hash
+ def success(pass_back = {})
+ pass_back[:status] = :success
+ pass_back
+ end
+end
diff --git a/app/services/concerns/git/logger.rb b/app/services/concerns/git/logger.rb
deleted file mode 100644
index 7c036212e66..00000000000
--- a/app/services/concerns/git/logger.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-module Git
- module Logger
- def log_error(message, save_message_on_model: false)
- Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}")
- merge_request.update(merge_error: message) if save_message_on_model
- end
- end
-end
diff --git a/app/services/concerns/measurable.rb b/app/services/concerns/measurable.rb
new file mode 100644
index 00000000000..5a74f15506e
--- /dev/null
+++ b/app/services/concerns/measurable.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+# In order to measure and log execution of our service, we just need to 'prepend Measurable' module
+# Example:
+# ```
+# class DummyService
+# prepend Measurable
+#
+# def execute
+# # ...
+# end
+# end
+
+# DummyService.prepend(Measurable)
+# ```
+#
+# In case when we are prepending a module from the `EE` namespace with EE features
+# we need to prepend Measurable after prepending `EE` module.
+# This way Measurable will be at the bottom of the ancestor chain,
+# in order to measure execution of `EE` features as well
+# ```
+# class DummyService
+# def execute
+# # ...
+# end
+# end
+#
+# DummyService.prepend_if_ee('EE::DummyService')
+# DummyService.prepend(Measurable)
+# ```
+#
+module Measurable
+ extend ::Gitlab::Utils::Override
+
+ override :execute
+ def execute(*args)
+ measuring? ? ::Gitlab::Utils::Measuring.new(base_log_data).with_measuring { super(*args) } : super(*args)
+ end
+
+ protected
+
+ # You can set extra attributes for performance measurement log.
+ def extra_attributes_for_measurement
+ defined?(super) ? super : {}
+ end
+
+ private
+
+ def measuring?
+ Feature.enabled?("gitlab_service_measuring_#{service_class}")
+ end
+
+ # These attributes are always present in log.
+ def base_log_data
+ extra_attributes_for_measurement.merge({ class: self.class.name })
+ end
+
+ def service_class
+ self.class.name.underscore.tr('/', '_')
+ end
+end
diff --git a/app/services/concerns/spam_check_methods.rb b/app/services/concerns/spam_check_methods.rb
index 695bdf92b49..53e9e001463 100644
--- a/app/services/concerns/spam_check_methods.rb
+++ b/app/services/concerns/spam_check_methods.rb
@@ -23,14 +23,14 @@ module SpamCheckMethods
# attribute values.
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def spam_check(spammable, user)
- Spam::SpamCheckService.new(
+ Spam::SpamActionService.new(
spammable: spammable,
request: @request
).execute(
api: @api,
recaptcha_verified: @recaptcha_verified,
spam_log_id: @spam_log_id,
- user_id: user.id)
+ user: user)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb
index 122f8ac89ed..e765d2484ea 100644
--- a/app/services/deployments/older_deployments_drop_service.rb
+++ b/app/services/deployments/older_deployments_drop_service.rb
@@ -12,7 +12,9 @@ module Deployments
return unless @deployment&.running?
older_deployments.find_each do |older_deployment|
- older_deployment.deployable&.drop!(:forward_deployment_failure)
+ Gitlab::OptimisticLocking.retry_lock(older_deployment.deployable) do |deployable|
+ deployable.drop(:forward_deployment_failure)
+ end
rescue => e
Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, deployment_id: older_deployment.id)
end
diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb
new file mode 100644
index 00000000000..e69f07db5bf
--- /dev/null
+++ b/app/services/design_management/delete_designs_service.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DeleteDesignsService < DesignService
+ include RunsDesignActions
+ include OnSuccessCallbacks
+
+ def initialize(project, user, params = {})
+ super
+
+ @designs = params.fetch(:designs)
+ end
+
+ def execute
+ return error('Forbidden!') unless can_delete_designs?
+
+ version = delete_designs!
+
+ success(version: version)
+ end
+
+ def commit_message
+ n = designs.size
+
+ <<~MSG
+ Removed #{n} #{'designs'.pluralize(n)}
+
+ #{formatted_file_list}
+ MSG
+ end
+
+ private
+
+ attr_reader :designs
+
+ def delete_designs!
+ DesignManagement::Version.with_lock(project.id, repository) do
+ run_actions(build_actions)
+ end
+ end
+
+ def can_delete_designs?
+ Ability.allowed?(current_user, :destroy_design, issue)
+ end
+
+ def build_actions
+ designs.map { |d| design_action(d) }
+ end
+
+ def design_action(design)
+ on_success { counter.count(:delete) }
+
+ DesignManagement::DesignAction.new(design, :delete)
+ end
+
+ def counter
+ ::Gitlab::UsageDataCounters::DesignsCounter
+ end
+
+ def formatted_file_list
+ designs.map { |design| "- #{design.full_path}" }.join("\n")
+ end
+ end
+end
+
+DesignManagement::DeleteDesignsService.prepend_if_ee('EE::DesignManagement::DeleteDesignsService')
diff --git a/app/services/design_management/design_service.rb b/app/services/design_management/design_service.rb
new file mode 100644
index 00000000000..54e53609646
--- /dev/null
+++ b/app/services/design_management/design_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignService < ::BaseService
+ def initialize(project, user, params = {})
+ super
+
+ @issue = params.fetch(:issue)
+ end
+
+ # Accessors common to all subclasses:
+
+ attr_reader :issue
+
+ def target_branch
+ repository.root_ref || "master"
+ end
+
+ def collection
+ issue.design_collection
+ end
+
+ def repository
+ collection.repository
+ end
+
+ def project
+ issue.project
+ end
+ end
+end
diff --git a/app/services/design_management/design_user_notes_count_service.rb b/app/services/design_management/design_user_notes_count_service.rb
new file mode 100644
index 00000000000..e49914ea6d3
--- /dev/null
+++ b/app/services/design_management/design_user_notes_count_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # Service class for counting and caching the number of unresolved
+ # notes of a Design
+ class DesignUserNotesCountService < ::BaseCountService
+ # The version of the cache format. This should be bumped whenever the
+ # underlying logic changes. This removes the need for explicitly flushing
+ # all caches.
+ VERSION = 1
+
+ def initialize(design)
+ @design = design
+ end
+
+ def relation_for_count
+ design.notes.user
+ end
+
+ def raw?
+ # Since we're storing simple integers we don't need all of the
+ # additional Marshal data Rails includes by default.
+ true
+ end
+
+ def cache_key
+ ['designs', 'notes_count', VERSION, design.id]
+ end
+
+ private
+
+ attr_reader :design
+ end
+end
diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb
new file mode 100644
index 00000000000..213aac164ff
--- /dev/null
+++ b/app/services/design_management/generate_image_versions_service.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # This service generates smaller image versions for `DesignManagement::Design`
+ # records within a given `DesignManagement::Version`.
+ class GenerateImageVersionsService < DesignService
+ # We limit processing to only designs with file sizes that don't
+ # exceed `MAX_DESIGN_SIZE`.
+ #
+ # Note, we may be able to remove checking this limit, if when we come to
+ # implement a file size limit for designs, there are no designs that
+ # exceed 40MB on GitLab.com
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22860#note_281780387
+ MAX_DESIGN_SIZE = 40.megabytes.freeze
+
+ def initialize(version)
+ super(version.project, version.author, issue: version.issue)
+
+ @version = version
+ end
+
+ def execute
+ # rubocop: disable CodeReuse/ActiveRecord
+ version.actions.includes(:design).each do |action|
+ generate_image(action)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ success(version: version)
+ end
+
+ private
+
+ attr_reader :version
+
+ def generate_image(action)
+ raw_file = get_raw_file(action)
+
+ unless raw_file
+ log_error("No design file found for Action: #{action.id}")
+ return
+ end
+
+ # Skip attempting to process images that would be rejected by CarrierWave.
+ return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST.include?(raw_file.content_type)
+
+ # Store and process the file
+ action.image_v432x230.store!(raw_file)
+ action.save!
+ rescue CarrierWave::UploadError => e
+ Gitlab::ErrorTracking.track_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id)
+ log_error(e.message)
+ end
+
+ # Returns the `CarrierWave::SanitizedFile` of the original design file
+ def get_raw_file(action)
+ raw_files_by_path[action.design.full_path]
+ end
+
+ # Returns the `Carrierwave:SanitizedFile` instances for all of the original
+ # design files, mapping to { design.filename => `Carrierwave::SanitizedFile` }.
+ #
+ # As design files are stored in Git LFS, the only way to retrieve their original
+ # files is to first fetch the LFS pointer file data from the Git design repository.
+ # The LFS pointer file data contains an "OID" that lets us retrieve `LfsObject`
+ # records, which have an Uploader (`LfsObjectUploader`) for the original design file.
+ def raw_files_by_path
+ @raw_files_by_path ||= begin
+ LfsObject.for_oids(blobs_by_oid.keys).each_with_object({}) do |lfs_object, h|
+ blob = blobs_by_oid[lfs_object.oid]
+ file = lfs_object.file.file
+ # The `CarrierWave::SanitizedFile` is loaded without knowing the `content_type`
+ # of the file, due to the file not having an extension.
+ #
+ # Set the content_type from the `Blob`.
+ file.content_type = blob.content_type
+ h[blob.path] = file
+ end
+ end
+ end
+
+ # Returns the `Blob`s that correspond to the design files in the repository.
+ #
+ # All design `Blob`s are LFS Pointer files, and are therefore small amounts
+ # of data to load.
+ #
+ # `Blob`s whose size are above a certain threshold: `MAX_DESIGN_SIZE`
+ # are filtered out.
+ def blobs_by_oid
+ @blobs ||= begin
+ items = version.designs.map { |design| [version.sha, design.full_path] }
+ blobs = repository.blobs_at(items)
+ blobs.reject! { |blob| blob.lfs_size > MAX_DESIGN_SIZE }
+ blobs.index_by(&:lfs_oid)
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/on_success_callbacks.rb b/app/services/design_management/on_success_callbacks.rb
new file mode 100644
index 00000000000..be55890a02d
--- /dev/null
+++ b/app/services/design_management/on_success_callbacks.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ module OnSuccessCallbacks
+ def on_success(&block)
+ success_callbacks.push(block)
+ end
+
+ def success(*_)
+ while cb = success_callbacks.pop
+ cb.call
+ end
+
+ super
+ end
+
+ private
+
+ def success_callbacks
+ @success_callbacks ||= []
+ end
+ end
+end
diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb
new file mode 100644
index 00000000000..4bd6bb45658
--- /dev/null
+++ b/app/services/design_management/runs_design_actions.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ module RunsDesignActions
+ NoActions = Class.new(StandardError)
+
+ # this concern requires the following methods to be implemented:
+ # current_user, target_branch, repository, commit_message
+ #
+ # Before calling `run_actions`, you should ensure the repository exists, by
+ # calling `repository.create_if_not_exists`.
+ #
+ # @raise [NoActions] if actions are empty
+ def run_actions(actions)
+ raise NoActions if actions.empty?
+
+ sha = repository.multi_action(current_user,
+ branch_name: target_branch,
+ message: commit_message,
+ actions: actions.map(&:gitaly_action))
+
+ ::DesignManagement::Version
+ .create_for_designs(actions, sha, current_user)
+ .tap { |version| post_process(version) }
+ end
+
+ private
+
+ def post_process(version)
+ version.run_after_commit_or_now do
+ ::DesignManagement::NewVersionWorker.perform_async(id)
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb
new file mode 100644
index 00000000000..a09c19bc885
--- /dev/null
+++ b/app/services/design_management/save_designs_service.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class SaveDesignsService < DesignService
+ include RunsDesignActions
+ include OnSuccessCallbacks
+
+ MAX_FILES = 10
+
+ def initialize(project, user, params = {})
+ super
+
+ @files = params.fetch(:files)
+ end
+
+ def execute
+ return error("Not allowed!") unless can_create_designs?
+ return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES
+
+ uploaded_designs, version = upload_designs!
+ skipped_designs = designs - uploaded_designs
+
+ success({ designs: uploaded_designs, version: version, skipped_designs: skipped_designs })
+ rescue ::ActiveRecord::RecordInvalid => e
+ error(e.message)
+ end
+
+ private
+
+ attr_reader :files
+
+ def upload_designs!
+ ::DesignManagement::Version.with_lock(project.id, repository) do
+ actions = build_actions
+
+ [actions.map(&:design), actions.presence && run_actions(actions)]
+ end
+ end
+
+ # Returns `Design` instances that correspond with `files`.
+ # New `Design`s will be created where a file name does not match
+ # an existing `Design`
+ def designs
+ @designs ||= files.map do |file|
+ collection.find_or_create_design!(filename: file.original_filename)
+ end
+ end
+
+ def build_actions
+ files.zip(designs).flat_map do |(file, design)|
+ Array.wrap(build_design_action(file, design))
+ end
+ end
+
+ def build_design_action(file, design)
+ content = file_content(file, design.full_path)
+ return if design_unchanged?(design, content)
+
+ action = new_file?(design) ? :create : :update
+ on_success { ::Gitlab::UsageDataCounters::DesignsCounter.count(action) }
+
+ DesignManagement::DesignAction.new(design, action, content)
+ end
+
+ # Returns true if the design file is the same as its latest version
+ def design_unchanged?(design, content)
+ content == existing_blobs[design]&.data
+ end
+
+ def commit_message
+ <<~MSG
+ Updated #{files.size} #{'designs'.pluralize(files.size)}
+
+ #{formatted_file_list}
+ MSG
+ end
+
+ def formatted_file_list
+ filenames.map { |name| "- #{name}" }.join("\n")
+ end
+
+ def filenames
+ @filenames ||= files.map(&:original_filename)
+ end
+
+ def can_create_designs?
+ Ability.allowed?(current_user, :create_design, issue)
+ end
+
+ def new_file?(design)
+ !existing_blobs[design]
+ end
+
+ def file_content(file, full_path)
+ transformer = ::Lfs::FileTransformer.new(project, repository, target_branch)
+ transformer.new_file(full_path, file.to_io).content
+ end
+
+ # Returns the latest blobs for the designs as a Hash of `{ Design => Blob }`
+ def existing_blobs
+ @existing_blobs ||= begin
+ items = designs.map { |d| ['HEAD', d.full_path] }
+
+ repository.blobs_at(items).each_with_object({}) do |blob, h|
+ design = designs.find { |d| d.full_path == blob.path }
+
+ h[design] = blob
+ end
+ end
+ end
+ end
+end
+
+DesignManagement::SaveDesignsService.prepend_if_ee('EE::DesignManagement::SaveDesignsService')
diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb
index 99324638300..c94505b2068 100644
--- a/app/services/emails/base_service.rb
+++ b/app/services/emails/base_service.rb
@@ -11,3 +11,5 @@ module Emails
end
end
end
+
+Emails::BaseService.prepend_if_ee('::EE::Emails::BaseService')
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 0b044e1679a..522f36cda46 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -85,18 +85,40 @@ class EventCreateService
# Create a new wiki page event
#
# @param [WikiPage::Meta] wiki_page_meta The event target
- # @param [User] current_user The event author
+ # @param [User] author The event author
# @param [Integer] action One of the Event::WIKI_ACTIONS
- def wiki_event(wiki_page_meta, current_user, action)
+ #
+ # @return a tuple of event and either :found or :created
+ def wiki_event(wiki_page_meta, author, action)
return unless Feature.enabled?(:wiki_events)
raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action)
- create_record_event(wiki_page_meta, current_user, action)
+ if duplicate = existing_wiki_event(wiki_page_meta, action)
+ return duplicate
+ end
+
+ 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
+ time_stamp = wiki_page_meta.updated_at
+ event.update_columns(updated_at: time_stamp, created_at: time_stamp)
+ end
+
+ event
end
private
+ def existing_wiki_event(wiki_page_meta, action)
+ if action == Event::DESTROYED
+ most_recent = Event.for_wiki_meta(wiki_page_meta).recent.first
+ return most_recent if most_recent.present? && most_recent.action == 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)
end
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index e1cc1f8c834..92e7702727c 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -112,7 +112,7 @@ module Git
end
def enqueue_update_signatures
- unsigned = unsigned_x509_shas(commits) & unsigned_gpg_shas(commits)
+ unsigned = unsigned_x509_shas(limited_commits) & unsigned_gpg_shas(limited_commits)
return if unsigned.empty?
signable = Gitlab::Git::Commit.shas_with_signatures(project.repository, unsigned)
diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb
index d4267d4a3c5..8bdbc28f3e8 100644
--- a/app/services/git/wiki_push_service.rb
+++ b/app/services/git/wiki_push_service.rb
@@ -2,8 +2,63 @@
module Git
class WikiPushService < ::BaseService
+ # Maximum number of change events we will process on any single push
+ MAX_CHANGES = 100
+
def execute
- # This is used in EE
+ process_changes
+ end
+
+ private
+
+ def process_changes
+ return unless can_process_wiki_events?
+
+ push_changes.take(MAX_CHANGES).each do |change| # rubocop:disable CodeReuse/ActiveRecord
+ next unless change.page.present?
+
+ response = create_event_for(change)
+ log_error(response.message) if response.error?
+ end
+ end
+
+ def can_process_wiki_events?
+ Feature.enabled?(:wiki_events) && Feature.enabled?(:wiki_events_on_git_push, project)
+ end
+
+ def push_changes
+ default_branch_changes.flat_map do |change|
+ raw_changes(change).map { |raw| Git::WikiPushService::Change.new(wiki, change, raw) }
+ end
+ end
+
+ def raw_changes(change)
+ wiki.repository.raw.raw_changes_between(change[:oldrev], change[:newrev])
+ end
+
+ def wiki
+ project.wiki
+ end
+
+ def create_event_for(change)
+ event_service.execute(change.last_known_slug, change.page, change.event_action)
+ end
+
+ def event_service
+ @event_service ||= WikiPages::EventCreateService.new(current_user)
+ end
+
+ def on_default_branch?(change)
+ project.wiki.default_branch == ::Gitlab::Git.branch_name(change[:ref])
+ end
+
+ # See: [Gitlab::GitPostReceive#changes]
+ def changes
+ params[:changes] || []
+ end
+
+ def default_branch_changes
+ @default_branch_changes ||= changes.select { |change| on_default_branch?(change) }
end
end
end
diff --git a/app/services/git/wiki_push_service/change.rb b/app/services/git/wiki_push_service/change.rb
new file mode 100644
index 00000000000..8685850165a
--- /dev/null
+++ b/app/services/git/wiki_push_service/change.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Git
+ class WikiPushService
+ class Change
+ include Gitlab::Utils::StrongMemoize
+
+ # @param [ProjectWiki] wiki
+ # @param [Hash] change - must have keys `:oldrev` and `:newrev`
+ # @param [Gitlab::Git::RawDiffChange] raw_change
+ def initialize(project_wiki, change, raw_change)
+ @wiki, @raw_change, @change = project_wiki, raw_change, change
+ end
+
+ def page
+ strong_memoize(:page) { wiki.find_page(slug, revision) }
+ end
+
+ # See [Gitlab::Git::RawDiffChange#extract_operation] for the
+ # definition of the full range of operation values.
+ def event_action
+ case raw_change.operation
+ when :added
+ Event::CREATED
+ when :deleted
+ Event::DESTROYED
+ else
+ Event::UPDATED
+ end
+ end
+
+ def last_known_slug
+ strip_extension(raw_change.old_path || raw_change.new_path)
+ end
+
+ private
+
+ attr_reader :raw_change, :change, :wiki
+
+ def filename
+ return raw_change.old_path if deleted?
+
+ raw_change.new_path
+ end
+
+ def slug
+ strip_extension(filename)
+ end
+
+ def revision
+ return change[:oldrev] if deleted?
+
+ change[:newrev]
+ end
+
+ def deleted?
+ raw_change.operation == :deleted
+ end
+
+ def strip_extension(filename)
+ return unless filename
+
+ File.basename(filename, File.extname(filename))
+ end
+ end
+ end
+end
diff --git a/app/services/grafana/proxy_service.rb b/app/services/grafana/proxy_service.rb
index 74fcdc750b0..ac4c3cc091c 100644
--- a/app/services/grafana/proxy_service.rb
+++ b/app/services/grafana/proxy_service.rb
@@ -12,6 +12,7 @@ module Grafana
self.reactive_cache_key = ->(service) { service.cache_key }
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds
+ self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
attr_accessor :project, :datasource_id, :proxy_path, :query_params
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 8cc31200689..eb1b8d4fcc0 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -38,6 +38,10 @@ module Groups
# overridden in EE
end
+ def remove_unallowed_params
+ params.delete(:default_branch_protection) unless can?(current_user, :create_group_with_default_branch_protection)
+ end
+
def create_chat_team?
Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil?
end
diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb
index f8715b57d6e..0f2e3bb65f9 100644
--- a/app/services/groups/import_export/export_service.rb
+++ b/app/services/groups/import_export/export_service.rb
@@ -52,11 +52,11 @@ module Groups
end
def savers
- [tree_exporter, file_saver]
+ [version_saver, tree_exporter, file_saver]
end
def tree_exporter
- Gitlab::ImportExport::Group::LegacyTreeSaver.new(
+ tree_exporter_class.new(
group: @group,
current_user: @current_user,
shared: @shared,
@@ -64,6 +64,18 @@ module Groups
)
end
+ def tree_exporter_class
+ if ::Feature.enabled?(:group_export_ndjson, @group&.parent, default_enabled: true)
+ Gitlab::ImportExport::Group::TreeSaver
+ else
+ Gitlab::ImportExport::Group::LegacyTreeSaver
+ end
+ end
+
+ def version_saver
+ Gitlab::ImportExport::VersionSaver.new(shared: shared)
+ end
+
def file_saver
Gitlab::ImportExport::Saver.new(exportable: @group, shared: @shared)
end
@@ -84,6 +96,8 @@ module Groups
group_name: @group.name,
message: 'Group Import/Export: Export succeeded'
)
+
+ notification_service.group_was_exported(@group, @current_user)
end
def notify_error
@@ -93,6 +107,12 @@ module Groups
error: @shared.errors.join(', '),
message: 'Group Import/Export: Export failed'
)
+
+ notification_service.group_was_not_exported(@group, @current_user, @shared.errors)
+ end
+
+ def notification_service
+ @notification_service ||= NotificationService.new
end
end
end
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
index f62b9d3c8a6..6f692c98c38 100644
--- a/app/services/groups/import_export/import_service.rb
+++ b/app/services/groups/import_export/import_service.rb
@@ -27,18 +27,34 @@ module Groups
private
def import_file
- @import_file ||= Gitlab::ImportExport::FileImporter.import(importable: @group,
- archive_file: nil,
- shared: @shared)
+ @import_file ||= Gitlab::ImportExport::FileImporter.import(
+ importable: @group,
+ archive_file: nil,
+ shared: @shared
+ )
end
def restorer
- @restorer ||= Gitlab::ImportExport::Group::LegacyTreeRestorer.new(
- user: @current_user,
- shared: @shared,
- group: @group,
- group_hash: nil
- )
+ @restorer ||=
+ if ndjson?
+ Gitlab::ImportExport::Group::TreeRestorer.new(
+ user: @current_user,
+ shared: @shared,
+ group: @group
+ )
+ else
+ Gitlab::ImportExport::Group::LegacyTreeRestorer.new(
+ user: @current_user,
+ shared: @shared,
+ group: @group,
+ group_hash: nil
+ )
+ end
+ end
+
+ def ndjson?
+ ::Feature.enabled?(:group_import_ndjson, @group&.parent, default_enabled: true) &&
+ File.exist?(File.join(@shared.export_path, 'tree/groups/_all.ndjson'))
end
def remove_import_file
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 8635b82461b..948540619ae 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -66,6 +66,7 @@ module Groups
# overridden in EE
def remove_unallowed_params
params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, group)
+ params.delete(:default_branch_protection) unless can?(current_user, :update_default_branch_protection, group)
end
def valid_share_with_group_lock_change?
diff --git a/app/services/incident_management/create_issue_service.rb b/app/services/incident_management/create_issue_service.rb
index 43077e03e6d..4b59dc64cec 100644
--- a/app/services/incident_management/create_issue_service.rb
+++ b/app/services/incident_management/create_issue_service.rb
@@ -13,12 +13,12 @@ module IncidentManagement
DESCRIPTION
}.freeze
- def initialize(project, params)
- super(project, User.alert_bot, params)
+ def initialize(project, params, user = User.alert_bot)
+ super(project, user, params)
end
- def execute
- return error_with('setting disabled') unless incident_management_setting.create_issue?
+ def execute(skip_settings_check: false)
+ return error_with('setting disabled') unless skip_settings_check || incident_management_setting.create_issue?
return error_with('invalid alert') unless alert.valid?
issue = create_issue
diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb
index 55f5629baac..a78e191c85f 100644
--- a/app/services/issuable/clone/attributes_rewriter.rb
+++ b/app/services/issuable/clone/attributes_rewriter.rb
@@ -20,6 +20,7 @@ module Issuable
copy_resource_label_events
copy_resource_weight_events
copy_resource_milestone_events
+ copy_resource_state_events
end
private
@@ -47,8 +48,6 @@ module Issuable
end
def copy_resource_label_events
- entity_key = new_entity.class.name.underscore.foreign_key
-
copy_events(ResourceLabelEvent.table_name, original_entity.resource_label_events) do |event|
event.attributes
.except('id', 'reference', 'reference_html')
@@ -67,22 +66,39 @@ module Issuable
end
def copy_resource_milestone_events
- entity_key = new_entity.class.name.underscore.foreign_key
+ return unless milestone_events_supported?
copy_events(ResourceMilestoneEvent.table_name, original_entity.resource_milestone_events) do |event|
- matching_destination_milestone = matching_milestone(event.milestone.title)
-
- if matching_destination_milestone.present?
- event.attributes
- .except('id')
- .merge(entity_key => new_entity.id,
- 'milestone_id' => matching_destination_milestone.id,
- 'action' => ResourceMilestoneEvent.actions[event.action],
- 'state' => ResourceMilestoneEvent.states[event.state])
+ if event.remove?
+ event_attributes_with_milestone(event, nil)
+ else
+ matching_destination_milestone = matching_milestone(event.milestone_title)
+
+ event_attributes_with_milestone(event, matching_destination_milestone) if matching_destination_milestone.present?
end
end
end
+ def copy_resource_state_events
+ return unless state_events_supported?
+
+ copy_events(ResourceStateEvent.table_name, original_entity.resource_state_events) do |event|
+ event.attributes
+ .except('id')
+ .merge(entity_key => new_entity.id,
+ 'state' => ResourceStateEvent.states[event.state])
+ end
+ end
+
+ def event_attributes_with_milestone(event, milestone)
+ event.attributes
+ .except('id')
+ .merge(entity_key => new_entity.id,
+ 'milestone_id' => milestone&.id,
+ 'action' => ResourceMilestoneEvent.actions[event.action],
+ 'state' => ResourceMilestoneEvent.states[event.state])
+ end
+
def copy_events(table_name, events_to_copy)
events_to_copy.find_in_batches do |batch|
events = batch.map do |event|
@@ -94,7 +110,20 @@ module Issuable
end
def entity_key
- new_entity.class.name.parameterize('_').foreign_key
+ new_entity.class.name.underscore.foreign_key
+ end
+
+ def milestone_events_supported?
+ both_respond_to?(:resource_milestone_events)
+ end
+
+ def state_events_supported?
+ both_respond_to?(:resource_state_events)
+ end
+
+ def both_respond_to?(method)
+ original_entity.respond_to?(method) &&
+ new_entity.respond_to?(method)
end
end
end
diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb
index 54576e82030..0d1640924e5 100644
--- a/app/services/issuable/clone/base_service.rb
+++ b/app/services/issuable/clone/base_service.rb
@@ -47,7 +47,7 @@ module Issuable
end
def new_parent
- new_entity.project ? new_entity.project : new_entity.group
+ new_entity.project || new_entity.group
end
def group
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index 67cf212691f..195616857dc 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -4,7 +4,7 @@ module Issuable
class CommonSystemNotesService < ::BaseService
attr_reader :issuable
- def execute(issuable, old_labels: [], is_update: true)
+ def execute(issuable, old_labels: [], old_milestone: nil, is_update: true)
@issuable = issuable
# We disable touch so that created system notes do not update
@@ -22,17 +22,13 @@ module Issuable
end
create_due_date_note if issuable.previous_changes.include?('due_date')
- create_milestone_note if has_milestone_changes?
+ create_milestone_note(old_milestone) if issuable.previous_changes.include?('milestone_id')
create_labels_note(old_labels) if old_labels && issuable.labels != old_labels
end
end
private
- def has_milestone_changes?
- issuable.previous_changes.include?('milestone_id')
- end
-
def handle_time_tracking_note
if issuable.previous_changes.include?('time_estimate')
create_time_estimate_note
@@ -98,15 +94,19 @@ module Issuable
SystemNoteService.change_time_spent(issuable, issuable.project, issuable.time_spent_user)
end
- def create_milestone_note
+ def create_milestone_note(old_milestone)
if milestone_changes_tracking_enabled?
- # Creates a synthetic note
- ResourceEvents::ChangeMilestoneService.new(issuable, current_user).execute
+ create_milestone_change_event(old_milestone)
else
SystemNoteService.change_milestone(issuable, issuable.project, current_user, issuable.milestone)
end
end
+ def create_milestone_change_event(old_milestone)
+ ResourceEvents::ChangeMilestoneService.new(issuable, current_user, old_milestone: old_milestone)
+ .execute
+ end
+
def milestone_changes_tracking_enabled?
::Feature.enabled?(:track_resource_milestone_change_events, issuable.project)
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 506f4309a1e..18062bd60da 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -22,7 +22,9 @@ class IssuableBaseService < BaseService
params.delete(:milestone_id)
params.delete(:labels)
params.delete(:add_label_ids)
+ params.delete(:add_labels)
params.delete(:remove_label_ids)
+ params.delete(:remove_labels)
params.delete(:label_ids)
params.delete(:assignee_ids)
params.delete(:assignee_id)
@@ -91,6 +93,8 @@ class IssuableBaseService < BaseService
elsif params[label_key]
params[label_id_key] = labels_service.find_or_create_by_titles(label_key, find_only: find_only).map(&:id)
end
+
+ params.delete(label_key) if params[label_key].nil?
end
def filter_labels_in_param(key)
@@ -217,7 +221,7 @@ class IssuableBaseService < BaseService
issuable.assign_attributes(params)
if has_title_or_description_changed?(issuable)
- issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user)
+ issuable.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user)
end
before_update(issuable)
@@ -237,7 +241,8 @@ class IssuableBaseService < BaseService
end
if issuable_saved
- Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels])
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(
+ issuable, old_labels: old_associations[:labels], old_milestone: old_associations[:milestone])
handle_changes(issuable, old_associations: old_associations)
@@ -265,7 +270,7 @@ class IssuableBaseService < BaseService
if issuable.changed? || params.present?
issuable.assign_attributes(params.merge(updated_by: current_user,
- last_edited_at: Time.now,
+ last_edited_at: Time.current,
last_edited_by: current_user))
before_update(issuable, skip_spam_check: true)
@@ -360,7 +365,8 @@ class IssuableBaseService < BaseService
{
labels: issuable.labels.to_a,
mentioned_users: issuable.mentioned_users(current_user).to_a,
- assignees: issuable.assignees.to_a
+ assignees: issuable.assignees.to_a,
+ milestone: issuable.try(:milestone)
}
associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent)
associations[:description] = issuable.description
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index daef468987e..e62315de5f9 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -65,15 +65,19 @@ module Issues
private
def whitelisted_issue_params
+ base_params = [:title, :description, :confidential]
+ admin_params = [:milestone_id]
+
if can?(current_user, :admin_issue, project)
- params.slice(:title, :description, :milestone_id)
+ params.slice(*(base_params + admin_params))
else
- params.slice(:title, :description)
+ params.slice(*base_params)
end
end
def build_issue_params
- issue_params_with_info_from_discussions.merge(whitelisted_issue_params)
+ { author: current_user }.merge(issue_params_with_info_from_discussions)
+ .merge(whitelisted_issue_params)
end
end
end
diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb
index 76af482b7ac..46076218857 100644
--- a/app/services/issues/related_branches_service.rb
+++ b/app/services/issues/related_branches_service.rb
@@ -5,11 +5,29 @@
module Issues
class RelatedBranchesService < Issues::BaseService
def execute(issue)
- branches_with_iid_of(issue) - branches_with_merge_request_for(issue)
+ branch_names = branches_with_iid_of(issue) - branches_with_merge_request_for(issue)
+ branch_names.map { |branch_name| branch_data(branch_name) }
end
private
+ def branch_data(branch_name)
+ {
+ name: branch_name,
+ pipeline_status: pipeline_status(branch_name)
+ }
+ end
+
+ def pipeline_status(branch_name)
+ branch = project.repository.find_branch(branch_name)
+ target = branch&.dereferenced_target
+
+ return unless target
+
+ pipeline = project.pipeline_for(branch_name, target.sha)
+ pipeline.detailed_status(current_user) if can?(current_user, :read_pipeline, pipeline)
+ end
+
def branches_with_merge_request_for(issue)
Issues::ReferencedMergeRequestsService
.new(project, current_user)
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 78ebbd7bff2..ee1a22634af 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -21,6 +21,10 @@ module Issues
spam_check(issue, current_user) unless skip_spam_check
end
+ def after_update(issue)
+ IssuesChannel.broadcast_to(issue, event: 'updated') if Feature.enabled?(:broadcast_issue_updates, issue.project)
+ end
+
def handle_changes(issue, options)
old_associations = options.fetch(:old_associations, {})
old_labels = old_associations.fetch(:labels, [])
diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb
index de4e490281f..59fd463022f 100644
--- a/app/services/jira_import/start_import_service.rb
+++ b/app/services/jira_import/start_import_service.rb
@@ -56,7 +56,7 @@ module JiraImport
import_start_time = Time.zone.now
jira_imports_for_project = project.jira_imports.by_jira_project_key(jira_project_key).size + 1
title = "jira-import::#{jira_project_key}-#{jira_imports_for_project}"
- description = "Label for issues that were imported from jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}"
+ description = "Label for issues that were imported from Jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}"
color = "#{Label.color_for(title)}"
{ title: title, description: description, color: color }
end
diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb
index 88f59b820a4..69d33e1c873 100644
--- a/app/services/lfs/file_transformer.rb
+++ b/app/services/lfs/file_transformer.rb
@@ -5,8 +5,7 @@ module Lfs
# return a transformed result with `content` and `encoding` to commit.
#
# The `repository` passed to the initializer can be a Repository or
- # a DesignManagement::Repository (an EE-specific class that inherits
- # from Repository).
+ # class that inherits from Repository.
#
# The `repository_type` property will be one of the types named in
# `Gitlab::GlRepository.types`, and is recorded on the `LfsObjectsProject`
diff --git a/app/services/members/request_access_service.rb b/app/services/members/request_access_service.rb
index b9b0550e290..4dfedc6cd4e 100644
--- a/app/services/members/request_access_service.rb
+++ b/app/services/members/request_access_service.rb
@@ -8,7 +8,7 @@ module Members
source.members.create(
access_level: Gitlab::Access::DEVELOPER,
user: current_user,
- requested_at: Time.now.utc)
+ requested_at: Time.current.utc)
end
private
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 00bf69739ad..7f7bfa29af7 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -39,6 +39,8 @@ module MergeRequests
# Don't try to print expensive instance variables.
def inspect
+ return "#<#{self.class}>" unless respond_to?(:merge_request)
+
"#<#{self.class} #{merge_request.to_reference(full: true)}>"
end
@@ -89,8 +91,7 @@ module MergeRequests
end
def can_use_merge_request_ref?(merge_request)
- Feature.enabled?(:ci_use_merge_request_ref, project, default_enabled: true) &&
- !merge_request.for_fork?
+ !merge_request.for_fork?
end
def abort_auto_merge(merge_request, reason)
@@ -115,6 +116,32 @@ module MergeRequests
yield merge_request
end
end
+
+ def log_error(exception:, message:, save_message_on_model: false)
+ reference = merge_request.to_reference(full: true)
+ data = {
+ class: self.class.name,
+ message: message,
+ merge_request_id: merge_request.id,
+ merge_request: reference,
+ save_message_on_model: save_message_on_model
+ }
+
+ if exception
+ Gitlab::ErrorTracking.with_context(current_user) do
+ Gitlab::ErrorTracking.track_exception(exception, data)
+ end
+
+ data[:"exception.message"] = exception.message
+ end
+
+ # TODO: Deprecate Gitlab::GitLogger since ErrorTracking should suffice:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/216379
+ data[:message] = "#{self.class.name} error (#{reference}): #{message}"
+ Gitlab::GitLogger.error(data)
+
+ merge_request.update(merge_error: message) if save_message_on_model
+ end
end
end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index bc1e97088af..87808a21a15 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -2,8 +2,6 @@
module MergeRequests
class RebaseService < MergeRequests::BaseService
- include Git::Logger
-
REBASE_ERROR = 'Rebase failed. Please rebase locally'
attr_reader :merge_request
@@ -22,7 +20,7 @@ module MergeRequests
def rebase
# Ensure Gitaly isn't already running a rebase
if source_project.repository.rebase_in_progress?(merge_request.id)
- log_error('Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
+ log_error(exception: nil, message: 'Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
return false
end
@@ -30,8 +28,8 @@ module MergeRequests
true
rescue => e
- log_error(REBASE_ERROR, save_message_on_model: true)
- log_error(e.message)
+ log_error(exception: e, message: REBASE_ERROR, save_message_on_model: true)
+
false
ensure
merge_request.update_column(:rebase_jid, nil)
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index c6e1651fa26..56a91fa0305 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -115,6 +115,10 @@ module MergeRequests
filter_merge_requests(merge_requests).each do |merge_request|
if branch_and_project_match?(merge_request) || @push.force_push?
merge_request.reload_diff(current_user)
+ # Clear existing merge error if the push were directed at the
+ # source branch. Clearing the error when the target branch
+ # changes will hide the error from the user.
+ merge_request.merge_error = nil
elsif merge_request.merge_request_diff.includes_any_commits?(push_commit_ids)
merge_request.reload_diff(current_user)
end
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index d25997c925e..4b04d42b48e 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -2,7 +2,7 @@
module MergeRequests
class SquashService < MergeRequests::BaseService
- include Git::Logger
+ SquashInProgressError = Class.new(RuntimeError)
def execute
# If performing a squash would result in no change, then
@@ -11,11 +11,13 @@ module MergeRequests
return success(squash_sha: merge_request.diff_head_sha)
end
- if merge_request.squash_in_progress?
+ if squash_in_progress?
return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.'))
end
squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.'))
+ rescue SquashInProgressError
+ error(s_('MergeRequests|An error occurred while checking whether another squash is in progress.'))
end
private
@@ -25,11 +27,19 @@ module MergeRequests
success(squash_sha: squash_sha)
rescue => e
- log_error("Failed to squash merge request #{merge_request.to_reference(full: true)}:")
- log_error(e.message)
+ log_error(exception: e, message: 'Failed to squash merge request')
+
false
end
+ def squash_in_progress?
+ merge_request.squash_in_progress?
+ rescue => e
+ log_error(exception: e, message: 'Failed to check squash in progress')
+
+ raise SquashInProgressError, e.message
+ end
+
def repository
target_project.repository
end
diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb
index c112d75a9b5..514793694ba 100644
--- a/app/services/metrics/dashboard/base_service.rb
+++ b/app/services/metrics/dashboard/base_service.rb
@@ -42,7 +42,7 @@ module Metrics
def allowed?
return false unless params[:environment]
- Ability.allowed?(current_user, :read_environment, project)
+ project&.feature_available?(:metrics_dashboard, current_user)
end
# Returns a new dashboard Hash, supplemented with DB info
diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
index d58b80162f5..d9ce2c5e905 100644
--- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb
+++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
@@ -18,6 +18,7 @@ module Metrics
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.minutes
self.reactive_cache_lifetime = 30.minutes
+ self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
class << self
@@ -112,7 +113,7 @@ module Metrics
end
def parse_json(json)
- JSON.parse(json, symbolize_names: true)
+ Gitlab::Json.parse(json, symbolize_names: true)
rescue JSON::ParserError
raise DashboardProcessingError.new('Grafana response contains invalid json')
end
diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb
index ce81f337e47..cb6ca215447 100644
--- a/app/services/metrics/dashboard/transient_embed_service.rb
+++ b/app/services/metrics/dashboard/transient_embed_service.rb
@@ -23,7 +23,9 @@ module Metrics
override :get_raw_dashboard
def get_raw_dashboard
- JSON.parse(params[:embed_json])
+ Gitlab::Json.parse(params[:embed_json])
+ rescue JSON::ParserError => e
+ invalid_embed_json!(e.message)
end
override :sequence
@@ -35,6 +37,10 @@ module Metrics
def identifiers
Digest::SHA256.hexdigest(params[:embed_json])
end
+
+ def invalid_embed_json!(message)
+ raise DashboardProcessingError.new("Parsing error for param :embed_json. #{message}")
+ end
end
end
end
diff --git a/app/services/metrics/users_starred_dashboards/create_service.rb b/app/services/metrics/users_starred_dashboards/create_service.rb
new file mode 100644
index 00000000000..7784ed4eb4e
--- /dev/null
+++ b/app/services/metrics/users_starred_dashboards/create_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+# Create Metrics::UsersStarredDashboard entry for given user based on matched dashboard_path, project
+module Metrics
+ module UsersStarredDashboards
+ class CreateService < ::BaseService
+ include Stepable
+
+ steps :authorize_create_action,
+ :parse_dashboard_path,
+ :create
+
+ def initialize(user, project, dashboard_path)
+ @user, @project, @dashboard_path = user, project, dashboard_path
+ end
+
+ def execute
+ keys = %i[status message starred_dashboard]
+ status, message, dashboards = execute_steps.values_at(*keys)
+
+ if status != :success
+ ServiceResponse.error(message: message)
+ else
+ ServiceResponse.success(payload: dashboards)
+ end
+ end
+
+ private
+
+ attr_reader :user, :project, :dashboard_path
+
+ def authorize_create_action(_options)
+ if Ability.allowed?(user, :create_metrics_user_starred_dashboard, project)
+ success(user: user, project: project)
+ else
+ error(s_('Metrics::UsersStarredDashboards|You are not authorized to add star to this dashboard'))
+ end
+ end
+
+ def parse_dashboard_path(options)
+ if dashboard_path_exists?
+ options[:dashboard_path] = dashboard_path
+ success(options)
+ else
+ error(s_('Metrics::UsersStarredDashboards|Dashboard with requested path can not be found'))
+ end
+ end
+
+ def create(options)
+ starred_dashboard = build_starred_dashboard_from(options)
+
+ if starred_dashboard.save
+ success(starred_dashboard: starred_dashboard)
+ else
+ error(starred_dashboard.errors.messages)
+ end
+ end
+
+ def build_starred_dashboard_from(options)
+ Metrics::UsersStarredDashboard.new(
+ user: options.fetch(:user),
+ project: options.fetch(:project),
+ dashboard_path: options.fetch(:dashboard_path)
+ )
+ end
+
+ def dashboard_path_exists?
+ Gitlab::Metrics::Dashboard::Finder
+ .find_all_paths(project)
+ .any? { |dashboard| dashboard[:path] == dashboard_path }
+ end
+ end
+ end
+end
diff --git a/app/services/metrics/users_starred_dashboards/delete_service.rb b/app/services/metrics/users_starred_dashboards/delete_service.rb
new file mode 100644
index 00000000000..579715bd49f
--- /dev/null
+++ b/app/services/metrics/users_starred_dashboards/delete_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+# Delete all matching Metrics::UsersStarredDashboard entries for given user based on matched dashboard_path, project
+module Metrics
+ module UsersStarredDashboards
+ class DeleteService < ::BaseService
+ def initialize(user, project, dashboard_path = nil)
+ @user, @project, @dashboard_path = user, project, dashboard_path
+ end
+
+ def execute
+ ServiceResponse.success(payload: { deleted_rows: starred_dashboards.delete_all })
+ end
+
+ private
+
+ attr_reader :user, :project, :dashboard_path
+
+ def starred_dashboards
+ # since deleted records are scoped to their owner there is no need to
+ # check if that user can delete them, also if user lost access to
+ # project it shouldn't block that user from removing them
+ dashboards = user.metrics_users_starred_dashboards
+
+ if dashboard_path.present?
+ dashboards.for_project_dashboard(project, dashboard_path)
+ else
+ dashboards.for_project(project)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/namespaces/check_storage_size_service.rb b/app/services/namespaces/check_storage_size_service.rb
new file mode 100644
index 00000000000..b3cf17681ee
--- /dev/null
+++ b/app/services/namespaces/check_storage_size_service.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class CheckStorageSizeService
+ include ActiveSupport::NumberHelper
+ include Gitlab::Allowable
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(namespace, user)
+ @root_namespace = namespace.root_ancestor
+ @root_storage_size = Namespace::RootStorageSize.new(root_namespace)
+ @user = user
+ end
+
+ def execute
+ return ServiceResponse.success unless Feature.enabled?(:namespace_storage_limit, root_namespace)
+ return ServiceResponse.success if alert_level == :none
+
+ if root_storage_size.above_size_limit?
+ ServiceResponse.error(message: above_size_limit_message, payload: payload)
+ else
+ ServiceResponse.success(payload: payload)
+ end
+ end
+
+ private
+
+ attr_reader :root_namespace, :root_storage_size, :user
+
+ USAGE_THRESHOLDS = {
+ none: 0.0,
+ info: 0.5,
+ warning: 0.75,
+ alert: 0.95,
+ error: 1.0
+ }.freeze
+
+ def payload
+ return {} unless can?(user, :admin_namespace, root_namespace)
+
+ {
+ explanation_message: explanation_message,
+ usage_message: usage_message,
+ alert_level: alert_level
+ }
+ end
+
+ def explanation_message
+ root_storage_size.above_size_limit? ? above_size_limit_message : below_size_limit_message
+ end
+
+ def usage_message
+ s_("You reached %{usage_in_percent} of %{namespace_name}'s capacity (%{used_storage} of %{storage_limit})" % current_usage_params)
+ end
+
+ def alert_level
+ strong_memoize(:alert_level) do
+ usage_ratio = root_storage_size.usage_ratio
+ current_level = USAGE_THRESHOLDS.each_key.first
+
+ USAGE_THRESHOLDS.each do |level, threshold|
+ current_level = level if usage_ratio >= threshold
+ end
+
+ current_level
+ end
+ end
+
+ def below_size_limit_message
+ s_("If you reach 100%% storage capacity, you will not be able to: %{base_message}" % { base_message: base_message } )
+ end
+
+ def above_size_limit_message
+ s_("%{namespace_name} is now read-only. You cannot: %{base_message}" % { namespace_name: root_namespace.name, base_message: base_message })
+ end
+
+ def base_message
+ s_("push to your repository, create pipelines, create issues or add comments. To reduce storage capacity, delete unused repositories, artifacts, wikis, issues, and pipelines.")
+ end
+
+ def current_usage_params
+ {
+ usage_in_percent: number_to_percentage(root_storage_size.usage_ratio * 100, precision: 0),
+ namespace_name: root_namespace.name,
+ used_storage: formatted(root_storage_size.current_size),
+ storage_limit: formatted(root_storage_size.limit)
+ }
+ end
+
+ def formatted(number)
+ number_to_human_size(number, delimiter: ',', precision: 2)
+ end
+ end
+end
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index 53b3b57f4af..bc86118a150 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -16,10 +16,18 @@ module Notes
return if @note.for_personal_snippet?
@note.create_cross_references!
+ ::SystemNoteService.design_discussion_added(@note) if create_design_discussion_system_note?
+
execute_note_hooks
end
end
+ private
+
+ def create_design_discussion_system_note?
+ @note && @note.for_design? && @note.start_of_discussion?
+ end
+
def hook_data
Gitlab::DataBuilder::Note.build(@note, @note.author)
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 91e19d190bd..4c1db03fab8 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -66,6 +66,14 @@ class NotificationService
mailer.access_token_about_to_expire_email(user).deliver_later
end
+ # Notify a user when a previously unknown IP or device is used to
+ # sign in to their account
+ def unknown_sign_in(user, ip)
+ return unless user.can?(:receive_notifications)
+
+ mailer.unknown_sign_in_email(user, ip).deliver_later
+ end
+
# When create an issue we should send an email to:
#
# * issue assignee if their notification level is not Disabled
@@ -537,6 +545,18 @@ class NotificationService
end
end
+ def group_was_exported(group, current_user)
+ return true unless notifiable?(current_user, :mention, group: group)
+
+ mailer.group_was_exported_email(current_user, group).deliver_later
+ end
+
+ def group_was_not_exported(group, current_user, errors)
+ return true unless notifiable?(current_user, :mention, group: group)
+
+ mailer.group_was_not_exported_email(current_user, group, errors).deliver_later
+ end
+
protected
def new_resource_email(target, method)
diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
index 1c03641469e..e14241158a6 100644
--- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
+++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
@@ -51,8 +51,6 @@ module PagesDomains
def save_order_error(acme_order, api_order)
log_error(api_order)
- return unless Feature.enabled?(:pages_letsencrypt_errors, pages_domain.project)
-
pages_domain.assign_attributes(auto_ssl_failed: true)
pages_domain.save!(validate: false)
diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb
index 2451ab8e0ce..8936f9b67a5 100644
--- a/app/services/pod_logs/base_service.rb
+++ b/app/services/pod_logs/base_service.rb
@@ -58,6 +58,9 @@ module PodLogs
result[:pod_name] = params['pod_name'].presence
result[:container_name] = params['container_name'].presence
+ return error(_('Invalid pod_name')) if result[:pod_name] && !result[:pod_name].is_a?(String)
+ return error(_('Invalid container_name')) if result[:container_name] && !result[:container_name].is_a?(String)
+
success(result)
end
diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb
index aac0fa424ca..f79562c8ab3 100644
--- a/app/services/pod_logs/elasticsearch_service.rb
+++ b/app/services/pod_logs/elasticsearch_service.rb
@@ -11,6 +11,7 @@ module PodLogs
:pod_logs,
:filter_return_keys
+ self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
private
@@ -52,12 +53,16 @@ module PodLogs
def check_search(result)
result[:search] = params['search'] if params.key?('search')
+ return error(_('Invalid search parameter')) if result[:search] && !result[:search].is_a?(String)
+
success(result)
end
def check_cursor(result)
result[:cursor] = params['cursor'] if params.key?('cursor')
+ return error(_('Invalid cursor parameter')) if result[:cursor] && !result[:cursor].is_a?(String)
+
success(result)
end
@@ -65,6 +70,8 @@ module PodLogs
client = cluster&.application_elastic_stack&.elasticsearch_client
return error(_('Unable to connect to Elasticsearch')) unless client
+ chart_above_v2 = cluster.application_elastic_stack.chart_above_v2?
+
response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs(
namespace,
pod_name: result[:pod_name],
@@ -72,7 +79,8 @@ module PodLogs
search: result[:search],
start_time: result[:start_time],
end_time: result[:end_time],
- cursor: result[:cursor]
+ cursor: result[:cursor],
+ chart_above_v2: chart_above_v2
)
result.merge!(response)
diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb
index 0a8072a9037..b573ceae1aa 100644
--- a/app/services/pod_logs/kubernetes_service.rb
+++ b/app/services/pod_logs/kubernetes_service.rb
@@ -17,6 +17,7 @@ module PodLogs
:split_logs,
:filter_return_keys
+ self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
private
@@ -46,6 +47,10 @@ module PodLogs
' chars' % { max_length: K8S_NAME_MAX_LENGTH }))
end
+ unless result[:pod_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex
+ return error(_('pod_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character'))
+ end
+
unless result[:pods].include?(result[:pod_name])
return error(_('Pod does not exist'))
end
@@ -69,6 +74,10 @@ module PodLogs
' %{max_length} chars' % { max_length: K8S_NAME_MAX_LENGTH }))
end
+ unless result[:container_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex
+ return error(_('container_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character'))
+ end
+
unless container_names.include?(result[:container_name])
return error(_('Container does not exist'))
end
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index f12e45d701a..65e6ebc17d2 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -29,6 +29,8 @@ class PostReceiveService
response.add_alert_message(message)
end
+ response.add_alert_message(storage_size_limit_alert)
+
broadcast_message = BroadcastMessage.current_banner_messages&.last&.message
response.add_alert_message(broadcast_message)
@@ -74,4 +76,19 @@ class PostReceiveService
::MergeRequests::GetUrlsService.new(project).execute(params[:changes])
end
+
+ private
+
+ def storage_size_limit_alert
+ return unless repository&.repo_type&.project?
+
+ payload = Namespaces::CheckStorageSizeService.new(project.namespace, user).execute.payload
+ return unless payload.present?
+
+ alert_level = "##### #{payload[:alert_level].to_s.upcase} #####"
+
+ [alert_level, payload[:usage_message], payload[:explanation_message]].join("\n")
+ end
end
+
+PostReceiveService.prepend_if_ee('EE::PostReceiveService')
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index 1ce1ef7a1cd..76c89e85f17 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -10,7 +10,10 @@ module Projects
return forbidden unless alerts_service_activated?
return unauthorized unless valid_token?(token)
- process_incident_issues if process_issues?
+ alert = create_alert
+ return bad_request unless alert.persisted?
+
+ process_incident_issues(alert) if process_issues?
send_alert_email if send_email?
ServiceResponse.success
@@ -22,13 +25,21 @@ 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)
+ end
+
+ def create_alert
+ AlertManagement::Alert.create(am_alert_params)
+ end
+
def send_email?
incident_management_setting.send_email?
end
- def process_incident_issues
+ def process_incident_issues(alert)
IncidentManagement::ProcessAlertWorker
- .perform_async(project.id, parsed_payload)
+ .perform_async(project.id, parsed_payload, alert.id)
end
def send_alert_email
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index fc09d14ba4d..b53a9c1561e 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -33,7 +33,7 @@ module Projects
end
def order_by_date(tags)
- now = DateTime.now
+ now = DateTime.current
tags.sort_by { |tag| tag.created_at || now }.reverse
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 429ae905e3d..3233d1799b8 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -108,8 +108,22 @@ module Projects
# users in the background
def setup_authorizations
if @project.group
- @project.group.refresh_members_authorized_projects(blocking: false)
current_user.refresh_authorized_projects
+
+ if Feature.enabled?(:specialized_project_authorization_workers)
+ AuthorizedProjectUpdate::ProjectCreateWorker.perform_async(@project.id)
+ # AuthorizedProjectsWorker uses an exclusive lease per user but
+ # specialized workers might have synchronization issues. Until we
+ # compare the inconsistency rates of both approaches, we still run
+ # AuthorizedProjectsWorker but with some delay and lower urgency as a
+ # safety net.
+ @project.group.refresh_members_authorized_projects(
+ blocking: false,
+ priority: UserProjectAccessChangedService::LOW_PRIORITY
+ )
+ else
+ @project.group.refresh_members_authorized_projects(blocking: false)
+ end
else
@project.add_maintainer(@project.namespace.owner, current_user: current_user)
end
@@ -202,8 +216,19 @@ module Projects
end
end
+ def extra_attributes_for_measurement
+ {
+ current_user: current_user&.name,
+ project_full_path: "#{project_namespace&.full_path}/#{@params[:path]}"
+ }
+ end
+
private
+ def project_namespace
+ @project_namespace ||= Namespace.find_by_id(@params[:namespace_id]) || current_user.namespace
+ end
+
def create_from_template?
@params[:template_name].present? || @params[:template_project_id].present?
end
@@ -224,4 +249,9 @@ 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/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb
index 234ebbc6651..2e192942b9c 100644
--- a/app/services/projects/gitlab_projects_import_service.rb
+++ b/app/services/projects/gitlab_projects_import_service.rb
@@ -29,17 +29,21 @@ module Projects
end
def project_with_same_full_path?
- Project.find_by_full_path("#{current_namespace.full_path}/#{params[:path]}").present?
+ Project.find_by_full_path(project_path).present?
end
# rubocop: disable CodeReuse/ActiveRecord
def current_namespace
strong_memoize(:current_namespace) do
- Namespace.find_by(id: params[:namespace_id])
+ Namespace.find_by(id: params[:namespace_id]) || current_user.namespace
end
end
# rubocop: enable CodeReuse/ActiveRecord
+ def project_path
+ "#{current_namespace.full_path}/#{params[:path]}"
+ end
+
def overwrite?
strong_memoize(:overwrite) do
params.delete(:overwrite)
diff --git a/app/services/projects/hashed_storage/base_attachment_service.rb b/app/services/projects/hashed_storage/base_attachment_service.rb
index f8852c206e3..a2a7895ba17 100644
--- a/app/services/projects/hashed_storage/base_attachment_service.rb
+++ b/app/services/projects/hashed_storage/base_attachment_service.rb
@@ -70,7 +70,7 @@ module Projects
#
# @param [String] new_path
def discard_path!(new_path)
- discarded_path = "#{new_path}-#{Time.now.utc.to_i}"
+ discarded_path = "#{new_path}-#{Time.current.utc.to_i}"
logger.info("Moving existing empty attachments folder from '#{new_path}' to '#{discarded_path}', (PROJECT_ID=#{project.id})")
FileUtils.mv(new_path, discarded_path)
diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb
index d81aa4de9f1..065bf8725be 100644
--- a/app/services/projects/hashed_storage/base_repository_service.rb
+++ b/app/services/projects/hashed_storage/base_repository_service.rb
@@ -8,13 +8,15 @@ module Projects
class BaseRepositoryService < BaseService
include Gitlab::ShellAdapter
- attr_reader :old_disk_path, :new_disk_path, :old_storage_version, :logger, :move_wiki
+ attr_reader :old_disk_path, :new_disk_path, :old_storage_version,
+ :logger, :move_wiki, :move_design
def initialize(project:, old_disk_path:, logger: nil)
@project = project
@logger = logger || Gitlab::AppLogger
@old_disk_path = old_disk_path
@move_wiki = has_wiki?
+ @move_design = has_design?
end
protected
@@ -23,6 +25,10 @@ module Projects
gitlab_shell.repository_exists?(project.repository_storage, "#{old_wiki_disk_path}.git")
end
+ def has_design?
+ gitlab_shell.repository_exists?(project.repository_storage, "#{old_design_disk_path}.git")
+ end
+
def move_repository(from_name, to_name)
from_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{from_name}.git")
to_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{to_name}.git")
@@ -58,12 +64,18 @@ module Projects
project.clear_memoization(:wiki)
end
+ if move_design
+ result &&= move_repository(old_design_disk_path, new_design_disk_path)
+ project.clear_memoization(:design_repository)
+ end
+
result
end
def rollback_folder_move
move_repository(new_disk_path, old_disk_path)
move_repository(new_wiki_disk_path, old_wiki_disk_path)
+ move_repository(new_design_disk_path, old_design_disk_path) if move_design
end
def try_to_set_repository_read_only!
@@ -87,8 +99,18 @@ module Projects
def new_wiki_disk_path
@new_wiki_disk_path ||= "#{new_disk_path}#{wiki_path_suffix}"
end
+
+ def design_path_suffix
+ @design_path_suffix ||= ::Gitlab::GlRepository::DESIGN.path_suffix
+ end
+
+ def old_design_disk_path
+ @old_design_disk_path ||= "#{old_disk_path}#{design_path_suffix}"
+ end
+
+ def new_design_disk_path
+ @new_design_disk_path ||= "#{new_disk_path}#{design_path_suffix}"
+ end
end
end
end
-
-Projects::HashedStorage::BaseRepositoryService.prepend_if_ee('EE::Projects::HashedStorage::BaseRepositoryService')
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index 8893bf18e1f..86cb4f35206 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -3,19 +3,35 @@
module Projects
module ImportExport
class ExportService < BaseService
- def execute(after_export_strategy = nil, options = {})
+ prepend Measurable
+
+ def initialize(*args)
+ super
+
+ @shared = project.import_export_shared
+ end
+
+ def execute(after_export_strategy = nil)
unless project.template_source? || can?(current_user, :admin_project, project)
raise ::Gitlab::ImportExport::Error.permission_error(current_user, project)
end
- @shared = project.import_export_shared
-
save_all!
execute_after_export_action(after_export_strategy)
ensure
cleanup
end
+ protected
+
+ def extra_attributes_for_measurement
+ {
+ current_user: current_user&.name,
+ project_full_path: project&.full_path,
+ file_path: shared.export_path
+ }
+ end
+
private
attr_accessor :shared
@@ -42,7 +58,10 @@ module Projects
end
def exporters
- [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver, snippets_repo_saver]
+ [
+ version_saver, avatar_saver, project_tree_saver, uploads_saver,
+ repo_saver, wiki_repo_saver, lfs_saver, snippets_repo_saver, design_repo_saver
+ ]
end
def version_saver
@@ -81,6 +100,10 @@ module Projects
Gitlab::ImportExport::SnippetsRepoSaver.new(current_user: current_user, project: project, shared: shared)
end
+ def design_repo_saver
+ Gitlab::ImportExport::DesignRepoSaver.new(project: project, shared: shared)
+ end
+
def cleanup
FileUtils.rm_rf(shared.archive_path) if shared&.archive_path
end
@@ -103,5 +126,3 @@ module Projects
end
end
end
-
-Projects::ImportExport::ExportService.prepend_if_ee('EE::Projects::ImportExport::ExportService')
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index 4b294a97516..449c4c3de6b 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -3,6 +3,7 @@
module Projects
class ImportService < BaseService
Error = Class.new(StandardError)
+ PermissionError = Class.new(StandardError)
# Returns true if this importer is supposed to perform its work in the
# background.
@@ -21,6 +22,8 @@ module Projects
import_data
+ after_execute_hook
+
success
rescue Gitlab::UrlBlocker::BlockedUrlError => e
Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type)
@@ -34,8 +37,23 @@ module Projects
error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message })
end
+ protected
+
+ def extra_attributes_for_measurement
+ {
+ current_user: current_user&.name,
+ project_full_path: project&.full_path,
+ import_type: project&.import_type,
+ file_path: project&.import_source
+ }
+ end
+
private
+ def after_execute_hook
+ # Defined in EE::Projects::ImportService
+ end
+
def add_repository_to_project
if project.external_import? && !unknown_url?
begin
@@ -130,3 +148,10 @@ module Projects
end
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_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
index 48a21bf94ba..efd410088ab 100644
--- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
@@ -69,7 +69,7 @@ module Projects
# application/vnd.git-lfs+json
# (https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md#requests),
# HTTParty does not know this is actually JSON.
- data = JSON.parse(response.body)
+ data = Gitlab::Json.parse(response.body)
raise DownloadLinksError, "LFS Batch API did return any objects" unless data.is_a?(Hash) && data.key?('objects')
diff --git a/app/services/projects/lsif_data_service.rb b/app/services/projects/lsif_data_service.rb
index 142a5a910d4..5e7055b3309 100644
--- a/app/services/projects/lsif_data_service.rb
+++ b/app/services/projects/lsif_data_service.rb
@@ -42,7 +42,7 @@ module Projects
file.open do |stream|
Zlib::GzipReader.wrap(stream) do |gz_stream|
- data = JSON.parse(gz_stream.read)
+ data = Gitlab::Json.parse(gz_stream.read)
end
end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 6ebc061c2e3..2583a6cae9f 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -12,6 +12,7 @@ module Projects
return unprocessable_entity unless valid_version?
return unauthorized unless valid_alert_manager_token?(token)
+ process_prometheus_alerts
persist_events
send_alert_email if send_email?
process_incident_issues if process_issues?
@@ -115,6 +116,14 @@ module Projects
end
end
+ def process_prometheus_alerts
+ alerts.each do |alert|
+ AlertManagement::ProcessPrometheusAlertService
+ .new(project, nil, alert.to_h)
+ .execute
+ end
+ end
+
def persist_events
CreateEventsService.new(project, nil, params).execute
end
diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb
index 6013b00b8c6..0483c951f1e 100644
--- a/app/services/projects/propagate_service_template.rb
+++ b/app/services/projects/propagate_service_template.rb
@@ -4,8 +4,10 @@ module Projects
class PropagateServiceTemplate
BATCH_SIZE = 100
- def self.propagate(*args)
- new(*args).propagate
+ delegate :data_fields_present?, to: :template
+
+ def self.propagate(template)
+ new(template).propagate
end
def initialize(template)
@@ -13,15 +15,15 @@ module Projects
end
def propagate
- return unless @template.active?
-
- Rails.logger.info("Propagating services for template #{@template.id}") # rubocop:disable Gitlab/RailsLogger
+ return unless template.active?
propagate_projects_with_template
end
private
+ attr_reader :template
+
def propagate_projects_with_template
loop do
batch = Project.uncached { project_ids_batch }
@@ -38,7 +40,14 @@ module Projects
end
Project.transaction do
- bulk_insert_services(service_hash.keys << 'project_id', service_list)
+ results = bulk_insert(Service, service_hash.keys << 'project_id', service_list)
+
+ if data_fields_present?
+ data_list = results.map { |row| data_hash.values << row['id'] }
+
+ bulk_insert(template.data_fields.class, data_hash.keys << 'service_id', data_list)
+ end
+
run_callbacks(batch)
end
end
@@ -52,36 +61,27 @@ module Projects
SELECT true
FROM services
WHERE services.project_id = projects.id
- AND services.type = '#{@template.type}'
+ AND services.type = #{ActiveRecord::Base.connection.quote(template.type)}
)
AND projects.pending_delete = false
AND projects.archived = false
LIMIT #{BATCH_SIZE}
- SQL
+ SQL
)
end
- def bulk_insert_services(columns, values_array)
- ActiveRecord::Base.connection.execute(
- <<-SQL.strip_heredoc
- INSERT INTO services (#{columns.join(', ')})
- VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')}
- SQL
- )
+ 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
def service_hash
- @service_hash ||=
- begin
- template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id')
-
- template_hash.each_with_object({}) do |(key, value), service_hash|
- value = value.is_a?(Hash) ? value.to_json : value
+ @service_hash ||= template.as_json(methods: :type, except: %w[id template project_id])
+ end
- service_hash[ActiveRecord::Base.connection.quote_column_name(key)] =
- ActiveRecord::Base.connection.quote(value)
- end
- end
+ def data_hash
+ @data_hash ||= template.data_fields.as_json(only: template.data_fields.class.column_names).except('id', 'service_id')
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -97,11 +97,11 @@ module Projects
# rubocop: enable CodeReuse/ActiveRecord
def active_external_issue_tracker?
- @template.issue_tracker? && !@template.default
+ template.issue_tracker? && !template.default
end
def active_external_wiki?
- @template.type == 'ExternalWikiService'
+ template.type == 'ExternalWikiService'
end
end
end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 309eab59463..60e5b7e2639 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -135,7 +135,8 @@ module Projects
return if project.hashed_storage?(:repository)
move_repo_folder(@new_path, @old_path)
- move_repo_folder("#{@new_path}.wiki", "#{@old_path}.wiki")
+ move_repo_folder(new_wiki_repo_path, old_wiki_repo_path)
+ move_repo_folder(new_design_repo_path, old_design_repo_path)
end
def move_repo_folder(from_name, to_name)
@@ -157,8 +158,9 @@ module Projects
# Disk path is changed; we need to ensure we reload it
project.reload_repository!
- # Move wiki repo also if present
- move_repo_folder("#{@old_path}.wiki", "#{@new_path}.wiki")
+ # Move wiki and design repos also if present
+ move_repo_folder(old_wiki_repo_path, new_wiki_repo_path)
+ move_repo_folder(old_design_repo_path, new_design_repo_path)
end
def move_project_uploads(project)
@@ -170,6 +172,22 @@ module Projects
@new_namespace.full_path
)
end
+
+ def old_wiki_repo_path
+ "#{old_path}#{::Gitlab::GlRepository::WIKI.path_suffix}"
+ end
+
+ def new_wiki_repo_path
+ "#{new_path}#{::Gitlab::GlRepository::WIKI.path_suffix}"
+ end
+
+ def old_design_repo_path
+ "#{old_path}#{::Gitlab::GlRepository::DESIGN.path_suffix}"
+ end
+
+ def new_design_repo_path
+ "#{new_path}#{::Gitlab::GlRepository::DESIGN.path_suffix}"
+ end
end
end
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index 13a467a3ef9..e554bed6819 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -29,14 +29,16 @@ module Projects
remote_mirror.ensure_remote!
repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true)
- opts = {}
- if remote_mirror.only_protected_branches?
- opts[:only_branches_matching] = project.protected_branches.select(:name).map(&:name)
- end
+ response = remote_mirror.update_repository
- remote_mirror.update_repository(opts)
+ if response.divergent_refs.any?
+ message = "Some refs have diverged and have not been updated on the remote:"
+ message += "\n\n#{response.divergent_refs.join("\n")}"
- remote_mirror.update_finish!
+ remote_mirror.mark_as_failed!(message)
+ else
+ remote_mirror.update_finish!
+ end
end
def retry_or_fail(mirror, message, tries)
diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb
index 2e5de9411d1..0632df6f6d7 100644
--- a/app/services/projects/update_repository_storage_service.rb
+++ b/app/services/projects/update_repository_storage_service.rb
@@ -1,37 +1,49 @@
# frozen_string_literal: true
module Projects
- class UpdateRepositoryStorageService < BaseService
- include Gitlab::ShellAdapter
-
+ class UpdateRepositoryStorageService
Error = Class.new(StandardError)
SameFilesystemError = Class.new(Error)
- def initialize(project)
- @project = project
+ attr_reader :repository_storage_move
+ delegate :project, :destination_storage_name, to: :repository_storage_move
+ delegate :repository, to: :project
+
+ def initialize(repository_storage_move)
+ @repository_storage_move = repository_storage_move
end
- def execute(new_repository_storage_key)
- raise SameFilesystemError if same_filesystem?(project.repository.storage, new_repository_storage_key)
+ def execute
+ repository_storage_move.start!
- mirror_repositories(new_repository_storage_key)
+ raise SameFilesystemError if same_filesystem?(repository.storage, destination_storage_name)
- mark_old_paths_for_archive
+ mirror_repositories
- project.update(repository_storage: new_repository_storage_key, repository_read_only: false)
- project.leave_pool_repository
- project.track_project_repository
+ project.transaction do
+ 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
enqueue_housekeeping
- success
+ ServiceResponse.success
- rescue Error, ArgumentError, Gitlab::Git::BaseError => e
- project.update(repository_read_only: false)
+ rescue StandardError => e
+ project.transaction do
+ repository_storage_move.do_fail!
+ project.update!(repository_read_only: false)
+ end
Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path)
- error(s_("UpdateRepositoryStorage|Error moving repository storage for %{project_full_path} - %{message}") % { project_full_path: project.full_path, message: e.message })
+ ServiceResponse.error(
+ message: s_("UpdateRepositoryStorage|Error moving repository storage for %{project_full_path} - %{message}") % { project_full_path: project.full_path, message: e.message }
+ )
end
private
@@ -40,15 +52,19 @@ module Projects
Gitlab::GitalyClient.filesystem_id(old_storage) == Gitlab::GitalyClient.filesystem_id(new_storage)
end
- def mirror_repositories(new_repository_storage_key)
- mirror_repository(new_repository_storage_key)
+ def mirror_repositories
+ mirror_repository
if project.wiki.repository_exists?
- mirror_repository(new_repository_storage_key, type: Gitlab::GlRepository::WIKI)
+ mirror_repository(type: Gitlab::GlRepository::WIKI)
+ end
+
+ if project.design_repository.exists?
+ mirror_repository(type: ::Gitlab::GlRepository::DESIGN)
end
end
- def mirror_repository(new_storage_key, type: Gitlab::GlRepository::PROJECT)
+ def mirror_repository(type: Gitlab::GlRepository::PROJECT)
unless wait_for_pushes(type)
raise Error, s_('UpdateRepositoryStorage|Timeout waiting for %{type} repository pushes') % { type: type.name }
end
@@ -60,7 +76,7 @@ module Projects
# Initialize a git repository on the target path
new_repository = Gitlab::Git::Repository.new(
- new_storage_key,
+ destination_storage_name,
raw_repository.relative_path,
raw_repository.gl_repository,
full_path
@@ -94,11 +110,18 @@ module Projects
wiki.disk_path,
"#{new_project_path}.wiki")
end
+
+ if design_repository.exists?
+ GitlabShellWorker.perform_async(:mv_repository,
+ old_repository_storage,
+ design_repository.disk_path,
+ "#{new_project_path}.design")
+ end
end
end
def moved_path(path)
- "#{path}+#{project.id}+moved+#{Time.now.to_i}"
+ "#{path}+#{project.id}+moved+#{Time.current.to_i}"
end
# The underlying FetchInternalRemote call uses a `git fetch` to move data
@@ -128,5 +151,3 @@ module Projects
end
end
end
-
-Projects::UpdateRepositoryStorageService.prepend_if_ee('EE::Projects::UpdateRepositoryStorageService')
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
index 99c739a630b..085cfc76196 100644
--- a/app/services/prometheus/proxy_service.rb
+++ b/app/services/prometheus/proxy_service.rb
@@ -17,6 +17,7 @@ module Prometheus
# is expected to change *and* be fetched again by the frontend
self.reactive_cache_refresh_interval = 90.seconds
self.reactive_cache_lifetime = 1.minute
+ self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
attr_accessor :proxyable, :method, :path, :params
diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb
index 240586c8419..aa3a09ba05c 100644
--- a/app/services/prometheus/proxy_variable_substitution_service.rb
+++ b/app/services/prometheus/proxy_variable_substitution_service.rb
@@ -4,11 +4,20 @@ module Prometheus
class ProxyVariableSubstitutionService < BaseService
include Stepable
+ VARIABLE_INTERPOLATION_REGEX = /
+ {{ # Variable needs to be wrapped in these chars.
+ \s* # Allow whitespace before and after the variable name.
+ (?<variable> # Named capture.
+ \w+ # Match one or more word characters.
+ )
+ \s*
+ }}
+ /x.freeze
+
steps :validate_variables,
:add_params_to_result,
:substitute_params,
- :substitute_ruby_variables,
- :substitute_liquid_variables
+ :substitute_variables
def initialize(environment, params = {})
@environment, @params = environment, params.deep_dup
@@ -46,37 +55,28 @@ module Prometheus
success(result)
end
- def substitute_liquid_variables(result)
+ def substitute_variables(result)
return success(result) unless query(result)
- result[:params][:query] =
- TemplateEngines::LiquidService.new(query(result)).render(full_context)
+ result[:params][:query] = gsub(query(result), full_context)
success(result)
- rescue TemplateEngines::LiquidService::RenderError => e
- error(e.message)
end
- def substitute_ruby_variables(result)
- return success(result) unless query(result)
-
- # The % operator doesn't replace variables if the hash contains string
- # keys.
- result[:params][:query] = query(result) % predefined_context.symbolize_keys
-
- success(result)
- rescue TypeError, ArgumentError => exception
- log_error(exception.message)
- Gitlab::ErrorTracking.track_exception(exception, {
- template_string: query(result),
- variables: predefined_context
- })
-
- error(_('Malformed string'))
+ def gsub(string, context)
+ # Search for variables of the form `{{variable}}` in the string and replace
+ # them with their value.
+ string.gsub(VARIABLE_INTERPOLATION_REGEX) do |match|
+ # Replace with the value of the variable, or if there is no such variable,
+ # replace the invalid variable with itself. So,
+ # `up{instance="{{invalid_variable}}"}` will remain
+ # `up{instance="{{invalid_variable}}"}` after substitution.
+ context.fetch($~[:variable], match)
+ end
end
def predefined_context
- @predefined_context ||= Gitlab::Prometheus::QueryVariables.call(@environment)
+ Gitlab::Prometheus::QueryVariables.call(@environment).stringify_keys
end
def full_context
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index 9a0a876454f..81ca9d6d123 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -47,11 +47,17 @@ module Releases
release.save!
+ notify_create_release(release)
+
success(tag: tag, release: release)
rescue => e
error(e.message, 400)
end
+ def notify_create_release(release)
+ NotificationService.new.async.send_new_release_notifications(release)
+ end
+
def build_release(tag)
project.releases.build(
name: name,
diff --git a/app/services/resources/create_access_token_service.rb b/app/services/resource_access_tokens/create_service.rb
index fd3c8d78e58..c8e86e68383 100644
--- a/app/services/resources/create_access_token_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -1,13 +1,11 @@
# frozen_string_literal: true
-module Resources
- class CreateAccessTokenService < BaseService
- attr_accessor :resource_type, :resource
-
- def initialize(resource_type, resource, user, params = {})
- @resource_type = resource_type
+module ResourceAccessTokens
+ class CreateService < BaseService
+ def initialize(current_user, resource, params = {})
+ @resource_type = resource.class.name.downcase
@resource = resource
- @current_user = user
+ @current_user = current_user
@params = params.dup
end
@@ -33,6 +31,8 @@ module Resources
private
+ attr_reader :resource_type, :resource
+
def feature_enabled?
::Feature.enabled?(:resource_access_token, resource)
end
@@ -85,7 +85,7 @@ module Resources
def personal_access_token_params
{
- name: "#{resource_type}_bot",
+ name: params[:name] || "#{resource_type}_bot",
impersonation: false,
scopes: params[:scopes] || default_scopes,
expires_at: params[:expires_at] || nil
@@ -93,7 +93,7 @@ module Resources
end
def default_scopes
- Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user]
+ Gitlab::Auth.resource_bot_scopes
end
def provision_access(resource, user)
diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb
new file mode 100644
index 00000000000..eea6bff572b
--- /dev/null
+++ b/app/services/resource_access_tokens/revoke_service.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module ResourceAccessTokens
+ class RevokeService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ RevokeAccessTokenError = Class.new(RuntimeError)
+
+ def initialize(current_user, resource, access_token)
+ @current_user = current_user
+ @access_token = access_token
+ @bot_user = access_token.user
+ @resource = resource
+ end
+
+ def execute
+ return error("Failed to find bot user") unless find_member
+
+ PersonalAccessToken.transaction do
+ access_token.revoke!
+
+ raise RevokeAccessTokenError, "Failed to remove #{bot_user.name} member from: #{resource.name}" unless remove_member
+
+ raise RevokeAccessTokenError, "Migration to ghost user failed" unless migrate_to_ghost_user
+ end
+
+ success("Revoked access token: #{access_token.name}")
+ rescue ActiveRecord::ActiveRecordError, RevokeAccessTokenError => error
+ log_error("Failed to revoke access token for #{bot_user.name}: #{error.message}")
+ error(error.message)
+ end
+
+ private
+
+ attr_reader :current_user, :access_token, :bot_user, :resource
+
+ def remove_member
+ ::Members::DestroyService.new(current_user).execute(find_member)
+ end
+
+ def migrate_to_ghost_user
+ ::Users::MigrateToGhostUserService.new(bot_user).execute
+ end
+
+ def find_member
+ strong_memoize(:member) do
+ if resource.is_a?(Project)
+ resource.project_member(bot_user)
+ elsif resource.is_a?(Group)
+ resource.group_member(bot_user)
+ else
+ false
+ end
+ end
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def success(message)
+ ServiceResponse.success(message: message)
+ end
+ end
+end
diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb
index 1b85ca811a1..db8bf6e4b74 100644
--- a/app/services/resource_events/base_synthetic_notes_builder_service.rb
+++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb
@@ -26,7 +26,7 @@ module ResourceEvents
def since_fetch_at(events)
return events unless params[:last_fetched_at].present?
- last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i)
+ last_fetched_at = Time.zone.at(params.fetch(:last_fetched_at).to_i)
events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
end
diff --git a/app/services/resource_events/change_milestone_service.rb b/app/services/resource_events/change_milestone_service.rb
index ea196822f74..82c3e2acad5 100644
--- a/app/services/resource_events/change_milestone_service.rb
+++ b/app/services/resource_events/change_milestone_service.rb
@@ -2,13 +2,14 @@
module ResourceEvents
class ChangeMilestoneService
- attr_reader :resource, :user, :event_created_at, :milestone
+ attr_reader :resource, :user, :event_created_at, :milestone, :old_milestone
- def initialize(resource, user, created_at: Time.now)
+ def initialize(resource, user, created_at: Time.current, old_milestone:)
@resource = resource
@user = user
@event_created_at = created_at
@milestone = resource&.milestone
+ @old_milestone = old_milestone
end
def execute
@@ -26,7 +27,7 @@ module ResourceEvents
{
user_id: user.id,
created_at: event_created_at,
- milestone_id: milestone&.id,
+ milestone_id: action == :add ? milestone&.id : old_milestone&.id,
state: ResourceMilestoneEvent.states[resource.state],
action: ResourceMilestoneEvent.actions[action],
key => resource.id
diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb
index e686d3bf7c2..30401b28571 100644
--- a/app/services/search/snippet_service.rb
+++ b/app/services/search/snippet_service.rb
@@ -7,7 +7,7 @@ module Search
end
def scope
- @scope ||= %w[snippet_titles].delete(params[:scope]) { 'snippet_blobs' }
+ @scope ||= 'snippet_titles'
end
end
end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index c96599f9958..bf21eba28f7 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -6,6 +6,9 @@ class SearchService
SEARCH_TERM_LIMIT = 64
SEARCH_CHAR_LIMIT = 4096
+ DEFAULT_PER_PAGE = Gitlab::SearchResults::DEFAULT_PER_PAGE
+ MAX_PER_PAGE = 200
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params.dup
@@ -60,11 +63,19 @@ class SearchService
end
def search_objects
- @search_objects ||= redact_unauthorized_results(search_results.objects(scope, params[:page]))
+ @search_objects ||= redact_unauthorized_results(search_results.objects(scope, page: params[:page], per_page: per_page))
end
private
+ def per_page
+ per_page_param = params[:per_page].to_i
+
+ return DEFAULT_PER_PAGE unless per_page_param.positive?
+
+ [MAX_PER_PAGE, per_page_param].min
+ end
+
def visible_result?(object)
return true unless object.respond_to?(:to_ability_name) && DeclarativePolicy.has_policy?(object)
@@ -75,13 +86,13 @@ class SearchService
results = results_collection.to_a
permitted_results = results.select { |object| visible_result?(object) }
- filtered_results = (results - permitted_results).each_with_object({}) do |object, memo|
+ 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 }
end
- log_redacted_search_results(filtered_results.values) if filtered_results.any?
+ log_redacted_search_results(redacted_results.values) if redacted_results.any?
- return results_collection.id_not_in(filtered_results.keys) if results_collection.is_a?(ActiveRecord::Relation)
+ return results_collection.id_not_in(redacted_results.keys) if results_collection.is_a?(ActiveRecord::Relation)
Kaminari.paginate_array(
permitted_results,
diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb
index 2b450db0b83..81d12997335 100644
--- a/app/services/snippets/base_service.rb
+++ b/app/services/snippets/base_service.rb
@@ -2,8 +2,32 @@
module Snippets
class BaseService < ::BaseService
+ include SpamCheckMethods
+
+ CreateRepositoryError = Class.new(StandardError)
+
+ attr_reader :uploaded_files
+
+ def initialize(project, user = nil, params = {})
+ super
+
+ @uploaded_files = Array(@params.delete(:files).presence)
+
+ filter_spam_check_params
+ end
+
private
+ def visibility_allowed?(snippet, visibility_level)
+ Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level)
+ end
+
+ def error_forbidden_visibility(snippet)
+ deny_visibility_level(snippet)
+
+ snippet_error_response(snippet, 403)
+ end
+
def snippet_error_response(snippet, http_status)
ServiceResponse.error(
message: snippet.errors.full_messages.to_sentence,
@@ -11,5 +35,22 @@ module Snippets
payload: { snippet: snippet }
)
end
+
+ def add_snippet_repository_error(snippet:, error:)
+ message = repository_error_message(error)
+
+ snippet.errors.add(:repository, message)
+ end
+
+ def repository_error_message(error)
+ message = self.is_a?(Snippets::CreateService) ? _("Error creating the snippet") : _("Error updating the snippet")
+
+ # We only want to include additional error detail in the message
+ # if the error is not a CommitError because we cannot guarantee the message
+ # will be user-friendly
+ message += " - #{error.message}" unless error.instance_of?(SnippetRepository::CommitError)
+
+ message
+ end
end
end
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 155013db344..ed6da3a0ad0 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -2,23 +2,11 @@
module Snippets
class CreateService < Snippets::BaseService
- include SpamCheckMethods
-
- CreateRepositoryError = Class.new(StandardError)
-
def execute
- filter_spam_check_params
-
- @snippet = if project
- project.snippets.build(params)
- else
- PersonalSnippet.new(params)
- end
-
- unless Gitlab::VisibilityLevel.allowed_for?(current_user, @snippet.visibility_level)
- deny_visibility_level(@snippet)
+ @snippet = build_from_params
- return snippet_error_response(@snippet, 403)
+ unless visibility_allowed?(@snippet, @snippet.visibility_level)
+ return error_forbidden_visibility(@snippet)
end
@snippet.author = current_user
@@ -29,6 +17,8 @@ module Snippets
UserAgentDetailService.new(@snippet, @request).create
Gitlab::UsageDataCounters::SnippetCounter.count(:create)
+ move_temporary_files
+
ServiceResponse.success(payload: { snippet: @snippet } )
else
snippet_error_response(@snippet, 400)
@@ -37,10 +27,18 @@ module Snippets
private
+ def build_from_params
+ if project
+ project.snippets.build(params)
+ else
+ PersonalSnippet.new(params)
+ end
+ end
+
def save_and_commit
snippet_saved = @snippet.save
- if snippet_saved && Feature.enabled?(:version_snippets, current_user)
+ if snippet_saved
create_repository
create_commit
end
@@ -60,7 +58,7 @@ module Snippets
@snippet = @snippet.dup
end
- @snippet.errors.add(:base, e.message)
+ add_snippet_repository_error(snippet: @snippet, error: e)
false
end
@@ -83,5 +81,13 @@ module Snippets
def snippet_files
[{ file_path: params[:file_name], content: params[:content] }]
end
+
+ def move_temporary_files
+ return unless @snippet.is_a?(PersonalSnippet)
+
+ uploaded_files.each do |file|
+ FileMover.new(file, from_model: current_user, to_model: @snippet).execute
+ end
+ end
end
end
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index e56b20c6057..2dc9266dbd0 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -2,24 +2,15 @@
module Snippets
class UpdateService < Snippets::BaseService
- include SpamCheckMethods
+ COMMITTABLE_ATTRIBUTES = %w(file_name content).freeze
UpdateError = Class.new(StandardError)
- CreateRepositoryError = Class.new(StandardError)
def execute(snippet)
- # check that user is allowed to set specified visibility_level
- new_visibility = visibility_level
-
- if new_visibility && new_visibility.to_i != snippet.visibility_level
- unless Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
- deny_visibility_level(snippet, new_visibility)
-
- return snippet_error_response(snippet, 403)
- end
+ if visibility_changed?(snippet) && !visibility_allowed?(snippet, visibility_level)
+ return error_forbidden_visibility(snippet)
end
- filter_spam_check_params
snippet.assign_attributes(params)
spam_check(snippet, current_user)
@@ -34,30 +25,32 @@ module Snippets
private
+ def visibility_changed?(snippet)
+ visibility_level && visibility_level.to_i != snippet.visibility_level
+ end
+
def save_and_commit(snippet)
return false unless snippet.save
- # In order to avoid non migrated snippets scenarios,
- # if the snippet does not have a repository we created it
- # We don't need to check if the repository exists
- # because `create_repository` already handles it
- if Feature.enabled?(:version_snippets, current_user)
- create_repository_for(snippet)
- end
+ # If the updated attributes does not need to update
+ # the repository we can just return
+ return true unless committable_attributes?
- # If the snippet repository exists we commit always
- # the changes
- create_commit(snippet) if snippet.repository_exists?
+ create_repository_for(snippet)
+ create_commit(snippet)
true
rescue => e
- # Restore old attributes
+ # Restore old attributes but re-assign changes so they're not lost
unless snippet.previous_changes.empty?
snippet.previous_changes.each { |attr, value| snippet[attr] = value[0] }
snippet.save
+
+ snippet.assign_attributes(params)
end
- snippet.errors.add(:repository, 'Error updating the snippet')
+ add_snippet_repository_error(snippet: snippet, error: e)
+
log_error(e.message)
# If the commit action failed we remove it because
@@ -92,7 +85,7 @@ module Snippets
end
def snippet_files(snippet)
- [{ previous_path: snippet.blobs.first&.path,
+ [{ previous_path: snippet.file_name_on_repo,
file_path: params[:file_name],
content: params[:content] }]
end
@@ -104,5 +97,9 @@ module Snippets
def repository_empty?(snippet)
snippet.repository._uncached_exists? && !snippet.repository._uncached_has_visible_content?
end
+
+ def committable_attributes?
+ (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present?
+ end
end
end
diff --git a/app/services/spam/akismet_service.rb b/app/services/spam/akismet_service.rb
index 7d16743b3ed..ab35fb8700f 100644
--- a/app/services/spam/akismet_service.rb
+++ b/app/services/spam/akismet_service.rb
@@ -17,7 +17,7 @@ module Spam
params = {
type: 'comment',
text: text,
- created_at: DateTime.now,
+ created_at: DateTime.current,
author: owner_name,
author_email: owner_email,
referrer: options[:referrer]
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
new file mode 100644
index 00000000000..f0a4aff4443
--- /dev/null
+++ b/app/services/spam/spam_action_service.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module Spam
+ class SpamActionService
+ include SpamConstants
+
+ attr_accessor :target, :request, :options
+ attr_reader :spam_log
+
+ def initialize(spammable:, request:)
+ @target = spammable
+ @request = request
+ @options = {}
+
+ if @request
+ @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s
+ @options[:user_agent] = @request.env['HTTP_USER_AGENT']
+ @options[:referrer] = @request.env['HTTP_REFERRER']
+ else
+ @options[:ip_address] = @target.ip_address
+ @options[:user_agent] = @target.user_agent
+ end
+ end
+
+ def execute(api: false, recaptcha_verified:, spam_log_id:, user:)
+ if recaptcha_verified
+ # If it's a request which is already verified through reCAPTCHA,
+ # update the spam log accordingly.
+ SpamLog.verify_recaptcha!(user_id: user.id, id: spam_log_id)
+ else
+ return if allowlisted?(user)
+ return unless request
+ return unless check_for_spam?
+
+ perform_spam_service_check(api)
+ end
+ end
+
+ delegate :check_for_spam?, to: :target
+
+ private
+
+ def allowlisted?(user)
+ user.respond_to?(:gitlab_employee) && user.gitlab_employee?
+ end
+
+ def perform_spam_service_check(api)
+ # since we can check for spam, and recaptcha is not verified,
+ # ask the SpamVerdictService what to do with the target.
+ spam_verdict_service.execute.tap do |result|
+ case result
+ when REQUIRE_RECAPTCHA
+ create_spam_log(api)
+
+ break if target.allow_possible_spam?
+
+ target.needs_recaptcha!
+ when DISALLOW
+ # TODO: remove `unless target.allow_possible_spam?` once this flag has been passed to `SpamVerdictService`
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/214739
+ target.spam! unless target.allow_possible_spam?
+ create_spam_log(api)
+ when ALLOW
+ target.clear_spam_flags!
+ end
+ end
+ end
+
+ def create_spam_log(api)
+ @spam_log = SpamLog.create!(
+ {
+ user_id: target.author_id,
+ title: target.spam_title,
+ description: target.spam_description,
+ source_ip: options[:ip_address],
+ user_agent: options[:user_agent],
+ noteable_type: target.class.to_s,
+ via_api: api
+ }
+ )
+
+ target.spam_log = spam_log
+ end
+
+ def spam_verdict_service
+ SpamVerdictService.new(target: target,
+ request: @request,
+ options: options)
+ end
+ end
+end
diff --git a/app/services/spam/spam_check_service.rb b/app/services/spam/spam_check_service.rb
deleted file mode 100644
index 3269f9d687a..00000000000
--- a/app/services/spam/spam_check_service.rb
+++ /dev/null
@@ -1,68 +0,0 @@
-# frozen_string_literal: true
-
-module Spam
- class SpamCheckService
- include AkismetMethods
-
- attr_accessor :target, :request, :options
- attr_reader :spam_log
-
- def initialize(spammable:, request:)
- @target = spammable
- @request = request
- @options = {}
-
- if @request
- @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s
- @options[:user_agent] = @request.env['HTTP_USER_AGENT']
- @options[:referrer] = @request.env['HTTP_REFERRER']
- else
- @options[:ip_address] = @target.ip_address
- @options[:user_agent] = @target.user_agent
- end
- end
-
- def execute(api: false, recaptcha_verified:, spam_log_id:, user_id:)
- if recaptcha_verified
- # If it's a request which is already verified through recaptcha,
- # update the spam log accordingly.
- SpamLog.verify_recaptcha!(user_id: user_id, id: spam_log_id)
- else
- # Otherwise, it goes to Akismet for spam check.
- # If so, it assigns spammable object as "spam" and creates a SpamLog record.
- possible_spam = check(api)
- target.spam = possible_spam unless target.allow_possible_spam?
- target.spam_log = spam_log
- end
- end
-
- private
-
- def check(api)
- return unless request
- return unless check_for_spam?
- return unless akismet.spam?
-
- create_spam_log(api)
- true
- end
-
- def check_for_spam?
- target.check_for_spam?
- end
-
- def create_spam_log(api)
- @spam_log = SpamLog.create!(
- {
- user_id: target.author_id,
- title: target.spam_title,
- description: target.spam_description,
- source_ip: options[:ip_address],
- user_agent: options[:user_agent],
- noteable_type: target.class.to_s,
- via_api: api
- }
- )
- end
- end
-end
diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb
new file mode 100644
index 00000000000..085bac684c4
--- /dev/null
+++ b/app/services/spam/spam_constants.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Spam
+ module SpamConstants
+ REQUIRE_RECAPTCHA = :recaptcha
+ DISALLOW = :disallow
+ ALLOW = :allow
+ end
+end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
new file mode 100644
index 00000000000..2b4d5f4a984
--- /dev/null
+++ b/app/services/spam/spam_verdict_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Spam
+ class SpamVerdictService
+ include AkismetMethods
+ include SpamConstants
+
+ def initialize(target:, request:, options:)
+ @target = target
+ @request = request
+ @options = options
+ end
+
+ def execute
+ if akismet.spam?
+ Gitlab::Recaptcha.enabled? ? REQUIRE_RECAPTCHA : DISALLOW
+ else
+ ALLOW
+ end
+ end
+
+ private
+
+ attr_reader :target, :request, :options
+ end
+end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 1b9f5971f73..6bf04c55415 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -245,6 +245,34 @@ module SystemNoteService
def auto_resolve_prometheus_alert(noteable, project, author)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).auto_resolve_prometheus_alert
end
+
+ # Parameters:
+ # - version [DesignManagement::Version]
+ #
+ # Example Note text:
+ #
+ # "added [1 designs](link-to-version)"
+ # "changed [2 designs](link-to-version)"
+ #
+ # Returns [Array<Note>]: the created Note objects
+ def design_version_added(version)
+ ::SystemNotes::DesignManagementService.new(noteable: version.issue, project: version.issue.project, author: version.author).design_version_added(version)
+ end
+
+ # Called when a new discussion is created on a design
+ #
+ # discussion_note - DiscussionNote
+ #
+ # Example Note text:
+ #
+ # "started a discussion on screen.png"
+ #
+ # Returns the created Note object
+ def design_discussion_added(discussion_note)
+ design = discussion_note.noteable
+
+ ::SystemNotes::DesignManagementService.new(noteable: design.issue, project: design.project, author: discussion_note.author).design_discussion_added(discussion_note)
+ end
end
SystemNoteService.prepend_if_ee('EE::SystemNoteService')
diff --git a/app/services/system_notes/design_management_service.rb b/app/services/system_notes/design_management_service.rb
new file mode 100644
index 00000000000..a773877e25b
--- /dev/null
+++ b/app/services/system_notes/design_management_service.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class DesignManagementService < ::SystemNotes::BaseService
+ include ActionView::RecordIdentifier
+
+ # Parameters:
+ # - version [DesignManagement::Version]
+ #
+ # Example Note text:
+ #
+ # "added [1 designs](link-to-version)"
+ # "changed [2 designs](link-to-version)"
+ #
+ # Returns [Array<Note>]: the created Note objects
+ def design_version_added(version)
+ events = DesignManagement::Action.events
+ link_href = designs_path(version: version.id)
+
+ version.designs_by_event.map do |(event_name, designs)|
+ note_data = self.class.design_event_note_data(events[event_name])
+ icon_name = note_data[:icon]
+ n = designs.size
+
+ body = "%s [%d %s](%s)" % [note_data[:past_tense], n, 'design'.pluralize(n), link_href]
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: icon_name))
+ end
+ end
+
+ # Called when a new discussion is created on a design
+ #
+ # discussion_note - DiscussionNote
+ #
+ # Example Note text:
+ #
+ # "started a discussion on screen.png"
+ #
+ # Returns the created Note object
+ def design_discussion_added(discussion_note)
+ design = discussion_note.noteable
+
+ body = _('started a discussion on %{design_link}') % {
+ design_link: '[%s](%s)' % [
+ design.filename,
+ designs_path(vueroute: design.filename, anchor: dom_id(discussion_note))
+ ]
+ }
+
+ action = :designs_discussion_added
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: action))
+ end
+
+ # Take one of the `DesignManagement::Action.events` and
+ # return:
+ # * an English past-tense verb.
+ # * the name of an icon used in renderin a system note
+ #
+ # We do not currently internationalize our system notes,
+ # instead we just produce English-language descriptions.
+ # See: https://gitlab.com/gitlab-org/gitlab/issues/30408
+ # See: https://gitlab.com/gitlab-org/gitlab/issues/14056
+ def self.design_event_note_data(event)
+ case event
+ when DesignManagement::Action.events[:creation]
+ { icon: 'designs_added', past_tense: 'added' }
+ when DesignManagement::Action.events[:modification]
+ { icon: 'designs_modified', past_tense: 'updated' }
+ when DesignManagement::Action.events[:deletion]
+ { icon: 'designs_removed', past_tense: 'removed' }
+ else
+ raise "Unknown event: #{event}"
+ end
+ end
+
+ private
+
+ def designs_path(params = {})
+ url_helpers.designs_project_issue_path(project, noteable, params)
+ end
+ end
+end
diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb
index 4f6ae07be7d..3a01192487d 100644
--- a/app/services/tags/destroy_service.rb
+++ b/app/services/tags/destroy_service.rb
@@ -18,11 +18,6 @@ module Tags
.new(project, current_user, tag: tag_name)
.execute
- push_data = build_push_data(tag)
- EventCreateService.new.push(project, current_user, push_data)
- project.execute_hooks(push_data.dup, :tag_push_hooks)
- project.execute_services(push_data.dup, :tag_push_hooks)
-
success('Tag was removed')
else
error('Failed to remove tag')
@@ -38,14 +33,5 @@ module Tags
def success(message)
super().merge(message: message)
end
-
- def build_push_data(tag)
- Gitlab::DataBuilder::Push.build(
- project: project,
- user: current_user,
- oldrev: tag.dereferenced_target.sha,
- newrev: Gitlab::Git::BLANK_SHA,
- ref: "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}")
- end
end
end
diff --git a/app/services/template_engines/liquid_service.rb b/app/services/template_engines/liquid_service.rb
deleted file mode 100644
index 809ebd0316b..00000000000
--- a/app/services/template_engines/liquid_service.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-module TemplateEngines
- class LiquidService < BaseService
- RenderError = Class.new(StandardError)
-
- DEFAULT_RENDER_SCORE_LIMIT = 1_000
-
- def initialize(string)
- @template = Liquid::Template.parse(string)
- end
-
- def render(context, render_score_limit: DEFAULT_RENDER_SCORE_LIMIT)
- set_limits(render_score_limit)
-
- @template.render!(context.stringify_keys)
- rescue Liquid::MemoryError => e
- handle_exception(e, string: @string, context: context)
-
- raise RenderError, _('Memory limit exceeded while rendering template')
- rescue Liquid::Error => e
- handle_exception(e, string: @string, context: context)
-
- raise RenderError, _('Error rendering query')
- end
-
- private
-
- def set_limits(render_score_limit)
- @template.resource_limits.render_score_limit = render_score_limit
-
- # We can also set assign_score_limit and render_length_limit if required.
-
- # render_score_limit limits the number of nodes (string, variable, block, tags)
- # that are allowed in the template.
- # render_length_limit seems to limit the sum of the bytesize of all node blocks.
- # assign_score_limit seems to limit the sum of the bytesize of all capture blocks.
- end
-
- def handle_exception(exception, extra = {})
- log_error(exception.message)
- Gitlab::ErrorTracking.track_exception(exception, {
- template_string: extra[:string],
- variables: extra[:context]
- })
- end
- end
-end
diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb
index 5bb6f6a1dee..d180a3a2432 100644
--- a/app/services/terraform/remote_state_handler.rb
+++ b/app/services/terraform/remote_state_handler.rb
@@ -42,7 +42,7 @@ module Terraform
state.lock_xid = params[:lock_id]
state.locked_by_user = current_user
- state.locked_at = Time.now
+ state.locked_at = Time.current
state.save!
end
diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb
index 21d0861ac3f..66f1ccfab70 100644
--- a/app/services/user_project_access_changed_service.rb
+++ b/app/services/user_project_access_changed_service.rb
@@ -1,17 +1,26 @@
# frozen_string_literal: true
class UserProjectAccessChangedService
+ DELAY = 1.hour
+
+ HIGH_PRIORITY = :high
+ LOW_PRIORITY = :low
+
def initialize(user_ids)
@user_ids = Array.wrap(user_ids)
end
- def execute(blocking: true)
+ def execute(blocking: true, priority: HIGH_PRIORITY)
bulk_args = @user_ids.map { |id| [id] }
if blocking
AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args)
else
- AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext
+ 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
+ end
end
end
end
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index e7186fdfb63..5ca9ed67e56 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -52,6 +52,7 @@ module Users
migrate_notes
migrate_abuse_reports
migrate_award_emoji
+ migrate_snippets
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -79,6 +80,11 @@ module Users
def migrate_award_emoji
user.award_emoji.update_all(user_id: ghost_user.id)
end
+
+ def migrate_snippets
+ snippets = user.snippets.only_project_snippets
+ snippets.update_all(author_id: ghost_user.id)
+ end
end
end
diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb
index b53c3145caf..a9e219547d7 100644
--- a/app/services/verify_pages_domain_service.rb
+++ b/app/services/verify_pages_domain_service.rb
@@ -37,7 +37,7 @@ class VerifyPagesDomainService < BaseService
# Prevent any pre-existing grace period from being truncated
reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max
- domain.assign_attributes(verified_at: Time.now, enabled_until: reverify, remove_at: nil)
+ domain.assign_attributes(verified_at: Time.current, enabled_until: reverify, remove_at: nil)
domain.save!(validate: false)
if was_disabled
@@ -73,7 +73,7 @@ class VerifyPagesDomainService < BaseService
# A domain is only expired until `disable!` has been called
def expired?
- domain.enabled_until && domain.enabled_until < Time.now
+ domain.enabled_until && domain.enabled_until < Time.current
end
def dns_record_present?
diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb
index 2e774973ca5..a0256ea5e69 100644
--- a/app/services/wiki_pages/base_service.rb
+++ b/app/services/wiki_pages/base_service.rb
@@ -6,13 +6,13 @@ module WikiPages
# - external_action: the action we report to external clients with webhooks
# - usage_counter_action: the action that we count in out internal counters
# - event_action: what we record as the value of `Event#action`
- class BaseService < ::BaseService
+ class BaseService < ::BaseContainerService
private
def execute_hooks(page)
page_data = payload(page)
- @project.execute_hooks(page_data, :wiki_page_hooks)
- @project.execute_services(page_data, :wiki_page_hooks)
+ container.execute_hooks(page_data, :wiki_page_hooks)
+ container.execute_services(page_data, :wiki_page_hooks)
increment_usage
create_wiki_event(page)
end
@@ -46,12 +46,9 @@ module WikiPages
def create_wiki_event(page)
return unless ::Feature.enabled?(:wiki_events)
- slug = slug_for_page(page)
+ response = WikiPages::EventCreateService.new(current_user).execute(slug_for_page(page), page, event_action)
- Event.transaction do
- wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
- EventCreateService.new.wiki_event(wiki_page_meta, current_user, event_action)
- end
+ log_error(response.message) if response.error?
end
def slug_for_page(page)
diff --git a/app/services/wiki_pages/create_service.rb b/app/services/wiki_pages/create_service.rb
index 811f460e042..4ef19676d82 100644
--- a/app/services/wiki_pages/create_service.rb
+++ b/app/services/wiki_pages/create_service.rb
@@ -3,8 +3,8 @@
module WikiPages
class CreateService < WikiPages::BaseService
def execute
- project_wiki = ProjectWiki.new(@project, current_user)
- page = WikiPage.new(project_wiki)
+ wiki = Wiki.for_container(container, current_user)
+ page = WikiPage.new(wiki)
if page.create(@params)
execute_hooks(page)
diff --git a/app/services/wiki_pages/event_create_service.rb b/app/services/wiki_pages/event_create_service.rb
new file mode 100644
index 00000000000..18a45d057a9
--- /dev/null
+++ b/app/services/wiki_pages/event_create_service.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module WikiPages
+ class EventCreateService
+ # @param [User] author The event author
+ def initialize(author)
+ raise ArgumentError, 'author must not be nil' unless author
+
+ @author = author
+ end
+
+ def execute(slug, page, action)
+ return ServiceResponse.success(message: 'No event created as `wiki_events` feature is disabled') unless ::Feature.enabled?(:wiki_events)
+
+ event = Event.transaction do
+ wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
+
+ ::EventCreateService.new.wiki_event(wiki_page_meta, author, action)
+ end
+
+ ServiceResponse.success(payload: { event: event })
+ rescue ::EventCreateService::IllegalActionError, ::ActiveRecord::ActiveRecordError => e
+ ServiceResponse.error(message: e.message, payload: { error: e })
+ end
+
+ private
+
+ attr_reader :author
+ end
+end
diff --git a/app/services/wikis/create_attachment_service.rb b/app/services/wikis/create_attachment_service.rb
index 6ef6cbc3c12..82179459345 100644
--- a/app/services/wikis/create_attachment_service.rb
+++ b/app/services/wikis/create_attachment_service.rb
@@ -5,12 +5,15 @@ module Wikis
ATTACHMENT_PATH = 'uploads'
MAX_FILENAME_LENGTH = 255
- delegate :wiki, to: :project
+ attr_reader :container
+
+ delegate :wiki, to: :container
delegate :repository, to: :wiki
- def initialize(*args)
- super
+ def initialize(container:, current_user: nil, params: {})
+ super(nil, current_user, params)
+ @container = container
@file_name = clean_file_name(params[:file_name])
@file_path = File.join(ATTACHMENT_PATH, SecureRandom.hex, @file_name) if @file_name
@commit_message ||= "Upload attachment #{@file_name}"
@@ -51,7 +54,7 @@ module Wikis
end
def validate_permissions!
- unless can?(current_user, :create_wiki, project)
+ unless can?(current_user, :create_wiki, container)
raise_error('You are not allowed to push to the wiki')
end
end
diff --git a/app/uploaders/design_management/design_v432x230_uploader.rb b/app/uploaders/design_management/design_v432x230_uploader.rb
new file mode 100644
index 00000000000..ba48f381bbd
--- /dev/null
+++ b/app/uploaders/design_management/design_v432x230_uploader.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # This Uploader is used to generate and serve the smaller versions of
+ # the design files.
+ #
+ # The original (full-sized) design files are stored in Git LFS, and so
+ # have a different uploader, `LfsObjectUploader`.
+ class DesignV432x230Uploader < GitlabUploader
+ include CarrierWave::MiniMagick
+ include RecordsUploads::Concern
+ include ObjectStorage::Concern
+ prepend ObjectStorage::Extension::RecordsUploads
+
+ # We choose not to resize `image/ico` as we assume there will be no
+ # benefit in generating an 432x230 sized icon.
+ #
+ # We currently cannot resize `image/tiff`.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/207740
+ #
+ # We currently choose not to resize `image/svg+xml` for security reasons.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/207740#note_302766171
+ MIME_TYPE_WHITELIST = %w(image/png image/jpeg image/bmp image/gif).freeze
+
+ process resize_to_fit: [432, 230]
+
+ # Allow CarrierWave to reject files without correct mimetypes.
+ def content_type_whitelist
+ MIME_TYPE_WHITELIST
+ end
+
+ # Override `GitlabUploader` and always return false, otherwise local
+ # `LfsObject` files would be deleted.
+ # https://github.com/carrierwaveuploader/carrierwave/blob/f84672a/lib/carrierwave/uploader/cache.rb#L131-L135
+ def move_to_cache
+ false
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
+ end
+ end
+end
diff --git a/app/validators/cron_freeze_period_timezone_validator.rb b/app/validators/cron_freeze_period_timezone_validator.rb
new file mode 100644
index 00000000000..143a0262136
--- /dev/null
+++ b/app/validators/cron_freeze_period_timezone_validator.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+# CronTimezoneValidator
+#
+# Custom validator for CronTimezone.
+class CronFreezePeriodTimezoneValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ freeze_start_parser = Gitlab::Ci::CronParser.new(record.freeze_start, record.cron_timezone)
+ freeze_end_parser = Gitlab::Ci::CronParser.new(record.freeze_end, record.cron_timezone)
+
+ record.errors.add(attribute, " is invalid syntax") unless freeze_start_parser.cron_timezone_valid? && freeze_end_parser.cron_timezone_valid?
+ end
+end
diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb
index bd48a7a6efb..6f42bdb5f9b 100644
--- a/app/validators/cron_validator.rb
+++ b/app/validators/cron_validator.rb
@@ -1,11 +1,16 @@
# frozen_string_literal: true
-# CronValidator
-#
-# Custom validator for Cron.
class CronValidator < ActiveModel::EachValidator
+ ATTRIBUTE_WHITELIST = %i[cron freeze_start freeze_end].freeze
+
+ NonWhitelistedAttributeError = Class.new(StandardError)
+
def validate_each(record, attribute, value)
- cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
- record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid?
+ if ATTRIBUTE_WHITELIST.include?(attribute)
+ cron_parser = Gitlab::Ci::CronParser.new(record.public_send(attribute), record.cron_timezone) # rubocop:disable GitlabSecurity/PublicSend
+ record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid?
+ else
+ raise NonWhitelistedAttributeError.new "Non-whitelisted attribute"
+ end
end
end
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index a5f34d0dab2..5bb05bcba26 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -1,3 +1,5 @@
+- parsed_with_gfm = "Content parsed with #{link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank')}.".html_safe
+
= form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f|
= form_errors(@appearance)
@@ -57,7 +59,7 @@
= f.label :description, class: 'col-form-label label-bold'
= f.text_area :description, class: "form-control", rows: 10
.hint
- Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+ = parsed_with_gfm
.form-group
= f.label :logo, class: 'col-form-label label-bold pt-0'
%p
@@ -83,15 +85,30 @@
%p
= f.text_area :new_project_guidelines, class: "form-control", rows: 10
.hint
- Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+ = parsed_with_gfm
+
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Profile image guideline
+
+ .col-lg-8
+ .form-group
+ = f.label :profile_image_guidelines, class: 'col-form-label label-bold'
+ %p
+ = f.text_area :profile_image_guidelines, class: "form-control", rows: 10
+ .hint
+ = parsed_with_gfm
.prepend-top-default.append-bottom-default
= f.submit 'Update appearance settings', class: 'btn btn-success'
- - if @appearance.persisted?
- Preview last save:
- = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ - if @appearance.persisted? || @appearance.updated_at
+ .mt-4
+ - if @appearance.persisted?
+ Preview last save:
+ = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- - if @appearance.updated_at
- %span.float-right
- Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
+ - if @appearance.updated_at
+ %span.float-right
+ Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
diff --git a/app/views/admin/application_settings/_influx.html.haml b/app/views/admin/application_settings/_influx.html.haml
deleted file mode 100644
index 300b01c6777..00000000000
--- a/app/views/admin/application_settings/_influx.html.haml
+++ /dev/null
@@ -1,60 +0,0 @@
-= form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-influx-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
-
- %fieldset
- %p
- Set up InfluxDB to measure a wide variety of statistics like the time spent
- in running SQL queries. These settings require a
- = link_to 'restart', help_page_path('administration/restart_gitlab')
- to take effect.
- = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/index')
- .form-group
- .form-check
- = f.check_box :metrics_enabled, class: 'form-check-input'
- = f.label :metrics_enabled, class: 'form-check-label' do
- Enable InfluxDB Metrics
- .form-group
- = f.label :metrics_host, 'InfluxDB host', class: 'label-bold'
- = f.text_field :metrics_host, class: 'form-control', placeholder: 'influxdb.example.com'
- .form-group
- = f.label :metrics_port, 'InfluxDB port', class: 'label-bold'
- = f.text_field :metrics_port, class: 'form-control', placeholder: '8089'
- .form-text.text-muted
- The UDP port to use for connecting to InfluxDB. InfluxDB requires that
- your server configuration specifies a database to store data in when
- sending messages to this port, without it metrics data will not be
- saved.
- .form-group
- = f.label :metrics_pool_size, 'Connection pool size', class: 'label-bold'
- = f.number_field :metrics_pool_size, class: 'form-control'
- .form-text.text-muted
- The amount of InfluxDB connections to open. Connections are opened
- lazily. Users using multi-threaded application servers should ensure
- enough connections are available (at minimum the amount of application
- server threads).
- .form-group
- = f.label :metrics_timeout, 'Connection timeout', class: 'label-bold'
- = f.number_field :metrics_timeout, class: 'form-control'
- .form-text.text-muted
- The amount of seconds after which an InfluxDB connection will time
- out.
- .form-group
- = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'label-bold'
- = f.number_field :metrics_method_call_threshold, class: 'form-control'
- .form-text.text-muted
- A method call is only tracked when it takes longer to complete than
- the given amount of milliseconds.
- .form-group
- = f.label :metrics_sample_interval, 'Sampler Interval (sec)', class: 'label-bold'
- = f.number_field :metrics_sample_interval, class: 'form-control'
- .form-text.text-muted
- The sampling interval in seconds. Sampled data includes memory usage,
- retained Ruby objects, file descriptors and so on.
- .form-group
- = f.label :metrics_packet_size, 'Metrics per packet', class: 'label-bold'
- = f.number_field :metrics_packet_size, class: 'form-control'
- .form-text.text-muted
- The amount of points to store in a single UDP packet. More points
- results in fewer but larger UDP packets being sent.
-
- = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml
index 4c0ff3a18e8..b2ec25cdf8d 100644
--- a/app/views/admin/application_settings/_prometheus.html.haml
+++ b/app/views/admin/application_settings/_prometheus.html.haml
@@ -23,5 +23,11 @@
%code prometheus_multiproc_dir
does not exist or is not pointing to a valid directory.
= link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory')
+ .form-group
+ = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'label-bold'
+ = f.number_field :metrics_method_call_threshold, class: 'form-control'
+ .form-text.text-muted
+ A method call is only tracked when it takes longer to complete than
+ the given amount of milliseconds.
= f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index 6e5fa6eb62c..8ec9b3c528a 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -10,7 +10,7 @@
= _('Allow repository mirroring to be configured by project maintainers')
%span.form-text.text-muted
= _('If disabled, only admins will be able to configure repository mirroring.')
- = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring')
+ = link_to icon('question-circle'), help_page_path('user/project/repository/repository_mirroring.md')
= render_if_exists 'admin/application_settings/mirror_settings', form: f
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index c3ae39ddd48..6fabafe3fc1 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -6,10 +6,10 @@
%h4= _("Hashed repository storage paths")
.form-group
.form-check
- = f.check_box :hashed_storage_enabled, class: 'form-check-input qa-hashed-storage-checkbox'
+ = f.check_box :hashed_storage_enabled, class: 'form-check-input qa-hashed-storage-checkbox', disabled: @application_setting.hashed_storage_enabled?
= f.label :hashed_storage_enabled, _("Use hashed storage"), class: 'label-bold form-check-label'
.form-text.text-muted
- = _("Use hashed storage paths for newly created and renamed repositories. Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Repository URL changes and may improve disk I/O performance.")
+ = _("Use hashed storage paths for newly created and renamed repositories. Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Repository URL changes and may improve disk I/O performance. (Always enabled since 13.0)")
.sub-section
%h4= _("Storage nodes for new repositories")
.form-group
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index dc6d68e54ec..d8495c82af1 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -49,20 +49,19 @@
= f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'label-bold'
= f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
.form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com
- - if Feature.enabled?(:email_restrictions)
- .form-group
- = f.label :email_restrictions_enabled, _('Email restrictions'), class: 'label-bold'
- .form-check
- = f.check_box :email_restrictions_enabled, class: 'form-check-input'
- = f.label :email_restrictions_enabled, class: 'form-check-label' do
- = _('Enable email restrictions for sign ups')
- .form-group
- = f.label :email_restrictions, _('Email restrictions for sign-ups'), class: 'label-bold'
- = f.text_area :email_restrictions, class: 'form-control', rows: 4
- .form-text.text-muted
- - supported_syntax_link_url = 'https://github.com/google/re2/wiki/Syntax'
- - supported_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: supported_syntax_link_url }
- = _('Restricts sign-ups for email addresses that match the given regex. See the %{supported_syntax_link_start}supported syntax%{supported_syntax_link_end} for more information.').html_safe % { supported_syntax_link_start: supported_syntax_link_start, supported_syntax_link_end: '</a>'.html_safe }
+ .form-group
+ = f.label :email_restrictions_enabled, _('Email restrictions'), class: 'label-bold'
+ .form-check
+ = f.check_box :email_restrictions_enabled, class: 'form-check-input'
+ = f.label :email_restrictions_enabled, class: 'form-check-label' do
+ = _('Enable email restrictions for sign ups')
+ .form-group
+ = f.label :email_restrictions, _('Email restrictions for sign-ups'), class: 'label-bold'
+ = f.text_area :email_restrictions, class: 'form-control', rows: 4
+ .form-text.text-muted
+ - supported_syntax_link_url = 'https://github.com/google/re2/wiki/Syntax'
+ - supported_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: supported_syntax_link_url }
+ = _('Restricts sign-ups for email addresses that match the given regex. See the %{supported_syntax_link_start}supported syntax%{supported_syntax_link_end} for more information.').html_safe % { supported_syntax_link_start: supported_syntax_link_start, supported_syntax_link_end: '</a>'.html_safe }
.form-group
= f.label :after_sign_up_text, class: 'label-bold'
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index a4acbe6c885..3c4fc75dbee 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -3,6 +3,7 @@
%fieldset
= render 'shared/default_branch_protection', f: f, selected_level: @application_setting.default_branch_protection
+ = render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f
.form-group
= f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold'
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index bebda385886..fd3f04fefd1 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -98,9 +98,9 @@
.form-check
= f.check_box :web_ide_clientside_preview_enabled, class: 'form-check-input'
= f.label :web_ide_clientside_preview_enabled, class: 'form-check-label' do
- = s_('IDE|Client side evaluation')
+ = s_('IDE|Live Preview')
%span.form-text.text-muted
- = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation.')
+ = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.')
= f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml
index 2b01160a230..a8eff26b94c 100644
--- a/app/views/admin/application_settings/integrations.html.haml
+++ b/app/views/admin/application_settings/integrations.html.haml
@@ -18,7 +18,7 @@
%p
= s_('AdminSettings|Integrations configured here will automatically apply to all projects on this instance.')
= link_to _('Learn more'), '#'
- = render 'shared/integrations/integrations', integrations: @integrations
+ = render 'shared/integrations/index', integrations: @integrations
- else
= render_if_exists 'admin/application_settings/elasticsearch_form'
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index 6a703d0b70c..befe10ea510 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -2,17 +2,6 @@
- page_title _("Metrics and profiling")
- @content_class = "limit-container-width" unless fluid_layout
-%section.settings.as-influx.no-animate#js-influx-settings{ class: ('expanded' if expanded_by_default?) }
- .settings-header
- %h4
- = _('Metrics - Influx')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
- = expanded_by_default? ? _('Collapse') : _('Expand')
- %p
- = _('Enable and configure InfluxDB metrics.')
- .settings-content
- = render 'influx'
-
%section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml
index a7da14d16ff..8342507d8a6 100644
--- a/app/views/admin/impersonation_tokens/index.html.haml
+++ b/app/views/admin/impersonation_tokens/index.html.haml
@@ -1,15 +1,29 @@
-- add_to_breadcrumbs "Users", admin_users_path
+- add_to_breadcrumbs 'Users', admin_users_path
- breadcrumb_title @user.name
-- page_title "Impersonation Tokens", @user.name, "Users"
+- page_title _('Impersonation Tokens'), @user.name, _('Users')
+- type = _('impersonation token')
+- type_plural = _('impersonation tokens')
+
= render 'admin/users/head'
.row.prepend-top-default
.col-lg-12
- if @new_impersonation_token
- = render "shared/personal_access_tokens_created_container", new_token_value: @new_impersonation_token,
- container_title: 'Your New Impersonation Token',
- clipboard_button_title: _('Copy impersonation token')
+ = render 'shared/access_tokens/created_container',
+ type: type,
+ new_token_value: @new_impersonation_token
- = render "shared/personal_access_tokens_form", path: admin_user_impersonation_tokens_path, impersonation: true, token: @impersonation_token, scopes: @scopes
+ = render 'shared/access_tokens/form',
+ type: type,
+ title: _('Add an impersonation token'),
+ path: admin_user_impersonation_tokens_path,
+ impersonation: true,
+ token: @impersonation_token,
+ scopes: @scopes
- = render "shared/personal_access_tokens_table", impersonation: true, active_tokens: @active_impersonation_tokens, inactive_tokens: @inactive_impersonation_tokens
+ = render 'shared/access_tokens/table',
+ type: type,
+ type_plural: type_plural,
+ impersonation: true,
+ active_tokens: @active_impersonation_tokens,
+ revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) }
diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml
deleted file mode 100644
index eb93f645ea6..00000000000
--- a/app/views/admin/logs/show.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-- page_title "Logs"
-
-%ul.nav-links.log-tabs.nav.nav-tabs
- - @loggers.each do |klass|
- %li.nav-item
- = link_to klass.file_name, "##{klass.file_name_noext}", data: { toggle: 'tab' }, class: "#{active_when(klass == @loggers.first)} nav-link"
-.row-content-block
- To prevent performance issues admin logs output the last 2000 lines
-.tab-content
- - @loggers.each do |klass|
- .tab-pane{ class: active_when(klass == @loggers.first), id: klass.file_name_noext }
- .file-holder#README
- .js-file-title.file-title
- %i.fa.fa-file
- = klass.file_name
- .float-right
- = link_to '#', class: 'log-bottom' do
- %i.fa.fa-arrow-down
- Scroll down
- .file-content.logs
- %ol
- - klass.read_latest.each do |line|
- %li
- %p= line
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 7274099806d..8abc4c37e70 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -14,11 +14,9 @@
.col-md-12
.card
.card-header.alert.alert-danger
- Last repository check
- = "(#{time_ago_with_tooltip(@project.last_repository_check_at)})"
- failed. See
- = link_to 'repocheck.log', admin_logs_path
- for error messages.
+ - last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.")
+ - last_check_message = last_check_message % { last_check_timestamp: time_ago_with_tooltip(@project.last_repository_check_at) }
+ = last_check_message.html_safe
.row
.col-md-6
.card
@@ -135,24 +133,18 @@
.card.repository-check
.card-header
- Repository check
+ = _("Repository check")
.card-body
= form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f|
.form-group
- if @project.last_repository_check_at.nil?
- This repository has never been checked.
+ = _("This repository has never been checked.")
+ - elsif @project.last_repository_check_failed?
+ - failed_message = _("This repository was last checked %{last_check_timestamp}. The check %{strong_start}failed.%{strong_end} See the 'repocheck.log' file for error messages.")
+ - failed_message = failed_message % { last_check_timestamp: @project.last_repository_check_at.to_s(:medium), strong_start: "<strong class='cred'>", strong_end: "</strong>" }
+ = failed_message.html_safe
- else
- This repository was last checked
- = @project.last_repository_check_at.to_s(:medium) + '.'
- The check
- - if @project.last_repository_check_failed?
- = succeed '.' do
- %strong.cred failed
- See
- = link_to 'repocheck.log', admin_logs_path
- for error messages.
- - else
- passed.
+ = _("This repository was last checked %{last_check_timestamp}. The check passed.") % { last_check_timestamp: @project.last_repository_check_at.to_s(:medium) }
= link_to icon('question-circle'), help_page_path('administration/repository_checks')
diff --git a/app/views/admin/services/_deprecated_message.html.haml b/app/views/admin/services/_deprecated_message.html.haml
deleted file mode 100644
index fea9506a4bb..00000000000
--- a/app/views/admin/services/_deprecated_message.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-.flash-container.flash-container-page
- .flash-alert.deprecated-service
- %span= @service.deprecation_message
diff --git a/app/views/admin/services/edit.html.haml b/app/views/admin/services/edit.html.haml
index 79f5ab0d77d..00ed5464a44 100644
--- a/app/views/admin/services/edit.html.haml
+++ b/app/views/admin/services/edit.html.haml
@@ -2,6 +2,4 @@
- breadcrumb_title @service.title
- page_title @service.title, "Service Templates"
-= render 'deprecated_message' if @service.deprecation_message
-
= render 'form'
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index a8d678d2b61..5be1c90d6aa 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -1,4 +1,4 @@
-= form_tag(admin_session_path, method: :post, html: { class: 'new_user gl-show-field-errors', 'aria-live': 'assertive'}) do
+= form_tag(admin_session_path, method: :post, class: 'new_user gl-show-field-errors', 'aria-live': 'assertive') do
.form-group
= label_tag :user_password, _('Password'), class: 'label-bold'
= password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml
new file mode 100644
index 00000000000..cb6c0a76e56
--- /dev/null
+++ b/app/views/admin/sessions/_signin_box.html.haml
@@ -0,0 +1,19 @@
+- if any_form_based_providers_enabled?
+ - if crowd_enabled?
+ .login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) }
+ .login-body
+ = render 'devise/sessions/new_crowd'
+
+ = render_if_exists 'devise/sessions/new_kerberos_tab'
+
+ - ldap_servers.each_with_index do |server, i|
+ .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) }
+ .login-body
+ = render 'devise/sessions/new_ldap', server: server, hide_remember_me: true, submit_message: _('Enter Admin Mode')
+
+ = render_if_exists 'devise/sessions/new_smartcard'
+
+- if allow_admin_mode_password_authentication_for_web?
+ .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel', class: active_when(!any_form_based_providers_enabled?) }
+ .login-body
+ = render 'admin/sessions/new_base'
diff --git a/app/views/admin/sessions/_tabs_normal.html.haml b/app/views/admin/sessions/_tabs_normal.html.haml
deleted file mode 100644
index 2e279013720..00000000000
--- a/app/views/admin/sessions/_tabs_normal.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-%ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' }
- %li.nav-item{ role: 'presentation' }
- %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= tab_title
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index 0a7f20b861e..4ce1629bb53 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -5,18 +5,19 @@
.col-md-5.new-session-forms-container
.login-page
#signin-container
- = render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode')
+ - if any_form_based_providers_enabled?
+ = render 'devise/shared/tabs_ldap', show_password_form: allow_admin_mode_password_authentication_for_web?, render_signup_link: false
+ - else
+ = render 'devise/shared/tabs_normal', tab_title: _('Enter Admin Mode'), render_signup_link: false
.tab-content
- - if !current_user.require_password_creation_for_web?
- .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
- .login-body
- = render 'admin/sessions/new_base'
+ - if allow_admin_mode_password_authentication_for_web? || ldap_sign_in_enabled? || crowd_enabled?
+ = render 'admin/sessions/signin_box'
- - if omniauth_enabled? && button_based_providers_enabled?
- .clearfix
- = render 'devise/shared/omniauth_box', hide_remember_me: true
+ -# Show a message if none of the mechanisms above are enabled
+ - if !allow_admin_mode_password_authentication_for_web? && !ldap_sign_in_enabled? && !omniauth_enabled?
+ .prepend-top-default.center
+ = _('No authentication methods configured.')
- -# Show a message if none of the mechanisms above are enabled
- - if current_user.require_password_creation_for_web? && !omniauth_enabled?
- .prepend-top-default.center
- = _('No authentication methods configured.')
+ - if omniauth_enabled? && button_based_providers_enabled?
+ .clearfix
+ = render 'devise/shared/omniauth_box', hide_remember_me: true
diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml
index 3a0cbe3facb..57a3452cf35 100644
--- a/app/views/admin/sessions/two_factor.html.haml
+++ b/app/views/admin/sessions/two_factor.html.haml
@@ -5,7 +5,7 @@
.col-md-5.new-session-forms-container
.login-page
#signin-container
- = render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode')
+ = render 'devise/shared/tabs_normal', tab_title: _('Enter Admin Mode'), render_signup_link: false
.tab-content
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index 369b0f7e62c..d9d646c77d9 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -8,12 +8,12 @@
- if status.has_details?
= link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item d-flex', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name
+ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1-deprecated-no-really-do-not-use-me= subject.name
- else
.menu-item.mini-pipeline-graph-dropdown-item.d-flex{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name
+ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1-deprecated-no-really-do-not-use-me= subject.name
- if status.has_action?
= link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 4d8df4cc12a..26051261715 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -8,7 +8,7 @@
- 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} }
+ #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} }
- else
.row
diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml
index 9b6c0c20080..f11117ea5c4 100644
--- a/app/views/clusters/clusters/_cluster.html.haml
+++ b/app/views/clusters/clusters/_cluster.html.haml
@@ -2,7 +2,8 @@
.card-body.gl-responsive-table-row
.table-section.section-60
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
- .table-mobile-content
+ .table-mobile-content.gl-display-flex.gl-align-items-center.gl-justify-content-end.gl-justify-content-md-start
+ .gl-w-6.gl-h-6.gl-mr-3.gl-display-flex.gl-align-items-center= provider_icon(cluster.provider_type)
= cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } })
- if cluster.status_name == :creating
.spinner.ml-2.align-middle.has-tooltip{ title: s_("ClusterIntegration|Cluster being created") }
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index 28002dbff92..86194842664 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: 'todo/add/endpoint' } }
+ #js-clusters-list-app{ data: { endpoint: 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 7fc76880480..1cc68d927bd 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -17,6 +17,7 @@
install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative),
update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative),
install_elastic_stack_path: clusterable.install_applications_cluster_path(@cluster, :elastic_stack),
+ install_fluentd_path: clusterable.install_applications_cluster_path(@cluster, :fluentd),
cluster_environments_path: cluster_environments_path,
toggle_status: @cluster.enabled? ? 'true': 'false',
has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false',
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index 05214346496..2f0cc76f2e0 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -6,8 +6,6 @@
= render 'dashboard/snippets_head'
- if current_user.snippets.exists?
= render partial: 'snippets/snippets_scope_menu', locals: { include_private: true, counts: @snippet_counts }
-
-- if current_user.snippets.exists?
= render partial: 'shared/snippets/list', locals: { link_project: true }
- else
= render 'shared/empty_states/snippets', button_path: button_path
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 232dffa28b4..9fb5e27b692 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -8,7 +8,7 @@
= _("GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.")
.col-lg-5.order-12
.text-center.mb-3
- %h2.font-weight-bold.gl-font-size-20= _('Register for GitLab')
+ %h2.font-weight-bold.gl-font-size-20-deprecated-no-really-do-not-use-me= _('Register for GitLab')
= render 'devise/shared/experimental_separate_sign_up_flow_box'
= render 'devise/shared/sign_in_link'
- else
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 31c4bb0e33e..3fc99b6a47d 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -1,4 +1,6 @@
- server = local_assigns.fetch(:server)
+- hide_remember_me = local_assigns.fetch(:hide_remember_me, false)
+- submit_message = local_assigns.fetch(:submit_message, _('Sign in'))
= form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do
.form-group
@@ -7,9 +9,11 @@
.form-group
= label_tag :password
= password_field_tag :password, nil, { class: "form-control bottom", title: "This field is required.", data: { qa_selector: 'password_field' }, required: true }
- - if devise_mapping.rememberable?
+ - if !hide_remember_me && devise_mapping.rememberable?
.remember-me
%label{ for: "remember_me" }
= check_box_tag :remember_me, '1', false, id: 'remember_me'
%span Remember me
- = submit_tag "Sign in", class: "btn-success btn", data: { qa_selector: 'sign_in_button' }
+
+ .submit-container.move-submit-down
+ = submit_tag submit_message, class: "btn-success btn", data: { qa_selector: 'sign_in_button' }
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index cca0f756e76..5c3e4ccbfe5 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -1,3 +1,5 @@
+- hide_remember_me = local_assigns.fetch(:hide_remember_me, false)
+
.omniauth-container.prepend-top-15
%label.label-bold.d-block
Sign in with
@@ -10,7 +12,7 @@
= provider_image_tag(provider)
%span
= label_for_provider(provider)
- - unless defined?(hide_remember_me) && hide_remember_me
+ - unless hide_remember_me
%fieldset.remember-me
%label
= check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index 6ddb7e1ac48..c0b005bac77 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -6,7 +6,7 @@
= render_if_exists 'devise/sessions/new_kerberos_tab'
- - @ldap_servers.each_with_index do |server, i|
+ - ldap_servers.each_with_index do |server, i|
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) }
.login-body
= render 'devise/sessions/new_ldap', server: server
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index b8f0cd2a91a..eb14ad6006f 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -1,17 +1,20 @@
+- show_password_form = local_assigns.fetch(:show_password_form, password_authentication_enabled_for_web?)
+- render_signup_link = local_assigns.fetch(:render_signup_link, true)
+
%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if any_form_based_providers_enabled?) }
- if crowd_enabled?
%li.nav-item
= link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab'
= render_if_exists "devise/shared/kerberos_tab"
- - @ldap_servers.each_with_index do |server, i|
+ - ldap_servers.each_with_index do |server, i|
%li.nav-item
= link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab' }
= render_if_exists 'devise/shared/tab_smartcard'
- - if password_authentication_enabled_for_web?
+ - if show_password_form
%li.nav-item
- = link_to 'Standard', '#login-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'standard_tab' }
- - if allow_signup?
+ = link_to _('Standard'), '#login-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'standard_tab' }
+ - if render_signup_link && allow_signup?
%li.nav-item
= link_to 'Register', '#register-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'register_tab' }
diff --git a/app/views/devise/shared/_tabs_normal.html.haml b/app/views/devise/shared/_tabs_normal.html.haml
index b6a1b8805ee..a2d5a8be625 100644
--- a/app/views/devise/shared/_tabs_normal.html.haml
+++ b/app/views/devise/shared/_tabs_normal.html.haml
@@ -1,6 +1,9 @@
+- tab_title = local_assigns.fetch(:tab_title, _('Sign in'))
+- render_signup_link = local_assigns.fetch(:render_signup_link, true)
+
%ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' }
%li.nav-item{ role: 'presentation' }
- %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' } Sign in
- - if allow_signup?
+ %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= tab_title
+ - if render_signup_link && allow_signup?
%li.nav-item{ role: 'presentation' }
%a.nav-link{ href: '#register-pane', data: { track_label: 'sign_in_register', track_property: '', track_event: 'click_button', track_value: '', toggle: 'tab', qa_selector: 'register_tab' }, role: 'tab' } Register
diff --git a/app/views/groups/_flash_messages.html.haml b/app/views/groups/_flash_messages.html.haml
new file mode 100644
index 00000000000..fa1a9d2cca4
--- /dev/null
+++ b/app/views/groups/_flash_messages.html.haml
@@ -0,0 +1,2 @@
+= content_for :flash_message do
+ = render_if_exists 'shared/shared_runners_minutes_limit', 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 6772ee94d46..d083288edc8 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -1,4 +1,5 @@
- can_create_subgroups = can?(current_user, :create_subgroup, @group)
+- can_create_projects = can?(current_user, :create_projects, @group)
- emails_disabled = @group.emails_disabled?
.group-home-panel
@@ -23,32 +24,33 @@
- if current_user
.group-buttons
= render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn', emails_disabled: emails_disabled
- - if can? current_user, :create_projects, @group
- - new_project_label = _("New project")
- - new_subgroup_label = _("New subgroup")
- - if can_create_subgroups
- .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
- %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } }
- %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } }
- = sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon")
- %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } }
- %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } }
+ - new_project_label = _("New project")
+ - new_subgroup_label = _("New subgroup")
+ - if can_create_projects and can_create_subgroups
+ .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
+ %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } }
+ %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } }
+ = sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon")
+ %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } }
+ %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_project_label
+ %span= s_("GroupsTree|Create a project in this group.")
+ %li.divider.droplap-item-ignore
+ %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
.menu-item
.icon-container
= icon("check", class: "list-item-checkmark")
.description
- %strong= new_project_label
- %span= s_("GroupsTree|Create a project in this group.")
- %li.divider.droplap-item-ignore
- %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
- .menu-item
- .icon-container
- = icon("check", class: "list-item-checkmark")
- .description
- %strong= new_subgroup_label
- %span= s_("GroupsTree|Create a subgroup in this group.")
- - else
- = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default"
+ %strong= new_subgroup_label
+ %span= s_("GroupsTree|Create a subgroup in this group.")
+ - elsif can_create_projects
+ = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default"
+ - elsif can_create_subgroups
+ = link_to new_subgroup_label, new_group_path(parent_id: @group.id), class: "btn btn-success prepend-top-default"
- if @group.description.present?
.group-home-desc.mt-1
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index fe5a00e3be9..2e58517fdc7 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -45,11 +45,11 @@
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
- = _('Path, transfer, remove')
+ = _('Advanced')
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _('Perform advanced options such as changing path, transferring, or removing the group.')
+ = _('Perform advanced options such as changing path, transferring, exporting, or removing the group.')
.settings-content
= render 'groups/settings/advanced'
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 048edb80d99..1f2fb747c7d 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -9,20 +9,16 @@
= _("Group members")
%hr
- if can_manage_members
- - if Feature.enabled?(:share_group_with_group, default_enabled: true)
- %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
+ %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
+ %li.nav-tab{ role: 'presentation' }
+ %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
%li.nav-tab{ role: 'presentation' }
- %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
- %li.nav-tab{ role: 'presentation' }
- %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group")
- .tab-content.gitlab-tab-content
- .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
- = render_invite_member_for_group(@group, @group_member.access_level)
- - if Feature.enabled?(:share_group_with_group, default_enabled: true)
- .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
- = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access'
- - else
- = render_invite_member_for_group(@group, @group_member.access_level)
+ %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group")
+ .tab-content.gitlab-tab-content
+ .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
+ = render_invite_member_for_group(@group, @group_member.access_level)
+ .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
+ = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access'
= render 'shared/members/requests', membership_source: @group, requesters: @requesters
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 2734ab538a0..0df82898644 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -1,3 +1,5 @@
+= render 'groups/settings/export', group: @group
+
.sub-section
%h4.warning-title= s_('GroupSettings|Change group path')
= form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
diff --git a/app/views/groups/settings/_default_branch_protection.html.haml b/app/views/groups/settings/_default_branch_protection.html.haml
new file mode 100644
index 00000000000..e0e901cbc4a
--- /dev/null
+++ b/app/views/groups/settings/_default_branch_protection.html.haml
@@ -0,0 +1,3 @@
+- return unless can_update_default_branch_protection?(group)
+
+= render 'shared/default_branch_protection', f: f, selected_level: group.default_branch_protection
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
new file mode 100644
index 00000000000..ef7bf562c69
--- /dev/null
+++ b/app/views/groups/settings/_export.html.haml
@@ -0,0 +1,28 @@
+- return unless Feature.enabled?(:group_import_export, @group, default_enabled: true)
+
+- group = local_assigns.fetch(:group)
+
+.sub-section
+ %h4= s_('GroupSettings|Export group')
+ %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= _('The following items will be exported:')
+ %ul
+ - group_export_descriptions.each do |description|
+ %li= description
+ %p= _('The following items will NOT be exported:')
+ %ul
+ %li= _('Projects')
+ %li= _('Runner tokens')
+ %li= _('SAML discovery tokens')
+ %p= _('Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.')
+ - if group.export_file_exists?
+ = link_to _('Regenerate export'), export_group_path(group),
+ method: :post, class: 'btn btn-default', data: { qa_selector: 'regenerate_export_group_link' }
+ = link_to _('Download export'), download_export_group_path(group),
+ rel: 'nofollow', method: :get, class: 'btn btn-default', data: { qa_selector: 'download_export_link' }
+ - else
+ = link_to _('Export group'), export_group_path(group),
+ method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' }
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 1ddaa855e62..e886c99a656 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -33,7 +33,7 @@
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
= render 'groups/settings/lfs', f: f
- = render 'shared/default_branch_protection', f: f, selected_level: @group.default_branch_protection
+ = render 'groups/settings/default_branch_protection', f: f, group: @group
= render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/subgroup_creation_level', f: f, group: @group
= render 'groups/settings/two_factor_auth', f: f
diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml
index 78825cc72b0..96bd6d69a96 100644
--- a/app/views/groups/settings/integrations/index.html.haml
+++ b/app/views/groups/settings/integrations/index.html.haml
@@ -6,4 +6,4 @@
%p
= s_('GroupSettings|Integrations configured here will automatically apply to all projects in this group.')
= link_to _('Learn more'), '#'
-= render 'shared/integrations/integrations', integrations: @integrations
+= render 'shared/integrations/index', integrations: @integrations
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index 1f1d7779267..ff0c9de4fef 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -1,6 +1,6 @@
- breadcrumb_title _('Repository Settings')
- page_title _('Repository')
-- deploy_token_description = s_('DeployTokens|Group deploy tokens allow read-only access to the repositories and registry images within the group.')
+- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.')
= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index a9c19502a7c..032766327ca 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,9 +1,15 @@
- 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")
+= render partial: 'flash_messages'
+
%div{ class: [("limit-container-width" unless fluid_layout)] }
= render_if_exists 'trials/banner', namespace: @group
diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml
index 16b902a18b9..67e759a4d63 100644
--- a/app/views/groups/sidebar/_packages.html.haml
+++ b/app/views/groups/sidebar/_packages.html.haml
@@ -4,12 +4,12 @@
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
- = _('Packages')
+ = _('Packages & Registries')
%ul.sidebar-sub-level-items
= nav_link(controller: [:packages, :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')
+ = _('Packages & Registries')
%li.divider.fly-out-top-item
= nav_link(controller: 'groups/container_registries') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index 4b9304cfdb9..bd5424c30c6 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -65,25 +65,23 @@
%tbody
%tr
%th
- %th= _('Web IDE')
+ %th= _('Editing')
%tr
%td.shortcut
- if browser.platform.mac?
- %kbd &#8984; p
+ %kbd &#8984; shift p
- else
- %kbd ctrl p
- %td= _('Go to file')
+ %kbd ctrl shift p
+ %td= _('Toggle Markdown preview')
%tr
%td.shortcut
- - if browser.platform.mac?
- %kbd &#8984; enter
- - else
- %kbd ctrl enter
- %td= _('Commit (when editing commit message)')
+ %kbd
+ %i.fa.fa-arrow-up
+ %td= _('Edit your most recent comment in a thread (from an empty textarea)')
%tbody
%tr
%th
- %th= _('Wiki pages')
+ %th= _('Wiki')
%tr
%td.shortcut
%kbd e
@@ -91,19 +89,49 @@
%tbody
%tr
%th
- %th= _('Editing')
+ %th= _('Repository Graph')
%tr
%td.shortcut
- - if browser.platform.mac?
- %kbd &#8984; shift p
- - else
- %kbd ctrl shift p
- %td= _('Toggle Markdown preview')
+ %kbd
+ %i.fa.fa-arrow-left
+ \/
+ %kbd h
+ %td= _('Scroll left')
+ %tr
+ %td.shortcut
+ %kbd
+ %i.fa.fa-arrow-right
+ \/
+ %kbd l
+ %td= _('Scroll right')
%tr
%td.shortcut
%kbd
%i.fa.fa-arrow-up
- %td= _('Edit your most recent comment in a thread (from an empty textarea)')
+ \/
+ %kbd k
+ %td= _('Scroll up')
+ %tr
+ %td.shortcut
+ %kbd
+ %i.fa.fa-arrow-down
+ \/
+ %kbd j
+ %td= _('Scroll down')
+ %tr
+ %td.shortcut
+ %kbd
+ shift
+ %i.fa.fa-arrow-up
+ \/ k
+ %td= _('Scroll to top')
+ %tr
+ %td.shortcut
+ %kbd
+ shift
+ %i.fa.fa-arrow-down
+ \/ j
+ %td= _('Scroll to bottom')
.col-lg-4
%table.shortcut-mappings.text-2
%tbody
@@ -229,15 +257,7 @@
%tbody
%tr
%th
- %th= _('Issues / Merge Requests')
- %tr
- %td.shortcut
- %kbd a
- %td= _('Change assignee')
- %tr
- %td.shortcut
- %kbd m
- %td= _('Change milestone')
+ %th= _('Epics, Issues, and Merge Requests')
%tr
%td.shortcut
%kbd r
@@ -250,92 +270,76 @@
%td.shortcut
%kbd l
%td= _('Change label')
+ %tbody
+ %tr
+ %th
+ %th= _('Issues and Merge Requests')
+ %tr
+ %td.shortcut
+ %kbd a
+ %td= _('Change assignee')
+ %tr
+ %td.shortcut
+ %kbd m
+ %td= _('Change milestone')
+ %tbody
+ %tr
+ %th
+ %th= _('Merge Requests')
%tr
%td.shortcut
%kbd ]
\/
%kbd j
- %td= _('Next file in diff (MRs only)')
+ %td= _('Next file in diff')
%tr
%td.shortcut
%kbd [
\/
%kbd k
- %td= _('Previous file in diff (MRs only)')
+ %td= _('Previous file in diff')
%tr
%td.shortcut
- if browser.platform.mac?
%kbd &#8984; p
- else
%kbd ctrl p
- %td= _('Go to file (MRs only)')
+ %td= _('Go to file')
%tr
%td.shortcut
%kbd n
- %td= _('Next unresolved discussion (MRs only)')
+ %td= _('Next unresolved discussion')
%tr
%td.shortcut
%kbd p
- %td= _('Previous unresolved discussion (MRs only)')
+ %td= _('Previous unresolved discussion')
%tbody
%tr
%th
- %th= _('Epics (Ultimate / Gold license only)')
+ %th= _('Merge Request Commits')
%tr
%td.shortcut
- %kbd r
- %td= _('Comment/Reply (quoting selected text)')
- %tr
- %td.shortcut
- %kbd e
- %td= _('Edit epic description')
+ %kbd c
+ %td= _('Next commit')
%tr
%td.shortcut
- %kbd l
- %td= _('Change label')
+ %kbd x
+ %td= _('Previous commit')
%tbody
%tr
%th
- %th= _('Repository Graph')
- %tr
- %td.shortcut
- %kbd
- %i.fa.fa-arrow-left
- \/
- %kbd h
- %td= _('Scroll left')
- %tr
- %td.shortcut
- %kbd
- %i.fa.fa-arrow-right
- \/
- %kbd l
- %td= _('Scroll right')
- %tr
- %td.shortcut
- %kbd
- %i.fa.fa-arrow-up
- \/
- %kbd k
- %td= _('Scroll up')
- %tr
- %td.shortcut
- %kbd
- %i.fa.fa-arrow-down
- \/
- %kbd j
- %td= _('Scroll down')
+ %th= _('Web IDE')
%tr
%td.shortcut
- %kbd
- shift
- %i.fa.fa-arrow-up
- \/ k
- %td= _('Scroll to top')
+ - if browser.platform.mac?
+ %kbd &#8984; p
+ - else
+ %kbd ctrl p
+ %td= _('Go to file')
%tr
%td.shortcut
- %kbd
- shift
- %i.fa.fa-arrow-down
- \/ j
- %td= _('Scroll to bottom')
+ - if browser.platform.mac?
+ %kbd &#8984; enter
+ - else
+ %kbd ctrl enter
+ %td= _('Commit (when editing commit message)')
diff --git a/app/views/import/google_code/new_user_map.html.haml b/app/views/import/google_code/new_user_map.html.haml
index f523b993aa7..732ba95a63f 100644
--- a/app/views/import/google_code/new_user_map.html.haml
+++ b/app/views/import/google_code/new_user_map.html.haml
@@ -30,7 +30,7 @@
.form-group.row
.col-sm-12
- = text_area_tag :user_map, JSON.pretty_generate(@user_map), class: 'form-control', rows: 15
+ = text_area_tag :user_map, Gitlab::Json.pretty_generate(@user_map), class: 'form-control', rows: 15
.form-actions
= submit_tag _('Continue to the next step'), class: "btn btn-success"
diff --git a/app/views/kaminari/gitlab/_page.html.haml b/app/views/kaminari/gitlab/_page.html.haml
index 33e00256100..b000a490e3e 100644
--- a/app/views/kaminari/gitlab/_page.html.haml
+++ b/app/views/kaminari/gitlab/_page.html.haml
@@ -11,4 +11,4 @@
('js-first-button' if page.first?),
('js-last-button' if page.last?),
('d-none d-md-block' if !page.current?) ] }
- = link_to page, url, { remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil, class: 'page-link' }
+ = link_to page, url, { remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil, class: ['page-link', active_when(page.current?)] }
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 8c272a73d40..99c4fc0d1b6 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -68,6 +68,7 @@
= csrf_meta_tags
= csp_meta_tag
+ = action_cable_meta_tag
- unless browser.safari?
%meta{ name: 'referrer', content: 'origin-when-cross-origin' }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 49345b7b215..3885fa311ba 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -5,6 +5,7 @@
.mobile-overlay
.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 "layouts/broadcast"
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 6a261bbbc46..bbcb525ea4f 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -22,10 +22,10 @@
= brand_text
- else
%h3.mt-sm-0
- = _('Open source software to collaborate on code')
+ = _('A complete DevOps platform')
%p
- = _('Manage Git repositories with fine-grained access controls that keep your code secure. Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki.')
+ = _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.')
- if Gitlab::CurrentSettings.sign_in_text.present?
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 410b120396d..7d9924719a2 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -27,6 +27,7 @@
%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/upgrade'
- if current_user_menu?(:help)
%li.divider.d-md-none
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index a003d6f8903..2b3f5d266b0 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -1,5 +1,6 @@
%ul
- if current_user_menu?(:help)
+ = render_if_exists 'layouts/header/whats_new_dropdown_item'
%li
= link_to _("Help"), help_path
%li
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 52964dd6739..28e52dc85db 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -56,7 +56,7 @@
= _('Monitoring')
%ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_monitoring_submenu_content' } }
- = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles), html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(controller: %w(system_info background_jobs health_check requests_profiles), html_options: { class: "fly-out-top-item" } ) do
= link_to admin_system_info_path do
%strong.fly-out-top-item-name
= _('Monitoring')
@@ -69,10 +69,6 @@
= link_to admin_background_jobs_path, title: _('Background Jobs') do
%span
= _('Background Jobs')
- = nav_link(controller: :logs) do
- = link_to admin_logs_path, title: _('Logs') do
- %span
- = _('Logs')
= nav_link(controller: :health_check) do
= link_to admin_health_check_path, title: _('Health Check') do
%span
@@ -271,11 +267,6 @@
= link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do
%span
= _('Network')
- - if template_exists?('admin/geo/settings/show')
- = nav_link do
- = link_to geo_admin_application_settings_path, title: _('Geo') do
- %span
- = _('Geo')
= nav_link(path: 'application_settings#preferences') do
= link_to preferences_admin_application_settings_path, title: _('Preferences'), data: { qa_selector: 'admin_settings_preferences_link' } do
%span
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index f63a7b3a664..92b6174795b 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -102,6 +102,8 @@
= render_if_exists "layouts/nav/ee/security_link" # EE-specific
+ = render_if_exists "layouts/nav/ee/push_rules_link" # EE-specific
+
- if group_sidebar_link?(:kubernetes)
= nav_link(controller: [:clusters]) do
= link_to group_clusters_path(@group) do
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index 15f1067f0d9..95d66786984 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -152,10 +152,6 @@
= link_to audit_log_profile_path do
%strong.fly-out-top-item-name
= _('Authentication Log')
-
- - if Feature.enabled?(:user_usage_quota)
= render_if_exists 'layouts/nav/sidebar/profile_usage_quotas_link'
- - else
- = render_if_exists 'layouts/nav/sidebar/profile_pipeline_quota_link'
= render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index c11d1256d21..a67860e8e2e 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -203,7 +203,7 @@
- if project_nav_tab? :operations
= nav_link(controller: sidebar_operations_paths) do
- = link_to sidebar_operations_link_path, class: 'shortcuts-operations qa-link-operations' do
+ = link_to sidebar_operations_link_path, class: 'shortcuts-operations', data: { qa_selector: 'operations_link' } do
.nav-icon-container
= sprite_icon('cloud-gear')
%span.nav-item-name
@@ -222,6 +222,13 @@
%span
= _('Metrics')
+ - if project_nav_tab?(:alert_management)
+ = nav_link(controller: :alert_management) do
+ = link_to project_alert_management_index_path(@project), title: _('Alerts'), class: 'shortcuts-tracking qa-operations-tracking-link' do
+ %span
+ = _('Alerts')
+
+ - if project_nav_tab? :environments
= render_if_exists "layouts/nav/sidebar/tracing_link"
= nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do
@@ -356,6 +363,11 @@
= link_to project_hooks_path(@project), title: _('Webhooks'), data: { qa_selector: 'webhooks_settings_link' } do
%span
= _('Webhooks')
+ - if project_access_token_available?(@project)
+ = nav_link(controller: [:access_tokens]) do
+ = link_to project_settings_access_tokens_path(@project), title: _('Access Tokens'), data: { qa_selector: 'access_tokens_settings_link' } do
+ %span
+ = _('Access Tokens')
= nav_link(controller: :repository) do
= link_to project_settings_repository_path(@project), title: _('Repository') do
%span
@@ -367,7 +379,7 @@
= _('CI / CD')
- if !@project.archived? && settings_operations_available?
= nav_link(controller: [:operations]) do
- = link_to project_settings_operations_path(@project), title: _('Operations') do
+ = link_to project_settings_operations_path(@project), title: _('Operations'), data: { qa_selector: 'operations_settings_link' } do
= _('Operations')
- if @project.pages_available?
= nav_link(controller: :pages) do
diff --git a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
index 0fdfc6cd2ab..0931ccdf637 100644
--- a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
+++ b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
@@ -4,12 +4,12 @@
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
- = _('Packages')
+ = _('Packages & Registries')
%ul.sidebar-sub-level-items
= nav_link(controller: :repositories, html_options: { class: "fly-out-top-item" } ) do
= link_to project_container_registry_index_path(@project) do
%strong.fly-out-top-item-name
- = _('Packages')
+ = _('Packages & Registries')
%li.divider.fly-out-top-item
= nav_link controller: :repositories do
= link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry', title: _('Container Registry') do
diff --git a/app/views/notify/group_was_exported_email.html.haml b/app/views/notify/group_was_exported_email.html.haml
new file mode 100644
index 00000000000..a2f34537662
--- /dev/null
+++ b/app/views/notify/group_was_exported_email.html.haml
@@ -0,0 +1,9 @@
+%p
+ = _('Group %{group_name} was exported successfully.') % { group_name: @group.name }
+
+%p
+ = _('The group export can be downloaded from:')
+ = link_to download_export_group_url(@group), rel: 'nofollow', download: '' do
+ #{@group.full_name} export
+%p
+ = _('The download link will expire in 24 hours.')
diff --git a/app/views/notify/group_was_exported_email.text.erb b/app/views/notify/group_was_exported_email.text.erb
new file mode 100644
index 00000000000..02571459af0
--- /dev/null
+++ b/app/views/notify/group_was_exported_email.text.erb
@@ -0,0 +1,6 @@
+<%= _('Group %{group_name} was exported successfully.') % { group_name: @group.name } %>
+
+<%= _('The group export can be downloaded from:') %>
+<%= download_export_group_url(@group) %>
+
+<%= _('The download link will expire in 24 hours.') %>
diff --git a/app/views/notify/group_was_not_exported_email.html.haml b/app/views/notify/group_was_not_exported_email.html.haml
new file mode 100644
index 00000000000..58fc34d41a3
--- /dev/null
+++ b/app/views/notify/group_was_not_exported_email.html.haml
@@ -0,0 +1,10 @@
+%p
+ = _("Group %{group_name} couldn't be exported.") % { group_name: @group.name }
+
+%p
+ = _('The errors we encountered were:')
+
+ %ul
+ - @errors.each do |error|
+ %li
+ #{error}
diff --git a/app/views/notify/group_was_not_exported_email.text.erb b/app/views/notify/group_was_not_exported_email.text.erb
new file mode 100644
index 00000000000..92bd79b7b85
--- /dev/null
+++ b/app/views/notify/group_was_not_exported_email.text.erb
@@ -0,0 +1,7 @@
+<%= _("Group %{group_name} couldn't be exported.") % { group_name: @group.name } %>
+
+<%= _('The errors we encountered were:') %>
+
+<% @errors.each do |error| -%>
+ - <%= error %>
+<% end -%>
diff --git a/app/views/notify/issues_csv_email.html.haml b/app/views/notify/issues_csv_email.html.haml
index b777ca1e57d..77502a45f02 100644
--- a/app/views/notify/issues_csv_email.html.haml
+++ b/app/views/notify/issues_csv_email.html.haml
@@ -1,9 +1,6 @@
--# haml-lint:disable NoPlainNodes
%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
- Your CSV export of #{ pluralize(@written_count, 'issue') } from project
- %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" }
- = @project.full_name
- has been added to this email as an attachment.
+ - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;")
+ = _('Your CSV export of %{issues_count} from project %{project_link} has been added to this email as an attachment.').html_safe % { issues_count: pluralize(@written_count, 'issue'), project_link: project_link }
- if @truncated
%p
- This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues.
+ = _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{issues_count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, issues_count: @issues_count }
diff --git a/app/views/notify/issues_csv_email.text.erb b/app/views/notify/issues_csv_email.text.erb
index 5d4128e3ae9..a1d2a4691bc 100644
--- a/app/views/notify/issues_csv_email.text.erb
+++ b/app/views/notify/issues_csv_email.text.erb
@@ -1,5 +1,5 @@
-Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment.
+<%= _('Your CSV export of %{written_count} from project %{project_name} (%{project_url}) has been added to this email as an attachment.') % { written_count: pluralize(@written_count, 'issue'), project_name: @project.full_name, project_url: project_url(@project) } %>
<% if @truncated %>
-This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues.
+ <%= _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{issues_count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, issues_count: @issues_count} %>
<% end %>
diff --git a/app/views/notify/note_design_email.html.haml b/app/views/notify/note_design_email.html.haml
new file mode 100644
index 00000000000..5e69f01a486
--- /dev/null
+++ b/app/views/notify/note_design_email.html.haml
@@ -0,0 +1 @@
+= render 'note_email'
diff --git a/app/views/notify/note_design_email.text.erb b/app/views/notify/note_design_email.text.erb
new file mode 100644
index 00000000000..413d9e6e9ac
--- /dev/null
+++ b/app/views/notify/note_design_email.text.erb
@@ -0,0 +1 @@
+<%= render 'note_email' %>
diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml
new file mode 100644
index 00000000000..a4123fada1b
--- /dev/null
+++ b/app/views/notify/unknown_sign_in_email.html.haml
@@ -0,0 +1,14 @@
+%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.')
+
+- 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/notify/unknown_sign_in_email.text.haml b/app/views/notify/unknown_sign_in_email.text.haml
new file mode 100644
index 00000000000..f3efc4c4fcd
--- /dev/null
+++ b/app/views/notify/unknown_sign_in_email.text.haml
@@ -0,0 +1,10 @@
+= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
+
+= _('A sign-in to your account has been made from the following IP address: %{ip}') % { ip: @ip }
+
+= _('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 change your password: %{password_link}.') % { password_link: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' }
+= _('Passwords should be unique and not used for any other sites or services.')
+
+- unless @user.two_factor_enabled?
+ = _('To further protect your account, consider configuring a two-factor authentication method: %{mfa_link}.') % { mfa_link: 'https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html' }
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 34e81285328..7709aa8f4b9 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -14,7 +14,7 @@
.col.form-group
= f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold'
- = f.date_field :expires_at, class: "form-control input-lg qa-key-expiry-field", min: Date.tomorrow
+ = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, data: { qa_selector: 'key_expiry_date_field' }
.js-add-ssh-key-validation-warning.hide
.bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' }
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index d9e94908b80..81b22d964a5 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -1,6 +1,8 @@
- breadcrumb_title s_('AccessTokens|Access Tokens')
- page_title s_('AccessTokens|Personal Access Tokens')
-- @content_class = "limit-container-width" unless fluid_layout
+- type = _('personal access token')
+- type_plural = _('personal access tokens')
+- @content_class = 'limit-container-width' unless fluid_layout
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
@@ -14,11 +16,21 @@
.col-lg-8
- if @new_personal_access_token
- = render "shared/personal_access_tokens_created_container", new_token_value: @new_personal_access_token
+ = render 'shared/access_tokens/created_container',
+ type: type,
+ new_token_value: @new_personal_access_token
- = render "shared/personal_access_tokens_form", path: profile_personal_access_tokens_path, impersonation: false, token: @personal_access_token, scopes: @scopes
+ = render 'shared/access_tokens/form',
+ type: type,
+ path: profile_personal_access_tokens_path,
+ token: @personal_access_token,
+ scopes: @scopes
- = render "shared/personal_access_tokens_table", impersonation: false, active_tokens: @active_personal_access_tokens, inactive_tokens: @inactive_personal_access_tokens
+ = render 'shared/access_tokens/table',
+ type: type,
+ type_plural: type_plural,
+ active_tokens: @active_personal_access_tokens,
+ revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) }
%hr
.row.prepend-top-default
@@ -30,7 +42,7 @@
%p
= s_('AccessTokens|It cannot be used to access any other data.')
.col-lg-8.feed-token-reset
- = label_tag :feed_token, s_('AccessTokens|Feed token'), class: "label-bold"
+ = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold'
= text_field_tag :feed_token, current_user.feed_token, class: 'form-control js-select-on-focus', readonly: true
%p.form-text.text-muted
- reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') }
@@ -48,7 +60,7 @@
%p
= s_('AccessTokens|It cannot be used to access any other data.')
.col-lg-8.incoming-email-token-reset
- = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: "label-bold"
+ = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold'
= text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control js-select-on-focus', readonly: true
%p.form-text.text-muted
- reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') }
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index da2b8c40191..43fc9150e99 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -20,6 +20,9 @@
= s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
- else
= s_("Profiles|You can upload your avatar here")
+ - if current_appearance&.profile_image_guidelines?
+ .md
+ = brand_profile_image_guidelines
.col-lg-8
.clearfix.avatar-image.append-bottom-default
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
@@ -101,7 +104,7 @@
- else
= f.text_field :location, label: s_('Profiles|Location'), class: 'input-lg', placeholder: s_("Profiles|City, country")
= f.text_field :job_title, class: 'input-md'
- = f.text_field :organization, readonly: @user.gitlab_employee?, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for")
+ = f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for")
= f.text_area :bio, label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters")
%hr
%h5= s_("Private profile")
diff --git a/app/views/projects/alert_management/details.html.haml b/app/views/projects/alert_management/details.html.haml
new file mode 100644
index 00000000000..5230d5e3476
--- /dev/null
+++ b/app/views/projects/alert_management/details.html.haml
@@ -0,0 +1,4 @@
+- add_to_breadcrumbs s_('AlertManagement|Alerts'), project_alert_management_index_path(@project)
+- page_title s_('AlertManagement|Alert detail')
+
+#js-alert_details{ data: alert_management_detail_data(@project, @alert_id) }
diff --git a/app/views/projects/alert_management/index.html.haml b/app/views/projects/alert_management/index.html.haml
new file mode 100644
index 00000000000..415820ac3ad
--- /dev/null
+++ b/app/views/projects/alert_management/index.html.haml
@@ -0,0 +1,3 @@
+- page_title _('Alerts')
+
+#js-alert_management{ data: alert_management_data(@current_user, @project) }
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 76a9d3df5d7..2a1545e7db7 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -4,13 +4,13 @@
.file-actions<
= render 'projects/blob/viewer_switcher', blob: blob unless blame
- = edit_blob_button
- = ide_edit_button
+ = edit_blob_button(@project, @ref, @path, blob: blob)
+ = ide_edit_button(@project, @ref, @path, blob: blob)
.btn-group.ml-2{ role: "group" }>
= render_if_exists 'projects/blob/header_file_locks_link'
- if current_user
- = replace_blob_link
- = delete_blob_link
+ = replace_blob_link(@project, @ref, @path, blob: blob)
+ = delete_blob_link(@project, @ref, @path, blob: blob)
.btn-group.ml-2{ role: "group" }
= copy_blob_source_button(blob) unless blame
= open_raw_blob_button(blob)
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
index 2be95bc5541..ba8029ac32a 100644
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ b/app/views/projects/blob/_template_selectors.html.haml
@@ -1,4 +1,4 @@
-.template-selectors-menu.gl-pl-2
+.template-selectors-menu.gl-pl-2-deprecated-no-really-do-not-use-me
.template-selector-dropdowns-wrap
.template-type-selector.js-template-type-selector-wrap.hidden
- toggle_text = should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : 'Select a template type'
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index cae8bbf8c01..445752d0a15 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -12,14 +12,13 @@
%h5.m-0.dropdown-bold-header= _('Download source code')
.dropdown-menu-content
= render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
- - if Feature.enabled?(:git_archive_path, default_enabled: true)
- - if vue_file_list_enabled?
- #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } }
- - elsif directory?
- %section.border-top.pt-1.mt-1
- %h5.m-0.dropdown-bold-header= _('Download this directory')
- .dropdown-menu-content
- = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
+ - if vue_file_list_enabled?
+ #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } }
+ - elsif directory?
+ %section.border-top.pt-1.mt-1
+ %h5.m-0.dropdown-bold-header= _('Download this directory')
+ .dropdown-menu-content
+ = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
- if pipeline && pipeline.latest_builds_with_artifacts.any?
%section.border-top.pt-1.mt-1
%h5.m-0.dropdown-bold-header= _('Download artifacts')
diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml
index aa7c90bad66..fb31ac44118 100644
--- a/app/views/projects/commit/_signature.html.haml
+++ b/app/views/projects/commit/_signature.html.haml
@@ -1,3 +1,3 @@
- if signature
- - uri = "projects/commit/#{"x509/" if signature.instance_of?(X509CommitSignature)}"
+ - uri = "projects/commit/#{"x509/" if x509_signature?(signature)}"
= render partial: "#{uri}#{signature.verification_status}_signature_badge", locals: { signature: signature }
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index 8ecaa1329fd..8004a5facd7 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -17,13 +17,13 @@
- content = capture do
- if show_user
.clearfix
- - uri_signature_badge_user = "projects/commit/#{"x509/" if signature.instance_of?(X509CommitSignature)}signature_badge_user"
+ - uri_signature_badge_user = "projects/commit/#{"x509/" if x509_signature?(signature)}signature_badge_user"
= render partial: "#{uri_signature_badge_user}", locals: { signature: signature }
- - if signature.instance_of?(X509CommitSignature)
+ - if x509_signature?(signature)
= render partial: "projects/commit/x509/certificate_details", locals: { signature: signature }
- = link_to(_('Learn more about x509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gpg-popover-help-link')
+ = link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gpg-popover-help-link')
- else
= _('GPG Key ID:')
%span.monospace= signature.gpg_key_primary_keyid
diff --git a/app/views/projects/commit/x509/_signature_badge_user.html.haml b/app/views/projects/commit/x509/_signature_badge_user.html.haml
index b64ccba2a18..f3d39b21ec2 100644
--- a/app/views/projects/commit/x509/_signature_badge_user.html.haml
+++ b/app/views/projects/commit/x509/_signature_badge_user.html.haml
@@ -1,5 +1,5 @@
-- user = signature.commit.committer
- user_email = signature.x509_certificate.email
+- user = signature.user
- if user
= link_to user_path(user), class: 'gpg-popover-user-link' do
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 8b659034fe6..b42eef32a76 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -1,6 +1,8 @@
-#-----------------------------------------------------------------
WARNING: Please keep changes up-to-date with the following files:
- `assets/javascripts/diffs/components/commit_item.vue`
+
+ EXCEPTION WARNING - see above `.vue` file for de-sync drift
-#-----------------------------------------------------------------
- view_details = local_assigns.fetch(:view_details, false)
- merge_request = local_assigns.fetch(:merge_request, nil)
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index da20fee227a..b6c30c680e4 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -5,6 +5,9 @@
%banner{ "v-if" => "!isOverviewDialogDismissed",
"documentation-link": help_page_path('user/analytics/value_stream_analytics.md'),
"v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" }
+ .mb-3
+ %h3
+ = _("Value Stream Analytics")
%gl-loading-icon{ "v-show" => "isLoading", "size" => "lg" }
.wrapper{ "v-show" => "!isLoading && !hasError" }
.card
@@ -54,7 +57,7 @@
%nav.stage-nav
%ul
%stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" }
- .section.stage-events
+ .section.stage-events.overflow-auto
%gl-loading-icon{ "v-show" => "isLoadingStage", "size" => "lg" }
%template{ "v-if" => "currentStage && !currentStage.isUserAllowed" }
= render partial: "no_access"
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index 19fe7ba4360..7257dacf680 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -1,5 +1,9 @@
- page_title _("Repository Analytics")
+.mb-3
+ %h3
+ = _("Repository Analytics")
+
.repo-charts
%h4.sub-header
= _("Programming languages used in this repository")
@@ -9,6 +13,23 @@
#js-languages-chart{ data: { chart_data: @languages.to_json.html_safe } }
+- if defined?(@daily_coverage_options)
+ .repo-charts.my-5
+ .sub-header-block.border-top
+ .d-flex.justify-content-between.align-items-center
+ %h4.sub-header.m-0
+ - start_date = capture do
+ #{@daily_coverage_options[:base_params][:start_date].strftime('%b %d')}
+ - end_date = capture do
+ #{@daily_coverage_options[:base_params][:end_date].strftime('%b %d')}
+ = (_("Code coverage statistics for master %{start_date} - %{end_date}") % {start_date: start_date, end_date: end_date})
+ - download_path = capture do
+ #{@daily_coverage_options[:download_path]}
+ %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 } }
+
.repo-charts
.sub-header-block.border-top
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index a952db0eea3..495a4ac50bf 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -1,6 +1,6 @@
- page_title _('Contributors')
-.sub-header-block.bg-gray-light.gl-p-3
+.sub-header-block.bg-gray-light.gl-p-3-deprecated-no-really-do-not-use-me
.tree-ref-holder.inline.vertical-align-middle
= render 'shared/ref_switcher', destination: 'graphs'
= link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn'
diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml
index 4106bcc2e5a..cddd97cbc84 100644
--- a/app/views/projects/import/jira/show.html.haml
+++ b/app/views/projects/import/jira/show.html.haml
@@ -1,6 +1,7 @@
- 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'),
diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml
new file mode 100644
index 00000000000..96f1dc0155c
--- /dev/null
+++ b/app/views/projects/issues/_design_management.html.haml
@@ -0,0 +1,15 @@
+- if @project.design_management_enabled?
+ .js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
+- else
+ .mt-4
+ .row.empty-state
+ .col-12
+ .text-content
+ %h4.center
+ = _('The one place for your designs')
+ %p.center
+ - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
+ - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
+ - support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url }
+ - link_end = '</a>'.html_safe
+ = s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end }
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 54002b9ca2e..1bf0c8eb031 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -6,12 +6,13 @@
= check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable"
.issuable-info-container
.issuable-main-info
- .issue-title.title
+ .issue-title.title.d-flex.align-items-center
%span.issue-title-text.js-onboarding-issue-item{ dir: "auto" }
- if issue.confidential?
%span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue)
= link_to issue.title, issue_path(issue)
+ = render_if_exists 'projects/issues/subepic_flag', issue: issue
- if issue.tasks?
%span.task-status.d-none.d-sm-inline-block
&nbsp;
@@ -24,7 +25,7 @@
&middot;
opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')}
by #{link_to_member(@project, issue.author, avatar: false)}
- = gitlab_team_member_badge(issue.author)
+ = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: issue.author}
- if issue.milestone
%span.issuable-milestone.d-none.d-sm-inline-block
&nbsp;
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 69b030ed76a..0604e89be6e 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -4,11 +4,9 @@
%ul.unstyled-list.related-merge-requests
- @related_branches.each do |branch|
%li
- - target = @project.repository.find_branch(branch).dereferenced_target
- - pipeline = @project.pipeline_for(branch, target.sha) if target
- - if can?(current_user, :read_pipeline, pipeline)
+ - if branch[:pipeline_status].present?
%span.related-branch-ci-status
- = render 'ci/status/icon', status: pipeline.detailed_status(current_user)
+ = render 'ci/status/icon', status: branch[:pipeline_status]
%span.related-branch-info
%strong
- = link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name"
+ = link_to branch[:name], branch[:link], class: "ref-name"
diff --git a/app/views/projects/issues/_tabs.html.haml b/app/views/projects/issues/_tabs.html.haml
new file mode 100644
index 00000000000..d998a01623f
--- /dev/null
+++ b/app/views/projects/issues/_tabs.html.haml
@@ -0,0 +1,14 @@
+%ul.nav-tabs.nav.nav-links{ role: 'tablist' }
+ %li
+ = link_to '#discussion-tab', class: 'active js-issue-tabs', id: 'discussion', role: 'tab', 'aria-controls': 'js-discussion', 'aria-selected': 'true', data: { toggle: 'tab', target: '#discussion-tab', qa_selector: 'discussion_tab_link' } do
+ = _('Discussion')
+ %span.badge.badge-pill.js-discussions-count
+ %li
+ = link_to '#designs-tab', class: 'js-issue-tabs', id: 'designs', role: 'tab', 'aria-controls': 'js-designs', 'aria-selected': 'false', data: { toggle: 'tab', target: '#designs-tab', qa_selector: 'designs_tab_link' } do
+ = _('Designs')
+ %span.badge.badge-pill.js-designs-count
+.tab-content
+ #discussion-tab.tab-pane.show.active{ role: 'tabpanel', 'aria-labelledby': 'discussion', data: { qa_selector: 'discussion_tab_content' } }
+ = render 'projects/issues/discussion'
+ #designs-tab.tab-pane{ role: 'tabpanel', 'aria-labelledby': 'designs', data: { qa_selector: 'designs_tab_content' } }
+ = render 'projects/issues/design_management'
diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml
index af3a087ca59..9fdeb901b56 100644
--- a/app/views/projects/issues/export_csv/_modal.html.haml
+++ b/app/views/projects/issues/export_csv/_modal.html.haml
@@ -1,4 +1,3 @@
--# haml-lint:disable NoPlainNodes
- if current_user
.issues-export-modal.modal
.modal-dialog
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 2633a3899f7..0aef4e39466 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -6,6 +6,12 @@
= 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 } }
+
- if project_issues(@project).exists?
.top-area
= render 'shared/issuable/nav', type: :issues
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 4fc67884584..c8ffa2e3720 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -10,6 +10,8 @@
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
- can_create_issue = show_new_issue_link?(@project)
+= render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user
+
.detail-page-header
.detail-page-header-body
.issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(@issue, status_box: :closed) }
@@ -50,7 +52,7 @@
%li.divider
%li= link_to 'New issue', new_project_issue_path(@project), id: 'new_issue_link'
- = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue
+ = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue, warn_before_close: defined?(@issue.blocked?) && @issue.blocked?
- if can_report_spam
= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'd-none d-sm-none d-md-block btn btn-grouped btn-spam', title: 'Submit as spam'
@@ -84,13 +86,13 @@
.content-block.emoji-block.emoji-block-sticky
.row
- .col-md-12.col-lg-6.js-noteable-awards
+ .col-md-12.col-lg-4.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
- .col-md-12.col-lg-6.new-branch-col
+ .col-md-12.col-lg-8.new-branch-col
#js-vue-sort-issue-discussions
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } }
= render 'new_branch' if show_new_branch_button?
- = render_if_exists 'projects/issues/discussion'
+ = render 'projects/issues/tabs'
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 0373e37818d..760d81136c6 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -22,13 +22,13 @@
.content-list.manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
#js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
= render 'shared/empty_states/priority_labels'
- - if @prioritized_labels.present?
+ - if @prioritized_labels.any?
= render partial: 'shared/label', collection: @prioritized_labels, as: :label, locals: { force_priority: true, subject: @project }
- elsif search.present?
.nothing-here-block
= _('No prioritized labels with such name or description')
- - if @labels.present?
+ - if @labels.any?
.other-labels
%h5{ class: ('hide' if hide) }= _('Other Labels')
.content-list.manage-labels-list.js-other-labels
diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml
index 9cdbbe7204b..a2da0e707d3 100644
--- a/app/views/projects/merge_requests/_how_to_merge.html.haml
+++ b/app/views/projects/merge_requests/_how_to_merge.html.haml
@@ -53,4 +53,4 @@
%strong Tip:
= succeed '.' do
You can also checkout merge requests locally by
- = link_to 'following these guidelines', help_page_path('user/project/merge_requests/index.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer'
+ = link_to 'following these guidelines', help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index f7f5388a54a..a753ee50c43 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -20,7 +20,7 @@
&middot;
opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
by #{link_to_member(@project, merge_request.author, avatar: false)}
- = gitlab_team_member_badge(merge_request.author)
+ = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: merge_request.author}
- if merge_request.milestone
%span.issuable-milestone.d-none.d-sm-inline-block
&nbsp;
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 1853d40c2e4..6aba5c98d52 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -8,6 +8,7 @@
window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}';
+ window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}';
window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}';
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
diff --git a/app/views/projects/merge_requests/creations/update_branches.html.haml b/app/views/projects/merge_requests/creations/update_branches.html.haml
deleted file mode 100644
index 64482973a89..00000000000
--- a/app/views/projects/merge_requests/creations/update_branches.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-= render 'projects/merge_requests/dropdowns/branch',
-branches: @target_branches,
-selected: nil
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 4004c4f4b07..38e4fbf73e0 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -10,7 +10,7 @@
= expanded ? _('Collapse') : _('Expand')
%p
= _('Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically.')
- = link_to _('Read more'), help_page_path('workflow/repository_mirroring'), target: '_blank'
+ = link_to _('Read more'), help_page_path('user/project/repository/repository_mirroring.md'), target: '_blank'
.settings-content
- if mirror_settings_enabled
diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml
index 8482424a184..9b5b31bfc15 100644
--- a/app/views/projects/mirrors/_mirror_repos_push.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml
@@ -1,15 +1,13 @@
- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|')
-- keep_divergent_refs = Feature.enabled?(:keep_divergent_refs, @project)
= f.fields_for :remote_mirrors, @project.remote_mirrors.build do |rm_f|
= rm_f.hidden_field :enabled, value: '1'
= rm_f.hidden_field :url, class: 'js-mirror-url-hidden', required: true, pattern: "(#{protocols}):\/\/.+"
= rm_f.hidden_field :only_protected_branches, class: 'js-mirror-protected-hidden'
- - if keep_divergent_refs
- = rm_f.hidden_field :keep_divergent_refs, class: 'js-mirror-keep-divergent-refs-hidden'
+ = rm_f.hidden_field :keep_divergent_refs, class: 'js-mirror-keep-divergent-refs-hidden'
= render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f }
= render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f }
- - if keep_divergent_refs
- .form-check.append-bottom-10
- = check_box_tag :keep_divergent_refs, '1', false, class: 'js-mirror-keep-divergent-refs form-check-input'
- = label_tag :keep_divergent_refs, 'Keep divergent refs', class: 'form-check-label'
+ .form-check.append-bottom-10
+ = check_box_tag :keep_divergent_refs, '1', false, class: 'js-mirror-keep-divergent-refs form-check-input'
+ = label_tag :keep_divergent_refs, _('Keep divergent refs'), class: 'form-check-label'
+ = link_to icon('question-circle'), help_page_path('user/project/repository/repository_mirroring', anchor: 'keep-divergent-refs-core'), target: '_blank'
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 3ff4ab354b9..c18af6a267b 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -18,7 +18,7 @@
= _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.')
= render_if_exists 'projects/new_ci_cd_banner_external_repo'
%p
- - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank'
+ - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/index", anchor: "getting-started"), target: '_blank'
= _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide }
.md
= brand_new_project_guidelines
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 37ca020cfb6..e39f543d42e 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -1,4 +1,5 @@
-- test_reports_enabled = Feature.enabled?(:junit_pipeline_view)
+- test_reports_enabled = Feature.enabled?(:junit_pipeline_view, @project)
+- dag_pipeline_tab_enabled = Feature.enabled?(:dag_pipeline_tab)
.tabs-holder
%ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs
@@ -9,6 +10,10 @@
= 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')
- 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
@@ -75,6 +80,9 @@
%code.bash.js-build-output
= build_summary(build)
+ - if dag_pipeline_tab_enabled
+ #js-tab-dag.tab-pane
+
#js-tab-tests.tab-pane
#js-pipeline-tests-detail
= render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index f64f07487fd..64789c7c263 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -3,6 +3,7 @@
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
+ project_id: @project.id,
"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/services/_deprecated_message.html.haml b/app/views/projects/services/_deprecated_message.html.haml
deleted file mode 100644
index fea9506a4bb..00000000000
--- a/app/views/projects/services/_deprecated_message.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-.flash-container.flash-container-page
- .flash-alert.deprecated-service
- %span= @service.deprecation_message
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index 0dbd6a48ec5..3f91bdc4266 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -1,19 +1,19 @@
.row.prepend-top-default.append-bottom-default
- .col-lg-3
+ .col-lg-4
%h4.prepend-top-0
= @service.title
- [true, false].each do |value|
- - hide_class = 'd-none' if @service.activated? != value
+ - hide_class = 'd-none' if @service.operating? != value
%span.js-service-active-status{ class: hide_class, data: { value: value.to_s } }
= boolean_to_icon value
- if @service.respond_to?(:detailed_description)
%p= @service.detailed_description
- .col-lg-9
+ .col-lg-8
= 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
- = service_save_button(@service)
+ = service_save_button
&nbsp;
= link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml
deleted file mode 100644
index dca324ac846..00000000000
--- a/app/views/projects/services/_index.html.haml
+++ /dev/null
@@ -1,30 +0,0 @@
-.row.prepend-top-default
- .col-lg-4
- %h4.prepend-top-0
- = _('Integrations')
- %p= _('Integrations allow you to integrate GitLab with other applications')
- .col-lg-8
- %table.table
- %colgroup
- %col
- %col
- %col
- %col{ width: "120" }
- %thead
- %tr
- %th
- %th= _('Integration')
- %th.d-none.d-sm-block= _("Description")
- %th= s_("ProjectService|Last edit")
- - @services.sort_by(&:title).each do |service|
- %tr
- %td{ "aria-label" => (service.activated? ? s_("ProjectService|%{service_title}: status on") : s_("ProjectService|%{service_title}: status off")) % { service_title: service.title } }
- = boolean_to_icon service.activated?
- %td
- = link_to edit_project_service_path(@project, service.to_param), { data: { qa_selector: "#{service.title.downcase.gsub(/[\s\(\)]/,'_')}_link" } } do
- %strong= service.title
- %td.d-none.d-sm-block
- = service.description
- %td.light
- - if service.updated_at.present?
- = time_ago_with_tooltip service.updated_at
diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml
index 4195dce7780..1aaea50c8d5 100644
--- a/app/views/projects/services/edit.html.haml
+++ b/app/views/projects/services/edit.html.haml
@@ -1,8 +1,7 @@
- breadcrumb_title @service.title
- add_to_breadcrumbs _('Integration Settings'), project_settings_integrations_path(@project)
- page_title @service.title, _('Integrations')
-
-= render 'deprecated_message' if @service.deprecation_message
+- @content_class = 'limit-container-width' unless fluid_layout
= render 'form'
- if @web_hook_logs
diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml
index 21f9d1125e0..210d0f37d65 100644
--- a/app/views/projects/services/prometheus/_custom_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml
@@ -6,14 +6,14 @@
= link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer"
.col-lg-9
- .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{@service.active}" } }
+ .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { qa_selector: 'custom_metrics_container', active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{@service.active}" } }
.card-header
%strong
= s_('PrometheusService|Custom metrics')
-# haml-lint:disable NoPlainNodes
%span.badge.badge-pill.js-custom-monitored-count 0
-# haml-lint:enable NoPlainNodes
- = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn btn-success js-new-metric-button hidden'
+ = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' }
.card-body
.flash-container.hidden
.flash-warning
diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml
index 93ea17a3a3d..0cf78d4f681 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -57,7 +57,7 @@
.form-group
= label_tag nil, _('Customize icon'), class: 'col-12 col-form-label label-bold'
.col-12
- = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36, class: 'mr-3')
+ = image_tag(asset_url('slash-command-logo.png', skip_pipeline: true), width: 36, height: 36, class: 'mr-3')
= link_to(_('Download image'), asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer')
.form-group
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 0f60fc18026..5eeebe4160f 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -16,7 +16,7 @@
.row
.form-group.col-md-9
- = f.label :tag_list, _('Topics'), class: 'label-bold'
+ = f.label :tag_list, _('Topics (optional)'), class: 'label-bold'
= f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control"
%p.form-text.text-muted= _('Separate topics with commas.')
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
new file mode 100644
index 00000000000..07784dce677
--- /dev/null
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -0,0 +1,34 @@
+- breadcrumb_title s_('AccessTokens|Access Tokens')
+- page_title _('Project Access Tokens')
+- type = _('project access token')
+- type_plural = _('project access tokens')
+- @content_class = 'limit-container-width' unless fluid_layout
+
+.row.prepend-top-default
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
+ = page_title
+ %p
+ = _('You can generate an access token scoped to this project for each application to use the GitLab API.')
+ %p
+ = _('You can also use project access tokens to authenticate against Git over HTTP.')
+
+ .col-lg-8
+ - if @new_project_access_token
+ = render 'shared/access_tokens/created_container',
+ type: type,
+ new_token_value: @new_project_access_token
+
+ = render 'shared/access_tokens/form',
+ type: type,
+ path: project_settings_access_tokens_path(@project),
+ token: @project_access_token,
+ scopes: @scopes,
+ prefix: :project_access_token
+
+ = render 'shared/access_tokens/table',
+ active_tokens: @active_project_access_tokens,
+ type: type,
+ type_plural: type_plural,
+ revoke_route_helper: ->(token) { revoke_namespace_project_settings_access_token_path(id: token) },
+ no_active_tokens_message: _('This project has no active access tokens.')
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 6702786fdb3..8b84acb67c1 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -30,7 +30,7 @@
.card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' }
- if @project.all_clusters.empty?
%p.settings-message.text-center
- = s_('CICD|You must add a %{kubernetes_cluster_link_start}Kubernetes cluster integration%{link_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_link_start: kubernetes_cluster_link_start, link_end: link_end }
+ = s_('CICD|Add a %{kubernetes_cluster_link_start}Kubernetes cluster integration%{link_end} with a domain or create an AUTO_DEVOPS_PLATFORM_TARGET CI variable.').html_safe % { kubernetes_cluster_link_start: kubernetes_cluster_link_start, link_end: link_end }
- elsif !has_base_domain
%p.settings-message.text-center
= s_('CICD|You must add a %{base_domain_link_start}base domain%{link_end} to your %{kubernetes_cluster_link_start}Kubernetes cluster%{link_end} in order for your deployment strategy to work.').html_safe % { base_domain_link_start: base_domain_link_start, kubernetes_cluster_link_start: kubernetes_cluster_link_start, link_end: link_end }
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 4040b1094aa..b50f712922f 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -103,7 +103,7 @@
.input-group
%span.input-group-prepend
.input-group-text /
- = f.text_field :build_coverage_regex, class: 'form-control', placeholder: 'Regular expression'
+ = f.text_field :build_coverage_regex, class: 'form-control', placeholder: 'Regular expression', data: { qa_selector: 'build_coverage_regex_field' }
%span.input-group-append
.input-group-text /
%p.form-text.text-muted
@@ -143,7 +143,7 @@
go test -cover (Go)
%code coverage: \d+.\d+% of statements
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_general_pipelines_changes_button' }
%hr
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 1358077f2b2..4e14426a069 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -5,7 +5,7 @@
- expanded = expanded_by_default?
- general_expanded = @project.errors.empty? ? expanded : true
-%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) }
+%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded), data: { qa_selector: 'general_pipelines_settings_content' } }
.settings-header
%h4
= _("General pipelines")
diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml
index f603f23a2c7..4372763fcf7 100644
--- a/app/views/projects/settings/integrations/show.html.haml
+++ b/app/views/projects/settings/integrations/show.html.haml
@@ -12,4 +12,6 @@
.gl-alert-actions
= link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'btn gl-alert-action btn-info new-gl-button'
-= render 'projects/services/index'
+%h4= s_('Integrations')
+%p= s_('Integrations allow you to integrate GitLab with other applications')
+= render 'shared/integrations/index', integrations: @services
diff --git a/app/views/projects/settings/operations/_incidents.html.haml b/app/views/projects/settings/operations/_incidents.html.haml
index a96a41b78c2..92fffa42b73 100644
--- a/app/views/projects/settings/operations/_incidents.html.haml
+++ b/app/views/projects/settings/operations/_incidents.html.haml
@@ -2,7 +2,7 @@
- setting = project_incident_management_setting
- templates = setting.available_issue_templates.map { |t| [t.name, t.key] }
-%section.settings.no-animate.qa-incident-management-settings
+%section.settings.no-animate.qa-incident-management-settings{ data: { qa_selector: 'incidents_settings_content' } }
.settings-header
%h3{ :class => "h4" }= _('Incidents')
%button.btn.js-settings-toggle{ type: 'button' }
@@ -17,16 +17,16 @@
.form-group
= f.fields_for :incident_management_setting_attributes, setting do |form|
.form-group
- = form.check_box :create_issue
+ = form.check_box :create_issue, data: { qa_selector: 'create_issue_checkbox' }
= form.label :create_issue, _('Create an issue. Issues are created for each alert triggered.'), class: 'form-check-label'
.form-group.col-sm-8
= form.label :issue_template_key, class: 'label-bold' do
= _('Issue template (optional)')
= link_to icon('question-circle'), help_page_path('user/project/description_templates', anchor: 'creating-issue-templates'), target: '_blank', rel: 'noopener noreferrer'
.select-wrapper
- = form.select :issue_template_key, templates, {include_blank: 'No template selected'}, class: "form-control select-control"
+ = form.select :issue_template_key, templates, {include_blank: 'No template selected'}, class: "form-control select-control", data: { qa_selector: 'incident_templates_dropdown' }
= icon('chevron-down')
.form-group
= form.check_box :send_email
= form.label :send_email, _('Send a separate email notification to Developers.'), class: 'form-check-label'
- = f.submit _('Save changes'), class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'btn btn-success', data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 193053c8c97..24fc137fd29 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _("Repository Settings")
- page_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout
-- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to your repository and registry images.')
+- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to packages, your repository, and registry images.')
= render "projects/default_branch/show"
= render_if_exists "projects/push_rules/index"
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index ccf109968fc..7cf5de8947c 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -3,7 +3,7 @@
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-- if Feature.enabled?(:snippets_vue)
+- if Feature.enabled?(:snippets_vue, default_enabled: true)
#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
- else
= render 'shared/snippets/header'
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 75805192a61..da693a15ec2 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -30,6 +30,9 @@
= markdown_field(release, :description)
.row-fixed-content.controls.flex-row
+ - if tag.has_signature?
+ = render partial: 'projects/commit/signature', object: tag.signature
+
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
- if can?(current_user, :admin_tag, @project)
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 8086d47479d..6f53a687fb9 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -39,6 +39,8 @@
= s_("TagsPage|Can't find HEAD commit for this tag")
.nav-controls
+ - if @tag.has_signature?
+ = render partial: 'projects/commit/signature', object: @tag.signature
- if can?(current_user, :admin_tag, @project)
= link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-edit controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do
= icon("pencil")
diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml
index 0f2938686cc..2b8da83b126 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/projects/wikis/_sidebar.html.haml
@@ -17,6 +17,6 @@
%ul.wiki-pages
= render @sidebar_wiki_entries, context: 'sidebar'
.block.w-100
- - if @sidebar_wiki_entries&.length.to_i >= 15
+ - if @sidebar_limited
= link_to project_wikis_pages_path(@project), class: 'btn btn-block' do
= s_("Wiki|View All Pages")
diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml
index 6972eda9bb7..72c9f45779a 100644
--- a/app/views/projects/wikis/git_access.html.haml
+++ b/app/views/projects/wikis/git_access.html.haml
@@ -18,11 +18,6 @@
%pre.dark
:preserve
gem install gollum
- %p
- = (s_("WikiClone|It is recommended to install %{markdown} so that GFM features render locally:") % { markdown: "<code>github-markdown</code>" }).html_safe
- %pre.dark
- :preserve
- gem install github-markdown
%h3= s_("WikiClone|Clone your wiki")
%pre.dark
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 6ad155eb715..db7769fa743 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -24,7 +24,7 @@
= users
- elsif @show_snippets
- = search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil }
+ = search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }
- else
= search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' }
= search_filter_link 'issues', _("Issues")
diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml
index 01e42224428..218de30d707 100644
--- a/app/views/search/results/_blob_data.html.haml
+++ b/app/views/search/results/_blob_data.html.haml
@@ -7,4 +7,4 @@
= search_blob_title(project, path)
- if blob.data
.file-content.code.term{ data: { qa_selector: 'file_text_content' } }
- = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline
+ = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
deleted file mode 100644
index fa77566dddb..00000000000
--- a/app/views/search/results/_snippet_blob.html.haml
+++ /dev/null
@@ -1,50 +0,0 @@
-- snippet_blob = chunk_snippet(snippet_blob, @search_term)
-- snippet = snippet_blob[:snippet_object]
-- snippet_chunks = snippet_blob[:snippet_chunks]
-- snippet_path = gitlab_snippet_path(snippet)
-
-.search-result-row.snippet-row
- = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: ''
- .title
- = link_to gitlab_snippet_path(snippet) do
- = snippet.title
- .snippet-info
- = snippet.to_reference
- &middot;
- authored
- = time_ago_with_tooltip(snippet.created_at)
- by
- = link_to user_snippets_path(snippet.author) do
- = snippet.author_name
-
- .file-holder.my-2
- .js-file-title.file-title-flex-parent
- = link_to snippet_path do
- %i.fa.fa-file
- %strong= snippet.file_name
- - if markup?(snippet.file_name)
- .file-content.md
- - snippet_chunks.each do |chunk|
- - unless chunk[:data].empty?
- = markup(snippet.file_name, chunk[:data])
- - else
- .file-content.code
- .nothing-here-block= _("Empty file")
- - else
- .file-content.code.js-syntax-highlight
- .line-numbers
- - snippet_chunks.each do |chunk|
- - unless chunk[:data].empty?
- - Gitlab::Git::Util.count_lines(chunk[:data]).times do |index|
- - offset = defined?(chunk[:start_line]) ? chunk[:start_line] : 1
- - i = index + offset
- = link_to snippet_path+"#L#{i}", id: "L#{i}", rel: "#L#{i}", class: "diff-line-num" do
- %i.fa.fa-link
- = i
- .blob-content
- - snippet_chunks.each do |chunk|
- - unless chunk[:data].empty?
- = highlight(snippet.file_name, chunk[:data])
- - else
- .file-content.code
- .nothing-here-block= _("Empty file")
diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml
index 81e746c55a3..a28d9effbdd 100644
--- a/app/views/search/results/_snippet_title.html.haml
+++ b/app/views/search/results/_snippet_title.html.haml
@@ -1,5 +1,5 @@
.search-result-row
- %h4.snippet-title.term
+ %h4
= link_to gitlab_snippet_path(snippet_title) do
= truncate(snippet_title.title, length: 60)
= snippet_badge(snippet_title)
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index 128508e954e..bf1683be32d 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -11,5 +11,5 @@
= link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn btn-md new-gl-button js-close-callout'
%button.gl-banner-close.close.js-close-callout{ type: 'button',
- 'aria-label' => 'Dismiss Auto DevOps box' }
+ 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box') }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
index bc4db672938..b809696cccb 100644
--- a/app/views/shared/_broadcast_message.html.haml
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -6,5 +6,5 @@
= render_broadcast_message(message)
.flex-grow-1.text-right{ style: 'flex-basis: 0' }
- if (message.notification? || message.dismissable?) && opts[:preview].blank?
- %button.broadcast-message-dismiss.js-dismiss-current-broadcast-notification.btn.btn-link.pl-2.pr-2{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id } }
+ %button.broadcast-message-dismiss.js-dismiss-current-broadcast-notification.btn.btn-link.pl-2.pr-2{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id, expire_date: message.ends_at.iso8601 } }
%i.fa.fa-times
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 3e805189055..9ec8d3c18cd 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -18,7 +18,7 @@
= http_clone_button(project)
= render_if_exists 'shared/kerberos_clone_button', project: project
- = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' }
+ = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Project clone URL') }
.input-group-append
= clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml
index 68c14c307ac..d65b7492690 100644
--- a/app/views/shared/_commit_message_container.html.haml
+++ b/app/views/shared/_commit_message_container.html.haml
@@ -14,12 +14,11 @@
id: "commit_message-#{nonce}"
- if local_assigns[:hint]
%p.hint
- Try to keep the first line under 52 characters
- and the others under 72.
+ = _('Try to keep the first line under 52 characters and the others under 72.')
- if descriptions.present?
.hint.js-with-description-hint
= link_to "#", class: "js-with-description-link" do
- Include description in commit message
+ = _('Include description in commit message')
.hint.js-without-description-hint.hide
= link_to "#", class: "js-without-description-link" do
- Don't include description in commit message
+ = _("Don't include description in commit message")
diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml
index c6629cd33a5..25c841d2344 100644
--- a/app/views/shared/_delete_label_modal.html.haml
+++ b/app/views/shared/_delete_label_modal.html.haml
@@ -2,20 +2,19 @@
.modal-dialog
.modal-content
.modal-header
- %h3.page-title Delete label: #{label.name} ?
+ %h3.page-title= _('Delete label: %{label_name} ?') % { label_name: label.name }
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
.modal-body
%p
- %strong= label.name
- %span will be permanently deleted from #{label.subject_name}. This cannot be undone.
+ = _('<strong>%{label_name}</strong> <span>will be permanently deleted from %{subject_name}. This cannot be undone.</span>').html_safe % { label_name: label.name, subject_name: label.subject_name }
.modal-footer
- %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel
+ %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }= _('Cancel')
- = link_to 'Delete label',
+ = link_to _('Delete label'),
label.destroy_path,
- title: 'Delete',
+ title: _('Delete'),
method: :delete,
class: 'btn btn-remove'
diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml
index a7ad6d6f2c4..4f416c483f2 100644
--- a/app/views/shared/_field.html.haml
+++ b/app/views/shared/_field.html.haml
@@ -7,23 +7,22 @@
- choices = field[:choices]
- default_choice = field[:default_choice]
- help = field[:help]
-- disabled = disable_fields_service?(@service)
.form-group.row
- if type == "password" && value.present?
- = form.label name, "Enter new #{title.downcase}", class: "col-form-label col-sm-2"
+ = form.label name, _("Enter new %{field_title}") % { field_title: title.downcase }, class: "col-form-label col-sm-2"
- else
= 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, disabled: disabled, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" }
+ = form.text_field name, class: "form-control", 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, disabled: disabled
+ = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required
- elsif type == 'checkbox'
- = form.check_box name, disabled: disabled
+ = form.check_box name
- elsif type == 'select'
- = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control", disabled: disabled}
+ = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control"}
- elsif type == 'password'
- = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, disabled: disabled, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" }
+ = 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
%span.form-text.text-muted= help
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 9a65981ed58..019b2ef89a4 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: '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',
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/shared/_group_tips.html.haml b/app/views/shared/_group_tips.html.haml
index 46e4340511a..2d7f8e36139 100644
--- a/app/views/shared/_group_tips.html.haml
+++ b/app/views/shared/_group_tips.html.haml
@@ -1,5 +1,5 @@
%ul
- %li A group is a collection of several projects
- %li Members of a group may only view projects they have permission to access
- %li Group project URLs are prefixed with the group namespace
- %li Existing projects may be moved into a group
+ %li= _('A group is a collection of several projects')
+ %li= _('Members of a group may only view projects they have permission to access')
+ %li= _('Group project URLs are prefixed with the group namespace')
+ %li= _('Existing projects may be moved into a group')
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index b05d903fabe..cd303dd7a3d 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -13,7 +13,7 @@
%ul.label-links
- if show_label_issues_link
%li.label-link-item.inline
- = link_to_label(label) { 'Issues' }
+ = link_to_label(label) { _('Issues') }
- if show_label_merge_requests_link
&middot;
%li.label-link-item.inline
diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml
index 099e3ac8462..48a97a18ca9 100644
--- a/app/views/shared/_milestone_expired.html.haml
+++ b/app/views/shared/_milestone_expired.html.haml
@@ -1,6 +1,6 @@
- if milestone.expired? and not milestone.closed?
- .status-box.status-box-expired.append-bottom-5 Expired
+ .status-box.status-box-expired.append-bottom-5= _('Expired')
- if milestone.upcoming?
- .status-box.status-box-mr-merged.append-bottom-5 Upcoming
+ .status-box.status-box-mr-merged.append-bottom-5= _('Upcoming')
- if milestone.closed?
- .status-box.status-box-closed.append-bottom-5 Closed
+ .status-box.status-box-closed.append-bottom-5= _('Closed')
diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml
index 6c1ac20d544..eb50960202a 100644
--- a/app/views/shared/_milestones_filter.html.haml
+++ b/app/views/shared/_milestones_filter.html.haml
@@ -1,13 +1,13 @@
%ul.nav-links.mobile-separator.nav.nav-tabs
%li{ class: milestone_class_for_state(params[:state], 'opened', true) }>
= link_to milestones_filter_path(state: 'opened') do
- Open
+ = _('Open')
%span.badge.badge-pill= counts[:opened]
%li{ class: milestone_class_for_state(params[:state], 'closed') }>
= link_to milestones_filter_path(state: 'closed', sort: 'due_date_desc') do
- Closed
+ = _('Closed')
%span.badge.badge-pill= counts[:closed]
%li{ class: milestone_class_for_state(params[:state], 'all') }>
= link_to milestones_filter_path(state: 'all', sort: 'due_date_desc') do
- All
+ = _('All')
%span.badge.badge-pill= counts[:all]
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index a1f21c2a83e..172f3d85472 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -14,4 +14,4 @@
%li.js-builds-dropdown-loading.hidden
.loading-container.text-center
- %span.spinner{ 'aria-label': 'Loading' }
+ %span.spinner{ 'aria-label': _('Loading') }
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index fbfd4d0e9a9..2b04e3e1c98 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -1,7 +1,7 @@
- if show_no_ssh_key_message?
%div{ class: 'no-ssh-key-message gl-alert gl-alert-warning', role: 'alert' }
= sprite_icon('warning', size: 16, css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
- %button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': 'Dismiss' }
+ %button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': _('Dismiss') }
= sprite_icon('close', size: 16, css_class: 'gl-icon s16')
.gl-alert-body
= s_("MissingSSHKeyWarningLink|You won't be able to pull or push project code via SSH until you add an SSH key to your profile").html_safe
diff --git a/app/views/shared/_personal_access_tokens_created_container.html.haml b/app/views/shared/_personal_access_tokens_created_container.html.haml
deleted file mode 100644
index df4577e2862..00000000000
--- a/app/views/shared/_personal_access_tokens_created_container.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-- container_title = local_assigns.fetch(:container_title, _('Your New Personal Access Token'))
-- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token'))
-
-.created-personal-access-token-container
- %h5.prepend-top-0
- = container_title
- .form-group
- .input-group
- = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "qa-created-personal-access-token form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block"
- %span.input-group-append
- = clipboard_button(text: new_token_value, title: clipboard_button_title, placement: "left", class: "input-group-text btn-default btn-clipboard")
- %span#created-token-help-block.form-text.text-muted.text-danger
- = _("Make sure you save it - you won't be able to access it again.")
-
-%hr
diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml
deleted file mode 100644
index 71f3447ebc7..00000000000
--- a/app/views/shared/_personal_access_tokens_form.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-- type = impersonation ? s_('Profiles|impersonation') : s_('Profiles|personal access')
-
-%h5.prepend-top-0
- = _('Add a %{type} token') % { type: type }
-%p.profile-settings-content
- = _("Pick a name for the application, and we'll give you a unique %{type} token.") % { type: type }
-
-= form_for token, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
-
- = form_errors(token)
-
- .row
- .form-group.col-md-6
- = f.label :name, _('Name'), class: 'label-bold'
- = f.text_field :name, class: "form-control", required: true, data: { qa_selector: 'personal_access_token_name_field' }
-
- .row
- .form-group.col-md-6
- = f.label :expires_at, _('Expires at'), class: 'label-bold'
- .input-icon-wrapper
-
- = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime'
-
- = f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD', data: { qa_selector: 'expiry_date_field' }
-
- .form-group
- = f.label :scopes, _('Scopes'), class: 'label-bold'
- = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes
-
- .prepend-top-default
- = f.submit _('Create %{type} token') % { type: type }, class: "btn btn-success", data: { qa_selector: 'create_token_button' }
diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml
index 2c52eccccb6..88f213612fc 100644
--- a/app/views/shared/_project_limit.html.haml
+++ b/app/views/shared/_project_limit.html.haml
@@ -1,8 +1,8 @@
- if cookies[:hide_project_limit_message].blank? && !current_user.hide_project_limit && !current_user.can_create_project? && current_user.projects_limit > 0
.project-limit-message.alert.alert-warning.d-none.d-sm-block
- You won't be able to create new projects because you have reached your project limit.
+ = _("You won't be able to create new projects because you have reached your project limit.")
.float-right
- = link_to "Don't show again", profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link'
+ = link_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link'
|
- = link_to 'Remind later', '#', class: 'hide-project-limit-message alert-link'
+ = link_to _('Remind later'), '#', class: 'hide-project-limit-message alert-link'
diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml
index 10f358402c1..245a86721eb 100644
--- a/app/views/shared/_recaptcha_form.html.haml
+++ b/app/views/shared/_recaptcha_form.html.haml
@@ -17,4 +17,4 @@
- if has_submit
.row-content-block.footer-block
- = f.submit "Submit #{humanized_resource_name}", class: 'btn btn-success'
+ = f.submit _("Submit %{humanized_resource_name}") % { humanized_resource_name: humanized_resource_name }, class: 'btn btn-success'
diff --git a/app/views/shared/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml
index 8b2a3bee407..ee2b2a17e21 100644
--- a/app/views/shared/_ref_dropdown.html.haml
+++ b/app/views/shared/_ref_dropdown.html.haml
@@ -1,7 +1,7 @@
- dropdown_class = local_assigns.fetch(:dropdown_class, '')
.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: dropdown_class }
- = dropdown_title "Select Git revision"
- = dropdown_filter "Filter by Git revision"
+ = dropdown_title _('Select Git revision')
+ = dropdown_filter _('Filter by Git revision')
= dropdown_content
= dropdown_loading
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 3da4b77b5eb..a9203459914 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -1,4 +1,5 @@
= 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
@@ -8,9 +9,10 @@
= markdown @service.help
.service-settings
- .js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, disabled: disable_fields_service?(@service).to_s } }
+ .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 } }
- - if @service.configurable_events.present?
+ - if @service.configurable_events.present? && !@service.is_a?(JiraService) && Feature.disabled?(:integration_form_refactor)
.form-group.row
%label.col-form-label.col-sm-2= _('Trigger')
@@ -31,32 +33,5 @@
%p.text-muted
= @service.class.event_description(event)
- - if @service.configurable_event_actions.present?
- .form-group.row
- %label.col-form-label.col-sm-2= _('Event Actions')
-
- .col-sm-10
- - @service.configurable_event_actions.each do |action|
- .form-group
- .form-check
- = form.check_box service_event_action_field_name(action), class: 'form-check-input'
- = form.label service_event_action_field_name(action), class: 'form-check-label' do
- %strong
- = event_action_description(action)
-
- %p.text-muted
- = event_action_description(action)
-
- @service.global_fields.each do |field|
- - type = field[:type]
-
- - if type == 'fieldset'
- - fields = field[:fields]
- - legend = field[:legend]
-
- %fieldset
- %legend= legend
- - fields.each do |subfield|
- = render 'shared/field', form: form, field: subfield
- - else
- = render 'shared/field', form: form, field: field
+ = render 'shared/field', form: form, field: field
diff --git a/app/views/shared/access_tokens/_created_container.html.haml b/app/views/shared/access_tokens/_created_container.html.haml
new file mode 100644
index 00000000000..f11ef1e01de
--- /dev/null
+++ b/app/views/shared/access_tokens/_created_container.html.haml
@@ -0,0 +1,12 @@
+.created-personal-access-token-container
+ %h5.prepend-top-0
+ = _('Your new %{type}') % { type: type }
+ .form-group
+ .input-group
+ = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: 'qa-created-access-token form-control js-select-on-focus', 'aria-describedby' => 'created-token-help-block'
+ %span.input-group-append
+ = clipboard_button(text: new_token_value, title: _('Copy %{type}') % { type: type }, placement: 'left', class: 'input-group-text btn-default btn-clipboard')
+ %span#created-token-help-block.form-text.text-muted.text-danger
+ = _("Make sure you save it - you won't be able to access it again.")
+
+%hr
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
new file mode 100644
index 00000000000..cb7f907308f
--- /dev/null
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -0,0 +1,34 @@
+- title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type })
+- prefix = local_assigns.fetch(:prefix, :personal_access_token)
+
+%h5.prepend-top-0
+ = title
+%p.profile-settings-content
+ = _("Enter the name of your application, and we'll return a unique %{type}.") % { type: type }
+
+= form_for token, as: prefix, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
+
+ = form_errors(token)
+
+ .row
+ .form-group.col-md-6
+ = f.label :name, _('Name'), class: 'label-bold'
+ = f.text_field :name, class: 'form-control', required: true, data: { qa_selector: 'access_token_name_field' }
+
+ .row
+ .form-group.col-md-6
+ = f.label :expires_at, _('Expires at'), class: 'label-bold'
+ .input-icon-wrapper
+
+ = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime'
+
+ .js-access-tokens-expires-at
+ %expires-at-field
+ = f.text_field :expires_at, class: 'datepicker form-control gl-datepicker-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', inputmode: 'none', data: { qa_selector: 'expiry_date_field' }
+
+ .form-group
+ = f.label :scopes, _('Scopes'), class: 'label-bold'
+ = render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes
+
+ .prepend-top-default
+ = f.submit _('Create %{type}') % { type: type }, class: 'btn btn-success', data: { qa_selector: 'create_token_button' }
diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
index 823117f37ca..5518c31cb06 100644
--- a/app/views/shared/_personal_access_tokens_table.html.haml
+++ b/app/views/shared/access_tokens/_table.html.haml
@@ -1,8 +1,10 @@
-- type = impersonation ? s_('Profiles|Impersonation') : s_('Profiles|Personal Access')
+- no_active_tokens_message = local_assigns.fetch(:no_active_tokens_message, _('This user has no active %{type}.') % { type: type_plural })
+- impersonation = local_assigns.fetch(:impersonation, false)
+
%hr
%h5
- = _('Active %{type} Tokens (%{token_length})') % { type: type, token_length: active_tokens.length }
+ = _('Active %{type} (%{token_length})') % { type: type_plural, token_length: active_tokens.length }
- if impersonation
%p.profile-settings-content
= _("To see all the user's personal access tokens you must impersonate them first.")
@@ -25,12 +27,11 @@
%td
- if token.expires?
%span{ class: ('text-warning' if token.expires_soon?) }
- In #{distance_of_time_in_words_to_now(token.expires_at)}
+ = _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) }
- else
%span.token-never-expires-label= _('Never')
- %td= token.scopes.present? ? token.scopes.join(", ") : _('<no scopes selected>')
- - path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token)
- %td= link_to _('Revoke'), path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: _('Are you sure you want to revoke this %{type} Token? This action cannot be undone.') % { type: type } }
+ %td= token.scopes.present? ? token.scopes.join(', ') : _('<no scopes selected>')
+ %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'btn btn-danger float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } }
- else
.settings-message.text-center
- = _('This user has no active %{type} Tokens.') % { type: type }
+ = no_active_tokens_message
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index ffb406ac35b..2a5b72d478a 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -38,8 +38,7 @@
":description" => "list.label.description",
"tooltipPlacement" => "bottom",
":size" => '(!list.isExpanded ? "sm" : "")',
- ":scoped" => "showScopedLabels(list.label)",
- ":scoped-labels-documentation-link" => "helpLink" }
+ ":scoped" => "showScopedLabels(list.label)" }
- if can?(current_user, :admin_list, current_board_parent)
%board-delete{ "inline-template" => true,
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index a1088dc5222..58ffa3942ef 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -12,8 +12,7 @@
":background-color" => "label.color",
":title" => "label.title",
":description" => "label.description",
- ":scoped" => "showScopedLabels(label)",
- ":scoped-labels-documentation-link" => "helpLink" }
+ ":scoped" => "showScopedLabels(label)" }
- if can_admin_issue?
.selectbox
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml
index 6b3b824f72f..f28e745f4c5 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/shared/deploy_keys/_index.html.haml
@@ -1,15 +1,14 @@
- expanded = expanded_by_default?
%section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings' } }
.settings-header
- %h4
- Deploy Keys
+ %h4= _('Deploy Keys')
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
- Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.
+ = _('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
- Create a new deploy key for this project
+ = _('Create a new deploy key for this project')
= render @deploy_keys.form_partial_path
%hr
#js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } }
diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index 568930595a2..8edd1d9deb8 100644
--- a/app/views/projects/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -8,17 +8,17 @@
= f.text_area :key, class: "form-control", rows: 5, required: true
.form-group.row
%p.light.append-bottom-0
- Paste a machine public key here. Read more about how to generate it
+ = _('Paste a machine public key here. Read more about how to generate it')
= link_to "here", help_page_path("ssh/README")
= f.fields_for :deploy_keys_projects do |deploy_keys_project_form|
.form-group.row
= deploy_keys_project_form.label :can_push do
= deploy_keys_project_form.check_box :can_push
- %strong Write access allowed
+ %strong= _('Write access allowed')
.form-group.row
%p.light.append-bottom-0
- Allow this key to push to repository as well? (Default only allows pull access.)
+ = _('Allow this key to push to repository as well? (Default only allows pull access.)')
.form-group.row
= f.submit "Add key", class: "btn-success btn"
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 5751ed9cb7a..512644518fa 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -35,5 +35,15 @@
= label_tag ("deploy_token_write_registry"), 'write_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows write access to the registry images')
+ %fieldset.form-group.form-check
+ = f.check_box :read_package_registry, class: 'form-check-input'
+ = label_tag ("deploy_token_read_package_registry"), 'read_package_registry', class: 'label-bold form-check-label'
+ .text-secondary= s_('DeployTokens|Allows read access to the package registry')
+
+ %fieldset.form-group.form-check
+ = f.check_box :write_package_registry, class: 'form-check-input'
+ = label_tag ("deploy_token_write_package_registry"), 'write_package_registry', class: 'label-bold form-check-label'
+ .text-secondary= s_('DeployTokens|Allows write access to the package registry')
+
.prepend-top-default
= f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token'
diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml
index 74eb6c94116..0e1f41bbbf6 100644
--- a/app/views/shared/file_hooks/_index.html.haml
+++ b/app/views/shared/file_hooks/_index.html.haml
@@ -19,6 +19,10 @@
%li
.monospace
= File.basename(file)
+ - if File.dirname(file).ends_with?('plugins')
+ .text-warning
+ = _('Plugins directory is deprecated and will be removed in 14.0. Please move this file into /file_hooks directory.')
+
- else
.card.bg-light.text-center
.nothing-here-block= _('No file hooks found.')
diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml
index f3b56df0c96..6b056e93460 100644
--- a/app/views/shared/hook_logs/_content.html.haml
+++ b/app/views/shared/hook_logs/_content.html.haml
@@ -31,7 +31,7 @@
%h5 Request body:
%pre
:escaped
- #{JSON.pretty_generate(hook_log.request_data)}
+ #{Gitlab::Json.pretty_generate(hook_log.request_data)}
%h5 Response headers:
%pre
- hook_log.response_headers.each do |k,v|
diff --git a/app/views/shared/integrations/_form.html.haml b/app/views/shared/integrations/_form.html.haml
index 0ddab1368c2..4ec7f286c7a 100644
--- a/app/views/shared/integrations/_form.html.haml
+++ b/app/views/shared/integrations/_form.html.haml
@@ -10,5 +10,5 @@
- if integration.editable?
.footer-block.row-content-block
- = service_save_button(integration)
+ = service_save_button
= link_to _('Cancel'), scoped_integration_path(integration), class: 'btn btn-cancel'
diff --git a/app/views/shared/integrations/_integrations.html.haml b/app/views/shared/integrations/_index.html.haml
index b2359aca016..2dbd612ea38 100644
--- a/app/views/shared/integrations/_integrations.html.haml
+++ b/app/views/shared/integrations/_index.html.haml
@@ -3,7 +3,7 @@
%col
%col
%col.d-none.d-sm-table-column
- %col{ width: 120 }
+ %col{ width: 130 }
%thead{ role: 'rowgroup' }
%tr{ role: 'row' }
%th{ role: 'columnheader', scope: 'col', 'aria-colindex': 1 }
@@ -13,13 +13,14 @@
%tbody{ role: 'rowgroup' }
- integrations.each do |integration|
+ - activated_label = (integration.activated? ? s_("ProjectService|%{service_title}: status on") : s_("ProjectService|%{service_title}: status off")) % { service_title: integration.title }
%tr{ role: 'row' }
- %td{ role: 'cell', 'aria-colindex': 1 }
- = boolean_to_icon integration.activated?
+ %td{ role: 'cell', 'aria-colindex': 1, 'aria-label': activated_label }
+ = boolean_to_icon integration.operating?
%td{ role: 'cell', 'aria-colindex': 2 }
- = link_to scoped_edit_integration_path(integration) do
+ = link_to scoped_edit_integration_path(integration), { data: { qa_selector: "#{integration.to_param}_link" } } do
%strong= integration.title
- %td.d-none.d-sm-block{ role: 'cell', 'aria-colindex': 3 }
+ %td.d-none.d-sm-table-cell{ role: 'cell', 'aria-colindex': 3 }
= integration.description
%td{ role: 'cell', 'aria-colindex': 4 }
- if integration.updated_at.present?
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index a05a13814ac..4bc6c1dee37 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -5,45 +5,49 @@
= form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do
.block.issuable-sidebar-header
.filter-item.inline.update-issues-btn.float-left
- = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true
- = button_tag "Cancel", class: "btn btn-default js-bulk-update-menu-hide float-right"
+ = button_tag _('Update all'), class: "btn update-selected-issues btn-info", disabled: true
+ = button_tag _('Cancel'), class: "btn btn-default js-bulk-update-menu-hide float-right"
.block
.title
- Status
+ = _('Status')
.filter-item
- = dropdown_tag("Select status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
+ = dropdown_tag(_("Select status"), options: { toggle_class: "js-issue-status", title: _("Change status"), dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: _("Status") } } ) do
%ul
%li
- %a{ href: "#", data: { id: "reopen" } } Open
+ %a{ href: "#", data: { id: "reopen" } }
+ = _('Open')
%li
- %a{ href: "#", data: { id: "close" } } Closed
+ %a{ href: "#", data: { id: "close" } }
+ = _('Closed')
.block
.title
- Assignee
+ = _('Assignee')
.filter-item
- field_name = "update[assignee_ids][]"
- = dropdown_tag("Select assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
- placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
+ = dropdown_tag(_("Select assignee"), options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: _("Assign to"), filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
+ placeholder: _("Search authors"), data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
.block
.title
- Milestone
+ = _('Milestone')
.filter-item
- = dropdown_tag("Select milestone", options: { title: "Assign milestone", toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: project_milestones_path(@project, :json), use_id: true, default_label: "Milestone" } })
+ = dropdown_tag(_("Select milestone"), options: { title: _("Assign milestone"), toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: _("Search milestones"), data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: project_milestones_path(@project, :json), use_id: true, default_label: _("Milestone") } })
.block
.title
- Labels
+ = _('Labels')
.filter-item.labels-filter
- = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: "Apply a label", show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }, label_name: "Select labels", no_default_styles: true
+ = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true
.block
.title
- Subscriptions
+ = _('Subscriptions')
.filter-item
- = dropdown_tag("Select subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
+ = dropdown_tag(_("Select subscription"), options: { toggle_class: "js-subscription-event", title: _("Change subscription"), dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: _("Subscription") } } ) do
%ul
%li
- %a{ href: "#", data: { id: "subscribe" } } Subscribe
+ %a{ href: "#", data: { id: "subscribe" } }
+ = _('Subscribe')
%li
- %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
+ %a{ href: "#", data: { id: "unsubscribe" } }
+ = _('Unsubscribe')
= hidden_field_tag "update[issuable_ids]", []
= hidden_field_tag :state_event, params[:state_event]
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index 2eb96a7bc9b..5f7cfdc9d03 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -2,17 +2,20 @@
- display_issuable_type = issuable_display_type(issuable)
- button_method = issuable_close_reopen_button_method(issuable)
- are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false)
+- add_blocked_class = false
+- if defined? warn_before_close
+ - add_blocked_class = warn_before_close
- if is_current_user
- if can_update
- = link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method,
- class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}", data: { qa_selector: 'close_issue_button' }
+ = link_to _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, close_issuable_path(issuable), method: button_method,
+ class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, data: { qa_selector: 'close_issue_button' }
- if can_reopen
- = link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method,
- class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}", data: { qa_selector: 'reopen_issue_button' }
+ = link_to _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, reopen_issuable_path(issuable), method: button_method,
+ class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, data: { qa_selector: 'reopen_issue_button' }
- else
- if can_update && !are_close_and_open_buttons_hidden
- = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
+ = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class
- else
- = link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
- class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: 'Report abuse'
+ = link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
+ class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse')
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
index 0d59c9304b4..9d718083d2d 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -5,45 +5,46 @@
- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
- button_method = issuable_close_reopen_button_method(issuable)
+- add_blocked_class = false
+- if defined? warn_before_close
+ - add_blocked_class = !issuable.closed? && warn_before_close
.float-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
= link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_path(issuable),
- method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}"
+ method: button_method, class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: "#{display_button_action} #{display_issuable_type}", data: { qa_selector: 'close_issue_button' }
= button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color",
- data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => 'Toggle dropdown' do
+ data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => _('Toggle dropdown') do
= icon('caret-down', class: 'toggle-icon icon')
%ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ data: { dropdown: true } }
%li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}",
- data: { text: "Close #{display_issuable_type}", url: close_issuable_path(issuable),
+ data: { text: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: close_issuable_path(issuable),
button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
%strong.title
- Close
+ = _('Close')
= display_issuable_type
%li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}",
- data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_path(issuable),
+ data: { text: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: reopen_issuable_path(issuable),
button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
%strong.title
- Reopen
+ = _('Reopen')
= display_issuable_type
%li.divider.droplab-item-ignore
- %li.report-item{ data: { text: 'Report abuse', url: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
+ %li.report-item{ data: { text: _('Report abuse'), url: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
button_class: "#{button_class} btn-close-color", toggle_class: "#{toggle_class} btn-close-color", method: '' } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
- %strong.title Report abuse
+ %strong.title= _('Report abuse')
%p.text
- Report
- = display_issuable_type.pluralize
- that are abusive, inappropriate or spam.
+ = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize }
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index bca5db16bd3..535af522c1a 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -8,10 +8,11 @@
- data_options = local_assigns.fetch(:data_options, {})
- classes = local_assigns.fetch(:classes, [])
- selected = local_assigns.fetch(:selected, nil)
-- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label")
-- dropdown_data = label_dropdown_data(edit_context, labels: labels_filter_path_with_defaults(only_group_labels: edit_context.is_a?(Group)), default_label: "Labels")
+- dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by label'))
+- dropdown_data = label_dropdown_data(edit_context, labels: labels_filter_path_with_defaults(only_group_labels: edit_context.is_a?(Group)), default_label: _('Labels'))
+
- dropdown_data.merge!(data_options)
-- label_name = local_assigns.fetch(:label_name, "Labels")
+- label_name = local_assigns.fetch(:label_name, _('Labels'))
- no_default_styles = local_assigns.fetch(:no_default_styles, false)
- classes << 'js-extra-options' if extra_options
- classes << 'js-filter-submit' if filter_submit
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index a0fb5229fc3..43e80c9db27 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -3,7 +3,7 @@
- show_title = local_assigns.fetch(:show_title, true)
- show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true)
-- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
+- filter_placeholder = local_assigns.fetch(:filter_placeholder, _('Search'))
- show_boards_content = local_assigns.fetch(:show_boards_content, false)
- subject = @project || @group
.dropdown-page-one
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index 93408e0bfc0..c715cd8f736 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -4,20 +4,20 @@
%ul.nav-links.issues-state-filters.mobile-separator.nav.nav-tabs
%li{ class: active_when(params[:state] == 'opened') }>
- = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do
+ = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: _("Filter by %{page_context_word} that are currently opened.") % { page_context_word: page_context_word }, data: { state: 'opened' } do
#{issuables_state_counter_text(type, :opened, display_count)}
- if type == :merge_requests
%li{ class: active_when(params[:state] == 'merged') }>
- = link_to page_filter_path(state: 'merged'), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do
+ = link_to page_filter_path(state: 'merged'), id: 'state-merged', title: _('Filter by merge requests that are currently merged.'), data: { state: 'merged' } do
#{issuables_state_counter_text(type, :merged, display_count)}
%li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do
+ = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: _('Filter by merge requests that are currently closed and unmerged.'), data: { state: 'closed' } do
#{issuables_state_counter_text(type, :closed, display_count)}
- else
%li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed', qa_selector: 'closed_issues_link' } do
+ = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: _('Filter by issues that are currently closed.'), data: { state: 'closed', qa_selector: 'closed_issues_link' } do
#{issuables_state_counter_text(type, :closed, display_count)}
= render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count)
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index d9ca0b8869f..34be9291f1f 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -1,6 +1,7 @@
- type = local_assigns.fetch(:type)
- board = local_assigns.fetch(:board, nil)
- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true)
+- placeholder = local_assigns[:placeholder] || _('Search or filter results...')
- is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics
- block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : ''
- user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent)
@@ -29,7 +30,7 @@
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
- %input.form-control.filtered-search{ search_filter_input_options(type) }
+ %input.form-control.filtered-search{ search_filter_input_options(type, placeholder) }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
@@ -73,6 +74,7 @@
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
= render_if_exists 'shared/issuable/approver_dropdown'
+ = render_if_exists 'shared/issuable/approved_by_dropdown'
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index e20573ed3a7..a1c56cdb64f 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -15,7 +15,7 @@
- if signed_in
%span.issuable-header-text.hide-collapsed.float-left
= _('To Do')
- %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" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
= sidebar_gutter_toggle_icon
- if signed_in
= render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar
@@ -65,7 +65,7 @@
.sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) }
= icon('calendar', 'aria-hidden': 'true')
%span.js-due-date-sidebar-value
- = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None'
+ = issuable_sidebar[:due_date].try(:to_s, :medium) || _('None')
.title.hide-collapsed
= _('Due date')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index b5a27f2f17d..4192ecd2238 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -1,7 +1,7 @@
- issuable_type = issuable_sidebar[:type]
- signed_in = !!issuable_sidebar.dig(:current_user, :id)
-#js-vue-sidebar-assignees{ data: { field: "#{issuable_type}", signed_in: signed_in } }
+#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in } }
.title.hide-collapsed
= _('Assignee')
.spinner.spinner-sm.align-bottom
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index d8253924e0a..3794a3b3845 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -6,7 +6,7 @@
- source_title, target_title = format_mr_branch_names(@merge_request)
-.form-group.row.d-flex.gl-pl-3.gl-pr-3.branch-selector
+.form-group.row.d-flex.gl-pl-3-deprecated-no-really-do-not-use-me.gl-pr-3-deprecated-no-really-do-not-use-me.branch-selector
.align-self-center
%span
= _('From <code>%{source_title}</code> into').html_safe % { source_title: source_title }
diff --git a/app/views/shared/members/_badge.html.haml b/app/views/shared/members/_badge.html.haml
new file mode 100644
index 00000000000..e304207f3e9
--- /dev/null
+++ b/app/views/shared/members/_badge.html.haml
@@ -0,0 +1,4 @@
+- type ||= 'info'
+
+%span.px-1.py-1
+ %span{ class: "badge badge-#{type}" }= yield
diff --git a/app/views/shared/members/_blocked_badge.html.haml b/app/views/shared/members/_blocked_badge.html.haml
new file mode 100644
index 00000000000..95335ebe74d
--- /dev/null
+++ b/app/views/shared/members/_blocked_badge.html.haml
@@ -0,0 +1,3 @@
+- if user.blocked?
+ = render 'shared/members/badge', type: 'danger' do
+ = _("Blocked")
diff --git a/app/views/shared/members/_its_you_badge.html.haml b/app/views/shared/members/_its_you_badge.html.haml
new file mode 100644
index 00000000000..b53ffd8032d
--- /dev/null
+++ b/app/views/shared/members/_its_you_badge.html.haml
@@ -0,0 +1,3 @@
+- if user == current_user
+ = render 'shared/members/badge', type: 'success' do
+ = _("It's you")
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index d74030c566f..f7d90a588c7 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -13,24 +13,23 @@
- if user
= image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
.user-info
- = link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id }
- = user_status(user)
- %span.cgray= user.to_reference
+ %span.mr-1
+ = link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id }
+ = user_status(user)
+ %span.cgray= user.to_reference
- = render_if_exists 'shared/members/ee/sso_badge', member: member
+ .mx-n1.d-inline-flex.flex-wrap
+ = render_if_exists 'shared/members/ee/sso_badge', member: member
- - if user == current_user
- %span.badge.badge-success.prepend-left-5= _("It's you")
+ = render_if_exists 'shared/members/ee/gma_badge', member: member
- = render_if_exists 'shared/members/ee/license_badge', user: user, group: @group
+ = render 'shared/members/its_you_badge', user: user, current_user: current_user
- - if user.blocked?
- %label.badge.badge-danger
- %strong= _("Blocked")
+ = render_if_exists 'shared/members/ee/license_badge', user: user, group: @group
- - if user.two_factor_enabled?
- %label.badge.badge-info
- = _("2FA")
+ = render 'shared/members/blocked_badge', user: user
+
+ = render 'shared/members/two_factor_auth_badge', user: user
- if source.instance_of?(Group) && source != @group
&middot;
@@ -68,7 +67,7 @@
class: 'btn btn-default align-self-center mr-sm-2',
title: _('Resend invite')
- - if user != current_user && member.can_update?
+ - if user != current_user && member.can_update? && !user&.project_bot?
= form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f|
= f.hidden_field :access_level
.member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] }
@@ -118,7 +117,7 @@
method: :delete,
data: { confirm: leave_confirmation_message(member.source) },
class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}"
- - else
+ - elsif !user&.project_bot?
= link_to member,
method: :delete,
data: { confirm: remove_member_message(member), qa_selector: 'delete_member_button' },
diff --git a/app/views/shared/members/_two_factor_auth_badge.html.haml b/app/views/shared/members/_two_factor_auth_badge.html.haml
new file mode 100644
index 00000000000..34850c135d6
--- /dev/null
+++ b/app/views/shared/members/_two_factor_auth_badge.html.haml
@@ -0,0 +1,3 @@
+- if user.two_factor_enabled?
+ = render 'shared/members/badge', type: 'info' do
+ = _("2FA")
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index 0adfe2f0c04..f8bf3e7ad6a 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -25,5 +25,5 @@
%span.assignee-icon
- assignees.each do |assignee|
= link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }),
- class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do
+ class: 'has-tooltip', title: _("Assigned to %{assignee_name}") % { assignee_name: assignee.name }, data: { container: 'body' } do
- image_tag(avatar_icon_for_user(assignee, 16), class: "avatar s16", alt: '')
diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml
index d7e4f2ed5a0..6684f6d752a 100644
--- a/app/views/shared/milestones/_issues_tab.html.haml
+++ b/app/views/shared/milestones/_issues_tab.html.haml
@@ -8,8 +8,8 @@
.row.prepend-top-default
.col-md-4
- = render 'shared/milestones/issuables', args.merge(title: 'Unstarted Issues (open and unassigned)', issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Unstarted Issues (open and unassigned)'), issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
.col-md-4
- = render 'shared/milestones/issuables', args.merge(title: 'Ongoing Issues (open and assigned)', issuables: issues.opened.assigned, id: 'ongoing', show_counter: true)
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Ongoing Issues (open and assigned)'), issuables: issues.opened.assigned, id: 'ongoing', show_counter: true)
.col-md-4
- = render 'shared/milestones/issuables', args.merge(title: 'Completed Issues (closed)', issuables: issues.closed, id: 'closed', show_counter: true)
+ = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Completed Issues (closed)'), issuables: issues.closed, id: 'closed', show_counter: true)
diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml
index 6d79b0d31b2..3b4d29ca7b0 100644
--- a/app/views/shared/milestones/_labels_tab.html.haml
+++ b/app/views/shared/milestones/_labels_tab.html.haml
@@ -9,6 +9,6 @@
.float-right.d-none.d-lg-block.d-xl-block
= link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do
- - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue'
+ - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue')
= link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do
- - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue'
+ - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue')
diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml
index 9c193f901e2..4dba2473efc 100644
--- a/app/views/shared/milestones/_merge_requests_tab.haml
+++ b/app/views/shared/milestones/_merge_requests_tab.haml
@@ -3,10 +3,10 @@
.row.prepend-top-default
.col-md-3
- = render 'shared/milestones/issuables', args.merge(title: 'Work in progress (open and unassigned)', issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true)
+ = render 'shared/milestones/issuables', args.merge(title: _('Work in progress (open and unassigned)'), issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true)
.col-md-3
- = render 'shared/milestones/issuables', args.merge(title: 'Waiting for merge (open and assigned)', issuables: merge_requests.opened.assigned, id: 'ongoing', show_counter: true)
+ = render 'shared/milestones/issuables', args.merge(title: _('Waiting for merge (open and assigned)'), issuables: merge_requests.opened.assigned, id: 'ongoing', show_counter: true)
.col-md-3
- = render 'shared/milestones/issuables', args.merge(title: 'Rejected (closed)', issuables: merge_requests.closed, id: 'closed', show_counter: true)
+ = render 'shared/milestones/issuables', args.merge(title: _('Rejected (closed)'), issuables: merge_requests.closed, id: 'closed', show_counter: true)
.col-md-3
- = render 'shared/milestones/issuables', args.merge(title: 'Merged', issuables: merge_requests.merged, id: 'merged', primary: true, show_counter: true)
+ = render 'shared/milestones/issuables', args.merge(title: _('Merged'), issuables: merge_requests.merged, id: 'merged', primary: true, show_counter: true)
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 451c2c2ba10..9f61082d605 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -1,6 +1,6 @@
- dashboard = local_assigns[:dashboard]
- custom_dom_id = dom_id(milestone.try(:milestone) ? milestone.milestone : milestone)
-- milestone_type = milestone.group_milestone? ? 'Group Milestone' : 'Project Milestone'
+- milestone_type = milestone.group_milestone? ? s_('Milestones|Group Milestone') : s_('Milestones|Project Milestone')
%li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id }
.row
@@ -42,17 +42,17 @@
.col-sm-4.milestone-progress
= milestone_progress_bar(milestone)
- = link_to pluralize(milestone.total_issues_count, 'Issue'), issues_path
+ = link_to pluralize(milestone.total_issues_count, _('Issue')), issues_path
- if milestone.merge_requests_enabled?
&middot;
- = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path
+ = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, _('Merge Request')), merge_requests_path
.float-lg-right.light #{milestone.percent_complete}% 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: _('Promote to Group Milestone'),
+ %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),
@@ -63,15 +63,15 @@
toggle: 'modal' } }
= sprite_icon('level-up', size: 14)
- = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped"
+ = 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 'Reopen Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
+ = 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 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen"
+ = 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 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close"
+ = 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/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 8d911d4247e..5f53e6316af 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -21,10 +21,10 @@
%table.table
%thead
%tr
- %th Project
- %th Open issues
- %th State
- %th Due date
+ %th= _('Project')
+ %th= _('Open issues')
+ %th= _('State')
+ %th= _('Due date')
%tr
%td
- project_name = group ? milestone.project.name : milestone.project.full_name
@@ -33,8 +33,8 @@
= milestone.milestone.issues_visible_to_user(current_user).opened.count
%td
- if milestone.closed?
- Closed
+ = _('Closed')
- else
- Open
+ = _('Open')
%td
= milestone.expires_at
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index d91bc6e57c9..327745e4f4d 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -5,7 +5,7 @@
- else
- preview_url = preview_markdown_path(@project)
-= form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
+= form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form discussion-reply-holder", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
= hidden_field_tag :view, diff_view
= hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
@@ -24,7 +24,7 @@
-# DiffNote
= f.hidden_field :position
- .discussion-form-container
+ .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,
attr: :note,
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 50bc4fb35df..df09c4338a1 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -40,9 +40,10 @@
- if note.system
%span.system-note-message
= markdown_field(note, :note)
- %span.system-note-separator
- &middot;
- %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
+ - if note.created_at
+ %span.system-note-separator
+ &middot;
+ %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- unless note.system?
.note-actions
- if note.for_personal_snippet?
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 396b6e56ea9..4695692fb53 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,7 +1,3 @@
-- if Feature.disabled?(:monaco_snippets)
- - content_for :page_specific_javascripts do
- = page_specific_javascript_tag('lib/ace.js')
-
- if Feature.enabled?(:snippets_edit_vue)
#js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access") } }
- else
@@ -24,7 +20,7 @@
= 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: 'description_field'
+ = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'snippet_description_field'
= render 'shared/notes/hints'
.form-group.file-editor
@@ -48,9 +44,9 @@
.form-actions
- if @snippet.new_record?
- = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button"
+ = f.submit 'Create snippet', class: "btn-success btn", data: { qa_selector: 'submit_button' }
- else
- = f.submit 'Save changes', class: "btn-success btn"
+ = f.submit 'Save changes', class: "btn-success btn", data: { qa_selector: 'submit_button' }
- if @snippet.project_id
= link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 1243bdab6dd..e663d57ae6a 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -21,7 +21,7 @@
= markdown_field(@snippet, :title)
- if @snippet.description.present?
- .description{ data: { qa_selector: 'snippet_description' } }
+ .description{ data: { qa_selector: 'snippet_description_field' } }
.md
= markdown_field(@snippet, :description)
%textarea.hidden.js-task-list-field
@@ -34,7 +34,7 @@
.embed-snippet
.input-group
.input-group-prepend
- %button.btn.btn-svg.embed-toggle.input-group-text.qa-embed-type{ 'data-toggle': 'dropdown', type: 'button' }
+ %button.btn.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' }
%span.js-embed-action= _("Embed")
= sprite_icon('angle-down', size: 12, css_class: 'caret-down')
%ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 3fea2c1e3fc..128ddbb8e8b 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -1,6 +1,5 @@
- link_project = local_assigns.fetch(:link_project, false)
- notes_count = @noteable_meta_data[snippet.id].user_notes_count
-- file_name = snippet_file_name(snippet)
%li.snippet-row.py-3
= image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: ''
@@ -8,10 +7,6 @@
.title
= link_to gitlab_snippet_path(snippet) do
= snippet.title
- - if file_name.present?
- %span.snippet-filename.d-none.d-sm-inline-block.ml-2
- = sprite_icon('doc-code', size: 16, css_class: 'file-icon align-text-bottom')
- = file_name
%ul.controls
%li
diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml
index a5d3e1c8de0..82e32597c94 100644
--- a/app/views/shared/tokens/_scopes_form.html.haml
+++ b/app/views/shared/tokens/_scopes_form.html.haml
@@ -6,4 +6,4 @@
%fieldset.form-group.form-check
= check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: "form-check-input qa-#{scope}-radio"
= label_tag ("#{prefix}_scopes_#{scope}"), scope, class: 'label-bold form-check-label'
- .text-secondary= t scope, scope: [:doorkeeper, :scope_desc]
+ .text-secondary= t scope, scope: scope_description(prefix)
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 741e38e3d84..819f02b78fe 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -4,7 +4,7 @@
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-- if Feature.enabled?(:snippets_vue)
+- if Feature.enabled?(:snippets_vue, default_enabled: true)
#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
- else
= render 'shared/snippets/header'
diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml
index 0545cab538c..7169aebea74 100644
--- a/app/views/users/_deletion_guidance.html.haml
+++ b/app/views/users/_deletion_guidance.html.haml
@@ -3,8 +3,9 @@
%ul
%li
%p
- Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the
- = link_to 'user account deletion documentation.', help_page_path("user/profile/account/delete_account", anchor: "associated-records")
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path("user/profile/account/delete_account", anchor: "associated-records") }
+ = _('Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the %{link_start}user account deletion documentation.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- personal_projects_count = user.personal_projects.count
- unless personal_projects_count.zero?
- %li #{pluralize(personal_projects_count, 'personal project')} will be removed and cannot be restored
+ %li
+ = n_('personal project will be removed and cannot be restored', '%d personal projects will be removed and cannot be restored', personal_projects_count)
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index 7516dfe1602..a5197a9950b 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -22,14 +22,14 @@
- elsif event.target
= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title
- at
+ = s_('UserProfile|at')
%strong
- if event.project
= link_to_project(event.project)
- else
= event.resource_parent_name
- else
- made a private contribution
+ = s_('UserProfile|made a private contribution')
- else
%p
= _('No contributions were found')
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 57d41bfaec2..1f9a53d64d9 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -3,6 +3,20 @@
#
# Do not edit it manually!
---
+- :name: authorized_project_update:authorized_project_update_project_create
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
- :name: auto_devops:auto_devops_disable
:feature_category: :auto_devops
:has_external_dependencies:
@@ -18,35 +32,35 @@
:weight: 3
:idempotent:
- :name: chaos:chaos_cpu_spin
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :name: chaos:chaos_db_spin
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :name: chaos:chaos_kill
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :name: chaos:chaos_leak_mem
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :name: chaos:chaos_sleep
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -269,6 +283,13 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+- :name: cronjob:x509_issuer_crl_check
+ :feature_category: :source_code_management
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
- :name: deployment:deployments_finished
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -290,13 +311,6 @@
:resource_boundary: :cpu
:weight: 3
:idempotent:
-- :name: gcp_cluster:cluster_configure
- :feature_category: :kubernetes_management
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent:
- :name: gcp_cluster:cluster_configure_istio
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -318,13 +332,6 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
-- :name: gcp_cluster:cluster_project_configure
- :feature_category: :kubernetes_management
- :has_external_dependencies: true
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent:
- :name: gcp_cluster:cluster_provision
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -689,7 +696,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
-- :name: pipeline_background:ci_daily_report_results
+- :name: pipeline_background:ci_daily_build_group_report_results
:feature_category: :continuous_integration
:has_external_dependencies:
:urgency: :low
@@ -849,14 +856,14 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent: true
- :name: pipeline_processing:update_head_pipeline_for_merge_request
:feature_category: :continuous_integration
:has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
:weight: 5
- :idempotent:
+ :idempotent: true
- :name: repository_check:repository_check_batch
:feature_category: :source_code_management
:has_external_dependencies:
@@ -961,7 +968,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 2
- :idempotent:
+ :idempotent: true
- :name: create_evidence
:feature_category: :release_evidence
:has_external_dependencies:
@@ -1011,6 +1018,13 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+- :name: design_management_new_version
+ :feature_category: :design_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :memory
+ :weight: 1
+ :idempotent:
- :name: detect_repository_languages
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1053,6 +1067,13 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
+- :name: external_service_reactive_caching
+ :feature_category: :not_owned
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
- :name: file_hook
:feature_category: :integrations
:has_external_dependencies:
@@ -1143,7 +1164,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent: true
- :name: migrate_external_diffs
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1220,7 +1241,7 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 3
- :idempotent:
+ :idempotent: true
- :name: project_cache
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1280,7 +1301,7 @@
- :name: reactive_caching
:feature_category: :not_owned
:has_external_dependencies:
- :urgency: :high
+ :urgency: :low
:resource_boundary: :cpu
:weight: 1
:idempotent:
diff --git a/app/workers/authorized_project_update/project_create_worker.rb b/app/workers/authorized_project_update/project_create_worker.rb
new file mode 100644
index 00000000000..651849b57ec
--- /dev/null
+++ b/app/workers/authorized_project_update/project_create_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module AuthorizedProjectUpdate
+ class ProjectCreateWorker
+ include ApplicationWorker
+
+ feature_category :authentication_and_authorization
+ urgency :low
+ queue_namespace :authorized_project_update
+
+ idempotent!
+
+ def perform(project_id)
+ project = Project.find(project_id)
+
+ AuthorizedProjectUpdate::ProjectCreateService.new(project).execute
+ end
+ end
+end
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
new file mode 100644
index 00000000000..19038cb8900
--- /dev/null
+++ b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module AuthorizedProjectUpdate
+ class UserRefreshWithLowUrgencyWorker < ::AuthorizedProjectsWorker
+ feature_category :authentication_and_authorization
+ urgency :low
+ queue_namespace :authorized_project_update
+
+ idempotent!
+ end
+end
diff --git a/app/workers/ci/daily_report_results_worker.rb b/app/workers/ci/daily_build_group_report_results_worker.rb
index 314fd44f86c..a6d3c485e24 100644
--- a/app/workers/ci/daily_report_results_worker.rb
+++ b/app/workers/ci/daily_build_group_report_results_worker.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class DailyReportResultsWorker
+ class DailyBuildGroupReportResultsWorker
include ApplicationWorker
include PipelineBackgroundQueue
@@ -9,7 +9,7 @@ module Ci
def perform(pipeline_id)
Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
- Ci::DailyReportResultService.new.execute(pipeline)
+ Ci::DailyBuildGroupReportResultService.new.execute(pipeline)
end
end
end
diff --git a/app/workers/cluster_configure_worker.rb b/app/workers/cluster_configure_worker.rb
deleted file mode 100644
index f9364ab7144..00000000000
--- a/app/workers/cluster_configure_worker.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-class ClusterConfigureWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
- include ClusterQueue
-
- def perform(cluster_id)
- # Scheduled for removal in https://gitlab.com/gitlab-org/gitlab-foss/issues/59319
- end
-end
diff --git a/app/workers/cluster_project_configure_worker.rb b/app/workers/cluster_project_configure_worker.rb
deleted file mode 100644
index b68df01dc7a..00000000000
--- a/app/workers/cluster_project_configure_worker.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-class ClusterProjectConfigureWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
- include ClusterQueue
-
- worker_has_external_dependencies!
-
- def perform(project_id)
- # Scheduled for removal in https://gitlab.com/gitlab-org/gitlab-foss/issues/59319
- end
-end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index c0062780688..7ab9a0c2a02 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -11,6 +11,8 @@ module ApplicationWorker
include WorkerAttributes
include WorkerContext
+ LOGGING_EXTRA_KEY = 'extra'
+
included do
set_queue
@@ -24,6 +26,21 @@ module ApplicationWorker
payload.stringify_keys.merge(context)
end
+
+ def log_extra_metadata_on_done(key, value)
+ @done_log_extra_metadata ||= {}
+ @done_log_extra_metadata[key] = value
+ end
+
+ def logging_extras
+ return {} unless @done_log_extra_metadata
+
+ # Prefix keys with class name to avoid conflicts in Elasticsearch types.
+ # Also prefix with "extra." so that we know to log these new fields.
+ @done_log_extra_metadata.transform_keys do |k|
+ "#{LOGGING_EXTRA_KEY}.#{self.class.name.gsub("::", "_").underscore}.#{k}"
+ end
+ end
end
class_methods do
diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb
index c5db10491f2..a9c557f0175 100644
--- a/app/workers/concerns/chaos_queue.rb
+++ b/app/workers/concerns/chaos_queue.rb
@@ -5,6 +5,6 @@ module ChaosQueue
included do
queue_namespace :chaos
- feature_category :chaos_engineering
+ feature_category_not_owned!
end
end
diff --git a/app/workers/concerns/reactive_cacheable_worker.rb b/app/workers/concerns/reactive_cacheable_worker.rb
new file mode 100644
index 00000000000..e73707c2b43
--- /dev/null
+++ b/app/workers/concerns/reactive_cacheable_worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module ReactiveCacheableWorker
+ extend ActiveSupport::Concern
+
+ included do
+ include ApplicationWorker
+
+ feature_category_not_owned!
+
+ def self.context_for_arguments(arguments)
+ class_name, *_other_args = arguments
+ Gitlab::ApplicationContext.new(related_class: class_name.to_s)
+ end
+ end
+
+ def perform(class_name, id, *args)
+ klass = begin
+ class_name.constantize
+ rescue NameError
+ nil
+ end
+
+ return unless klass
+
+ klass
+ .reactive_cache_worker_finder
+ .call(id, *args)
+ .try(:exclusively_update_reactive_cache!, *args)
+ rescue ReactiveCaching::ExceededReactiveCacheLimit => e
+ Gitlab::ErrorTracking.track_exception(e)
+ end
+end
diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb
index 9cbc75f8944..a88d2bf7d15 100644
--- a/app/workers/create_commit_signature_worker.rb
+++ b/app/workers/create_commit_signature_worker.rb
@@ -1,11 +1,13 @@
# frozen_string_literal: true
-class CreateCommitSignatureWorker # rubocop:disable Scalability/IdempotentWorker
+class CreateCommitSignatureWorker
include ApplicationWorker
feature_category :source_code_management
weight 2
+ idempotent!
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(commit_shas, project_id)
# Older versions of Git::BranchPushService may push a single commit ID on
diff --git a/app/workers/design_management/new_version_worker.rb b/app/workers/design_management/new_version_worker.rb
new file mode 100644
index 00000000000..3634dcbcebd
--- /dev/null
+++ b/app/workers/design_management/new_version_worker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class NewVersionWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ feature_category :design_management
+ # Declare this worker as memory bound due to
+ # `GenerateImageVersionsService` resizing designs
+ worker_resource_boundary :memory
+
+ def perform(version_id)
+ version = DesignManagement::Version.find(version_id)
+
+ add_system_note(version)
+ generate_image_versions(version)
+ rescue ActiveRecord::RecordNotFound => e
+ Sidekiq.logger.warn(e)
+ end
+
+ private
+
+ def add_system_note(version)
+ SystemNoteService.design_version_added(version)
+ end
+
+ def generate_image_versions(version)
+ DesignManagement::GenerateImageVersionsService.new(version).execute
+ end
+ end
+end
diff --git a/app/workers/external_service_reactive_caching_worker.rb b/app/workers/external_service_reactive_caching_worker.rb
new file mode 100644
index 00000000000..e3104b44a7f
--- /dev/null
+++ b/app/workers/external_service_reactive_caching_worker.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class ExternalServiceReactiveCachingWorker # rubocop:disable Scalability/IdempotentWorker
+ include ReactiveCacheableWorker
+
+ worker_has_external_dependencies!
+end
diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb
index 7ace0a35fd9..78de5cf1307 100644
--- a/app/workers/gitlab/jira_import/import_issue_worker.rb
+++ b/app/workers/gitlab/jira_import/import_issue_worker.rb
@@ -28,19 +28,35 @@ module Gitlab
private
def create_issue(issue_attributes, project_id)
+ label_ids = issue_attributes.delete('label_ids')
+ assignee_ids = issue_attributes.delete('assignee_ids')
issue_id = insert_and_return_id(issue_attributes, Issue)
- label_issue(project_id, issue_id)
+ label_issue(project_id, issue_id, label_ids)
+ assign_issue(project_id, issue_id, assignee_ids)
issue_id
end
- def label_issue(project_id, issue_id)
- label_id = JiraImport.get_import_label_id(project_id)
- return unless label_id
+ def label_issue(project_id, issue_id, label_ids)
+ label_link_attrs = label_ids.to_a.map do |label_id|
+ build_label_attrs(issue_id, label_id.to_i)
+ end
- label_link_attrs = build_label_attrs(issue_id, label_id.to_i)
- insert_and_return_id(label_link_attrs, LabelLink)
+ import_label_id = JiraImport.get_import_label_id(project_id)
+ return unless import_label_id
+
+ label_link_attrs << build_label_attrs(issue_id, import_label_id.to_i)
+
+ Gitlab::Database.bulk_insert(LabelLink.table_name, label_link_attrs)
+ end
+
+ def assign_issue(project_id, issue_id, assignee_ids)
+ return if assignee_ids.blank?
+
+ assignee_attrs = assignee_ids.map { |user_id| { issue_id: issue_id, user_id: user_id } }
+
+ Gitlab::Database.bulk_insert(IssueAssignee.table_name, assignee_attrs)
end
def build_label_attrs(issue_id, label_id)
diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb
index b6fc5afc28c..d8f236013bf 100644
--- a/app/workers/group_import_worker.rb
+++ b/app/workers/group_import_worker.rb
@@ -2,14 +2,23 @@
class GroupImportWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- include ExceptionBacktrace
+ sidekiq_options retry: false
feature_category :importers
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.start!
::Groups::ImportExport::ImportService.new(group: group, user: current_user).execute
+
+ group_import.finish!
+ rescue StandardError => e
+ group_import&.fail_op(e.message)
+
+ raise e
end
end
diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb
index 8d4294cc231..2ce9fe359b5 100644
--- a/app/workers/incident_management/process_alert_worker.rb
+++ b/app/workers/incident_management/process_alert_worker.rb
@@ -7,11 +7,14 @@ module IncidentManagement
queue_namespace :incident_management
feature_category :incident_management
- def perform(project_id, alert)
+ def perform(project_id, alert_payload, am_alert_id = nil)
project = find_project(project_id)
return unless project
- create_issue(project, alert)
+ new_issue = create_issue(project, alert_payload)
+ return unless am_alert_id && new_issue.persisted?
+
+ link_issue_with_alert(am_alert_id, new_issue.id)
end
private
@@ -20,10 +23,24 @@ module IncidentManagement
Project.find_by_id(project_id)
end
- def create_issue(project, alert)
+ def create_issue(project, alert_payload)
IncidentManagement::CreateIssueService
- .new(project, alert)
+ .new(project, alert_payload)
.execute
end
+
+ def link_issue_with_alert(alert_id, issue_id)
+ alert = AlertManagement::Alert.find_by_id(alert_id)
+ return unless alert
+
+ return if alert.update(issue_id: issue_id)
+
+ Gitlab::AppLogger.warn(
+ message: 'Cannot link an Issue with Alert',
+ issue_id: issue_id,
+ alert_id: alert_id,
+ alert_errors: alert.errors.messages
+ )
+ end
end
end
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index 73bc050d7be..7622f40a949 100644
--- a/app/workers/irker_worker.rb
+++ b/app/workers/irker_worker.rb
@@ -53,7 +53,7 @@ class IrkerWorker # rubocop:disable Scalability/IdempotentWorker
def sendtoirker(privmsg)
to_send = { to: @channels, privmsg: privmsg }
- @socket.puts JSON.dump(to_send)
+ @socket.puts Gitlab::Json.dump(to_send)
end
def close_connection
diff --git a/app/workers/merge_request_mergeability_check_worker.rb b/app/workers/merge_request_mergeability_check_worker.rb
index a26c1a886f6..1a84efb4e52 100644
--- a/app/workers/merge_request_mergeability_check_worker.rb
+++ b/app/workers/merge_request_mergeability_check_worker.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
-class MergeRequestMergeabilityCheckWorker # rubocop:disable Scalability/IdempotentWorker
+class MergeRequestMergeabilityCheckWorker
include ApplicationWorker
feature_category :source_code_management
+ idempotent!
def perform(merge_request_id)
merge_request = MergeRequest.find_by_id(merge_request_id)
diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb
index 3c19e5f3d2b..fa4703d10f2 100644
--- a/app/workers/new_release_worker.rb
+++ b/app/workers/new_release_worker.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# TODO: Worker can be removed in 13.2:
+# https://gitlab.com/gitlab-org/gitlab/-/issues/218231
class NewReleaseWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb
index 43fb35c5298..fe6d516d3cf 100644
--- a/app/workers/pages_domain_ssl_renewal_cron_worker.rb
+++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb
@@ -10,11 +10,6 @@ class PagesDomainSslRenewalCronWorker # rubocop:disable Scalability/IdempotentWo
return unless ::Gitlab::LetsEncrypt.enabled?
PagesDomain.need_auto_ssl_renewal.with_logging_info.find_each do |domain|
- # Ideally that should be handled in PagesDomain.need_auto_ssl_renewal scope
- # but it's hard to make scope work with feature flags
- # once we remove feature flag we can modify scope to implement this behaviour
- next if Feature.enabled?(:pages_letsencrypt_errors, domain.project) && domain.auto_ssl_failed
-
with_context(project: domain.project) do
PagesDomainSslRenewalWorker.perform_async(domain.id)
end
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 9960e812a2f..bdfabea8938 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -7,13 +7,15 @@
# result of this the workload of this worker should be kept to a bare minimum.
# Consider using an extra worker if you need to add any extra (and potentially
# slow) processing of commits.
-class ProcessCommitWorker # rubocop:disable Scalability/IdempotentWorker
+class ProcessCommitWorker
include ApplicationWorker
feature_category :source_code_management
urgency :high
weight 3
+ idempotent!
+
# project_id - The ID of the project this commit belongs to.
# user_id - The ID of the user that pushed the commit.
# commit_hash - Hash containing commit details to use for constructing a
diff --git a/app/workers/project_update_repository_storage_worker.rb b/app/workers/project_update_repository_storage_worker.rb
index ecee33e6421..5c1a8062f12 100644
--- a/app/workers/project_update_repository_storage_worker.rb
+++ b/app/workers/project_update_repository_storage_worker.rb
@@ -5,9 +5,19 @@ class ProjectUpdateRepositoryStorageWorker # rubocop:disable Scalability/Idempot
feature_category :gitaly
- def perform(project_id, new_repository_storage_key)
- project = Project.find(project_id)
+ def perform(project_id, new_repository_storage_key, repository_storage_move_id = nil)
+ repository_storage_move =
+ if repository_storage_move_id
+ ProjectRepositoryStorageMove.find(repository_storage_move_id)
+ else
+ # maintain compatibility with workers queued before release
+ project = Project.find(project_id)
+ project.repository_storage_moves.create!(
+ source_storage_name: project.repository_storage,
+ destination_storage_name: new_repository_storage_key
+ )
+ end
- ::Projects::UpdateRepositoryStorageService.new(project).execute(new_repository_storage_key)
+ ::Projects::UpdateRepositoryStorageService.new(repository_storage_move).execute
end
end
diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb
index 513033281e5..a0829c31280 100644
--- a/app/workers/reactive_caching_worker.rb
+++ b/app/workers/reactive_caching_worker.rb
@@ -1,36 +1,8 @@
# frozen_string_literal: true
class ReactiveCachingWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
+ include ReactiveCacheableWorker
- feature_category_not_owned!
-
- # TODO: The reactive caching worker should be split into
- # two different workers, one for high urgency jobs without external dependencies
- # and another worker without high urgency, but with external dependencies
- # https://gitlab.com/gitlab-com/gl-infra/scalability/issues/34
- # This worker should also have `worker_has_external_dependencies!` enabled
- urgency :high
+ urgency :low
worker_resource_boundary :cpu
-
- def self.context_for_arguments(arguments)
- class_name, *_other_args = arguments
- Gitlab::ApplicationContext.new(related_class: class_name.to_s)
- end
-
- def perform(class_name, id, *args)
- klass = begin
- class_name.constantize
- rescue NameError
- nil
- end
- return unless klass
-
- klass
- .reactive_cache_worker_finder
- .call(id, *args)
- .try(:exclusively_update_reactive_cache!, *args)
- rescue ReactiveCaching::ExceededReactiveCacheLimit => e
- Gitlab::ErrorTracking.track_exception(e)
- end
end
diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb
index aface8288e3..20db19536c3 100644
--- a/app/workers/stage_update_worker.rb
+++ b/app/workers/stage_update_worker.rb
@@ -1,12 +1,14 @@
# frozen_string_literal: true
-class StageUpdateWorker # rubocop:disable Scalability/IdempotentWorker
+class StageUpdateWorker
include ApplicationWorker
include PipelineQueue
queue_namespace :pipeline_processing
urgency :high
+ idempotent!
+
def perform(stage_id)
Ci::Stage.find_by_id(stage_id)&.update_legacy_status
end
diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb
index 69698ba81bd..63d11d33283 100644
--- a/app/workers/update_head_pipeline_for_merge_request_worker.rb
+++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UpdateHeadPipelineForMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker
+class UpdateHeadPipelineForMergeRequestWorker
include ApplicationWorker
include PipelineQueue
@@ -9,6 +9,8 @@ class UpdateHeadPipelineForMergeRequestWorker # rubocop:disable Scalability/Idem
urgency :high
worker_resource_boundary :cpu
+ idempotent!
+
def perform(merge_request_id)
MergeRequest.find_by_id(merge_request_id).try do |merge_request|
merge_request.update_head_pipeline
diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb
new file mode 100644
index 00000000000..5fc92da803c
--- /dev/null
+++ b/app/workers/x509_issuer_crl_check_worker.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+class X509IssuerCrlCheckWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ feature_category :source_code_management
+ urgency :low
+
+ idempotent!
+ worker_has_external_dependencies!
+
+ attr_accessor :logger
+
+ def perform
+ @logger = Gitlab::GitLogger.build
+
+ X509Issuer.all.find_each do |issuer|
+ with_context(related_class: X509IssuerCrlCheckWorker) do
+ update_certificates(issuer)
+ end
+ end
+ end
+
+ private
+
+ def update_certificates(issuer)
+ crl = download_crl(issuer)
+ return unless crl
+
+ serials = X509Certificate.serial_numbers(issuer)
+ return if serials.empty?
+
+ revoked_serials = serials & crl.revoked.map(&:serial).map(&:to_i)
+
+ revoked_serials.each_slice(1000) do |batch|
+ certs = issuer.x509_certificates.where(serial_number: batch, certificate_status: :good) # rubocop: disable CodeReuse/ActiveRecord
+
+ certs.find_each do |cert|
+ logger.info(message: "Certificate revoked",
+ id: cert.id,
+ email: cert.email,
+ subject: cert.subject,
+ serial_number: cert.serial_number,
+ issuer: cert.x509_issuer.id,
+ issuer_subject: cert.x509_issuer.subject,
+ issuer_crl_url: cert.x509_issuer.crl_url)
+ end
+
+ certs.update_all(certificate_status: :revoked)
+ end
+ end
+
+ def download_crl(issuer)
+ response = Gitlab::HTTP.try_get(issuer.crl_url)
+
+ if response&.code == 200
+ OpenSSL::X509::CRL.new(response.body)
+ else
+ logger.warn(message: "Failed to download certificate revocation list",
+ issuer: issuer.id,
+ issuer_subject: issuer.subject,
+ issuer_crl_url: issuer.crl_url)
+
+ nil
+ end
+
+ rescue OpenSSL::X509::CRLError
+ logger.warn(message: "Failed to parse certificate revocation list",
+ issuer: issuer.id,
+ issuer_subject: issuer.subject,
+ issuer_crl_url: issuer.crl_url)
+
+ nil
+ end
+end