summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/logos/zentao.svg14
-rw-r--r--app/assets/javascripts/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue4
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue4
-rw-r--r--app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue4
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue18
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue6
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue91
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js1
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js62
-rw-r--r--app/assets/javascripts/api.js4
-rw-r--r--app/assets/javascripts/api/bulk_imports_api.js7
-rw-r--r--app/assets/javascripts/artifacts_settings/index.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/emoji.js12
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js2
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js2
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js14
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js7
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js14
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue6
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js6
-rw-r--r--app/assets/javascripts/boards/boards_util.js7
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_trigger.vue25
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue64
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue4
-rw-r--r--app/assets/javascripts/boards/graphql.js22
-rw-r--r--app/assets/javascripts/boards/graphql/issue.fragment.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/lists_issues.query.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js21
-rw-r--r--app/assets/javascripts/boards/stores/actions.js17
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js4
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/ci_lint/index.js4
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue159
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue122
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js1
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql11
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql34
-rw-r--r--app/assets/javascripts/clusters/agents/index.js30
-rw-r--r--app/assets/javascripts/clusters_list/clusters_util.js8
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_empty_state.vue119
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue152
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue156
-rw-r--r--app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue83
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue259
-rw-r--r--app/assets/javascripts/clusters_list/constants.js85
-rw-r--r--app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql8
-rw-r--r--app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql9
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql15
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql47
-rw-r--r--app/assets/javascripts/clusters_list/index.js5
-rw-r--r--app/assets/javascripts/clusters_list/load_agents.js44
-rw-r--r--app/assets/javascripts/comment_type_toggle.js71
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue9
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/details.vue33
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue32
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/color_chip.js73
-rw-r--r--app/assets/javascripts/content_editor/extensions/details.js36
-rw-r--r--app/assets/javascripts/content_editor/extensions/details_content.js25
-rw-r--r--app/assets/javascripts/content_editor/extensions/frontmatter.js20
-rw-r--r--app/assets/javascripts/content_editor/extensions/math_inline.js35
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_of_contents.js51
-rw-r--r--app/assets/javascripts/content_editor/extensions/word_break.js29
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js14
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js40
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue12
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue16
-rw-r--r--app/assets/javascripts/cycle_analytics/components/filter_bar.vue1
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue3
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js20
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js8
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js21
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js11
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js30
-rw-r--r--app/assets/javascripts/dependency_proxy.js5
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue2
-rw-r--r--app/assets/javascripts/deprecated_notes.js73
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue2
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue2
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js2
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js72
-rw-r--r--app/assets/javascripts/diffs/components/app.vue15
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue13
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue2
-rw-r--r--app/assets/javascripts/diffs/utils/tree_worker_utils.js (renamed from app/assets/javascripts/diffs/utils/workers.js)0
-rw-r--r--app/assets/javascripts/diffs/workers/tree_worker.js2
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js10
-rw-r--r--app/assets/javascripts/editor/schema/NOTICE6
-rw-r--r--app/assets/javascripts/editor/schema/ci.json1444
-rw-r--r--app/assets/javascripts/environments/components/environment_delete.vue28
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue15
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue98
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue26
-rw-r--r--app/assets/javascripts/environments/components/environment_pin.vue16
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue18
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue19
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue26
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue2
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue22
-rw-r--r--app/assets/javascripts/error_tracking/queries/details.query.graphql1
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue31
-rw-r--r--app/assets/javascripts/error_tracking_settings/index.js2
-rw-r--r--app/assets/javascripts/experimentation/utils.js8
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue16
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue2
-rw-r--r--app/assets/javascripts/feature_flags/edit.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js2
-rw-r--r--app/assets/javascripts/filtered_search/droplab/constants.js (renamed from app/assets/javascripts/droplab/constants.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/drop_down.js (renamed from app/assets/javascripts/droplab/drop_down.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js (renamed from app/assets/javascripts/droplab/drop_lab.js)10
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook.js (renamed from app/assets/javascripts/droplab/hook.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook_button.js (renamed from app/assets/javascripts/droplab/hook_button.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook_input.js (renamed from app/assets/javascripts/droplab/hook_input.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/keyboard.js (renamed from app/assets/javascripts/droplab/keyboard.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/ajax.js (renamed from app/assets/javascripts/droplab/plugins/ajax.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js (renamed from app/assets/javascripts/droplab/plugins/ajax_filter.js)3
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/filter.js (renamed from app/assets/javascripts/droplab/plugins/filter.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js (renamed from app/assets/javascripts/droplab/plugins/input_setter.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/utils.js (renamed from app/assets/javascripts/droplab/utils.js)0
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js2
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue7
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql6
-rw-r--r--app/assets/javascripts/header_search/components/app.vue15
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue74
-rw-r--r--app/assets/javascripts/header_search/constants.js8
-rw-r--r--app/assets/javascripts/header_search/index.js4
-rw-r--r--app/assets/javascripts/header_search/store/actions.js14
-rw-r--r--app/assets/javascripts/header_search/store/getters.js32
-rw-r--r--app/assets/javascripts/header_search/store/index.js10
-rw-r--r--app/assets/javascripts/header_search/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/header_search/store/mutations.js12
-rw-r--r--app/assets/javascripts/header_search/store/state.js5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/success_message.vue6
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue14
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js9
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/actions.js1
-rw-r--r--app/assets/javascripts/ide/stores/utils.js2
-rw-r--r--app/assets/javascripts/import_entities/components/pagination_bar.vue90
-rw-r--r--app/assets/javascripts/integrations/constants.js (renamed from app/assets/javascripts/integrations/edit/constants.js)6
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue3
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue5
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue5
-rw-r--r--app/assets/javascripts/integrations/edit/components/override_dropdown.vue2
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js46
-rw-r--r--app/assets/javascripts/integrations/overrides/components/integration_overrides.vue19
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue13
-rw-r--r--app/assets/javascripts/invite_members/utils/response_message_parser.js5
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue69
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue9
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_modal.vue68
-rw-r--r--app/assets/javascripts/issuable_form.js6
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue239
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue124
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/app.vue2
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/item.vue35
-rw-r--r--app/assets/javascripts/issuable_suggestions/index.js7
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue37
-rw-r--r--app/assets/javascripts/issues_list/components/issue_card_time_info.vue17
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue49
-rw-r--r--app/assets/javascripts/issues_list/components/new_issue_dropdown.vue124
-rw-r--r--app/assets/javascripts/issues_list/index.js6
-rw-r--r--app/assets/javascripts/issues_list/queries/search_projects.query.graphql12
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue30
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue4
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue10
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue18
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue20
-rw-r--r--app/assets/javascripts/jobs/store/actions.js62
-rw-r--r--app/assets/javascripts/jobs/store/getters.js7
-rw-r--r--app/assets/javascripts/jobs/store/mutation_types.js14
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js56
-rw-r--r--app/assets/javascripts/jobs/store/state.js20
-rw-r--r--app/assets/javascripts/jobs/store/utils.js18
-rw-r--r--app/assets/javascripts/jobs/utils.js9
-rw-r--r--app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js36
-rw-r--r--app/assets/javascripts/lib/graphql.js2
-rw-r--r--app/assets/javascripts/lib/logger/hello.js29
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js8
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js19
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js6
-rw-r--r--app/assets/javascripts/lib/utils/constants.js4
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js29
-rw-r--r--app/assets/javascripts/lib/utils/datetime_range.js12
-rw-r--r--app/assets/javascripts/lib/utils/is_navigating_away.js23
-rw-r--r--app/assets/javascripts/lib/utils/regexp.js8
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js15
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js29
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue2
-rw-r--r--app/assets/javascripts/logs/stores/state.js2
-rw-r--r--app/assets/javascripts/main.js3
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue4
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue11
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue19
-rw-r--r--app/assets/javascripts/members/components/modals/remove_member_modal.vue24
-rw-r--r--app/assets/javascripts/members/components/table/expires_at.vue66
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue87
-rw-r--r--app/assets/javascripts/members/constants.js22
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue6
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue6
-rw-r--r--app/assets/javascripts/merge_request.js8
-rw-r--r--app/assets/javascripts/mr_popover/index.js7
-rw-r--r--app/assets/javascripts/namespace_select.js58
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue9
-rw-r--r--app/assets/javascripts/notes/components/comment_type_dropdown.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue7
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue13
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue11
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue15
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue1
-rw-r--r--app/assets/javascripts/notes/stores/actions.js4
-rw-r--r--app/assets/javascripts/notes/stores/getters.js11
-rw-r--r--app/assets/javascripts/notifications/constants.js2
-rw-r--r--app/assets/javascripts/packages/details/components/additional_metadata.vue94
-rw-r--r--app/assets/javascripts/packages/details/components/composer_installation.vue65
-rw-r--r--app/assets/javascripts/packages/details/components/conan_installation.vue59
-rw-r--r--app/assets/javascripts/packages/details/components/dependency_row.vue35
-rw-r--r--app/assets/javascripts/packages/details/components/installation_commands.vue55
-rw-r--r--app/assets/javascripts/packages/details/components/installation_title.vue38
-rw-r--r--app/assets/javascripts/packages/details/components/maven_installation.vue153
-rw-r--r--app/assets/javascripts/packages/details/components/npm_installation.vue103
-rw-r--r--app/assets/javascripts/packages/details/components/nuget_installation.vue58
-rw-r--r--app/assets/javascripts/packages/details/components/package_title.vue113
-rw-r--r--app/assets/javascripts/packages/details/components/pypi_installation.vue71
-rw-r--r--app/assets/javascripts/packages/details/constants.js55
-rw-r--r--app/assets/javascripts/packages/details/index.js32
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js140
-rw-r--r--app/assets/javascripts/packages/details/utils.js10
-rw-r--r--app/assets/javascripts/packages/shared/constants.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue105
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js14
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql10
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/index.js26
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue (renamed from app/assets/javascripts/packages/details/components/app.vue)74
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue (renamed from app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/file_sha.vue (renamed from app/assets/javascripts/packages/details/components/file_sha.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue (renamed from app/assets/javascripts/packages/details/components/package_files.vue)2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue (renamed from app/assets/javascripts/packages/details/components/package_history.vue)19
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue (renamed from app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js (renamed from app/assets/javascripts/packages/details/store/actions.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js (renamed from app/assets/javascripts/packages/details/store/index.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutation_types.js (renamed from app/assets/javascripts/packages/details/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutations.js (renamed from app/assets/javascripts/packages/details/store/mutations.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue15
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue30
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue19
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue134
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue151
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue81
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue132
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue61
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js12
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql27
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql27
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.js10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/bundle.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue110
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue167
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue139
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql3
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js13
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js12
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js2
-rw-r--r--app/assets/javascripts/pages/admin/projects/components/namespace_select.vue143
-rw-r--r--app/assets/javascripts/pages/admin/projects/index.js38
-rw-r--r--app/assets/javascripts/pages/admin/serverless/domains/index.js17
-rw-r--r--app/assets/javascripts/pages/admin/topics/edit/index.js8
-rw-r--r--app/assets/javascripts/pages/admin/topics/new/index.js8
-rw-r--r--app/assets/javascripts/pages/groups/dependency_proxies/index.js14
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/packages/index/index.js13
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue176
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/index.js15
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js3
-rw-r--r--app/assets/javascripts/pages/profiles/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/password_prompt/constants.js9
-rw-r--r--app/assets/javascripts/pages/profiles/password_prompt/index.js58
-rw-r--r--app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue82
-rw-r--r--app/assets/javascripts/pages/projects/cluster_agents/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue98
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js66
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/index/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js34
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/wikis/diff/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/wikis/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/wikis/git_access/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/wikis/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/wikis/show/index.js3
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js2
-rw-r--r--app/assets/javascripts/pages/shared/mount_runner_instructions.js7
-rw-r--r--app/assets/javascripts/pages/shared/wikis/async_edit.js11
-rw-r--r--app/assets/javascripts/pages/shared/wikis/edit.js (renamed from app/assets/javascripts/pages/shared/wikis/index.js)12
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js6
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue21
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue15
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue49
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue31
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue14
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql19
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js24
-rw-r--r--app/assets/javascripts/profile/profile.js1
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue (renamed from app/assets/javascripts/pages/projects/new/components/app.vue)0
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue (renamed from app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue)0
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue163
-rw-r--r--app/assets/javascripts/projects/new/event_hub.js3
-rw-r--r--app/assets/javascripts/projects/new/index.js66
-rw-r--r--app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql (renamed from app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql)0
-rw-r--r--app/assets/javascripts/projects/project_new.js58
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js53
-rw-r--r--app/assets/javascripts/projects/settings/api/access_dropdown_api.js45
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue409
-rw-r--r--app/assets/javascripts/projects/settings/init_access_dropdown.js39
-rw-r--r--app/assets/javascripts/prometheus_alerts/components/reset_key.vue36
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue45
-rw-r--r--app/assets/javascripts/registry/explorer/constants/common.js3
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js6
-rw-r--r--app/assets/javascripts/registry/explorer/constants/list.js2
-rw-r--r--app/assets/javascripts/registry/explorer/index.js4
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue5
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue18
-rw-r--r--app/assets/javascripts/related_issues/components/issue_token.vue15
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue27
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue3
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue6
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue14
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue10
-rw-r--r--app/assets/javascripts/releases/mount_show.js7
-rw-r--r--app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue9
-rw-r--r--app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue6
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/getters.js34
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js10
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue6
-rw-r--r--app/assets/javascripts/repository/commits_service.js65
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue76
-rw-r--r--app/assets/javascripts/repository/components/blob_edit.vue16
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js9
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue15
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue45
-rw-r--r--app/assets/javascripts/repository/components/fork_suggestion.vue45
-rw-r--r--app/assets/javascripts/repository/components/new_directory_modal.vue183
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue11
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue45
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue58
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue22
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue1
-rw-r--r--app/assets/javascripts/repository/constants.js7
-rw-r--r--app/assets/javascripts/repository/index.js1
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql5
-rw-r--r--app/assets/javascripts/repository/router.js21
-rw-r--r--app/assets/javascripts/rest_api.js1
-rw-r--r--app/assets/javascripts/right_sidebar.js2
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue29
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue20
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue (renamed from app/assets/javascripts/runner/components/cells/runner_name_cell.vue)19
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_type_cell.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue14
-rw-r--r--app/assets/javascripts/runner/components/runner_name.vue18
-rw-r--r--app/assets/javascripts/runner/components/runner_state_locked_badge.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_state_paused_badge.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_type_badge.vue19
-rw-r--r--app/assets/javascripts/runner/components/runner_type_help.vue60
-rw-r--r--app/assets/javascripts/runner/constants.js8
-rw-r--r--app/assets/javascripts/runner/graphql/get_group_runners.query.graphql7
-rw-r--r--app/assets/javascripts/runner/graphql/get_runners.query.graphql1
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue33
-rw-r--r--app/assets/javascripts/search_settings/components/search_settings.vue58
-rw-r--r--app/assets/javascripts/search_settings/constants.js3
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue33
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue31
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue2
-rw-r--r--app/assets/javascripts/sidebar/constants.js15
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_view.vue7
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue70
-rw-r--r--app/assets/javascripts/snippets/index.js2
-rw-r--r--app/assets/javascripts/token_access/index.js2
-rw-r--r--app/assets/javascripts/tracking/constants.js5
-rw-r--r--app/assets/javascripts/tracking/get_standard_context.js4
-rw-r--r--app/assets/javascripts/tracking/tracking.js13
-rw-r--r--app/assets/javascripts/tracking/utils.js8
-rw-r--r--app/assets/javascripts/user_popovers.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue70
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue137
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js53
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue61
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue43
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js97
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js29
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js52
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql16
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue138
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue87
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js34
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue114
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue137
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js37
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue (renamed from app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue)37
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue10
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js17
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue6
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js3
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js3
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss2
-rw-r--r--app/assets/stylesheets/application_dark.scss72
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss2
-rw-r--r--app/assets/stylesheets/components/batch_comments/review_bar.scss53
-rw-r--r--app/assets/stylesheets/components/content_editor.scss53
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss5
-rw-r--r--app/assets/stylesheets/components/design_management/design_version_dropdown.scss3
-rw-r--r--app/assets/stylesheets/components/project_list_item.scss24
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/banner.scss40
-rw-r--r--app/assets/stylesheets/framework/blocks.scss66
-rw-r--r--app/assets/stylesheets/framework/buttons.scss23
-rw-r--r--app/assets/stylesheets/framework/common.scss16
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss2
-rw-r--r--app/assets/stylesheets/framework/diffs.scss23
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss38
-rw-r--r--app/assets/stylesheets/framework/forms.scss8
-rw-r--r--app/assets/stylesheets/framework/job_log.scss8
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss4
-rw-r--r--app/assets/stylesheets/framework/media_object.scss4
-rw-r--r--app/assets/stylesheets/framework/mixins.scss10
-rw-r--r--app/assets/stylesheets/framework/modal.scss37
-rw-r--r--app/assets/stylesheets/framework/panels.scss4
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss2
-rw-r--r--app/assets/stylesheets/framework/selects.scss12
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss15
-rw-r--r--app/assets/stylesheets/framework/snippets.scss5
-rw-r--r--app/assets/stylesheets/framework/sortable.scss55
-rw-r--r--app/assets/stylesheets/framework/tables.scss1
-rw-r--r--app/assets/stylesheets/framework/typography.scss20
-rw-r--r--app/assets/stylesheets/framework/variables.scss3
-rw-r--r--app/assets/stylesheets/framework/zen.scss9
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_mixins.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss9
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss9
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/signup.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/wiki.scss2
-rw-r--r--app/assets/stylesheets/pages/ci_projects.scss54
-rw-r--r--app/assets/stylesheets/pages/clusters.scss151
-rw-r--r--app/assets/stylesheets/pages/commits.scss5
-rw-r--r--app/assets/stylesheets/pages/environment_logs.scss4
-rw-r--r--app/assets/stylesheets/pages/issuable.scss147
-rw-r--r--app/assets/stylesheets/pages/issues.scss95
-rw-r--r--app/assets/stylesheets/pages/issues/issue_count_badge.scss10
-rw-r--r--app/assets/stylesheets/pages/login.scss11
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss49
-rw-r--r--app/assets/stylesheets/pages/note_form.scss6
-rw-r--r--app/assets/stylesheets/pages/notes.scss44
-rw-r--r--app/assets/stylesheets/pages/notifications.scss5
-rw-r--r--app/assets/stylesheets/pages/pages.scss12
-rw-r--r--app/assets/stylesheets/pages/profile.scss48
-rw-r--r--app/assets/stylesheets/pages/profiles/preferences.scss16
-rw-r--r--app/assets/stylesheets/pages/projects.scss16
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss232
-rw-r--r--app/assets/stylesheets/pages/settings.scss31
-rw-r--r--app/assets/stylesheets/pages/tree.scss5
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss172
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss71
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss44
-rw-r--r--app/assets/stylesheets/themes/_dark.scss53
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss116
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss4
-rw-r--r--app/controllers/admin/dashboard_controller.rb9
-rw-r--r--app/controllers/admin/instance_review_controller.rb2
-rw-r--r--app/controllers/admin/serverless/domains_controller.rb78
-rw-r--r--app/controllers/admin/topics/avatars_controller.rb14
-rw-r--r--app/controllers/admin/topics_controller.rb57
-rw-r--r--app/controllers/application_controller.rb30
-rw-r--r--app/controllers/concerns/group_tree.rb9
-rw-r--r--app/controllers/concerns/issuable_actions.rb6
-rw-r--r--app/controllers/concerns/notes_actions.rb2
-rw-r--r--app/controllers/concerns/one_trust_csp.rb19
-rw-r--r--app/controllers/concerns/registry/connection_errors_handler.rb38
-rw-r--r--app/controllers/dashboard/projects_controller.rb2
-rw-r--r--app/controllers/graphql_controller.rb8
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/dependency_proxy/application_controller.rb32
-rw-r--r--app/controllers/groups/dependency_proxy_for_containers_controller.rb46
-rw-r--r--app/controllers/groups/group_members_controller.rb22
-rw-r--r--app/controllers/groups/packages_controller.rb4
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb1
-rw-r--r--app/controllers/groups/runners_controller.rb14
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb5
-rw-r--r--app/controllers/health_controller.rb2
-rw-r--r--app/controllers/help_controller.rb1
-rw-r--r--app/controllers/import/bulk_imports_controller.rb24
-rw-r--r--app/controllers/import/url_controller.rb20
-rw-r--r--app/controllers/jira_connect/app_descriptor_controller.rb1
-rw-r--r--app/controllers/jira_connect/application_controller.rb4
-rw-r--r--app/controllers/jira_connect/events_controller.rb31
-rw-r--r--app/controllers/metrics_controller.rb1
-rw-r--r--app/controllers/profiles/passwords_controller.rb25
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb3
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb12
-rw-r--r--app/controllers/projects/badges_controller.rb5
-rw-r--r--app/controllers/projects/blob_controller.rb1
-rw-r--r--app/controllers/projects/boards_controller.rb1
-rw-r--r--app/controllers/projects/branches_controller.rb5
-rw-r--r--app/controllers/projects/ci/daily_build_group_report_results_controller.rb2
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb3
-rw-r--r--app/controllers/projects/cluster_agents_controller.rb19
-rw-r--r--app/controllers/projects/google_cloud_controller.rb16
-rw-r--r--app/controllers/projects/issues_controller.rb2
-rw-r--r--app/controllers/projects/jobs_controller.rb5
-rw-r--r--app/controllers/projects/merge_requests_controller.rb20
-rw-r--r--app/controllers/projects/packages/packages_controller.rb4
-rw-r--r--app/controllers/projects/project_members_controller.rb22
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb3
-rw-r--r--app/controllers/projects/security/configuration_controller.rb2
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb2
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb5
-rw-r--r--app/controllers/projects/tags_controller.rb12
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects/usage_quotas_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb15
-rw-r--r--app/controllers/registrations_controller.rb6
-rw-r--r--app/controllers/repositories/git_http_controller.rb7
-rw-r--r--app/controllers/search_controller.rb6
-rw-r--r--app/controllers/sessions_controller.rb1
-rw-r--r--app/controllers/uploads_controller.rb5
-rw-r--r--app/experiments/new_project_sast_enabled_experiment.rb15
-rw-r--r--app/finders/ci/pipelines_for_merge_request_finder.rb17
-rw-r--r--app/finders/clusters/agents_finder.rb28
-rw-r--r--app/finders/concerns/packages/finder_helper.rb6
-rw-r--r--app/finders/error_tracking/errors_finder.rb7
-rw-r--r--app/finders/issuable_finder.rb7
-rw-r--r--app/finders/issuables/label_filter.rb42
-rw-r--r--app/finders/issues_finder.rb13
-rw-r--r--app/finders/members_finder.rb15
-rw-r--r--app/finders/packages/group_packages_finder.rb4
-rw-r--r--app/finders/projects/members/effective_access_level_finder.rb2
-rw-r--r--app/finders/projects/topics_finder.rb29
-rw-r--r--app/finders/projects_finder.rb4
-rw-r--r--app/finders/tags_finder.rb5
-rw-r--r--app/graphql/mutations/ci/runner/delete.rb2
-rw-r--r--app/graphql/mutations/clusters/agent_tokens/create.rb69
-rw-r--r--app/graphql/mutations/clusters/agent_tokens/delete.rb35
-rw-r--r--app/graphql/mutations/clusters/agents/create.rb38
-rw-r--r--app/graphql/mutations/clusters/agents/delete.rb39
-rw-r--r--app/graphql/mutations/customer_relations/contacts/create.rb69
-rw-r--r--app/graphql/mutations/customer_relations/contacts/update.rb61
-rw-r--r--app/graphql/mutations/customer_relations/organizations/create.rb2
-rw-r--r--app/graphql/mutations/customer_relations/organizations/update.rb2
-rw-r--r--app/graphql/mutations/dependency_proxy/group_settings/update.rb49
-rw-r--r--app/graphql/mutations/issues/create.rb5
-rw-r--r--app/graphql/resolvers/board_list_issues_resolver.rb5
-rw-r--r--app/graphql/resolvers/board_list_resolver.rb35
-rw-r--r--app/graphql/resolvers/clusters/agent_tokens_resolver.rb25
-rw-r--r--app/graphql/resolvers/clusters/agents_resolver.rb35
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb9
-rw-r--r--app/graphql/resolvers/concerns/search_arguments.rb18
-rw-r--r--app/graphql/resolvers/issues_resolver.rb3
-rw-r--r--app/graphql/resolvers/kas/agent_configurations_resolver.rb32
-rw-r--r--app/graphql/resolvers/kas/agent_connections_resolver.rb41
-rw-r--r--app/graphql/resolvers/project_pipeline_resolver.rb17
-rw-r--r--app/graphql/resolvers/project_pipelines_resolver.rb2
-rw-r--r--app/graphql/types/base_field.rb4
-rw-r--r--app/graphql/types/board_list_type.rb15
-rw-r--r--app/graphql/types/ci/runner_status_enum.rb15
-rw-r--r--app/graphql/types/ci/runner_type.rb15
-rw-r--r--app/graphql/types/ci/runner_web_url_edge.rb31
-rw-r--r--app/graphql/types/clusters/agent_token_type.rb52
-rw-r--r--app/graphql/types/clusters/agent_type.rb67
-rw-r--r--app/graphql/types/concerns/find_closest.rb14
-rw-r--r--app/graphql/types/container_expiration_policy_older_than_enum.rb1
-rw-r--r--app/graphql/types/container_repository_details_type.rb6
-rw-r--r--app/graphql/types/container_repository_type.rb6
-rw-r--r--app/graphql/types/customer_relations/contact_type.rb2
-rw-r--r--app/graphql/types/customer_relations/organization_type.rb2
-rw-r--r--app/graphql/types/error_tracking/sentry_detailed_error_type.rb3
-rw-r--r--app/graphql/types/group_type.rb4
-rw-r--r--app/graphql/types/issue_type.rb3
-rw-r--r--app/graphql/types/issues/negated_issue_filter_input_type.rb4
-rw-r--r--app/graphql/types/kas/agent_configuration_type.rb17
-rw-r--r--app/graphql/types/kas/agent_connection_type.rb32
-rw-r--r--app/graphql/types/kas/agent_metadata_type.rb33
-rw-r--r--app/graphql/types/merge_requests/interacts_with_merge_request.rb2
-rw-r--r--app/graphql/types/milestone_wildcard_id_enum.rb2
-rw-r--r--app/graphql/types/mutation_type.rb7
-rw-r--r--app/graphql/types/packages/nuget/metadatum_type.rb6
-rw-r--r--app/graphql/types/packages/package_type.rb7
-rw-r--r--app/graphql/types/permission_types/ci/runner.rb13
-rw-r--r--app/graphql/types/project_type.rb20
-rw-r--r--app/graphql/types/query_type.rb4
-rw-r--r--app/helpers/application_settings_helper.rb15
-rw-r--r--app/helpers/avatars_helper.rb4
-rw-r--r--app/helpers/ci/jobs_helper.rb2
-rw-r--r--app/helpers/ci/runners_helper.rb2
-rw-r--r--app/helpers/commits_helper.rb9
-rw-r--r--app/helpers/feature_flags_helper.rb11
-rw-r--r--app/helpers/groups_helper.rb4
-rw-r--r--app/helpers/hooks_helper.rb9
-rw-r--r--app/helpers/integrations_helper.rb9
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/helpers/issues_helper.rb5
-rw-r--r--app/helpers/one_trust_helper.rb10
-rw-r--r--app/helpers/packages_helper.rb2
-rw-r--r--app/helpers/projects/cluster_agents_helper.rb10
-rw-r--r--app/helpers/projects_helper.rb7
-rw-r--r--app/helpers/routing/pseudonymization_helper.rb7
-rw-r--r--app/helpers/search_helper.rb4
-rw-r--r--app/helpers/startupjs_helper.rb7
-rw-r--r--app/helpers/tab_helper.rb77
-rw-r--r--app/helpers/time_zone_helper.rb2
-rw-r--r--app/helpers/timeboxes_helper.rb13
-rw-r--r--app/helpers/user_callouts_helper.rb6
-rw-r--r--app/helpers/workhorse_helper.rb9
-rw-r--r--app/models/analytics/cycle_analytics/issue_stage_event.rb5
-rw-r--r--app/models/analytics/cycle_analytics/merge_request_stage_event.rb5
-rw-r--r--app/models/application_setting.rb10
-rw-r--r--app/models/application_setting_implementation.rb7
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/award_emoji.rb2
-rw-r--r--app/models/bulk_import.rb11
-rw-r--r--app/models/bulk_imports/entity.rb14
-rw-r--r--app/models/bulk_imports/export.rb2
-rw-r--r--app/models/bulk_imports/export_status.rb2
-rw-r--r--app/models/bulk_imports/file_transfer/base_config.rb32
-rw-r--r--app/models/bulk_imports/file_transfer/group_config.rb6
-rw-r--r--app/models/bulk_imports/file_transfer/project_config.rb15
-rw-r--r--app/models/bulk_imports/tracker.rb2
-rw-r--r--app/models/ci/bridge.rb2
-rw-r--r--app/models/ci/build.rb72
-rw-r--r--app/models/ci/build_metadata.rb4
-rw-r--r--app/models/ci/build_need.rb2
-rw-r--r--app/models/ci/build_runner_session.rb2
-rw-r--r--app/models/ci/build_trace_chunk.rb2
-rw-r--r--app/models/ci/build_trace_metadata.rb11
-rw-r--r--app/models/ci/job_token/project_scope_link.rb2
-rw-r--r--app/models/ci/job_token/scope.rb11
-rw-r--r--app/models/ci/pipeline.rb31
-rw-r--r--app/models/ci/processable.rb3
-rw-r--r--app/models/ci/resource_group.rb32
-rw-r--r--app/models/ci/runner.rb26
-rw-r--r--app/models/ci/runner_namespace.rb1
-rw-r--r--app/models/ci/runner_project.rb1
-rw-r--r--app/models/ci/stage.rb2
-rw-r--r--app/models/clusters/agents/group_authorization.rb4
-rw-r--r--app/models/clusters/agents/implicit_authorization.rb5
-rw-r--r--app/models/clusters/agents/project_authorization.rb4
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/integrations/elastic_stack.rb2
-rw-r--r--app/models/clusters/integrations/prometheus.rb2
-rw-r--r--app/models/commit.rb2
-rw-r--r--app/models/commit_status.rb3
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage.rb3
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage_event_model.rb54
-rw-r--r--app/models/concerns/avatarable.rb3
-rw-r--r--app/models/concerns/bulk_insert_safe.rb12
-rw-r--r--app/models/concerns/checksummable.rb6
-rw-r--r--app/models/concerns/ci/has_status.rb1
-rw-r--r--app/models/concerns/ci/metadatable.rb1
-rw-r--r--app/models/concerns/enums/ci/commit_status.rb1
-rw-r--r--app/models/concerns/has_repository.rb4
-rw-r--r--app/models/concerns/integrations/has_data_fields.rb1
-rw-r--r--app/models/concerns/issue_available_features.rb3
-rw-r--r--app/models/concerns/packages/debian/distribution.rb10
-rw-r--r--app/models/concerns/restricted_signup.rb42
-rw-r--r--app/models/concerns/routable.rb3
-rw-r--r--app/models/concerns/ttl_expirable.rb20
-rw-r--r--app/models/concerns/vulnerability_finding_helpers.rb9
-rw-r--r--app/models/container_expiration_policy.rb1
-rw-r--r--app/models/custom_emoji.rb2
-rw-r--r--app/models/customer_relations/contact.rb3
-rw-r--r--app/models/customer_relations/organization.rb2
-rw-r--r--app/models/dependency_proxy/blob.rb5
-rw-r--r--app/models/dependency_proxy/image_ttl_group_policy.rb2
-rw-r--r--app/models/dependency_proxy/manifest.rb5
-rw-r--r--app/models/deployment.rb62
-rw-r--r--app/models/environment.rb15
-rw-r--r--app/models/environment_status.rb10
-rw-r--r--app/models/error_tracking/error.rb15
-rw-r--r--app/models/error_tracking/error_event.rb4
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb25
-rw-r--r--app/models/group.rb20
-rw-r--r--app/models/instance_configuration.rb12
-rw-r--r--app/models/integrations/open_project.rb20
-rw-r--r--app/models/integrations/open_project_tracker_data.rb18
-rw-r--r--app/models/integrations/unify_circuit.rb11
-rw-r--r--app/models/issue.rb44
-rw-r--r--app/models/lfs_object.rb2
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb44
-rw-r--r--app/models/member.rb25
-rw-r--r--app/models/members/group_member.rb8
-rw-r--r--app/models/members/project_member.rb14
-rw-r--r--app/models/merge_request.rb32
-rw-r--r--app/models/merge_request_diff.rb2
-rw-r--r--app/models/metrics/dashboard/annotation.rb6
-rw-r--r--app/models/namespace.rb94
-rw-r--r--app/models/namespace/root_storage_statistics.rb2
-rw-r--r--app/models/namespaces/user_namespace.rb2
-rw-r--r--app/models/note.rb4
-rw-r--r--app/models/operations/feature_flag.rb37
-rw-r--r--app/models/operations/feature_flag_scope.rb66
-rw-r--r--app/models/packages/composer/cache_file.rb4
-rw-r--r--app/models/packages/helm/file_metadatum.rb2
-rw-r--r--app/models/pages_domain.rb13
-rw-r--r--app/models/preloaders/merge_requests_preloader.rb19
-rw-r--r--app/models/product_analytics_event.rb2
-rw-r--r--app/models/project.rb83
-rw-r--r--app/models/project_setting.rb4
-rw-r--r--app/models/project_statistics.rb3
-rw-r--r--app/models/projects/project_topic.rb2
-rw-r--r--app/models/projects/topic.rb20
-rw-r--r--app/models/protected_branch.rb2
-rw-r--r--app/models/release.rb1
-rw-r--r--app/models/repository.rb10
-rw-r--r--app/models/upload.rb12
-rw-r--r--app/models/user.rb35
-rw-r--r--app/models/user_callout.rb3
-rw-r--r--app/models/user_detail.rb7
-rw-r--r--app/models/user_highest_role.rb8
-rw-r--r--app/models/user_preference.rb1
-rw-r--r--app/models/users/credit_card_validation.rb13
-rw-r--r--app/policies/ci/resource_group_policy.rb7
-rw-r--r--app/policies/clusters/agent_policy.rb9
-rw-r--r--app/policies/clusters/agent_token_policy.rb9
-rw-r--r--app/policies/group_policy.rb5
-rw-r--r--app/policies/list_policy.rb5
-rw-r--r--app/policies/namespace_policy.rb29
-rw-r--r--app/policies/namespaces/project_namespace_policy.rb9
-rw-r--r--app/policies/namespaces/user_namespace_policy.rb28
-rw-r--r--app/policies/project_policy.rb3
-rw-r--r--app/presenters/README.md95
-rw-r--r--app/presenters/alert_management/alert_presenter.rb6
-rw-r--r--app/presenters/award_emoji_presenter.rb2
-rw-r--r--app/presenters/blob_presenter.rb2
-rw-r--r--app/presenters/blobs/unfold_presenter.rb2
-rw-r--r--app/presenters/board_presenter.rb2
-rw-r--r--app/presenters/ci/bridge_presenter.rb3
-rw-r--r--app/presenters/ci/build_metadata_presenter.rb3
-rw-r--r--app/presenters/ci/build_presenter.rb2
-rw-r--r--app/presenters/ci/group_variable_presenter.rb2
-rw-r--r--app/presenters/ci/legacy_stage_presenter.rb2
-rw-r--r--app/presenters/ci/pipeline_presenter.rb7
-rw-r--r--app/presenters/ci/processable_presenter.rb1
-rw-r--r--app/presenters/ci/runner_presenter.rb5
-rw-r--r--app/presenters/ci/stage_presenter.rb2
-rw-r--r--app/presenters/ci/trigger_presenter.rb3
-rw-r--r--app/presenters/ci/variable_presenter.rb2
-rw-r--r--app/presenters/clusterable_presenter.rb2
-rw-r--r--app/presenters/clusters/cluster_presenter.rb41
-rw-r--r--app/presenters/clusters/integration_presenter.rb2
-rw-r--r--app/presenters/commit_presenter.rb2
-rw-r--r--app/presenters/commit_status_presenter.rb23
-rw-r--r--app/presenters/dev_ops_report/metric_presenter.rb2
-rw-r--r--app/presenters/environment_presenter.rb2
-rw-r--r--app/presenters/event_presenter.rb3
-rw-r--r--app/presenters/gitlab/blame_presenter.rb2
-rw-r--r--app/presenters/group_clusterable_presenter.rb5
-rw-r--r--app/presenters/group_member_presenter.rb2
-rw-r--r--app/presenters/instance_clusterable_presenter.rb5
-rw-r--r--app/presenters/invitation_presenter.rb2
-rw-r--r--app/presenters/issue_presenter.rb3
-rw-r--r--app/presenters/label_presenter.rb4
-rw-r--r--app/presenters/member_presenter.rb2
-rw-r--r--app/presenters/members_presenter.rb2
-rw-r--r--app/presenters/merge_request_presenter.rb6
-rw-r--r--app/presenters/milestone_presenter.rb2
-rw-r--r--app/presenters/pages_domain_presenter.rb4
-rw-r--r--app/presenters/project_clusterable_presenter.rb5
-rw-r--r--app/presenters/project_hook_presenter.rb2
-rw-r--r--app/presenters/project_member_presenter.rb2
-rw-r--r--app/presenters/project_presenter.rb5
-rw-r--r--app/presenters/projects/import_export/project_export_presenter.rb10
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb2
-rw-r--r--app/presenters/prometheus_alert_presenter.rb2
-rw-r--r--app/presenters/release_presenter.rb6
-rw-r--r--app/presenters/releases/evidence_presenter.rb2
-rw-r--r--app/presenters/search_service_presenter.rb3
-rw-r--r--app/presenters/sentry_error_presenter.rb2
-rw-r--r--app/presenters/service_hook_presenter.rb2
-rw-r--r--app/presenters/snippet_blob_presenter.rb2
-rw-r--r--app/presenters/snippet_presenter.rb5
-rw-r--r--app/presenters/terraform/modules_presenter.rb2
-rw-r--r--app/presenters/todo_presenter.rb2
-rw-r--r--app/presenters/tree_entry_presenter.rb2
-rw-r--r--app/presenters/user_presenter.rb2
-rw-r--r--app/presenters/web_hook_log_presenter.rb2
-rw-r--r--app/serializers/feature_flag_entity.rb4
-rw-r--r--app/serializers/feature_flag_scope_entity.rb12
-rw-r--r--app/serializers/member_entity.rb6
-rw-r--r--app/serializers/merge_request_metrics_helper.rb28
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb25
-rw-r--r--app/serializers/merge_request_widget_entity.rb5
-rw-r--r--app/services/base_project_service.rb10
-rw-r--r--app/services/boards/issues/list_service.rb2
-rw-r--r--app/services/bulk_import_service.rb70
-rw-r--r--app/services/bulk_imports/create_service.rb83
-rw-r--r--app/services/bulk_imports/file_export_service.rb42
-rw-r--r--app/services/bulk_imports/get_importable_data_service.rb45
-rw-r--r--app/services/bulk_imports/relation_export_service.rb57
-rw-r--r--app/services/bulk_imports/tree_export_service.rb43
-rw-r--r--app/services/bulk_imports/uploads_export_service.rb49
-rw-r--r--app/services/ci/archive_trace_service.rb11
-rw-r--r--app/services/ci/destroy_pipeline_service.rb3
-rw-r--r--app/services/ci/pipelines/add_job_service.rb10
-rw-r--r--app/services/ci/pipelines/hook_service.rb34
-rw-r--r--app/services/ci/process_pipeline_service.rb37
-rw-r--r--app/services/ci/queue/build_queue_service.rb2
-rw-r--r--app/services/ci/register_job_service.rb57
-rw-r--r--app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb2
-rw-r--r--app/services/ci/retry_build_service.rb9
-rw-r--r--app/services/ci/retry_pipeline_service.rb9
-rw-r--r--app/services/ci/stuck_builds/drop_pending_service.rb (renamed from app/services/ci/stuck_builds/drop_service.rb)24
-rw-r--r--app/services/ci/stuck_builds/drop_running_service.rb31
-rw-r--r--app/services/ci/stuck_builds/drop_scheduled_service.rb23
-rw-r--r--app/services/ci/update_build_state_service.rb2
-rw-r--r--app/services/ci/update_pending_build_service.rb2
-rw-r--r--app/services/clusters/agent_tokens/create_service.rb31
-rw-r--r--app/services/clusters/agents/create_service.rb29
-rw-r--r--app/services/clusters/agents/delete_service.rb23
-rw-r--r--app/services/clusters/agents/refresh_authorization_service.rb2
-rw-r--r--app/services/concerns/rate_limited_service.rb93
-rw-r--r--app/services/container_expiration_policies/cleanup_service.rb6
-rw-r--r--app/services/customer_relations/contacts/base_service.rb17
-rw-r--r--app/services/customer_relations/contacts/create_service.rb41
-rw-r--r--app/services/customer_relations/contacts/update_service.rb24
-rw-r--r--app/services/customer_relations/organizations/base_service.rb2
-rw-r--r--app/services/customer_relations/organizations/create_service.rb4
-rw-r--r--app/services/dependency_proxy/auth_token_service.rb12
-rw-r--r--app/services/dependency_proxy/find_or_create_blob_service.rb4
-rw-r--r--app/services/dependency_proxy/find_or_create_manifest_service.rb10
-rw-r--r--app/services/dependency_proxy/group_settings/update_service.rb37
-rw-r--r--app/services/deployments/older_deployments_drop_service.rb12
-rw-r--r--app/services/error_tracking/list_issues_service.rb9
-rw-r--r--app/services/feature_flags/base_service.rb8
-rw-r--r--app/services/feature_flags/create_service.rb2
-rw-r--r--app/services/feature_flags/destroy_service.rb2
-rw-r--r--app/services/feature_flags/hook_service.rb28
-rw-r--r--app/services/feature_flags/update_service.rb24
-rw-r--r--app/services/groups/transfer_service.rb14
-rw-r--r--app/services/import/validate_remote_git_endpoint_service.rb76
-rw-r--r--app/services/issuable/import_csv/base_service.rb9
-rw-r--r--app/services/issues/clone_service.rb22
-rw-r--r--app/services/issues/close_service.rb19
-rw-r--r--app/services/issues/create_service.rb4
-rw-r--r--app/services/issues/move_service.rb22
-rw-r--r--app/services/issues/relative_position_rebalancing_service.rb6
-rw-r--r--app/services/issues/reopen_service.rb15
-rw-r--r--app/services/merge_requests/mergeability/check_base_service.rb36
-rw-r--r--app/services/merge_requests/mergeability/check_ci_status_service.rb22
-rw-r--r--app/services/merge_requests/mergeability/run_checks_service.rb54
-rw-r--r--app/services/merge_requests/update_service.rb2
-rw-r--r--app/services/metrics/dashboard/annotations/create_service.rb6
-rw-r--r--app/services/metrics/dashboard/annotations/delete_service.rb4
-rw-r--r--app/services/metrics/users_starred_dashboards/create_service.rb4
-rw-r--r--app/services/packages/composer/create_package_service.rb4
-rw-r--r--app/services/projects/after_rename_service.rb8
-rw-r--r--app/services/projects/container_repository/cache_tags_created_at_service.rb70
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb176
-rw-r--r--app/services/projects/create_service.rb6
-rw-r--r--app/services/projects/destroy_service.rb24
-rw-r--r--app/services/projects/group_links/update_service.rb22
-rw-r--r--app/services/projects/import_service.rb23
-rw-r--r--app/services/projects/overwrite_project_service.rb4
-rw-r--r--app/services/projects/participants_service.rb13
-rw-r--r--app/services/projects/transfer_service.rb45
-rw-r--r--app/services/projects/update_pages_service.rb10
-rw-r--r--app/services/projects/update_service.rb33
-rw-r--r--app/services/search/global_service.rb2
-rw-r--r--app/services/security/ci_configuration/base_create_service.rb6
-rw-r--r--app/services/security/ci_configuration/sast_create_service.rb17
-rw-r--r--app/services/service_ping/submit_service.rb2
-rw-r--r--app/services/terraform/remote_state_handler.rb4
-rw-r--r--app/services/user_project_access_changed_service.rb2
-rw-r--r--app/services/users/update_service.rb22
-rw-r--r--app/services/users/upsert_credit_card_validation_service.rb17
-rw-r--r--app/uploaders/dependency_proxy/file_uploader.rb1
-rw-r--r--app/views/admin/application_settings/_abuse.html.haml2
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml18
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml7
-rw-r--r--app/views/admin/application_settings/_files_limits.html.haml34
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml13
-rw-r--r--app/views/admin/application_settings/_mailgun.html.haml2
-rw-r--r--app/views/admin/application_settings/_network_rate_limits.html.haml33
-rw-r--r--app/views/admin/application_settings/_package_registry_limits.html.haml32
-rw-r--r--app/views/admin/application_settings/_performance.html.haml17
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_spam.html.haml49
-rw-r--r--app/views/admin/application_settings/_terminal.html.haml2
-rw-r--r--app/views/admin/application_settings/_usage.html.haml22
-rw-r--r--app/views/admin/application_settings/appearances/preview_sign_in.html.haml7
-rw-r--r--app/views/admin/application_settings/general.html.haml3
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml2
-rw-r--r--app/views/admin/application_settings/network.html.haml19
-rw-r--r--app/views/admin/application_settings/reporting.html.haml7
-rw-r--r--app/views/admin/dashboard/_security_newsletter_callout.html.haml14
-rw-r--r--app/views/admin/dashboard/index.html.haml1
-rw-r--r--app/views/admin/hook_logs/_index.html.haml40
-rw-r--r--app/views/admin/projects/index.html.haml35
-rw-r--r--app/views/admin/projects/show.html.haml11
-rw-r--r--app/views/admin/runners/show.html.haml2
-rw-r--r--app/views/admin/serverless/domains/_form.html.haml99
-rw-r--r--app/views/admin/serverless/domains/index.html.haml25
-rw-r--r--app/views/admin/sessions/_new_base.html.haml2
-rw-r--r--app/views/admin/topics/_form.html.haml40
-rw-r--r--app/views/admin/topics/_topic.html.haml17
-rw-r--r--app/views/admin/topics/edit.html.haml4
-rw-r--r--app/views/admin/topics/index.html.haml19
-rw-r--r--app/views/admin/topics/new.html.haml4
-rw-r--r--app/views/admin/users/_access_levels.html.haml24
-rw-r--r--app/views/admin/users/_form.html.haml6
-rw-r--r--app/views/admin/users/_head.html.haml17
-rw-r--r--app/views/admin/users/show.html.haml3
-rw-r--r--app/views/authentication/_authenticate.html.haml3
-rw-r--r--app/views/authentication/_register.html.haml4
-rw-r--r--app/views/clusters/clusters/_advanced_settings_tab.html.haml5
-rw-r--r--app/views/clusters/clusters/_details_tab.html.haml5
-rw-r--r--app/views/clusters/clusters/_health_tab.html.haml5
-rw-r--r--app/views/clusters/clusters/_integrations_tab.html.haml8
-rw-r--r--app/views/clusters/clusters/_namespace.html.haml3
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml4
-rw-r--r--app/views/clusters/clusters/show.html.haml17
-rw-r--r--app/views/dashboard/_activity_head.html.haml14
-rw-r--r--app/views/dashboard/_groups_head.html.haml10
-rw-r--r--app/views/devise/confirmations/almost_there.haml11
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.text.erb2
-rw-r--r--app/views/devise/passwords/edit.html.haml4
-rw-r--r--app/views/devise/registrations/new.html.haml1
-rw-r--r--app/views/devise/sessions/_new_base.html.haml8
-rw-r--r--app/views/devise/sessions/_new_crowd.html.haml2
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml2
-rw-r--r--app/views/devise/sessions/new.html.haml1
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml10
-rw-r--r--app/views/devise/shared/_signup_box.html.haml1
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml8
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers.haml2
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers_top.haml2
-rw-r--r--app/views/groups/_group_admin_settings.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml12
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml13
-rw-r--r--app/views/groups/dependency_proxies/_url.html.haml12
-rw-r--r--app/views/groups/dependency_proxies/show.html.haml31
-rw-r--r--app/views/groups/edit.html.haml7
-rw-r--r--app/views/groups/issues.html.haml2
-rw-r--r--app/views/groups/registry/repositories/index.html.haml3
-rw-r--r--app/views/groups/runners/_runner.html.haml2
-rw-r--r--app/views/groups/settings/_general.html.haml3
-rw-r--r--app/views/groups/settings/_lfs.html.haml4
-rw-r--r--app/views/groups/settings/_permissions.html.haml6
-rw-r--r--app/views/groups/settings/_two_factor_auth.html.haml10
-rw-r--r--app/views/groups/settings/packages_and_registries/show.html.haml5
-rw-r--r--app/views/help/instance_configuration/_gitlab_pages.html.haml2
-rw-r--r--app/views/help/instance_configuration/_rate_limits.html.haml1
-rw-r--r--app/views/import/_githubish_status.html.haml2
-rw-r--r--app/views/import/bitbucket_server/new.html.haml2
-rw-r--r--app/views/import/bulk_imports/history.html.haml6
-rw-r--r--app/views/import/fogbugz/new.html.haml2
-rw-r--r--app/views/import/fogbugz/new_user_map.html.haml2
-rw-r--r--app/views/import/gitea/new.html.haml2
-rw-r--r--app/views/import/github/new.html.haml2
-rw-r--r--app/views/import/gitlab_projects/new.html.haml2
-rw-r--r--app/views/import/manifest/new.html.haml2
-rw-r--r--app/views/import/phabricator/new.html.haml2
-rw-r--r--app/views/layouts/_one_trust.html.haml16
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/_startup_js.html.haml22
-rw-r--r--app/views/layouts/header/_default.html.haml3
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml3
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml13
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml2
-rw-r--r--app/views/profiles/_email_settings.html.haml3
-rw-r--r--app/views/profiles/passwords/edit.html.haml10
-rw-r--r--app/views/profiles/passwords/new.html.haml10
-rw-r--r--app/views/profiles/show.html.haml8
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_files.html.haml3
-rw-r--r--app/views/projects/_home_panel.html.haml9
-rw-r--r--app/views/projects/_import_project_pane.html.haml2
-rw-r--r--app/views/projects/_new_project_fields.html.haml46
-rw-r--r--app/views/projects/blob/show.html.haml1
-rw-r--r--app/views/projects/branches/index.html.haml24
-rw-r--r--app/views/projects/branches/new.html.haml1
-rw-r--r--app/views/projects/ci/pipeline_editor/show.html.haml2
-rw-r--r--app/views/projects/cluster_agents/show.html.haml4
-rw-r--r--app/views/projects/commits/_commit.html.haml10
-rw-r--r--app/views/projects/deployments/_deployment.html.haml2
-rw-r--r--app/views/projects/feature_flags/edit.html.haml8
-rw-r--r--app/views/projects/google_cloud/index.html.haml83
-rw-r--r--app/views/projects/hook_logs/_index.html.haml42
-rw-r--r--app/views/projects/hook_logs/show.html.haml4
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml5
-rw-r--r--app/views/projects/issues/_related_branches.html.haml4
-rw-r--r--app/views/projects/issues/_related_issues.html.haml1
-rw-r--r--app/views/projects/issues/_related_issues_block.html.haml5
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml10
-rw-r--r--app/views/projects/merge_requests/_nav_btns.html.haml6
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml4
-rw-r--r--app/views/projects/mirrors/_mirror_repos_push.html.haml2
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml6
-rw-r--r--app/views/projects/product_analytics/test.html.haml1
-rw-r--r--app/views/projects/registry/repositories/index.html.haml3
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml1
-rw-r--r--app/views/projects/snippets/show.html.haml2
-rw-r--r--app/views/projects/tags/index.html.haml3
-rw-r--r--app/views/projects/tags/new.html.haml1
-rw-r--r--app/views/projects/tree/show.html.haml1
-rw-r--r--app/views/projects/usage_quotas/index.html.haml2
-rw-r--r--app/views/search/results/_issuable.html.haml7
-rw-r--r--app/views/shared/_import_form.html.haml11
-rw-r--r--app/views/shared/_milestones_filter.html.haml28
-rw-r--r--app/views/shared/_web_ide_path.html.haml4
-rw-r--r--app/views/shared/boards/_show.html.haml2
-rw-r--r--app/views/shared/builds/_build_output.html.haml2
-rw-r--r--app/views/shared/builds/_tabs.html.haml41
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml1
-rw-r--r--app/views/shared/empty_states/_topics.html.haml7
-rw-r--r--app/views/shared/errors/_gitaly_unavailable.html.haml8
-rw-r--r--app/views/shared/hook_logs/_recent_deliveries_table.html.haml34
-rw-r--r--app/views/shared/hook_logs/_status_label.html.haml2
-rw-r--r--app/views/shared/integrations/_tabs.html.haml23
-rw-r--r--app/views/shared/issuable/_nav.html.haml24
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml11
-rw-r--r--app/views/shared/issuable/nav_links/_all.html.haml5
-rw-r--r--app/views/shared/issue_type/_emoji_block.html.haml2
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml6
-rw-r--r--app/views/shared/notes/_comment_button.html.haml29
-rw-r--r--app/views/shared/notes/_hints.html.haml56
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml1
-rw-r--r--app/views/shared/topics/_search_form.html.haml7
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml6
-rw-r--r--app/views/shared/wikis/edit.html.haml2
-rw-r--r--app/views/snippets/show.html.haml2
-rw-r--r--app/views/users/show.html.haml10
-rw-r--r--app/workers/all_queues.yml100
-rw-r--r--app/workers/authorized_project_update/project_recalculate_per_user_worker.rb25
-rw-r--r--app/workers/authorized_project_update/project_recalculate_worker.rb4
-rw-r--r--app/workers/authorized_project_update/user_refresh_from_replica_worker.rb2
-rw-r--r--app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb5
-rw-r--r--app/workers/bulk_import_worker.rb2
-rw-r--r--app/workers/bulk_imports/export_request_worker.rb5
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb32
-rw-r--r--app/workers/ci/build_finished_worker.rb10
-rw-r--r--app/workers/ci/create_downstream_pipeline_worker.rb19
-rw-r--r--app/workers/ci/delete_unit_tests_worker.rb2
-rw-r--r--app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb2
-rw-r--r--app/workers/ci/stuck_builds/drop_running_worker.rb34
-rw-r--r--app/workers/ci/stuck_builds/drop_scheduled_worker.rb34
-rw-r--r--app/workers/cleanup_container_repository_worker.rb4
-rw-r--r--app/workers/concerns/dependency_proxy/cleanup_worker.rb63
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb11
-rw-r--r--app/workers/concerns/worker_attributes.rb8
-rw-r--r--app/workers/container_expiration_policies/cleanup_container_repository_worker.rb17
-rw-r--r--app/workers/container_expiration_policy_worker.rb2
-rw-r--r--app/workers/create_note_diff_file_worker.rb6
-rw-r--r--app/workers/database/drop_detached_partitions_worker.rb2
-rw-r--r--app/workers/dependency_proxy/cleanup_blob_worker.rb40
-rw-r--r--app/workers/dependency_proxy/cleanup_manifest_worker.rb40
-rw-r--r--app/workers/dependency_proxy/image_ttl_group_policy_worker.rb70
-rw-r--r--app/workers/expire_build_artifacts_worker.rb2
-rw-r--r--app/workers/expire_build_instance_artifacts_worker.rb2
-rw-r--r--app/workers/expire_job_cache_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/stage/finish_import_worker.rb28
-rw-r--r--app/workers/gitlab/github_import/stage/import_base_data_worker.rb16
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb16
-rw-r--r--app/workers/gitlab/github_import/stage/import_repository_worker.rb11
-rw-r--r--app/workers/issue_placement_worker.rb2
-rw-r--r--app/workers/namespaces/in_product_marketing_emails_worker.rb2
-rw-r--r--app/workers/namespaces/onboarding_issue_created_worker.rb2
-rw-r--r--app/workers/namespaces/onboarding_pipeline_created_worker.rb2
-rw-r--r--app/workers/namespaces/onboarding_progress_worker.rb2
-rw-r--r--app/workers/namespaces/onboarding_user_added_worker.rb2
-rw-r--r--app/workers/packages/composer/cache_cleanup_worker.rb14
-rw-r--r--app/workers/packages/composer/cache_update_worker.rb12
-rw-r--r--app/workers/packages/debian/generate_distribution_worker.rb2
-rw-r--r--app/workers/pages_remove_worker.rb16
-rw-r--r--app/workers/pipeline_hooks_worker.rb7
-rw-r--r--app/workers/pipeline_process_worker.rb2
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb11
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb21
1165 files changed, 16839 insertions, 7792 deletions
diff --git a/app/assets/images/logos/zentao.svg b/app/assets/images/logos/zentao.svg
new file mode 100644
index 00000000000..d2115b72aee
--- /dev/null
+++ b/app/assets/images/logos/zentao.svg
@@ -0,0 +1,14 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" fill="none">
+ <path fill="url(#SVGID_1_)" d="M8,1C4.1,1,1,4.1,1,8s3.1,7,7,7s7-3.1,7-7S11.9,1,8,1L8,1z M11.3,8.2C9.8,7.7,7.9,6.1,5.8,7.6
+ C4,8.9,4.8,11.1,6,11.8c0.9,0.6,2.3,0.7,3,0.1C9.7,11.4,10,10,9,9.5C8.6,9.4,7.9,9.3,7.5,9.8c-0.5,0.6-0.3,1.4,0.4,1.7
+ c0,0-1.2-0.1-1.4-1.3C6.2,7.9,9,7.6,10.3,8.4c2.4,1.5,1.5,4.8-2,5.4c-1.8,0.3-4.8-0.3-5.9-2.7c-0.4-0.9-0.3-0.7-0.3-0.7
+ c0.1,0.1,0.3,0.3,0.4,0.4c0.8,0.6,1.6,0.1,1.4-0.8C3.3,7.2,4.4,6.7,5.1,6.2s0.4-1.5-0.9-1.3c-1.9,0.3-2.4,3-2.4,3s-0.3-4.6,3.7-5
+ c4.1-0.4,4.7,3.2,6.5,3.7c2.5,0.8,1.3-2.6,1.3-2.6s1.1,1.7,0.6,3.2C13.5,8.2,12.3,8.5,11.3,8.2z" />
+ <defs>
+ <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="8" y1="-271.1102" x2="8" y2="-257.1102"
+ gradientTransform="matrix(1 0 0 -1 0 -256.1102)">
+ <stop offset="0" style="stop-color:#445470" />
+ <stop offset="1" style="stop-color:#7A869A" />
+ </linearGradient>
+ </defs>
+</svg>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 7f5f0403de6..2cd3a8f12ee 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -49,7 +49,7 @@ export const initProjectsField = () => {
{ default: createDefaultClient },
]) => {
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index a0f4a4bf382..e6dde5898e7 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -14,7 +14,7 @@ export default {
type: Object,
required: true,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: Array,
required: false,
default: () => [],
@@ -29,7 +29,7 @@ export default {
:username="username"
:paths="paths"
:delete-path="paths.delete"
- :oncall-schedules="oncallSchedules"
+ :user-deletion-obstacles="userDeletionObstacles"
>
<slot></slot>
</shared-delete-action>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
index 02fd3efafa1..bd920a91516 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
@@ -14,7 +14,7 @@ export default {
type: Object,
required: true,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: Array,
required: false,
default: () => [],
@@ -29,7 +29,7 @@ export default {
:username="username"
:paths="paths"
:delete-path="paths.deleteWithContributions"
- :oncall-schedules="oncallSchedules"
+ :user-deletion-obstacles="userDeletionObstacles"
>
<slot></slot>
</shared-delete-action>
diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
index a1589c9d46d..c9f29b55dbf 100644
--- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
+++ b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
@@ -22,7 +22,7 @@ export default {
type: String,
required: true,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: Array,
required: true,
},
@@ -34,7 +34,7 @@ export default {
'data-delete-user-url': this.deletePath,
'data-gl-modal-action': this.modalType,
'data-username': this.username,
- 'data-oncall-schedules': JSON.stringify(this.oncallSchedules),
+ 'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles),
};
},
},
diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
index 413163c8536..ed90343777d 100644
--- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
+++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
@@ -2,7 +2,7 @@
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale';
-import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
+import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
export default {
components: {
@@ -10,7 +10,7 @@ export default {
GlButton,
GlFormInput,
GlSprintf,
- OncallSchedulesList,
+ UserDeletionObstaclesList,
},
props: {
title: {
@@ -45,7 +45,7 @@ export default {
type: String,
required: true,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: String,
required: false,
default: '[]',
@@ -66,9 +66,9 @@ export default {
canSubmit() {
return this.enteredUsername === this.username;
},
- schedules() {
+ obstacles() {
try {
- return JSON.parse(this.oncallSchedules);
+ return JSON.parse(this.userDeletionObstacles);
} catch (e) {
Sentry.captureException(e);
}
@@ -112,12 +112,16 @@ export default {
</gl-sprintf>
</p>
- <oncall-schedules-list v-if="schedules.length" :schedules="schedules" :user-name="username" />
+ <user-deletion-obstacles-list
+ v-if="obstacles.length"
+ :obstacles="obstacles"
+ :user-name="username"
+ />
<p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
<template #username>
- <code>{{ username }}</code>
+ <code class="gl-white-space-pre-wrap">{{ username }}</code>
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index c076e0bedf0..4f4e2947341 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { I18N_USER_ACTIONS } from '../constants';
import { generateUserPaths } from '../utils';
import Actions from './actions';
@@ -72,6 +73,9 @@ export default {
href: this.userPaths.edit,
};
},
+ obstaclesForUserDeletion() {
+ return parseUserDeletionObstacles(this.user);
+ },
},
methods: {
isLdapAction(action) {
@@ -141,7 +145,7 @@ export default {
:key="action"
:paths="userPaths"
:username="user.name"
- :oncall-schedules="user.oncallSchedules"
+ :user-deletion-obstacles="obstaclesForUserDeletion"
:data-testid="`delete-${action}`"
>
{{ $options.i18n[action] }}
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
index a490111e13b..0bdb45d35c9 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale';
import getProjects from '../graphql/projects.query.graphql';
+const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name));
+
export default {
name: 'ProjectsDropdownFilter',
components: {
@@ -88,6 +90,9 @@ export default {
selectedProjectIds() {
return this.selectedProjects.map((p) => p.id);
},
+ hasSelectedProjects() {
+ return Boolean(this.selectedProjects.length);
+ },
availableProjects() {
return filterBySearchTerm(this.projects, this.searchTerm);
},
@@ -95,6 +100,12 @@ export default {
const { loading, availableProjects } = this;
return !loading && !availableProjects.length;
},
+ selectedItems() {
+ return sortByProjectName(this.selectedProjects);
+ },
+ unselectedItems() {
+ return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id));
+ },
},
watch: {
searchTerm() {
@@ -105,44 +116,53 @@ export default {
this.search();
},
methods: {
+ handleUpdatedSelectedProjects() {
+ this.$emit('selected', this.selectedProjects);
+ },
search: debounce(function debouncedSearch() {
this.fetchData();
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- getSelectedProjects(selectedProject, isMarking) {
- return isMarking
+ getSelectedProjects(selectedProject, isSelected) {
+ return isSelected
? this.selectedProjects.concat([selectedProject])
: this.selectedProjects.filter((project) => project.id !== selectedProject.id);
},
singleSelectedProject(selectedObj, isMarking) {
return isMarking ? [selectedObj] : [];
},
- setSelectedProjects(selectedObj, isMarking) {
+ setSelectedProjects(project) {
this.selectedProjects = this.multiSelect
- ? this.getSelectedProjects(selectedObj, isMarking)
- : this.singleSelectedProject(selectedObj, isMarking);
+ ? this.getSelectedProjects(project, !this.isProjectSelected(project))
+ : this.singleSelectedProject(project, !this.isProjectSelected(project));
},
- onClick({ project, isSelected }) {
- this.setSelectedProjects(project, !isSelected);
- this.$emit('selected', this.selectedProjects);
+ onClick(project) {
+ this.setSelectedProjects(project);
+ this.handleUpdatedSelectedProjects();
},
- onMultiSelectClick({ project, isSelected }) {
- this.setSelectedProjects(project, !isSelected);
+ onMultiSelectClick(project) {
+ this.setSelectedProjects(project);
this.isDirty = true;
},
- onSelected(ev) {
+ onSelected(project) {
if (this.multiSelect) {
- this.onMultiSelectClick(ev);
+ this.onMultiSelectClick(project);
} else {
- this.onClick(ev);
+ this.onClick(project);
}
},
onHide() {
if (this.multiSelect && this.isDirty) {
- this.$emit('selected', this.selectedProjects);
+ this.handleUpdatedSelectedProjects();
}
this.searchTerm = '';
this.isDirty = false;
},
+ onClearAll() {
+ if (this.hasSelectedProjects) {
+ this.isDirty = true;
+ }
+ this.selectedProjects = [];
+ },
fetchData() {
this.loading = true;
@@ -168,8 +188,8 @@ export default {
this.projects = nodes;
});
},
- isProjectSelected(id) {
- return this.selectedProjects ? this.selectedProjectIds.includes(id) : false;
+ isProjectSelected(project) {
+ return this.selectedProjectIds.includes(project.id);
},
getEntityId(project) {
return getIdFromGraphQLId(project.id);
@@ -182,6 +202,10 @@ export default {
ref="projectsDropdown"
class="dropdown dropdown-projects"
toggle-class="gl-shadow-none"
+ :show-clear-all="hasSelectedProjects"
+ show-highlighted-items-title
+ highlighted-items-title-class="gl-p-3"
+ @clear-all.stop="onClearAll"
@hide="onHide"
>
<template #button-content>
@@ -204,14 +228,37 @@ export default {
<gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
<gl-search-box-by-type v-model.trim="searchTerm" />
</template>
+ <template #highlighted-items>
+ <gl-dropdown-item
+ v-for="project in selectedItems"
+ :key="project.id"
+ is-check-item
+ :is-checked="isProjectSelected(project)"
+ @click.native.capture.stop="onSelected(project)"
+ >
+ <div class="gl-display-flex">
+ <gl-avatar
+ class="gl-mr-2 gl-vertical-align-middle"
+ :alt="project.name"
+ :size="16"
+ :entity-id="getEntityId(project)"
+ :entity-name="project.name"
+ :src="project.avatarUrl"
+ shape="rect"
+ />
+ <div>
+ <div data-testid="project-name">{{ project.name }}</div>
+ <div class="gl-text-gray-500" data-testid="project-full-path">
+ {{ project.fullPath }}
+ </div>
+ </div>
+ </div>
+ </gl-dropdown-item>
+ </template>
<gl-dropdown-item
- v-for="project in availableProjects"
+ v-for="project in unselectedItems"
:key="project.id"
- :is-check-item="true"
- :is-checked="isProjectSelected(project.id)"
- @click.native.capture.stop="
- onSelected({ project, isSelected: isProjectSelected(project.id) })
- "
+ @click.native.capture.stop="onSelected(project)"
>
<div class="gl-display-flex">
<gl-avatar
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index 44d9b4b4262..c06bd34f86f 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -9,4 +9,5 @@ export const dateFormats = {
isoDate,
defaultDate: mediumDate,
defaultDateTime: 'mmm d, yyyy h:MMtt',
+ month: 'mmmm',
};
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index 52901d4c5bb..f55ef99964e 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,4 +1,5 @@
import dateFormat from 'dateformat';
+import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from './constants';
export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
@@ -7,3 +8,64 @@ export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'na
};
export const toYmd = (date) => dateFormat(date, dateFormats.isoDate);
+
+/**
+ * Takes a url and extracts query parameters used for the shared
+ * filter bar
+ *
+ * @param {string} url The URL to extract query parameters from
+ * @returns {Object}
+ */
+export const extractFilterQueryParameters = (url = '') => {
+ const {
+ source_branch_name = null,
+ target_branch_name = null,
+ author_username = null,
+ milestone_title = null,
+ assignee_username = [],
+ label_name = [],
+ } = urlQueryToFilter(url);
+
+ return {
+ selectedSourceBranch: source_branch_name,
+ selectedTargetBranch: target_branch_name,
+ selectedAuthor: author_username,
+ selectedMilestone: milestone_title,
+ selectedAssigneeList: assignee_username,
+ selectedLabelList: label_name,
+ };
+};
+
+/**
+ * Takes a url and extracts sorting and pagination query parameters into an object
+ *
+ * @param {string} url The URL to extract query parameters from
+ * @returns {Object}
+ */
+export const extractPaginationQueryParameters = (url = '') => {
+ const { sort, direction, page } = urlQueryToFilter(url);
+ return {
+ sort: sort?.value || null,
+ direction: direction?.value || null,
+ page: page?.value || null,
+ };
+};
+
+export const getDataZoomOption = ({
+ totalItems = 0,
+ maxItemsPerPage = 40,
+ dataZoom = [{ type: 'slider', bottom: 10, start: 0 }],
+}) => {
+ if (totalItems <= maxItemsPerPage) {
+ return {};
+ }
+
+ const intervalEnd = Math.ceil((maxItemsPerPage / totalItems) * 100);
+
+ return dataZoom.map((item) => {
+ return {
+ ...item,
+ end: intervalEnd,
+ };
+ });
+};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 01e463c1965..adf3e122a64 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -499,10 +499,10 @@ const Api = {
return axios.put(url, params);
},
- applySuggestionBatch(ids) {
+ applySuggestionBatch(ids, message) {
const url = Api.buildUrl(Api.applySuggestionBatchPath);
- return axios.put(url, { ids });
+ return axios.put(url, { ids, commit_message: message });
},
commitPipelines(projectId, sha) {
diff --git a/app/assets/javascripts/api/bulk_imports_api.js b/app/assets/javascripts/api/bulk_imports_api.js
new file mode 100644
index 00000000000..d636cfdff0b
--- /dev/null
+++ b/app/assets/javascripts/api/bulk_imports_api.js
@@ -0,0 +1,7 @@
+import { buildApiUrl } from '~/api/api_utils';
+import axios from '~/lib/utils/axios_utils';
+
+const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/entities';
+
+export const getBulkImportsHistory = (params) =>
+ axios.get(buildApiUrl(BULK_IMPORT_ENTITIES_PATH), { params });
diff --git a/app/assets/javascripts/artifacts_settings/index.js b/app/assets/javascripts/artifacts_settings/index.js
index 531b42bc185..5c9f1c3129c 100644
--- a/app/assets/javascripts/artifacts_settings/index.js
+++ b/app/assets/javascripts/artifacts_settings/index.js
@@ -6,7 +6,7 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export default (containerId = 'js-artifacts-settings-app') => {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
index 367a06ad3c1..9d0890aa1b4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
@@ -26,6 +26,18 @@ export default class Emoji extends Node {
moji: el.textContent,
}),
},
+ {
+ tag: 'img.emoji',
+ getAttrs: (el) => {
+ const name = el.getAttribute('title').replace(/^:|:$/g, '');
+
+ return {
+ name,
+ title: name,
+ moji: name,
+ };
+ },
+ },
],
toDOM: (node) => [
'gl-emoji',
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
index ade5839d10b..4cc28c45739 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/image.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -29,7 +29,7 @@ export default class Image extends BaseImage {
},
// Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
{
- tag: 'img[src]',
+ tag: 'img[src]:not(.emoji)',
getAttrs: (el) => {
const imageSrc = el.src;
const imageUrl =
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index a1911585f80..a548b283142 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -81,7 +81,7 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
})
.catch(() =>
createFlash({
- message: __('An error occurred while fetching markdown preview'),
+ message: __('An error occurred while fetching Markdown preview'),
}),
);
};
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index b1227fb3533..59905035257 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -38,23 +38,9 @@ $.fn.requiresInput = function requiresInput() {
$form.on('change input', fieldSelector, requireInput);
};
-// Hide or Show the help block when creating a new project
-// based on the option selected
-function hideOrShowHelpBlock(form) {
- const selected = $('.js-select-namespace option:selected');
- if (selected.length && selected.data('optionsParent') === 'groups') {
- form.find('.form-text.text-muted').hide();
- } else if (selected.length) {
- form.find('.form-text.text-muted').show();
- }
-}
-
$(() => {
$('form.js-requires-input').each((i, el) => {
const $form = $(el);
-
$form.requiresInput();
- hideOrShowHelpBlock($form);
- $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form));
});
});
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index ebf2ab0381e..b27dccabdf8 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -306,6 +306,12 @@ export const GO_TO_PROJECT_WIKI = {
defaultKeys: ['g w'], // eslint-disable-line @gitlab/require-i18n-strings
};
+export const GO_TO_PROJECT_WEBIDE = {
+ id: 'project.goToWebIDE',
+ description: __('Open in Web IDE'),
+ defaultKeys: ['.'],
+};
+
export const PROJECT_FILES_MOVE_SELECTION_UP = {
id: 'projectFiles.moveSelectionUp',
description: __('Move selection up'),
@@ -549,6 +555,7 @@ export const PROJECT_SHORTCUTS_GROUP = {
GO_TO_PROJECT_KUBERNETES,
GO_TO_PROJECT_SNIPPETS,
GO_TO_PROJECT_WIKI,
+ GO_TO_PROJECT_WEBIDE,
],
};
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index b188d3b0ec3..7d8e4dd490c 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -1,4 +1,5 @@
import Mousetrap from 'mousetrap';
+import { visitUrl, constructWebIDEPath } from '~/lib/utils/url_utility';
import findAndFollowLink from '../../lib/utils/navigation_utility';
import {
keysFor,
@@ -18,6 +19,7 @@ import {
GO_TO_PROJECT_KUBERNETES,
GO_TO_PROJECT_ENVIRONMENTS,
GO_TO_PROJECT_METRICS,
+ GO_TO_PROJECT_WEBIDE,
NEW_ISSUE,
} from './keybindings';
import Shortcuts from './shortcuts';
@@ -58,6 +60,18 @@ export default class ShortcutsNavigation extends Shortcuts {
findAndFollowLink('.shortcuts-environments'),
);
Mousetrap.bind(keysFor(GO_TO_PROJECT_METRICS), () => findAndFollowLink('.shortcuts-metrics'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_WEBIDE), ShortcutsNavigation.navigateToWebIDE);
Mousetrap.bind(keysFor(NEW_ISSUE), () => findAndFollowLink('.shortcuts-new-issue'));
}
+
+ static navigateToWebIDE() {
+ const path = constructWebIDEPath({
+ sourceProjectFullPath: window.gl.mrWidgetData?.source_project_full_path,
+ targetProjectFullPath: window.gl.mrWidgetData?.target_project_full_path,
+ iid: window.gl.mrWidgetData?.iid,
+ });
+ if (path) {
+ visitUrl(path);
+ }
+ }
}
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index 1a74675100b..213e026c41f 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -41,6 +41,11 @@ export default {
type: Object,
required: true,
},
+ hideLineNumbers: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
viewer() {
@@ -80,6 +85,7 @@ export default {
:is-raw-content="isRawContent"
:file-name="blob.name"
:type="activeViewer.fileType"
+ :hide-line-numbers="hideLineNumbers"
data-qa-selector="file_content"
/>
</template>
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 136457c115d..991f98c89e7 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -247,7 +247,11 @@ export default class FileTemplateMediator {
}
setFilename(name) {
- this.$filenameInput.val(name).trigger('change');
+ const input = this.$filenameInput.get(0);
+ if (name !== undefined && input.value !== name) {
+ input.value = name;
+ input.dispatchEvent(new Event('change'));
+ }
}
getSelected() {
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index d113a1d39d8..c10241d00d7 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -43,7 +43,9 @@ export function formatListIssues(listIssues) {
let sortedIssues = list.issues.edges.map((issueNode) => ({
...issueNode.node,
}));
- sortedIssues = sortBy(sortedIssues, 'relativePosition');
+ if (list.listType !== ListType.closed) {
+ sortedIssues = sortBy(sortedIssues, 'relativePosition');
+ }
return {
...map,
@@ -146,7 +148,8 @@ export function getMoveData(state, params) {
}
export function moveItemListHelper(item, fromList, toList) {
- const updatedItem = item;
+ const updatedItem = cloneDeep(item);
+
if (
toList.listType === ListType.label &&
!updatedItem.labels.find((label) => label.id === toList.label.id)
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
index 22ad619e76b..c5411ec313a 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -52,6 +52,8 @@ export default {
},
setSelectedItem(selectedId) {
+ this.selectedId = selectedId;
+
const label = this.labels.find(({ id }) => id === selectedId);
if (!selectedId || !label) {
this.selectedLabel = null;
@@ -87,8 +89,8 @@ export default {
<template #items>
<gl-form-radio-group
v-if="labels.length > 0"
- v-model="selectedId"
class="gl-overflow-y-auto gl-px-5 gl-pt-3"
+ :checked="selectedId"
@change="setSelectedItem"
>
<label
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
index 2aee84b805f..14c84d3c4e5 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
@@ -1,13 +1,23 @@
<script>
-import { GlButton } from '@gitlab/ui';
-import { mapActions } from 'vuex';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { __ } from '~/locale';
import Tracking from '~/tracking';
export default {
components: {
GlButton,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [Tracking.mixin()],
+ computed: {
+ ...mapState({ isNewListShowing: ({ addColumnForm }) => addColumnForm.visible }),
+ tooltip() {
+ return this.isNewListShowing ? __('The list creation wizard is already open') : '';
+ },
+ },
methods: {
...mapActions(['setAddColumnFormVisibility']),
handleClick() {
@@ -19,7 +29,14 @@ export default {
</script>
<template>
- <div class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list">
- <gl-button variant="confirm" @click="handleClick">{{ __('Create list') }} </gl-button>
+ <div
+ v-gl-tooltip="tooltip"
+ :tabindex="isNewListShowing ? '0' : undefined"
+ class="gl-ml-3 gl-display-flex gl-align-items-center"
+ data-testid="boards-create-list"
+ >
+ <gl-button :disabled="isNewListShowing" variant="confirm" @click="handleClick"
+ >{{ __('Create list') }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index db80d48239b..b6ccc6a00fe 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -316,7 +316,7 @@ export default {
</p>
</gl-tooltip>
- <span ref="countBadge" class="issue-count-badge board-card-info gl-mr-0 gl-pr-0">
+ <span ref="countBadge" class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3">
<span v-if="allowSubEpics" class="gl-mr-3">
<gl-icon name="epic" />
{{ totalEpicsCount }}
@@ -334,7 +334,7 @@ export default {
<span
v-if="shouldRenderEpicProgress"
ref="progressBadge"
- class="issue-count-badge board-card-info gl-pl-0"
+ class="board-card-info gl-pl-0"
>
<span class="gl-mr-3" data-testid="epic-progress">
<gl-icon name="progress" />
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index e0105d63d99..9bbb8a1a1b2 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -3,15 +3,18 @@ import { GlDrawer } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
+import { __, sprintf } from '~/locale';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
+import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@@ -23,6 +26,7 @@ export default {
SidebarConfidentialityWidget,
BoardSidebarTimeTracker,
BoardSidebarLabelsSelect,
+ SidebarLabelsWidget,
SidebarSubscriptionsWidget,
SidebarDropdownWidget,
SidebarTodoWidget,
@@ -46,16 +50,20 @@ export default {
weightFeatureAvailable: {
default: false,
},
+ allowLabelEdit: {
+ default: false,
+ },
},
inheritAttrs: false,
computed: {
...mapGetters([
+ 'isGroupBoard',
'isSidebarOpen',
'activeBoardItem',
'groupPathForActiveIssue',
'projectPathForActiveIssue',
]),
- ...mapState(['sidebarType', 'issuableType']),
+ ...mapState(['sidebarType', 'issuableType', 'isSettingLabels']),
isIssuableSidebar() {
return this.sidebarType === ISSUABLE;
},
@@ -65,17 +73,48 @@ export default {
fullPath() {
return this.activeBoardItem?.referencePath?.split('#')[0] || '';
},
+ createLabelTitle() {
+ return sprintf(__('Create %{workspace} label'), {
+ workspace: this.isGroupBoard ? 'group' : 'project',
+ });
+ },
+ manageLabelTitle() {
+ return sprintf(__('Manage %{workspace} labels'), {
+ workspace: this.isGroupBoard ? 'group' : 'project',
+ });
+ },
+ attrWorkspacePath() {
+ return this.isGroupBoard ? this.groupPathForActiveIssue : undefined;
+ },
},
methods: {
...mapActions([
'toggleBoardItem',
'setAssignees',
'setActiveItemConfidential',
+ 'setActiveBoardItemLabels',
'setActiveItemWeight',
]),
handleClose() {
this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
},
+ handleUpdateSelectedLabels(input) {
+ this.setActiveBoardItemLabels({
+ iid: this.activeBoardItem.iid,
+ projectPath: this.projectPathForActiveIssue,
+ addLabelIds: input.map((label) => getIdFromGraphQLId(label.id)),
+ removeLabelIds: this.activeBoardItem.labels
+ .filter((label) => !input.find((selected) => selected.id === label.id))
+ .map((label) => label.id),
+ });
+ },
+ handleLabelRemove(input) {
+ this.setActiveBoardItemLabels({
+ iid: this.activeBoardItem.iid,
+ projectPath: this.projectPathForActiveIssue,
+ removeLabelIds: [input],
+ });
+ },
},
};
</script>
@@ -160,7 +199,28 @@ export default {
:issuable-type="issuableType"
data-testid="sidebar-due-date"
/>
- <board-sidebar-labels-select class="block labels" />
+ <sidebar-labels-widget
+ v-if="glFeatures.labelsWidget"
+ class="block labels"
+ data-testid="sidebar-labels"
+ :iid="activeBoardItem.iid"
+ :full-path="projectPathForActiveIssue"
+ :allow-label-remove="allowLabelEdit"
+ :allow-multiselect="true"
+ :selected-labels="activeBoardItem.labels"
+ :labels-select-in-progress="isSettingLabels"
+ :footer-create-label-title="createLabelTitle"
+ :footer-manage-label-title="manageLabelTitle"
+ :labels-create-title="createLabelTitle"
+ :labels-filter-base-path="projectPathForActiveIssue"
+ :attr-workspace-path="attrWorkspacePath"
+ :issuable-type="issuableType"
+ @onLabelRemove="handleLabelRemove"
+ @updateSelectedLabels="handleUpdateSelectedLabels"
+ >
+ {{ __('None') }}
+ </sidebar-labels-widget>
+ <board-sidebar-labels-select v-else class="block labels" />
<sidebar-weight-widget
v-if="weightFeatureAvailable"
:iid="activeBoardItem.iid"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index dc5313b1bf6..a8d71ab7a35 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -365,7 +365,7 @@ export default {
>
<span class="gl-display-inline-flex">
<gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
- <span ref="itemCount" class="issue-count-badge-count">
+ <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center">
<gl-icon class="gl-mr-2" :name="countIcon" />
<item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" />
</span>
@@ -388,7 +388,7 @@ export default {
v-gl-tooltip.hover
:aria-label="$options.i18n.newIssue"
:title="$options.i18n.newIssue"
- class="issue-count-badge-add-button no-drag"
+ class="no-drag"
icon="plus"
@click="showNewIssueForm"
/>
diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js
new file mode 100644
index 00000000000..d8d16184936
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql.js
@@ -0,0 +1,22 @@
+import { IntrospectionFragmentMatcher, defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import createDefaultClient from '~/lib/graphql';
+import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
+
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+ introspectionQueryResultData,
+});
+
+export const gqlClient = createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ dataIdFromObject: (object) => {
+ // eslint-disable-next-line no-underscore-dangle
+ return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
+ },
+
+ fragmentMatcher,
+ },
+ assumeImmutableResults: true,
+ },
+);
diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
index 1b14396fb5c..314faae89f8 100644
--- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
@@ -1,3 +1,4 @@
+#import "~/graphql_shared/fragments/milestone.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
fragment IssueNode on Issue {
@@ -15,6 +16,9 @@ fragment IssueNode on Issue {
hidden
webUrl
relativePosition
+ milestone {
+ ...MilestoneFragment
+ }
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
index d1cb1ecf834..787dd77b901 100644
--- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
@@ -16,6 +16,7 @@ query ListIssues(
nodes {
id
issuesCount
+ listType
issues(first: $first, filters: $filters, after: $after) {
edges {
node {
@@ -37,6 +38,7 @@ query ListIssues(
nodes {
id
issuesCount
+ listType
issues(first: $first, filters: $filters, after: $after) {
edges {
node {
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 21c1bb23dc6..b6b1094fb3a 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,4 +1,3 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import PortalVue from 'portal-vue';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -14,30 +13,17 @@ import FilteredSearchBoards from '~/boards/filtered_search_boards';
import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores';
import toggleFocusMode from '~/boards/toggle_focus';
-import createDefaultClient from '~/lib/graphql';
import { NavigationType, parseBoolean } from '~/lib/utils/common_utils';
-import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
import { fullBoardId } from './boards_util';
import boardConfigToggle from './config_toggle';
+import { gqlClient } from './graphql';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
Vue.use(VueApollo);
Vue.use(PortalVue);
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: gqlClient,
});
function mountBoardApp(el) {
@@ -101,6 +87,9 @@ function mountBoardApp(el) {
iterationListsAvailable: parseBoolean(el.dataset.iterationListsAvailable),
issuableType: issuableTypes.issue,
emailsDisabled: parseBoolean(el.dataset.emailsDisabled),
+ allowLabelCreate: parseBoolean(el.dataset.canUpdate),
+ allowLabelEdit: parseBoolean(el.dataset.canUpdate),
+ allowScopedLabels: parseBoolean(el.dataset.scopedLabels),
},
render: (createComponent) => createComponent(BoardApp),
});
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index dc06b62cebb..ca993e75cf9 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -19,7 +19,6 @@ import {
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
@@ -35,6 +34,7 @@ import {
FiltersInfo,
filterVariables,
} from '../boards_util';
+import { gqlClient } from '../graphql';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
@@ -47,13 +47,6 @@ import projectBoardMilestonesQuery from '../graphql/project_board_milestones.que
import * as types from './mutation_types';
-export const gqlClient = createGqClient(
- {},
- {
- fetchPolicy: fetchPolicies.NO_CACHE,
- },
-);
-
export default {
setInitialBoardData: ({ commit }, data) => {
commit(types.SET_INITIAL_BOARD_DATA, data);
@@ -603,7 +596,7 @@ export default {
});
},
- addListItem: ({ commit }, { list, item, position, inProgress = false }) => {
+ addListItem: ({ commit, dispatch }, { list, item, position, inProgress = false }) => {
commit(types.ADD_BOARD_ITEM_TO_LIST, {
listId: list.id,
itemId: item.id,
@@ -611,6 +604,9 @@ export default {
inProgress,
});
commit(types.UPDATE_BOARD_ITEM, item);
+ if (!inProgress) {
+ dispatch('setActiveId', { id: item.id, sidebarType: ISSUABLE });
+ }
},
removeListItem: ({ commit }, { listId, itemId }) => {
@@ -660,6 +656,7 @@ export default {
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
+ commit(types.SET_LABELS_LOADING, true);
const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetLabelsMutation,
@@ -673,6 +670,8 @@ export default {
},
});
+ commit(types.SET_LABELS_LOADING, false);
+
if (data.updateIssue?.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
}
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 928cece19f7..26b785932bb 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -28,6 +28,7 @@ export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST';
export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID';
+export const SET_LABELS_LOADING = 'SET_LABELS_LOADING';
export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
export const RESET_ISSUES = 'RESET_ISSUES';
export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index ef5b84b4575..d381c076c19 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -195,6 +195,10 @@ export default {
Vue.set(state.boardItems[itemId], prop, value);
},
+ [mutationTypes.SET_LABELS_LOADING](state, isLoading) {
+ state.isSettingLabels = isLoading;
+ },
+
[mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
state.isSettingAssignees = isLoading;
},
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 80c51c966d2..2a6605e687b 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -12,6 +12,7 @@ export default () => ({
listsFlags: {},
boardItemsByListId: {},
backupItemsList: [],
+ isSettingLabels: false,
isSettingAssignees: false,
pageInfoByListId: {},
boardItems: {},
diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js
index 274aab45deb..f97590ec5db 100644
--- a/app/assets/javascripts/ci_lint/index.js
+++ b/app/assets/javascripts/ci_lint/index.js
@@ -8,7 +8,9 @@ import CiLint from './components/ci_lint.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(resolvers),
+ defaultClient: createDefaultClient(resolvers, {
+ assumeImmutableResults: true,
+ }),
});
export default (containerId = '#js-ci-lint') => {
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
new file mode 100644
index 00000000000..5c672d288c5
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -0,0 +1,159 @@
+<script>
+import {
+ GlAlert,
+ GlBadge,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTab,
+ GlTabs,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { MAX_LIST_COUNT } from '../constants';
+import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
+import TokenTable from './token_table.vue';
+
+export default {
+ i18n: {
+ installedInfo: s__('ClusterAgents|Created by %{name} %{time}'),
+ loadingError: s__('ClusterAgents|An error occurred while loading your agent'),
+ tokens: s__('ClusterAgents|Access tokens'),
+ unknownUser: s__('ClusterAgents|Unknown user'),
+ },
+ apollo: {
+ clusterAgent: {
+ query: getClusterAgentQuery,
+ variables() {
+ return {
+ agentName: this.agentName,
+ projectPath: this.projectPath,
+ ...this.cursor,
+ };
+ },
+ update: (data) => data?.project?.clusterAgent,
+ error() {
+ this.clusterAgent = null;
+ },
+ },
+ },
+ components: {
+ GlAlert,
+ GlBadge,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTab,
+ GlTabs,
+ TimeAgoTooltip,
+ TokenTable,
+ },
+ props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
+ projectPath: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ cursor: {
+ first: MAX_LIST_COUNT,
+ last: null,
+ },
+ };
+ },
+ computed: {
+ createdAt() {
+ return this.clusterAgent?.createdAt;
+ },
+ createdBy() {
+ return this.clusterAgent?.createdByUser?.name || this.$options.i18n.unknownUser;
+ },
+ isLoading() {
+ return this.$apollo.queries.clusterAgent.loading;
+ },
+ showPagination() {
+ return this.tokenPageInfo.hasPreviousPage || this.tokenPageInfo.hasNextPage;
+ },
+ tokenCount() {
+ return this.clusterAgent?.tokens?.count;
+ },
+ tokenPageInfo() {
+ return this.clusterAgent?.tokens?.pageInfo || {};
+ },
+ tokens() {
+ return this.clusterAgent?.tokens?.nodes || [];
+ },
+ },
+ methods: {
+ nextPage() {
+ this.cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ afterToken: this.tokenPageInfo.endCursor,
+ };
+ },
+ prevPage() {
+ this.cursor = {
+ first: null,
+ last: MAX_LIST_COUNT,
+ beforeToken: this.tokenPageInfo.startCursor,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <h2>{{ agentName }}</h2>
+
+ <gl-loading-icon v-if="isLoading && clusterAgent == null" size="lg" class="gl-m-3" />
+
+ <div v-else-if="clusterAgent">
+ <p data-testid="cluster-agent-create-info">
+ <gl-sprintf :message="$options.i18n.installedInfo">
+ <template #name>
+ {{ createdBy }}
+ </template>
+
+ <template #time>
+ <time-ago-tooltip :time="createdAt" />
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-tabs>
+ <gl-tab>
+ <template #title>
+ <span data-testid="cluster-agent-token-count">
+ {{ $options.i18n.tokens }}
+
+ <gl-badge v-if="tokenCount" size="sm" class="gl-tab-counter-badge">{{
+ tokenCount
+ }}</gl-badge>
+ </span>
+ </template>
+
+ <gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" />
+
+ <div v-else>
+ <TokenTable :tokens="tokens" />
+
+ <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" />
+ </div>
+ </div>
+ </gl-tab>
+ </gl-tabs>
+ </div>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ $options.i18n.loadingError }}
+ </gl-alert>
+ </section>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
new file mode 100644
index 00000000000..70ed2566134
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlEmptyState, GlLink, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlEmptyState,
+ GlLink,
+ GlTable,
+ GlTooltip,
+ GlTruncate,
+ TimeAgoTooltip,
+ },
+ i18n: {
+ createdBy: s__('ClusterAgents|Created by'),
+ createToken: s__('ClusterAgents|You will need to create a token to connect to your agent'),
+ dateCreated: s__('ClusterAgents|Date created'),
+ description: s__('ClusterAgents|Description'),
+ lastUsed: s__('ClusterAgents|Last contact'),
+ learnMore: s__('ClusterAgents|Learn how to create an agent access token'),
+ name: s__('ClusterAgents|Name'),
+ neverUsed: s__('ClusterAgents|Never'),
+ noTokens: s__('ClusterAgents|This agent has no tokens'),
+ unknownUser: s__('ClusterAgents|Unknown user'),
+ },
+ props: {
+ tokens: {
+ required: true,
+ type: Array,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: this.$options.i18n.name,
+ tdAttr: { 'data-testid': 'agent-token-name' },
+ },
+ {
+ key: 'lastUsed',
+ label: this.$options.i18n.lastUsed,
+ tdAttr: { 'data-testid': 'agent-token-used' },
+ },
+ {
+ key: 'createdAt',
+ label: this.$options.i18n.dateCreated,
+ tdAttr: { 'data-testid': 'agent-token-created-time' },
+ },
+ {
+ key: 'createdBy',
+ label: this.$options.i18n.createdBy,
+ tdAttr: { 'data-testid': 'agent-token-created-user' },
+ },
+ {
+ key: 'description',
+ label: this.$options.i18n.description,
+ tdAttr: { 'data-testid': 'agent-token-description' },
+ },
+ ];
+ },
+ learnMoreUrl() {
+ return helpPagePath('user/clusters/agent/index.md', {
+ anchor: 'create-an-agent-record-in-gitlab',
+ });
+ },
+ },
+ methods: {
+ createdByName(token) {
+ return token?.createdByUser?.name || this.$options.i18n.unknownUser;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="tokens.length">
+ <div class="gl-text-right gl-my-5">
+ <gl-link target="_blank" :href="learnMoreUrl">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </div>
+
+ <gl-table :items="tokens" :fields="fields" fixed stacked="md">
+ <template #cell(lastUsed)="{ item }">
+ <time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" />
+ <span v-else>{{ $options.i18n.neverUsed }}</span>
+ </template>
+
+ <template #cell(createdAt)="{ item }">
+ <time-ago-tooltip :time="item.createdAt" />
+ </template>
+
+ <template #cell(createdBy)="{ item }">
+ <span>{{ createdByName(item) }}</span>
+ </template>
+
+ <template #cell(description)="{ item }">
+ <div v-if="item.description" :id="`tooltip-description-container-${item.id}`">
+ <gl-truncate :id="`tooltip-description-${item.id}`" :text="item.description" />
+
+ <gl-tooltip
+ :container="`tooltip-description-container-${item.id}`"
+ :target="`tooltip-description-${item.id}`"
+ placement="top"
+ >
+ {{ item.description }}
+ </gl-tooltip>
+ </div>
+ </template>
+ </gl-table>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.noTokens"
+ :primary-button-link="learnMoreUrl"
+ :primary-button-text="$options.i18n.learnMore"
+ />
+</template>
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
new file mode 100644
index 00000000000..bbc4630f83b
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -0,0 +1 @@
+export const MAX_LIST_COUNT = 25;
diff --git a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
new file mode 100644
index 00000000000..1e9187e8ad1
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
@@ -0,0 +1,11 @@
+fragment Token on ClusterAgentToken {
+ id
+ createdAt
+ description
+ lastUsedAt
+ name
+
+ createdByUser {
+ name
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
new file mode 100644
index 00000000000..d01db8f0a6a
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
@@ -0,0 +1,34 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/cluster_agent_token.fragment.graphql"
+
+query getClusterAgent(
+ $projectPath: ID!
+ $agentName: String!
+ $first: Int
+ $last: Int
+ $afterToken: String
+ $beforeToken: String
+) {
+ project(fullPath: $projectPath) {
+ clusterAgent(name: $agentName) {
+ id
+ createdAt
+
+ createdByUser {
+ name
+ }
+
+ tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) {
+ count
+
+ nodes {
+ ...Token
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
new file mode 100644
index 00000000000..bcb5b271203
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import AgentShowPage from './components/show.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.querySelector('#js-cluster-agent-details');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient();
+ const { agentName, projectPath } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ render(createElement) {
+ return createElement(AgentShowPage, {
+ props: {
+ agentName,
+ projectPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js
new file mode 100644
index 00000000000..9b870134512
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/clusters_util.js
@@ -0,0 +1,8 @@
+export function generateAgentRegistrationCommand(agentToken, kasAddress) {
+ return `docker run --pull=always --rm \\
+ registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/cli:stable generate \\
+ --agent-token=${agentToken} \\
+ --kas-address=${kasAddress} \\
+ --agent-version stable \\
+ --namespace gitlab-kubernetes-agent | kubectl apply -f -`;
+}
diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
new file mode 100644
index 00000000000..405339b3d36
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui';
+import { INSTALL_AGENT_MODAL_ID } from '../constants';
+
+export default {
+ modalId: INSTALL_AGENT_MODAL_ID,
+ components: {
+ GlButton,
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
+ GlAlert,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ inject: [
+ 'emptyStateImage',
+ 'projectPath',
+ 'agentDocsUrl',
+ 'installDocsUrl',
+ 'getStartedDocsUrl',
+ 'integrationDocsUrl',
+ ],
+ props: {
+ hasConfigurations: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ repositoryPath() {
+ return `/${this.projectPath}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :svg-path="emptyStateImage"
+ :title="s__('ClusterAgents|Integrate Kubernetes with a GitLab Agent')"
+ class="empty-state--agent"
+ >
+ <template #description>
+ <p class="mw-460 gl-mx-auto">
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="agentDocsUrl" target="_blank" data-testid="agent-docs-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p class="mw-460 gl-mx-auto">
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="installDocsUrl" target="_blank" data-testid="install-docs-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-alert
+ v-if="!hasConfigurations"
+ variant="warning"
+ class="gl-mb-5 text-left"
+ :dismissible="false"
+ >
+ {{
+ s__(
+ 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.',
+ )
+ }}
+
+ <template #actions>
+ <gl-button
+ category="primary"
+ variant="info"
+ :href="getStartedDocsUrl"
+ target="_blank"
+ class="gl-ml-0!"
+ >
+ {{ s__('ClusterAgents|Read more about getting started') }}
+ </gl-button>
+ <gl-button category="secondary" variant="info" :href="repositoryPath">
+ {{ s__('ClusterAgents|Go to the repository') }}
+ </gl-button>
+ </template>
+ </gl-alert>
+ </template>
+
+ <template #actions>
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ :disabled="!hasConfigurations"
+ data-testid="integration-primary-button"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('ClusterAgents|Integrate with the GitLab Agent') }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
new file mode 100644
index 00000000000..487e512c06d
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -0,0 +1,152 @@
+<script>
+import {
+ GlButton,
+ GlLink,
+ GlModalDirective,
+ GlTable,
+ GlIcon,
+ GlSprintf,
+ GlTooltip,
+ GlPopover,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, TROUBLESHOOTING_LINK } from '../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ GlTable,
+ GlIcon,
+ GlSprintf,
+ GlTooltip,
+ GlPopover,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ mixins: [timeagoMixin],
+ inject: ['integrationDocsUrl'],
+ INSTALL_AGENT_MODAL_ID,
+ AGENT_STATUSES,
+ TROUBLESHOOTING_LINK,
+ props: {
+ agents: {
+ required: true,
+ type: Array,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: s__('ClusterAgents|Name'),
+ },
+ {
+ key: 'status',
+ label: s__('ClusterAgents|Connection status'),
+ },
+ {
+ key: 'lastContact',
+ label: s__('ClusterAgents|Last contact'),
+ },
+ {
+ key: 'configuration',
+ label: s__('ClusterAgents|Configuration'),
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-block gl-text-right gl-my-3">
+ <gl-button
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ variant="confirm"
+ category="primary"
+ >{{ s__('ClusterAgents|Install a new GitLab Agent') }}
+ </gl-button>
+ </div>
+
+ <gl-table
+ :items="agents"
+ :fields="fields"
+ stacked="md"
+ head-variant="white"
+ thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
+ data-testid="cluster-agent-list-table"
+ >
+ <template #cell(name)="{ item }">
+ <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">
+ {{ item.name }}
+ </gl-link>
+ </template>
+
+ <template #cell(status)="{ item }">
+ <span
+ :id="`connection-status-${item.name}`"
+ class="gl-pr-5"
+ data-testid="cluster-agent-connection-status"
+ >
+ <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
+ <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span
+ >{{ $options.AGENT_STATUSES[item.status].name }}
+ </span>
+ <gl-tooltip
+ v-if="item.status === 'active'"
+ :target="`connection-status-${item.name}`"
+ placement="right"
+ >
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
+ </gl-sprintf>
+ </gl-tooltip>
+ <gl-popover
+ v-else
+ :target="`connection-status-${item.name}`"
+ :title="$options.AGENT_STATUSES[item.status].tooltip.title"
+ placement="right"
+ container="viewport"
+ >
+ <p>
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf
+ >
+ </p>
+ <p class="gl-mb-0">
+ {{ s__('ClusterAgents|For more troubleshooting information go to') }}
+ <gl-link :href="$options.TROUBLESHOOTING_LINK" target="_blank" class="gl-font-sm">
+ {{ $options.TROUBLESHOOTING_LINK }}</gl-link
+ >
+ </p>
+ </gl-popover>
+ </template>
+
+ <template #cell(lastContact)="{ item }">
+ <span data-testid="cluster-agent-last-contact">
+ <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
+ <span v-else>{{ s__('ClusterAgents|Never') }}</span>
+ </span>
+ </template>
+
+ <template #cell(configuration)="{ item }">
+ <span data-testid="cluster-agent-configuration-link">
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
+ .gitlab/agents/{{ item.name }}
+ </gl-link>
+
+ <span v-else>.gitlab/agents/{{ item.name }}</span>
+ <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
+ </span>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
new file mode 100644
index 00000000000..ed44c1f5fa7
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -0,0 +1,156 @@
+<script>
+import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui';
+import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants';
+import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
+import AgentEmptyState from './agent_empty_state.vue';
+import AgentTable from './agent_table.vue';
+import InstallAgentModal from './install_agent_modal.vue';
+
+export default {
+ apollo: {
+ agents: {
+ query: getAgentsQuery,
+ variables() {
+ return {
+ defaultBranchName: this.defaultBranchName,
+ projectPath: this.projectPath,
+ ...this.cursor,
+ };
+ },
+ update(data) {
+ this.updateTreeList(data);
+ return data;
+ },
+ },
+ },
+ components: {
+ AgentEmptyState,
+ AgentTable,
+ InstallAgentModal,
+ GlAlert,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ },
+ inject: ['projectPath'],
+ props: {
+ defaultBranchName: {
+ default: '.noBranch',
+ required: false,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ cursor: {
+ first: MAX_LIST_COUNT,
+ last: null,
+ },
+ folderList: {},
+ };
+ },
+ computed: {
+ agentList() {
+ let list = this.agents?.project?.clusterAgents?.nodes;
+
+ if (list) {
+ list = list.map((agent) => {
+ const configFolder = this.folderList[agent.name];
+ const lastContact = this.getLastContact(agent);
+ const status = this.getStatus(lastContact);
+ return { ...agent, configFolder, lastContact, status };
+ });
+ }
+
+ return list;
+ },
+ agentPageInfo() {
+ return this.agents?.project?.clusterAgents?.pageInfo || {};
+ },
+ isLoading() {
+ return this.$apollo.queries.agents.loading;
+ },
+ showPagination() {
+ return this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage;
+ },
+ treePageInfo() {
+ return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
+ },
+ hasConfigurations() {
+ return Boolean(this.agents?.project?.repository?.tree?.trees?.nodes?.length);
+ },
+ },
+ methods: {
+ reloadAgents() {
+ this.$apollo.queries.agents.refetch();
+ },
+ nextPage() {
+ this.cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ afterAgent: this.agentPageInfo.endCursor,
+ afterTree: this.treePageInfo.endCursor,
+ };
+ },
+ prevPage() {
+ this.cursor = {
+ first: null,
+ last: MAX_LIST_COUNT,
+ beforeAgent: this.agentPageInfo.startCursor,
+ beforeTree: this.treePageInfo.endCursor,
+ };
+ },
+ updateTreeList(data) {
+ const configFolders = data?.project?.repository?.tree?.trees?.nodes;
+
+ if (configFolders) {
+ configFolders.forEach((folder) => {
+ this.folderList[folder.name] = folder;
+ });
+ }
+ },
+ getLastContact(agent) {
+ const tokens = agent?.tokens?.nodes;
+ let lastContact = null;
+ if (tokens?.length) {
+ tokens.forEach((token) => {
+ const lastContactToDate = new Date(token.lastUsedAt).getTime();
+ if (lastContactToDate > lastContact) {
+ lastContact = lastContactToDate;
+ }
+ });
+ }
+ return lastContact;
+ },
+ getStatus(lastContact) {
+ if (lastContact) {
+ const now = new Date().getTime();
+ const diff = now - lastContact;
+
+ return diff > ACTIVE_CONNECTION_TIME ? 'inactive' : 'active';
+ }
+ return 'unused';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" />
+
+ <section v-else-if="agentList" class="gl-mt-3">
+ <div v-if="agentList.length">
+ <AgentTable :agents="agentList" />
+
+ <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" />
+ </div>
+ </div>
+
+ <AgentEmptyState v-else :has-configurations="hasConfigurations" />
+ <InstallAgentModal @agentRegistered="reloadAgents" />
+ </section>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ s__('ClusterAgents|An error occurred while loading your GitLab Agents') }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
new file mode 100644
index 00000000000..9fb020d2f4f
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants';
+import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql';
+
+export default {
+ name: 'AvailableAgentsDropdown',
+ i18n: I18N_AVAILABLE_AGENTS_DROPDOWN,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ inject: ['projectPath'],
+ props: {
+ isRegistering: {
+ required: true,
+ type: Boolean,
+ },
+ },
+ apollo: {
+ agents: {
+ query: agentConfigurations,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ this.populateAvailableAgents(data);
+ },
+ },
+ },
+ data() {
+ return {
+ availableAgents: [],
+ selectedAgent: null,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.agents.loading;
+ },
+ dropdownText() {
+ if (this.isRegistering) {
+ return this.$options.i18n.registeringAgent;
+ } else if (this.selectedAgent === null) {
+ return this.$options.i18n.selectAgent;
+ }
+
+ return this.selectedAgent;
+ },
+ },
+ methods: {
+ selectAgent(agent) {
+ this.$emit('agentSelected', agent);
+ this.selectedAgent = agent;
+ },
+ isSelected(agent) {
+ return this.selectedAgent === agent;
+ },
+ populateAvailableAgents(data) {
+ const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? [];
+ const configuredAgents =
+ data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? [];
+
+ this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent));
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="dropdownText" :loading="isLoading || isRegistering">
+ <gl-dropdown-item
+ v-for="agent in availableAgents"
+ :key="agent"
+ :is-checked="isSelected(agent)"
+ is-check-item
+ @click="selectAgent(agent)"
+ >
+ {{ agent }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
new file mode 100644
index 00000000000..5f192fe4d5a
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -0,0 +1,259 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlLink,
+ GlModal,
+ GlSprintf,
+} from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+import { generateAgentRegistrationCommand } from '../clusters_util';
+import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants';
+import createAgent from '../graphql/mutations/create_agent.mutation.graphql';
+import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql';
+import AvailableAgentsDropdown from './available_agents_dropdown.vue';
+
+export default {
+ modalId: INSTALL_AGENT_MODAL_ID,
+ i18n: I18N_INSTALL_AGENT_MODAL,
+ components: {
+ AvailableAgentsDropdown,
+ ClipboardButton,
+ CodeBlock,
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ },
+ inject: ['projectPath', 'kasAddress'],
+ data() {
+ return {
+ registering: false,
+ agentName: null,
+ agentToken: null,
+ error: null,
+ };
+ },
+ computed: {
+ registered() {
+ return Boolean(this.agentToken);
+ },
+ nextButtonDisabled() {
+ return !this.registering && this.agentName !== null;
+ },
+ canCancel() {
+ return !this.registered && !this.registering;
+ },
+ agentRegistrationCommand() {
+ return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
+ },
+ basicInstallPath() {
+ return helpPagePath('user/clusters/agent/index', {
+ anchor: 'install-the-agent-into-the-cluster',
+ });
+ },
+ advancedInstallPath() {
+ return helpPagePath('user/clusters/agent/index', { anchor: 'advanced-installation' });
+ },
+ },
+ methods: {
+ setAgentName(name) {
+ this.agentName = name;
+ },
+ cancelClicked() {
+ this.$refs.modal.hide();
+ },
+ doneClicked() {
+ this.$emit('agentRegistered');
+ this.$refs.modal.hide();
+ },
+ resetModal() {
+ this.registering = null;
+ this.agentName = null;
+ this.agentToken = null;
+ this.error = null;
+ },
+ createAgentMutation() {
+ return this.$apollo
+ .mutate({
+ mutation: createAgent,
+ variables: {
+ input: {
+ name: this.agentName,
+ projectPath: this.projectPath,
+ },
+ },
+ })
+ .then(({ data: { createClusterAgent } }) => createClusterAgent);
+ },
+ createAgentTokenMutation(agendId) {
+ return this.$apollo
+ .mutate({
+ mutation: createAgentToken,
+ variables: {
+ input: {
+ clusterAgentId: agendId,
+ name: this.agentName,
+ },
+ },
+ })
+ .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
+ },
+ async registerAgent() {
+ this.registering = true;
+ this.error = null;
+
+ try {
+ const { errors: agentErrors, clusterAgent } = await this.createAgentMutation();
+
+ if (agentErrors?.length > 0) {
+ throw new Error(agentErrors[0]);
+ }
+
+ const { errors: tokenErrors, secret } = await this.createAgentTokenMutation(
+ clusterAgent.id,
+ );
+
+ if (tokenErrors?.length > 0) {
+ throw new Error(tokenErrors[0]);
+ }
+
+ this.agentToken = secret;
+ } catch (error) {
+ if (error) {
+ this.error = error.message;
+ } else {
+ this.error = this.$options.i18n.unknownError;
+ }
+ } finally {
+ this.registering = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ :title="$options.i18n.modalTitle"
+ static
+ lazy
+ @hidden="resetModal"
+ >
+ <template v-if="!registered">
+ <p>
+ <strong>{{ $options.i18n.selectAgentTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.selectAgentBody">
+ <template #link="{ content }">
+ <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <form>
+ <gl-form-group label-for="agent-name">
+ <available-agents-dropdown
+ class="gl-w-70p"
+ :is-registering="registering"
+ @agentSelected="setAgentName"
+ />
+ </gl-form-group>
+ </form>
+
+ <p v-if="error">
+ <gl-alert
+ :title="$options.i18n.registrationErrorTitle"
+ variant="danger"
+ :dismissible="false"
+ >
+ {{ error }}
+ </gl-alert>
+ </p>
+ </template>
+
+ <template v-else>
+ <p>
+ <strong>{{ $options.i18n.tokenTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.tokenBody">
+ <template #link="{ content }">
+ <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p>
+ <gl-alert
+ :title="$options.i18n.tokenSingleUseWarningTitle"
+ variant="warning"
+ :dismissible="false"
+ >
+ {{ $options.i18n.tokenSingleUseWarningBody }}
+ </gl-alert>
+ </p>
+
+ <p>
+ <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
+ <template #append>
+ <clipboard-button :text="agentToken" :title="$options.i18n.copyToken" />
+ </template>
+ </gl-form-input-group>
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.basicInstallTitle }}</strong>
+ </p>
+
+ <p>
+ {{ $options.i18n.basicInstallBody }}
+ </p>
+
+ <p>
+ <code-block :code="agentRegistrationCommand" />
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.advancedInstallTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.advancedInstallBody">
+ <template #link="{ content }">
+ <gl-link :href="advancedInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+
+ <template #modal-footer>
+ <gl-button v-if="canCancel" @click="cancelClicked">{{ $options.i18n.cancel }} </gl-button>
+
+ <gl-button v-if="registered" variant="confirm" category="primary" @click="doneClicked"
+ >{{ $options.i18n.done }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ :disabled="!nextButtonDisabled"
+ variant="confirm"
+ category="primary"
+ @click="registerAgent"
+ >{{ $options.i18n.next }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index f39678b73dc..0bade1fc281 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -1,4 +1,10 @@
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
+
+export const MAX_LIST_COUNT = 25;
+export const INSTALL_AGENT_MODAL_ID = 'install-agent';
+export const ACTIVE_CONNECTION_TIME = 480000;
+export const TROUBLESHOOTING_LINK =
+ 'https://docs.gitlab.com/ee/user/clusters/agent/#troubleshooting';
export const CLUSTER_ERRORS = {
default: {
@@ -58,3 +64,80 @@ export const STATUSES = {
deleting: { title: __('Deleting') },
creating: { title: __('Creating') },
};
+
+export const I18N_INSTALL_AGENT_MODAL = {
+ next: __('Next'),
+ done: __('Done'),
+ cancel: __('Cancel'),
+
+ modalTitle: s__('ClusterAgents|Install new Agent'),
+
+ selectAgentTitle: s__('ClusterAgents|Select which Agent you want to install'),
+ selectAgentBody: s__(
+ `ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}.`,
+ ),
+
+ copyToken: s__('ClusterAgents|Copy token'),
+ tokenTitle: s__('ClusterAgents|Registration token'),
+ tokenBody: s__(
+ `ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}.`,
+ ),
+
+ tokenSingleUseWarningTitle: s__(
+ 'ClusterAgents|The token value will not be shown again after you close this window.',
+ ),
+ tokenSingleUseWarningBody: s__(
+ `ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window.`,
+ ),
+
+ basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
+ basicInstallBody: s__(
+ `Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
+ ),
+
+ advancedInstallTitle: s__('ClusterAgents|Alternative installation methods'),
+ advancedInstallBody: s__(
+ 'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.',
+ ),
+
+ registrationErrorTitle: s__('Failed to register Agent'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+};
+
+export const I18N_AVAILABLE_AGENTS_DROPDOWN = {
+ selectAgent: s__('ClusterAgents|Select an Agent'),
+ registeringAgent: s__('ClusterAgents|Registering Agent'),
+};
+
+export const AGENT_STATUSES = {
+ active: {
+ name: s__('ClusterAgents|Connected'),
+ icon: 'status-success',
+ class: 'text-success-500',
+ tooltip: {
+ title: sprintf(s__('ClusterAgents|Last connected %{timeAgo}.')),
+ },
+ },
+ inactive: {
+ name: s__('ClusterAgents|Not connected'),
+ icon: 'severity-critical',
+ class: 'text-danger-800',
+ tooltip: {
+ title: s__('ClusterAgents|Agent might not be connected to GitLab'),
+ body: sprintf(
+ s__(
+ 'ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.',
+ ),
+ ),
+ },
+ },
+ unused: {
+ name: s__('ClusterAgents|Never connected'),
+ icon: 'status-neutral',
+ class: 'text-secondary-400',
+ tooltip: {
+ title: s__('ClusterAgents|Agent never connected to GitLab'),
+ body: s__('ClusterAgents|Make sure you are using a valid token.'),
+ },
+ },
+};
diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
new file mode 100644
index 00000000000..c29756159f5
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
@@ -0,0 +1,8 @@
+mutation createClusterAgent($input: CreateClusterAgentInput!) {
+ createClusterAgent(input: $input) {
+ clusterAgent {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql
new file mode 100644
index 00000000000..e93580cf416
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql
@@ -0,0 +1,9 @@
+mutation createClusterAgentToken($input: ClusterAgentTokenCreateInput!) {
+ clusterAgentTokenCreate(input: $input) {
+ secret
+ token {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
new file mode 100644
index 00000000000..40b61337024
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
@@ -0,0 +1,15 @@
+query agentConfigurations($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ agentConfigurations {
+ nodes {
+ agentName
+ }
+ }
+
+ clusterAgents {
+ nodes {
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
new file mode 100644
index 00000000000..61989e00d9e
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
@@ -0,0 +1,47 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getAgents(
+ $defaultBranchName: String!
+ $projectPath: ID!
+ $first: Int
+ $last: Int
+ $afterAgent: String
+ $afterTree: String
+ $beforeAgent: String
+ $beforeTree: String
+) {
+ project(fullPath: $projectPath) {
+ clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) {
+ nodes {
+ id
+ name
+ webPath
+ tokens {
+ nodes {
+ lastUsedAt
+ }
+ }
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+
+ repository {
+ tree(path: ".gitlab/agents", ref: $defaultBranchName) {
+ trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) {
+ nodes {
+ name
+ path
+ webPath
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index daa82892773..de18965abbd 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -1,6 +1,11 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import loadClusters from './load_clusters';
+import loadAgents from './load_agents';
+
+Vue.use(VueApollo);
export default () => {
loadClusters(Vue);
+ loadAgents(Vue, VueApollo);
};
diff --git a/app/assets/javascripts/clusters_list/load_agents.js b/app/assets/javascripts/clusters_list/load_agents.js
new file mode 100644
index 00000000000..b77d386df20
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/load_agents.js
@@ -0,0 +1,44 @@
+import createDefaultClient from '~/lib/graphql';
+import Agents from './components/agents.vue';
+
+export default (Vue, VueApollo) => {
+ const el = document.querySelector('#js-cluster-agents-list');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
+
+ const {
+ emptyStateImage,
+ defaultBranchName,
+ projectPath,
+ agentDocsUrl,
+ installDocsUrl,
+ getStartedDocsUrl,
+ integrationDocsUrl,
+ kasAddress,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ provide: {
+ emptyStateImage,
+ projectPath,
+ agentDocsUrl,
+ installDocsUrl,
+ getStartedDocsUrl,
+ integrationDocsUrl,
+ kasAddress,
+ },
+ render(createElement) {
+ return createElement(Agents, {
+ props: {
+ defaultBranchName,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js
deleted file mode 100644
index 2fcd40a901d..00000000000
--- a/app/assets/javascripts/comment_type_toggle.js
+++ /dev/null
@@ -1,71 +0,0 @@
-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 = { ...ISetter };
-
-class CommentTypeToggle {
- constructor(opts = {}) {
- this.dropdownTrigger = opts.dropdownTrigger;
- this.dropdownList = opts.dropdownList;
- this.noteTypeInput = opts.noteTypeInput;
- this.submitButton = opts.submitButton;
- this.closeButton = opts.closeButton;
- this.reopenButton = opts.reopenButton;
- }
-
- initDroplab() {
- this.droplab = new DropLab();
-
- const config = this.setConfig();
-
- this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config);
- }
-
- setConfig() {
- const config = {
- InputSetter: [
- {
- input: this.noteTypeInput,
- valueAttribute: 'data-value',
- },
- {
- input: this.submitButton,
- valueAttribute: 'data-submit-text',
- },
- ],
- };
-
- if (this.closeButton) {
- config.InputSetter.push(
- {
- input: this.closeButton,
- valueAttribute: 'data-close-text',
- },
- {
- input: this.closeButton,
- valueAttribute: 'data-close-text',
- inputAttribute: 'data-alternative-text',
- },
- );
- }
-
- if (this.reopenButton) {
- config.InputSetter.push(
- {
- input: this.reopenButton,
- valueAttribute: 'data-reopen-text',
- },
- {
- input: this.reopenButton,
- valueAttribute: 'data-reopen-text',
- inputAttribute: 'data-alternative-text',
- },
- );
- }
-
- return config;
- }
-}
-
-export default CommentTypeToggle;
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 82a449ae6af..89182b3a09f 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -112,6 +112,15 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
+ data-testid="details"
+ content-type="details"
+ icon-name="details-block"
+ class="gl-mx-2"
+ editor-command="toggleDetails"
+ :label="__('Add a collapsible section')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
data-testid="horizontal-rule"
content-type="horizontalRule"
icon-name="dash"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/details.vue b/app/assets/javascripts/content_editor/components/wrappers/details.vue
new file mode 100644
index 00000000000..aff15ac3e53
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/details.vue
@@ -0,0 +1,33 @@
+<script>
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+
+export default {
+ name: 'DetailsWrapper',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ open: true,
+ };
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-flex">
+ <div
+ class="details-toggle-icon"
+ data-testid="details-toggle-icon"
+ :class="{ 'is-open': open }"
+ @click="open = !open"
+ ></div>
+ <node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue
new file mode 100644
index 00000000000..97b69afd12e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue
@@ -0,0 +1,32 @@
+<script>
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+import { __ } from '~/locale';
+
+export default {
+ name: 'FrontMatter',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ frontmatter: __('frontmatter'),
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-relative code highlight" as="pre">
+ <span
+ data-testid="frontmatter-label"
+ class="gl-absolute gl-top-0 gl-right-3"
+ contenteditable="false"
+ >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span
+ >
+ <node-view-content as="code" />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
index 8f2ce8feb5d..9329bbcb2c7 100644
--- a/app/assets/javascripts/content_editor/content_editor.stories.js
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -2,7 +2,7 @@ import { ContentEditor } from './index';
export default {
component: ContentEditor,
- title: 'Components/Content Editor',
+ title: 'content_editor/components/content_editor',
};
const Template = (_, { argTypes }) => ({
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 25f5837d2a6..1ed1ab0315f 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -11,7 +11,8 @@ export default CodeBlockLowlight.extend({
parseHTML: (element) => extractLanguage(element),
},
class: {
- default: 'code highlight js-syntax-highlight',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ default: 'code highlight',
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/color_chip.js b/app/assets/javascripts/content_editor/extensions/color_chip.js
new file mode 100644
index 00000000000..deb5029a1f0
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/color_chip.js
@@ -0,0 +1,73 @@
+import { Node } from '@tiptap/core';
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { Decoration, DecorationSet } from 'prosemirror-view';
+import { isValidColorExpression } from '~/lib/utils/color_utils';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+const colorExpressionTypes = ['#', 'hsl', 'rgb'];
+
+const isValidColor = (color) => {
+ if (!colorExpressionTypes.some((type) => color.toLowerCase().startsWith(type))) {
+ return false;
+ }
+
+ return isValidColorExpression(color);
+};
+
+const highlightColors = (doc) => {
+ const decorations = [];
+
+ doc.descendants((node, position) => {
+ const { text, marks } = node;
+
+ if (!text || marks.length === 0 || marks[0].type.name !== 'code' || !isValidColor(text)) {
+ return;
+ }
+
+ const from = position;
+ const to = from + text.length;
+ const decoration = Decoration.inline(from, to, {
+ class: 'gl-display-inline-flex gl-align-items-center content-editor-color-chip',
+ style: `--gl-color-chip-color: ${text}`,
+ });
+
+ decorations.push(decoration);
+ });
+
+ return DecorationSet.create(doc, decorations);
+};
+
+export const colorDecoratorPlugin = new Plugin({
+ key: new PluginKey('colorDecorator'),
+ state: {
+ init(_, { doc }) {
+ return highlightColors(doc);
+ },
+ apply(transaction, oldState) {
+ return transaction.docChanged ? highlightColors(transaction.doc) : oldState;
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state);
+ },
+ },
+});
+
+export default Node.create({
+ name: 'colorChip',
+
+ parseHTML() {
+ return [
+ {
+ tag: '.gfm-color_chip',
+ ignore: true,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ addProseMirrorPlugins() {
+ return [colorDecoratorPlugin];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/details.js b/app/assets/javascripts/content_editor/extensions/details.js
new file mode 100644
index 00000000000..e3d54ed01fd
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/details.js
@@ -0,0 +1,36 @@
+import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { wrappingInputRule } from 'prosemirror-inputrules';
+import DetailsWrapper from '../components/wrappers/details.vue';
+
+export const inputRegex = /^\s*(<details>)$/;
+
+export default Node.create({
+ name: 'details',
+ content: 'detailsContent+',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ group: 'block list',
+
+ parseHTML() {
+ return [{ tag: 'details' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['ul', HTMLAttributes, 0];
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(DetailsWrapper);
+ },
+
+ addInputRules() {
+ return [wrappingInputRule(inputRegex, this.type)];
+ },
+
+ addCommands() {
+ return {
+ setDetails: () => ({ commands }) => commands.wrapInList('details'),
+ toggleDetails: () => ({ commands }) => commands.toggleList('details', 'detailsContent'),
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js
new file mode 100644
index 00000000000..fb6c49d91aa
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/details_content.js
@@ -0,0 +1,25 @@
+import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export default Node.create({
+ name: 'detailsContent',
+ content: 'block+',
+ defining: true,
+
+ parseHTML() {
+ return [
+ { tag: '*', consuming: false, context: 'details/', priority: PARSE_HTML_PRIORITY_HIGHEST },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['li', HTMLAttributes, 0];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: () => this.editor.commands.splitListItem('detailsContent'),
+ 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'),
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js
new file mode 100644
index 00000000000..64c84fe046b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js
@@ -0,0 +1,20 @@
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import FrontmatterWrapper from '../components/wrappers/frontmatter.vue';
+import CodeBlockHighlight from './code_block_highlight';
+
+export default CodeBlockHighlight.extend({
+ name: 'frontmatter',
+ parseHTML() {
+ return [
+ {
+ tag: 'pre[data-lang-params="frontmatter"]',
+ preserveWhitespace: 'full',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+ addNodeView() {
+ return new VueNodeViewRenderer(FrontmatterWrapper);
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/math_inline.js b/app/assets/javascripts/content_editor/extensions/math_inline.js
new file mode 100644
index 00000000000..60f5288dcf6
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/math_inline.js
@@ -0,0 +1,35 @@
+import { Mark, markInputRule } from '@tiptap/core';
+import { __ } from '~/locale';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm;
+
+export default Mark.create({
+ name: 'mathInline',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'code.math[data-math-style=inline]',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'code',
+ {
+ title: __('Inline math'),
+ 'data-toggle': 'tooltip',
+ class: 'gl-inset-border-1-gray-400',
+ ...HTMLAttributes,
+ },
+ 0,
+ ];
+ },
+
+ addInputRules() {
+ return [markInputRule(inputRegex, this.type)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
new file mode 100644
index 00000000000..9e31158837e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
@@ -0,0 +1,51 @@
+import { Node } from '@tiptap/core';
+import { InputRule } from 'prosemirror-inputrules';
+import { s__ } from '~/locale';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/];
+
+export default Node.create({
+ name: 'tableOfContents',
+
+ inline: false,
+
+ group: 'block',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'ul.section-nav',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ renderHTML() {
+ return [
+ 'div',
+ {
+ class:
+ 'table-of-contents gl-border-1 gl-border-solid gl-text-center gl-border-gray-100 gl-mb-5',
+ },
+ s__('ContentEditor|Table of Contents'),
+ ];
+ },
+
+ addInputRules() {
+ const { type } = this;
+
+ return inputRuleRegExps.map(
+ (regex) =>
+ new InputRule(regex, (state, match, start, end) => {
+ const { tr } = state;
+
+ if (match) {
+ tr.replaceWith(start - 1, end, type.create());
+ }
+
+ return tr;
+ }),
+ );
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js
new file mode 100644
index 00000000000..93b42466850
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/word_break.js
@@ -0,0 +1,29 @@
+import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
+
+export const inputRegex = /^<wbr>$/;
+
+export default Node.create({
+ name: 'wordBreak',
+ inline: true,
+ group: 'inline',
+ selectable: false,
+ atom: true,
+
+ defaultOptions: {
+ HTMLAttributes: {
+ class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm',
+ },
+ },
+
+ parseHTML() {
+ return [{ tag: 'wbr' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), '-'];
+ },
+
+ addInputRules() {
+ return [nodeInputRule(inputRegex, this.type)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 9b2d4c9a062..385f1c63801 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -8,14 +8,18 @@ import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
+import ColorChip from '../extensions/color_chip';
import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
+import Details from '../extensions/details';
+import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
+import Frontmatter from '../extensions/frontmatter';
import Gapcursor from '../extensions/gapcursor';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
@@ -28,6 +32,7 @@ import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
import Loading from '../extensions/loading';
+import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Reference from '../extensions/reference';
@@ -37,11 +42,13 @@ import Superscript from '../extensions/superscript';
import Table from '../extensions/table';
import TableCell from '../extensions/table_cell';
import TableHeader from '../extensions/table_header';
+import TableOfContents from '../extensions/table_of_contents';
import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
import Video from '../extensions/video';
+import WordBreak from '../extensions/word_break';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
@@ -75,15 +82,19 @@ export const createContentEditor = ({
Bold,
BulletList,
Code,
+ ColorChip,
CodeBlockHighlight,
DescriptionItem,
DescriptionList,
+ Details,
+ DetailsContent,
Document,
Division,
Dropcursor,
Emoji,
Figure,
FigureCaption,
+ Frontmatter,
Gapcursor,
HardBreak,
Heading,
@@ -96,6 +107,7 @@ export const createContentEditor = ({
Link,
ListItem,
Loading,
+ MathInline,
OrderedList,
Paragraph,
Reference,
@@ -104,12 +116,14 @@ export const createContentEditor = ({
Superscript,
TableCell,
TableHeader,
+ TableOfContents,
TableRow,
Table,
TaskItem,
TaskList,
Text,
Video,
+ WordBreak,
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index bc6d98511f9..0dd3cb5b73f 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -11,10 +11,13 @@ import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
+import Details from '../extensions/details';
+import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
+import Frontmatter from '../extensions/frontmatter';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
@@ -24,6 +27,7 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
+import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Reference from '../extensions/reference';
@@ -33,11 +37,13 @@ import Superscript from '../extensions/superscript';
import Table from '../extensions/table';
import TableCell from '../extensions/table_cell';
import TableHeader from '../extensions/table_header';
+import TableOfContents from '../extensions/table_of_contents';
import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
import Video from '../extensions/video';
+import WordBreak from '../extensions/word_break';
import {
isPlainURL,
renderHardBreak,
@@ -50,6 +56,7 @@ import {
renderImage,
renderPlayable,
renderHTMLNode,
+ renderContent,
} from './serialization_helpers';
const defaultSerializerConfig = {
@@ -80,6 +87,11 @@ const defaultSerializerConfig = {
: `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
},
},
+ [MathInline.name]: {
+ open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`,
+ close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`,
+ escape: false,
+ },
[Strike.name]: {
open: '~~',
close: '~~',
@@ -130,11 +142,34 @@ const defaultSerializerConfig = {
renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node);
if (index === parent.childCount - 1) state.ensureNewLine();
},
+ [Details.name]: renderHTMLNode('details', true),
+ [DetailsContent.name]: (state, node, parent, index) => {
+ if (!index) renderHTMLNode('summary')(state, node);
+ else {
+ if (index === 1) state.ensureNewLine();
+ renderContent(state, node);
+ if (index === parent.childCount - 1) state.ensureNewLine();
+ }
+ },
[Emoji.name]: (state, node) => {
const { name } = node.attrs;
state.write(`:${name}:`);
},
+ [Frontmatter.name]: (state, node) => {
+ const { language } = node.attrs;
+ const syntax = {
+ toml: '+++',
+ json: ';;;',
+ yaml: '---',
+ }[language];
+
+ state.write(`${syntax}\n`);
+ state.text(node.textContent, false);
+ state.ensureNewLine();
+ state.write(syntax);
+ state.closeBlock(node);
+ },
[Figure.name]: renderHTMLNode('figure'),
[FigureCaption.name]: renderHTMLNode('figcaption'),
[HardBreak.name]: renderHardBreak,
@@ -147,6 +182,10 @@ const defaultSerializerConfig = {
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
},
+ [TableOfContents.name]: (state, node) => {
+ state.write('[[_TOC_]]');
+ state.closeBlock(node);
+ },
[Table.name]: renderTable,
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
@@ -161,6 +200,7 @@ const defaultSerializerConfig = {
},
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Video.name]: renderPlayable,
+ [WordBreak.name]: (state) => state.write('<wbr>'),
},
};
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 4aee02e45c8..9d4eddc510a 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
@@ -293,7 +293,7 @@ export default {
:items="roles"
:loading="isLoadingRoles"
:loading-text="s__('ClusterIntegration|Loading IAM Roles')"
- :placeholder="s__('ClusterIntergation|Select service role')"
+ :placeholder="s__('ClusterIntegration|Select service role')"
:search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')"
:empty-text="s__('ClusterIntegration|No IAM Roles found')"
:has-errors="Boolean(loadingRolesError)"
@@ -330,7 +330,7 @@ export default {
:disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')"
:loading="isLoadingKeyPairs"
:loading-text="s__('ClusterIntegration|Loading Key Pairs')"
- :placeholder="s__('ClusterIntergation|Select key pair')"
+ :placeholder="s__('ClusterIntegration|Select key pair')"
:search-field-placeholder="s__('ClusterIntegration|Search Key Pairs')"
:empty-text="s__('ClusterIntegration|No Key Pairs found')"
:has-errors="Boolean(loadingKeyPairsError)"
@@ -359,7 +359,7 @@ export default {
:disabled="vpcDropdownDisabled"
:disabled-text="s__('ClusterIntegration|Select a region to choose a VPC')"
:loading-text="s__('ClusterIntegration|Loading VPCs')"
- :placeholder="s__('ClusterIntergation|Select a VPC')"
+ :placeholder="s__('ClusterIntegration|Select a VPC')"
:search-field-placeholder="s__('ClusterIntegration|Search VPCs')"
:empty-text="s__('ClusterIntegration|No VPCs found')"
:has-errors="Boolean(loadingVpcsError)"
@@ -389,7 +389,7 @@ export default {
:disabled="subnetDropdownDisabled"
:disabled-text="s__('ClusterIntegration|Select a VPC to choose a subnet')"
:loading-text="s__('ClusterIntegration|Loading subnets')"
- :placeholder="s__('ClusterIntergation|Select a subnet')"
+ :placeholder="s__('ClusterIntegration|Select a subnet')"
:search-field-placeholder="s__('ClusterIntegration|Search subnets')"
:empty-text="s__('ClusterIntegration|No subnet found')"
:has-errors="displaySubnetError"
@@ -420,7 +420,7 @@ export default {
:disabled="securityGroupDropdownDisabled"
:disabled-text="s__('ClusterIntegration|Select a VPC to choose a security group')"
:loading-text="s__('ClusterIntegration|Loading security groups')"
- :placeholder="s__('ClusterIntergation|Select a security group')"
+ :placeholder="s__('ClusterIntegration|Select a security group')"
:search-field-placeholder="s__('ClusterIntegration|Search security groups')"
:empty-text="s__('ClusterIntegration|No security group found')"
:has-errors="Boolean(loadingSecurityGroupsError)"
@@ -451,7 +451,7 @@ export default {
:items="instanceTypes"
:loading="isLoadingInstanceTypes"
:loading-text="s__('ClusterIntegration|Loading instance types')"
- :placeholder="s__('ClusterIntergation|Select an instance type')"
+ :placeholder="s__('ClusterIntegration|Select an instance type')"
:search-field-placeholder="s__('ClusterIntegration|Search instance types')"
:empty-text="s__('ClusterIntegration|No instance type found')"
:has-errors="Boolean(loadingInstanceTypesError)"
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue
index 12b6070a79a..8f18ac29c0f 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue
@@ -43,7 +43,7 @@ export default {
:loading="isLoadingItems"
:has-errors="Boolean(loadingItemsError)"
:loading-text="s__('ClusterIntegration|Loading networks')"
- :placeholder="s__('ClusterIntergation|Select a network')"
+ :placeholder="s__('ClusterIntegration|Select a network')"
:search-field-placeholder="s__('ClusterIntegration|Search networks')"
:empty-text="s__('ClusterIntegration|No networks found')"
:error-message="s__('ClusterIntegration|Could not load networks')"
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue
index ec7889e2907..dab4adc3789 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue
@@ -34,7 +34,7 @@ export default {
:loading="isLoadingItems"
:has-errors="Boolean(loadingItemsError)"
:loading-text="s__('ClusterIntegration|Loading subnetworks')"
- :placeholder="s__('ClusterIntergation|Select a subnetwork')"
+ :placeholder="s__('ClusterIntegration|Select a subnetwork')"
:search-field-placeholder="s__('ClusterIntegration|Search subnetworks')"
:empty-text="s__('ClusterIntegration|No subnetworks found')"
:error-message="s__('ClusterIntegration|Could not load subnetworks')"
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 1c0dab11392..f4a27dc7d1f 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -5,8 +5,8 @@ import {
canCreateConfidentialMergeRequest,
} from './confidential_merge_request';
import confidentialMergeRequestState from './confidential_merge_request/state';
-import DropLab from './droplab/drop_lab';
-import ISetter from './droplab/plugins/input_setter';
+import DropLab from './filtered_search/droplab/drop_lab_deprecated';
+import ISetter from './filtered_search/droplab/plugins/input_setter';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { __, sprintf } from './locale';
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index ae78ce33263..1d98a42ce58 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -51,6 +51,7 @@ export default {
'features',
'createdBefore',
'createdAfter',
+ 'pagination',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
displayStageEvents() {
@@ -99,7 +100,12 @@ export default {
},
},
methods: {
- ...mapActions(['fetchStageData', 'setSelectedStage', 'setDateRange']),
+ ...mapActions([
+ 'fetchStageData',
+ 'setSelectedStage',
+ 'setDateRange',
+ 'updateStageTablePagination',
+ ]),
onSetDateRange({ startDate, endDate }) {
this.setDateRange({
createdAfter: new Date(startDate),
@@ -108,6 +114,7 @@ export default {
},
onSelectStage(stage) {
this.setSelectedStage(stage);
+ this.updateStageTablePagination({ ...this.pagination, page: 1 });
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
@@ -117,6 +124,9 @@ export default {
const { permissions } = this;
return Boolean(permissions?.[id]);
},
+ onHandleUpdatePagination(data) {
+ this.updateStageTablePagination(data);
+ },
},
dayRangeOptions: [7, 30, 90],
i18n: {
@@ -163,8 +173,8 @@ export default {
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
- :pagination="null"
- :sortable="false"
+ :pagination="pagination"
+ @handleUpdatePagination="onHandleUpdatePagination"
/>
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
index 5140b05e189..016fea354fe 100644
--- a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
@@ -79,7 +79,6 @@ export default {
title: __('Assignees'),
type: 'assignees',
token: AuthorToken,
- defaultAuthors: [],
initialAuthors: this.assigneesData,
unique: false,
operators: OPERATOR_IS_ONLY,
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index 8a2667a4ab1..fc4dfafb809 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -194,6 +194,9 @@ export default {
><formatted-stage-count :stage-count="stageCount"
/></gl-badge>
</template>
+ <template #head(duration)="data">
+ <span data-testid="vsa-stage-header-duration">{{ data.label }}</span>
+ </template>
<template #cell(end_event)="{ item }">
<div data-testid="vsa-stage-event">
<div v-if="item.id" data-testid="vsa-stage-content">
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index c1be2ce7096..c205aa1e831 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -44,7 +44,7 @@ export const METRICS_POPOVER_CONTENT = {
},
'cycle-time': {
description: s__(
- 'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
+ "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
),
},
'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
index 620da0104e0..34ef03409b8 100644
--- a/app/assets/javascripts/cycle_analytics/index.js
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -45,6 +45,7 @@ export default () => {
new Vue({
el,
name: 'CycleAnalytics',
+ apolloProvider: {},
store,
render: (createElement) =>
createElement(CycleAnalytics, {
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index e39cd224199..24b62849db7 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -6,6 +6,7 @@ import {
getValueStreamStageRecords,
getValueStreamStageCounts,
} from '~/api/analytics_api';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants';
@@ -72,16 +73,21 @@ export const fetchCycleAnalyticsData = ({
});
};
-export const fetchStageData = ({ getters: { requestParams, filterParams }, commit }) => {
+export const fetchStageData = ({
+ getters: { requestParams, filterParams, paginationParams },
+ commit,
+}) => {
commit(types.REQUEST_STAGE_DATA);
- return getValueStreamStageRecords(requestParams, filterParams)
- .then(({ data }) => {
+ return getValueStreamStageRecords(requestParams, { ...filterParams, ...paginationParams })
+ .then(({ data, headers }) => {
// when there's a query timeout, the request succeeds but the error is encoded in the response data
if (data?.error) {
commit(types.RECEIVE_STAGE_DATA_ERROR, data.error);
} else {
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
+ const { page = null, nextPage = null } = parseIntPagination(normalizeHeaders(headers));
+ commit(types.SET_PAGINATION, { ...paginationParams, page, hasNextPage: Boolean(nextPage) });
}
})
.catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR));
@@ -176,6 +182,14 @@ export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore
return refetchStageData(dispatch);
};
+export const updateStageTablePagination = (
+ { commit, dispatch, state: { selectedStage } },
+ paginationParams,
+) => {
+ commit(types.SET_PAGINATION, paginationParams);
+ return dispatch('fetchStageData', selectedStage.id);
+};
+
export const initializeVsa = ({ commit, dispatch }, initialData = {}) => {
commit(types.INITIALIZE_VSA, initialData);
diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js
index 77c285f5ce0..962e1d50d12 100644
--- a/app/assets/javascripts/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/cycle_analytics/store/getters.js
@@ -1,6 +1,7 @@
import dateFormat from 'dateformat';
import { dateFormats } from '~/analytics/shared/constants';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import { PAGINATION_TYPE } from '../constants';
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
@@ -21,6 +22,13 @@ export const requestParams = (state) => {
return { requestPath: fullPath, valueStreamId, stageId };
};
+export const paginationParams = ({ pagination: { page, sort, direction } }) => ({
+ pagination: PAGINATION_TYPE,
+ sort,
+ direction,
+ page,
+});
+
const filterBarParams = ({ filters }) => {
const {
authors: { selected: selectedAuthor },
diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
index 0d94aad2ca5..0ad67d4e6bd 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
@@ -4,6 +4,7 @@ export const SET_LOADING = 'SET_LOADING';
export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
+export const SET_PAGINATION = 'SET_PAGINATION';
export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS';
export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS';
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index 301e7d95f8c..64930a5b51f 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -1,13 +1,24 @@
+import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import { formatMedianValues } from '../utils';
import * as types from './mutation_types';
export default {
- [types.INITIALIZE_VSA](state, { endpoints, features, createdBefore, createdAfter }) {
+ [types.INITIALIZE_VSA](
+ state,
+ { endpoints, features, createdBefore, createdAfter, pagination = {} },
+ ) {
state.endpoints = endpoints;
state.createdBefore = createdBefore;
state.createdAfter = createdAfter;
state.features = features;
+
+ Vue.set(state, 'pagination', {
+ page: pagination.page ?? state.pagination.page,
+ sort: pagination.sort ?? state.pagination.sort,
+ direction: pagination.direction ?? state.pagination.direction,
+ });
},
[types.SET_LOADING](state, loadingState) {
state.isLoading = loadingState;
@@ -22,6 +33,14 @@ export default {
state.createdBefore = createdBefore;
state.createdAfter = createdAfter;
},
+ [types.SET_PAGINATION](state, { page, hasNextPage, sort, direction }) {
+ Vue.set(state, 'pagination', {
+ page,
+ hasNextPage,
+ sort: sort || PAGINATION_SORT_FIELD_END_EVENT,
+ direction: direction || PAGINATION_SORT_DIRECTION_DESC,
+ });
+ },
[types.REQUEST_VALUE_STREAMS](state) {
state.valueStreams = [];
},
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index 0882db51218..52bc01cafa4 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -1,3 +1,8 @@
+import {
+ PAGINATION_SORT_FIELD_END_EVENT,
+ PAGINATION_SORT_DIRECTION_DESC,
+} from '~/cycle_analytics/constants';
+
export default () => ({
id: null,
features: {},
@@ -20,4 +25,10 @@ export default () => ({
isLoadingStage: false,
isEmptyStage: false,
permissions: {},
+ pagination: {
+ page: null,
+ hasNextPage: false,
+ sort: PAGINATION_SORT_FIELD_END_EVENT,
+ direction: PAGINATION_SORT_DIRECTION_DESC,
+ },
});
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
index fa02fdf914a..3c6267bac06 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -1,13 +1,10 @@
import dateFormat from 'dateformat';
-import { unescape } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
import { hideFlash } from '~/flash';
-import { sanitize } from '~/lib/dompurify';
-import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility';
+import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
import { slugify } from '~/lib/utils/text_utility';
-import { s__, sprintf } from '../locale';
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
@@ -45,29 +42,6 @@ export const transformStagesForPathNavigation = ({
return formattedStages;
};
-export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => {
- if (months) {
- return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
- value: roundToNearestHalf(months),
- });
- } else if (weeks) {
- return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
- value: roundToNearestHalf(weeks),
- });
- } else if (days) {
- return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
- value: roundToNearestHalf(days),
- });
- } else if (hours) {
- return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
- } else if (minutes) {
- return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
- } else if (seconds) {
- return unescape(sanitize(s__('ValueStreamAnalytics|&lt;1m'), { ALLOWED_TAGS: [] }));
- }
- return '-';
-};
-
/**
* Takes a raw median value in seconds and converts it to a string representation
* ie. converts 172800 => 2d (2 days)
@@ -76,7 +50,7 @@ export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, we
* @returns {String} String representation ie 2w
*/
export const medianTimeToParsedSeconds = (value) =>
- timeSummaryForPathNavigation({
+ formatTimeAsSummary({
...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }),
seconds: value,
});
diff --git a/app/assets/javascripts/dependency_proxy.js b/app/assets/javascripts/dependency_proxy.js
deleted file mode 100644
index ddf5703b28f..00000000000
--- a/app/assets/javascripts/dependency_proxy.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import setupToggleButtons from '~/toggle_buttons';
-
-export default () => {
- setupToggleButtons(document.querySelector('.js-dependency-proxy-toggle-area'));
-};
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
index 051ab710e5f..7acb5549273 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
@@ -25,7 +25,7 @@ export default {
lazy: true,
},
translations: {
- cronPlaceholder: __('* * * * *'),
+ cronPlaceholder: '* * * * *',
cronSyntaxInstructions: __(
'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
),
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index a42b50edb8a..4ab3f140b61 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -19,9 +19,10 @@ import Vue from 'vue';
import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
import syntaxHighlight from '~/syntax_highlight';
+import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
+import * as constants from '~/notes/constants';
import Autosave from './autosave';
import loadAwardsHandler from './awards_handler';
-import CommentTypeToggle from './comment_type_toggle';
import createFlash from './flash';
import { defaultAutocompleteConfig } from './gfm_auto_complete';
import GLForm from './gl_form';
@@ -128,7 +129,13 @@ export default class Notes {
this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this));
this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit);
// Reopen and close actions for Issue/MR combined with note form submit
- this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment);
+ this.$wrapperEl.on(
+ 'click',
+ // this oddly written selector needs to match the old style (input with class) as
+ // well as the new DOM styling from the Vue-based note form
+ 'input.js-comment-submit-button, .js-comment-submit-button > button:first-child',
+ this.postComment,
+ );
this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons);
// resolve a discussion
@@ -201,23 +208,39 @@ export default class Notes {
}
static initCommentTypeToggle(form) {
- const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle');
- const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu');
+ const el = form.querySelector('.js-comment-type-dropdown');
+ const { noteableName } = el.dataset;
const noteTypeInput = form.querySelector('#note_type');
- const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button');
- const closeButton = form.querySelector('.js-note-target-close');
- const reopenButton = form.querySelector('.js-note-target-reopen');
-
- const commentTypeToggle = new CommentTypeToggle({
- dropdownTrigger,
- dropdownList,
- noteTypeInput,
- submitButton,
- closeButton,
- reopenButton,
- });
+ const formHasContent = form.querySelector('.js-note-text').value.trim().length > 0;
- commentTypeToggle.initDroplab();
+ form.commentTypeComponent = new Vue({
+ el,
+ data() {
+ return {
+ noteType: constants.COMMENT,
+ disabled: !formHasContent,
+ };
+ },
+ render(createElement) {
+ return createElement(CommentTypeDropdown, {
+ props: {
+ noteType: this.noteType,
+ noteableDisplayName: noteableName,
+ disabled: this.disabled,
+ },
+ on: {
+ change: (arg) => {
+ this.noteType = arg;
+ if (this.noteType === constants.DISCUSSION) {
+ noteTypeInput.value = constants.DISCUSSION_NOTE;
+ } else {
+ noteTypeInput.value = '';
+ }
+ },
+ },
+ });
+ },
+ });
}
keydownNoteText(e) {
@@ -1107,6 +1130,7 @@ export default class Notes {
const form = textarea.parents('form');
const reopenbtn = form.find('.js-note-target-reopen');
const closebtn = form.find('.js-note-target-close');
+ const commentTypeComponent = form.get(0)?.commentTypeComponent;
if (textarea.val().trim().length > 0) {
reopentext = reopenbtn.attr('data-alternative-text');
@@ -1123,6 +1147,9 @@ export default class Notes {
if (closebtn.is(':not(.btn-comment-and-close)')) {
closebtn.addClass('btn-comment-and-close');
}
+ if (commentTypeComponent) {
+ commentTypeComponent.disabled = false;
+ }
} else {
reopentext = reopenbtn.data('originalText');
closetext = closebtn.data('originalText');
@@ -1138,6 +1165,9 @@ export default class Notes {
if (closebtn.is('.btn-comment-and-close')) {
closebtn.removeClass('btn-comment-and-close');
}
+ if (commentTypeComponent) {
+ commentTypeComponent.disabled = true;
+ }
}
}
@@ -1308,9 +1338,6 @@ export default class Notes {
}
cleanForm($form) {
- // Remove JS classes that are not needed here
- $form.find('.js-comment-type-dropdown').removeClass('btn-group');
-
// Remove dropdown
$form.find('.dropdown-menu').remove();
@@ -1505,6 +1532,8 @@ export default class Notes {
const $submitBtn = $(e.target);
$submitBtn.prop('disabled', true);
let $form = $submitBtn.parents('form');
+ const commentTypeComponent = $form.get(0)?.commentTypeComponent;
+ if (commentTypeComponent) commentTypeComponent.disabled = true;
const $closeBtn = $form.find('.js-note-target-close');
const isDiscussionNote =
$submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion';
@@ -1584,6 +1613,8 @@ export default class Notes {
const note = res.data;
$submitBtn.prop('disabled', false);
+ if (commentTypeComponent) commentTypeComponent.disabled = false;
+
// Submission successful! remove placeholder
$notesContainer.find(`#${noteUniqueId}`).remove();
@@ -1662,6 +1693,8 @@ export default class Notes {
// Submission failed, remove placeholder note and show Flash error message
$notesContainer.find(`#${noteUniqueId}`).remove();
$submitBtn.prop('disabled', false);
+ if (commentTypeComponent) commentTypeComponent.disabled = false;
+
const blurEvent = new CustomEvent('blur.imageDiff', {
detail: e,
});
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 38ea5406c02..837320b9423 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -273,7 +273,7 @@ export default {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
},
onDesignDeleteError(e) {
- this.onError(designDeletionError({ singular: true }), e);
+ this.onError(designDeletionError(), e);
},
onResolveDiscussionError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index e66ae822a34..5092c30aa60 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -255,7 +255,7 @@ export default {
if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME });
},
onDesignDeleteError() {
- const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
+ const errorMessage = designDeletionError(this.selectedDesigns.length);
createFlash({ message: errorMessage });
},
onDesignDropzoneError() {
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index 33c4fd5a7d9..c8f445bfb88 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -250,7 +250,7 @@ export const hasErrors = ({ errors = [] }) => errors?.length;
*/
export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
if (hasErrors(data)) {
- onError(data, designDeletionError({ singular: designs.length === 1 }));
+ onError(data, designDeletionError(designs.length));
} else {
deleteDesignsFromStore(store, query, designs);
addNewVersionToStore(store, query, data.version);
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index afee7e81791..981b50329b2 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { __, s__, n__, sprintf } from '~/locale';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
@@ -27,12 +26,6 @@ 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.',
);
@@ -53,12 +46,9 @@ export const DELETE_DESIGN_TODO_ERROR = __('Failed to remove a to-do item for th
export const TOGGLE_TODO_ERROR = __('Failed to toggle the to-do status for the design.');
-const MAX_SKIPPED_FILES_LISTINGS = 5;
+const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped. %{reason}');
-const oneDesignSkippedMessage = (filename) =>
- `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), {
- filename,
- })}`;
+const MAX_SKIPPED_FILES_LISTINGS = 5;
/**
* Return warning message indicating that some (but not all) uploaded
@@ -66,25 +56,40 @@ const oneDesignSkippedMessage = (filename) =>
* @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
+ const skippedFilesList = skippedFiles
.slice(0, MAX_SKIPPED_FILES_LISTINGS)
.map(({ filename }) => filename)
- .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`;
+ .join(', ');
+
+ const uploadSkippedReason =
+ skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS
+ ? sprintf(
+ s__(
+ 'DesignManagement|Some of the designs you tried uploading did not change: %{skippedFiles} and %{moreCount} more.',
+ ),
+ {
+ skippedFiles: skippedFilesList,
+ moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
+ },
+ )
+ : sprintf(
+ s__(
+ 'DesignManagement|Some of the designs you tried uploading did not change: %{skippedFiles}.',
+ ),
+ { skippedFiles: skippedFilesList },
+ );
+
+ return sprintf(DESIGN_UPLOAD_SKIPPED_MESSAGE, {
+ reason: uploadSkippedReason,
+ });
};
-export const designDeletionError = ({ singular = true } = {}) => {
- const design = singular ? __('a design') : __('designs');
- return sprintf(s__('Could not archive %{design}. Please try again.'), {
- design,
- });
+export const designDeletionError = (designsCount = 1) => {
+ return n__(
+ 'Failed to archive a design. Please try again.',
+ 'Failed to archive designs. Please try again.',
+ designsCount,
+ );
};
/**
@@ -101,7 +106,18 @@ export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
if (skippedFiles.length === uploadedDesigns.length) {
const { filename } = skippedFiles[0];
- return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length);
+ const uploadSkippedReason = sprintf(
+ n__(
+ 'DesignManagement|%{filename} did not change.',
+ 'DesignManagement|The designs you tried uploading did not change.',
+ skippedFiles.length,
+ ),
+ { filename },
+ );
+
+ return sprintf(DESIGN_UPLOAD_SKIPPED_MESSAGE, {
+ reason: uploadSkippedReason,
+ });
}
return someDesignsSkippedMessage(skippedFiles);
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index a2ea42e963c..465f9836140 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -19,6 +19,7 @@ import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '../../notes/event_hub';
import {
@@ -79,6 +80,7 @@ export default {
MrWidgetHowToMergeModal,
GlAlert,
},
+ mixins: [glFeatureFlagsMixin()],
alerts: {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
@@ -252,6 +254,10 @@ export default {
return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
},
isLimitedContainer() {
+ if (this.glFeatures.mrChangesFluidLayout) {
+ return false;
+ }
+
return !this.renderFileTree && !this.isParallelView && !this.isFluidLayout;
},
isFullChangeset() {
@@ -386,6 +392,8 @@ export default {
diffsApp.instrument();
},
created() {
+ this.mergeRequestContainers = document.querySelectorAll('.merge-request-container');
+
this.adjustView();
this.subscribeToEvents();
@@ -513,6 +521,13 @@ export default {
} else {
this.removeEventListeners();
}
+
+ if (!this.isFluidLayout && this.glFeatures.mrChangesFluidLayout) {
+ this.mergeRequestContainers.forEach((el) => {
+ el.classList.toggle('limit-container-width', !this.shouldShow);
+ el.classList.toggle('container-limited', !this.shouldShow);
+ });
+ }
},
setEventListeners() {
Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1));
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index f098d20afd1..da918947cc5 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -100,6 +100,7 @@ export default {
variant="default"
icon="file-tree"
class="gl-mr-3 js-toggle-tree-list btn-icon"
+ data-qa-selector="file_tree_button"
:title="toggleFileBrowserTitle"
:aria-label="toggleFileBrowserTitle"
:selected="showTreeList"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 737c4d8f33c..4e33a02ca0e 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -1,4 +1,9 @@
<script>
+/* eslint-disable vue/no-v-html */
+/**
+NOTE: This file uses v-html over v-safe-html for performance reasons, see:
+https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57842
+* */
import { memoize } from 'lodash';
import { isLoggedIn } from '~/lib/utils/common_utils';
import {
@@ -267,7 +272,9 @@ export default {
]"
class="diff-td line_content with-coverage left-side"
data-testid="left-content"
- v-html="$options.lineContent(props.line.left) /* eslint-disable-line vue/no-v-html */"
+ v-html="
+ $options.lineContent(props.line.left) /* v-html for performance, see top of file */
+ "
></div>
</template>
<template
@@ -389,7 +396,9 @@ export default {
},
]"
class="diff-td line_content with-coverage right-side parallel"
- v-html="$options.lineContent(props.line.right) /* eslint-disable-line vue/no-v-html */"
+ v-html="
+ $options.lineContent(props.line.right) /* v-html for performance, see top of file */
+ "
></div>
</template>
<template v-else>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 39ce849fc03..41d885d3dc1 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -62,7 +62,7 @@ export default {
</script>
<template>
- <div class="tree-list-holder d-flex flex-column">
+ <div class="tree-list-holder d-flex flex-column" data-qa-selector="file_tree_container">
<div class="gl-mb-3 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<gl-icon name="search" class="position-absolute tree-list-icon" />
diff --git a/app/assets/javascripts/diffs/utils/workers.js b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
index 985e75d1a17..985e75d1a17 100644
--- a/app/assets/javascripts/diffs/utils/workers.js
+++ b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js
index 6d1bc78ba1c..04010a99b52 100644
--- a/app/assets/javascripts/diffs/workers/tree_worker.js
+++ b/app/assets/javascripts/diffs/workers/tree_worker.js
@@ -1,5 +1,5 @@
import { sortTree } from '~/ide/stores/utils';
-import { generateTreeList } from '../utils/workers';
+import { generateTreeList } from '../utils/tree_worker_utils';
// eslint-disable-next-line no-restricted-globals
self.addEventListener('message', (e) => {
diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
index 410aaed86a7..7069568275d 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
@@ -1,6 +1,5 @@
-import Api from '~/api';
+import ciSchemaPath from '~/editor/schema/ci.json';
import { registerSchema } from '~/ide/utils';
-import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base';
export class CiSchemaExtension extends SourceEditorExtension {
@@ -16,12 +15,7 @@ export class CiSchemaExtension extends SourceEditorExtension {
* @param {String} opts.projectPath
* @param {String?} opts.ref - Current ref. Defaults to main
*/
- registerCiSchema({ projectNamespace, projectPath, ref } = {}) {
- const ciSchemaPath = Api.buildUrl(Api.projectFileSchemaPath)
- .replace(':namespace_path', projectNamespace)
- .replace(':project_path', projectPath)
- .replace(':ref', ref)
- .replace(':filename', EXTENSION_CI_SCHEMA_FILE_NAME_MATCH);
+ registerCiSchema() {
// In order for workers loaded from `data://` as the
// ones loaded by monaco editor, we use absolute URLs
// to fetch schema files, hence the `gon.gitlab_url`
diff --git a/app/assets/javascripts/editor/schema/NOTICE b/app/assets/javascripts/editor/schema/NOTICE
new file mode 100644
index 00000000000..60a7a81f082
--- /dev/null
+++ b/app/assets/javascripts/editor/schema/NOTICE
@@ -0,0 +1,6 @@
+Copyright (c) 2015-present Mads Kristensen
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
new file mode 100644
index 00000000000..0052bc00406
--- /dev/null
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -0,0 +1,1444 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://gitlab.com/.gitlab-ci.yml",
+ "title": "Gitlab CI configuration",
+ "description": "Gitlab has a built-in solution for doing CI called Gitlab CI. It is configured by supplying a file called `.gitlab-ci.yml`, which will list all the jobs that are going to run for the project. A full list of all options can be found at https://docs.gitlab.com/ee/ci/yaml/. You can read more about Gitlab CI at https://docs.gitlab.com/ee/ci/README.html.",
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string",
+ "format": "uri"
+ },
+ "image": { "$ref": "#/definitions/image" },
+ "services": { "$ref": "#/definitions/services" },
+ "before_script": { "$ref": "#/definitions/before_script" },
+ "after_script": { "$ref": "#/definitions/after_script" },
+ "variables": { "$ref": "#/definitions/globalVariables" },
+ "cache": { "$ref": "#/definitions/cache" },
+ "default": {
+ "type": "object",
+ "properties": {
+ "after_script": { "$ref": "#/definitions/after_script" },
+ "artifacts": { "$ref": "#/definitions/artifacts" },
+ "before_script": { "$ref": "#/definitions/before_script" },
+ "cache": { "$ref": "#/definitions/cache" },
+ "image": { "$ref": "#/definitions/image" },
+ "interruptible": { "$ref": "#/definitions/interruptible" },
+ "retry": { "$ref": "#/definitions/retry" },
+ "services": { "$ref": "#/definitions/services" },
+ "tags": { "$ref": "#/definitions/tags" },
+ "timeout": { "$ref": "#/definitions/timeout" }
+ },
+ "additionalProperties": false
+ },
+ "stages": {
+ "type": "array",
+ "description": "Groups jobs into stages. All jobs in one stage must complete before next stage is executed. Defaults to ['build', 'test', 'deploy'].",
+ "default": ["build", "test", "deploy"],
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true,
+ "minItems": 1
+ },
+ "include": {
+ "description": "Can be `IncludeItem` or `IncludeItem[]`. Each `IncludeItem` will be a string, or an object with properties for the method if including external YAML file. The external content will be fetched, included and evaluated along the `.gitlab-ci.yml`.",
+ "oneOf": [
+ { "$ref": "#/definitions/include_item" },
+ {
+ "type": "array",
+ "items": { "$ref": "#/definitions/include_item" }
+ }
+ ]
+ },
+ "pages": {
+ "$ref": "#/definitions/job",
+ "description": "A special job used to upload static sites to Gitlab pages. Requires a `public/` directory with `artifacts.path` pointing to it."
+ },
+ "workflow": {
+ "type": "object",
+ "properties": {
+ "rules": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "if": {
+ "type": "string"
+ },
+ "variables": { "$ref": "#/definitions/variables" },
+ "when": {
+ "type": "string",
+ "enum": ["always", "never"]
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ }
+ }
+ },
+ "patternProperties": {
+ "^[.]": {
+ "description": "Hidden keys.",
+ "anyOf": [
+ { "$ref": "#/definitions/job_template" },
+ { "description": "Arbitrary YAML anchor." }
+ ]
+ }
+ },
+ "additionalProperties": {
+ "$ref": "#/definitions/job"
+ },
+ "definitions": {
+ "artifacts": {
+ "type": "object",
+ "description": "Used to specify a list of files and directories that should be attached to the job if it succeeds. Artifacts are sent to Gitlab where they can be downloaded.",
+ "additionalProperties": false,
+ "properties": {
+ "paths": {
+ "type": "array",
+ "description": "A list of paths to files/folders that should be included in the artifact.",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
+ "exclude": {
+ "type": "array",
+ "description": "A list of paths to files/folders that should be excluded in the artifact.",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
+ "expose_as": {
+ "type": "string",
+ "description": "Can be used to expose job artifacts in the merge request UI. GitLab will add a link <expose_as> to the relevant merge request that points to the artifact."
+ },
+ "name": {
+ "type": "string",
+ "description": "Name for the archive created on job success. Can use variables in the name, e.g. '$CI_JOB_NAME'"
+ },
+ "untracked": {
+ "type": "boolean",
+ "description": "Whether to add all untracked files (along with 'artifacts.paths') to the artifact.",
+ "default": false
+ },
+ "when": {
+ "description": "Configure when artifacts are uploaded depended on job status.",
+ "default": "on_success",
+ "oneOf": [
+ {
+ "enum": ["on_success"],
+ "description": "Upload artifacts only when the job succeeds (this is the default)."
+ },
+ {
+ "enum": ["on_failure"],
+ "description": "Upload artifacts only when the job fails."
+ },
+ {
+ "enum": ["always"],
+ "description": "Upload artifacts regardless of job status."
+ }
+ ]
+ },
+ "expire_in": {
+ "type": "string",
+ "description": "How long artifacts should be kept. They are saved 30 days by default. Artifacts that have expired are removed periodically via cron job. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'.",
+ "default": "30 days"
+ },
+ "reports": {
+ "type": "object",
+ "description": "Reports will be uploaded as artifacts, and often displayed in the Gitlab UI, such as in Merge Requests.",
+ "additionalProperties": false,
+ "properties": {
+ "junit": {
+ "description": "Path for file(s) that should be parsed as JUnit XML result",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Path to a single XML file"
+ },
+ {
+ "type": "array",
+ "description": "A list of paths to XML files that will automatically be concatenated into a single file",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "cobertura": {
+ "description": "Path for file(s) that should be parsed as Cobertura XML coverage report",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Path to a single XML file"
+ },
+ {
+ "type": "array",
+ "description": "A list of paths to XML files that will automatically be merged into one report",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "codequality": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with code quality report(s) (such as Code Climate)."
+ },
+ "dotenv": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files containing runtime-created variables for this job."
+ },
+ "lsif": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files containing code intelligence (Language Server Index Format)."
+ },
+ "sast": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with SAST vulnerabilities report(s)."
+ },
+ "dependency_scanning": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with Dependency scanning vulnerabilities report(s)."
+ },
+ "container_scanning": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with Container scanning vulnerabilities report(s)."
+ },
+ "dast": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with DAST vulnerabilities report(s)."
+ },
+ "license_management": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Deprecated in 12.8: Path to file or list of files with license report(s)."
+ },
+ "license_scanning": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with license report(s)."
+ },
+ "performance": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with performance metrics report(s)."
+ },
+ "requirements": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with requirements report(s)."
+ },
+ "secret_detection": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with secret detection report(s)."
+ },
+ "metrics": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with custom metrics report(s)."
+ },
+ "terraform": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with terraform plan(s)."
+ }
+ }
+ }
+ }
+ },
+ "string_file_list": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ "include_item": {
+ "oneOf": [
+ {
+ "description": "Will infer the method based on the value. E.g. `https://...` strings will be of type `include:remote`, and `/templates/...` will be of type `include:local`.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "^(https?://|/).+\\.ya?ml$"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "local": {
+ "description": "Relative path from local repository root (`/`) to the `yaml`/`yml` file template. The file must be on the same branch, and does not work across git submodules.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ },
+ "rules": { "$ref": "#/definitions/rules" }
+ },
+ "required": ["local"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "project": {
+ "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
+ "type": "string",
+ "pattern": "\\S/\\S"
+ },
+ "ref": {
+ "description": "Branch/Tag/Commit-hash for the target project.",
+ "type": "string"
+ },
+ "file": {
+ "oneOf": [
+ {
+ "description": "Relative path from project root (`/`) to the `yaml`/`yml` file template.",
+ "type": "string",
+ "pattern": "\\.ya?ml$"
+ },
+ {
+ "description": "List of files by relative path from project root (`/`) to the `yaml`/`yml` file template.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "\\.ya?ml$"
+ }
+ }
+ ]
+ }
+ },
+ "required": ["project", "file"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "template": {
+ "description": "Use a `.gitlab-ci.yml` template as a base, e.g. `Nodejs.gitlab-ci.yml`.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ }
+ },
+ "required": ["template"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "remote": {
+ "description": "URL to a `yaml`/`yml` template file using HTTP/HTTPS.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "^https?://.+\\.ya?ml$"
+ }
+ },
+ "required": ["remote"]
+ }
+ ]
+ },
+ "image": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Full name of the image that should be used. It should contain the Registry part if needed."
+ },
+ {
+ "type": "object",
+ "description": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor.",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Full name of the image that should be used. It should contain the Registry part if needed."
+ },
+ "entrypoint": {
+ "type": "array",
+ "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.",
+ "minItems": 1
+ }
+ },
+ "required": ["name"]
+ }
+ ],
+ "description": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor."
+ },
+ "services": {
+ "type": "array",
+ "description": "Similar to `image` property, but will link the specified services to the `image` container.",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Full name of the image that should be used. It should contain the Registry part if needed."
+ },
+ {
+ "type": "object",
+ "description": "",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Full name of the image that should be used. It should contain the Registry part if needed.",
+ "minLength": 1
+ },
+ "entrypoint": {
+ "type": "array",
+ "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "type": "array",
+ "description": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array.",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ },
+ "alias": {
+ "type": "string",
+ "description": "Additional alias that can be used to access the service from the job's container. Read Accessing the services for more information.",
+ "minLength": 1
+ }
+ },
+ "required": ["name"]
+ }
+ ]
+ }
+ },
+ "secrets": {
+ "type": "object",
+ "description": "Defines secrets to be injected as environment variables",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "description": "Environment variable name",
+ "properties": {
+ "vault": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "engine": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "path": { "type": "string" }
+ },
+ "required": ["name", "path"]
+ },
+ "path": { "type": "string" },
+ "field": { "type": "string" }
+ },
+ "required": ["engine", "path", "field"]
+ }
+ ]
+ }
+ },
+ "required": ["vault"]
+ }
+ }
+ },
+ "before_script": {
+ "type": "array",
+ "description": "Defines scripts that should run *before* the job. Can be set globally or per job.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "after_script": {
+ "type": "array",
+ "description": "Defines scripts that should run *after* the job. Can be set globally or per job.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "rules": {
+ "type": "array",
+ "description": "Rules allows for an array of individual rule objects to be evaluated in order, until one matches and dynamically provides attributes to the job.",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "if": {
+ "type": "string",
+ "description": "Expression to evaluate whether additional attributes should be provided to the job"
+ },
+ "changes": {
+ "type": "array",
+ "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file",
+ "items": {
+ "type": "string"
+ }
+ },
+ "exists": {
+ "type": "array",
+ "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository",
+ "items": {
+ "type": "string"
+ }
+ },
+ "variables": { "$ref": "#/definitions/variables" },
+ "when": { "$ref": "#/definitions/when" },
+ "start_in": { "$ref": "#/definitions/start_in" },
+ "allow_failure": { "$ref": "#/definitions/allow_failure" }
+ }
+ }
+ },
+ "globalVariables": {
+ "description": "Defines environment variables globally. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. You can use the value and description keywords to define variables that are prefilled when running a pipeline manually.",
+ "type": "object",
+ "additionalProperties": {
+ "anyOf": [
+ {"type": ["string", "integer"]},
+ {
+ "type": "object",
+ "properties": {
+ "value": { "type": "string" },
+ "description": {
+ "type": "string",
+ "description": "Explains what the variable is used for, what the acceptable values are."
+ }
+ }
+ }
+ ]
+ }
+ },
+ "variables": {
+ "type": "object",
+ "description": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off.",
+ "additionalProperties": {
+ "type": ["string", "integer"]
+ }
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Allows you to configure a timeout for a specific job (e.g. `1 minute`, `1h 30m 12s`). Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#timeout",
+ "minLength": 1
+ },
+ "start_in": {
+ "type": "string",
+ "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job.",
+ "minLength": 1
+ },
+ "allow_failure": {
+ "description": "Allow job to fail. A failed job does not cause the pipeline to fail.",
+ "oneOf": [
+ {
+ "description": "Setting this option to true will allow the job to fail while still letting the pipeline pass.",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "description": "Exit code that are not considered failure. The job fails for any other exit code.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["exit_codes"],
+ "properties": {
+ "exit_codes": {
+ "type": "integer"
+ }
+ }
+ },
+ {
+ "description": "You can list which exit codes are not considered failures. The job fails for any other exit code.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["exit_codes"],
+ "properties": {
+ "exit_codes": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "when": {
+ "description": "Describes the conditions for when to run the job. Defaults to 'on_success'.",
+ "default": "on_success",
+ "oneOf": [
+ {
+ "enum": ["on_success"],
+ "description": "Execute job only when all jobs from prior stages succeed."
+ },
+ {
+ "enum": ["on_failure"],
+ "description": "Execute job when at least one job from prior stages fails."
+ },
+ {
+ "enum": ["always"],
+ "description": "Execute job regardless of the status from prior stages."
+ },
+ {
+ "enum": ["manual"],
+ "description": "Execute the job manually from Gitlab UI or API. Read more: https://docs.gitlab.com/ee/ci/yaml/#when-manual"
+ },
+ {
+ "enum": ["delayed"],
+ "description": "Execute a job after the time limit in 'start_in' expires. Read more: https://docs.gitlab.com/ee/ci/yaml/#when-delayed"
+ },
+ {
+ "enum": ["never"],
+ "description": "Never execute the job."
+ }
+ ]
+ },
+ "cache": {
+ "properties": {
+ "when": {
+ "description": "Defines when to save the cache, based on the status of the job.",
+ "default": "on_success",
+ "oneOf": [
+ {
+ "enum": ["on_success"],
+ "description": "Save the cache only when the job succeeds."
+ },
+ {
+ "enum": ["on_failure"],
+ "description": "Save the cache only when the job fails. "
+ },
+ {
+ "enum": ["always"],
+ "description": "Always save the cache. "
+ }
+ ]
+ }
+ }
+ },
+ "cache_entry": {
+ "type": "object",
+ "description": "Specify files or directories to cache between jobs. Can be set globally or per job.",
+ "additionalProperties": false,
+ "properties": {
+ "paths": {
+ "type": "array",
+ "description": "List of files or paths to cache.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "key": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Unique cache ID, to allow e.g. specific branch or job cache. Environment variables can be used to set up unique keys (e.g. \"$CI_COMMIT_REF_SLUG\" for per branch cache)."
+ },
+ {
+ "type": "object",
+ "description": "When you include cache:key:files, you must also list the project files that will be used to generate the key, up to a maximum of two files. The cache key will be a SHA checksum computed from the most recent commits (up to two, if two files are listed) that changed the given files.",
+ "properties": {
+ "files": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "maxItems": 2
+ }
+ }
+ }
+ ]
+ },
+ "untracked": {
+ "type": "boolean",
+ "description": "Set to `true` to cache untracked files.",
+ "default": false
+ },
+ "policy": {
+ "type": "string",
+ "description": "Determines the strategy for downloading and updating the cache.",
+ "default": "pull-push",
+ "oneOf": [
+ {
+ "enum": ["pull"],
+ "description": "Pull will download cache but skip uploading after job completes."
+ },
+ {
+ "enum": ["push"],
+ "description": "Push will skip downloading cache and always recreate cache after job completes."
+ },
+ {
+ "enum": ["pull-push"],
+ "description": "Pull-push will both download cache at job start and upload cache on job success."
+ }
+ ]
+ }
+ }
+ },
+ "filter_refs": {
+ "type": "array",
+ "description": "Filter job by different keywords that determine origin or state, or by supplying string/regex to check against branch/tag names.",
+ "items": {
+ "anyOf": [
+ {
+ "oneOf": [
+ {
+ "enum": ["branches"],
+ "description": "When a branch is pushed."
+ },
+ {
+ "enum": ["tags"],
+ "description": "When a tag is pushed."
+ },
+ {
+ "enum": ["api"],
+ "description": "When a pipeline has been triggered by a second pipelines API (not triggers API)."
+ },
+ {
+ "enum": ["external"],
+ "description": "When using CI services other than Gitlab"
+ },
+ {
+ "enum": ["pipelines"],
+ "description": "For multi-project triggers, created using the API with 'CI_JOB_TOKEN'."
+ },
+ {
+ "enum": ["pushes"],
+ "description": "Pipeline is triggered by a `git push` by the user"
+ },
+ {
+ "enum": ["schedules"],
+ "description": "For scheduled pipelines."
+ },
+ {
+ "enum": ["triggers"],
+ "description": "For pipelines created using a trigger token."
+ },
+ {
+ "enum": ["web"],
+ "description": "For pipelines created using *Run pipeline* button in Gitlab UI (under your project's *Pipelines*)."
+ }
+ ]
+ },
+ {
+ "type": "string",
+ "description": "String or regular expression to match against tag or branch names."
+ }
+ ]
+ }
+ },
+ "filter": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/filter_refs"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "refs": {
+ "$ref": "#/definitions/filter_refs"
+ },
+ "kubernetes": {
+ "enum": ["active"],
+ "description": "Filter job based on if Kubernetes integration is active."
+ },
+ "variables": {
+ "type": "array",
+ "description": "Filter job by checking comparing values of environment variables. Read more about variable expressions: https://docs.gitlab.com/ee/ci/variables/README.html#variables-expressions",
+ "items": {
+ "type": "string"
+ }
+ },
+ "changes": {
+ "type": "array",
+ "description": "Filter job creation based on files that were modified in a git push.",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "retry": {
+ "description": "Retry a job if it fails. Can be a simple integer or object definition.",
+ "oneOf": [
+ { "$ref": "#/definitions/retry_max" },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "max": { "$ref": "#/definitions/retry_max" },
+ "when": {
+ "description": "Either a single or array of error types to trigger job retry.",
+ "oneOf": [
+ { "$ref": "#/definitions/retry_errors" },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/retry_errors"
+ }
+ }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ "retry_max": {
+ "type": "integer",
+ "description": "The number of times the job will be retried if it fails. Defaults to 0 and can max be retried 2 times (3 times total).",
+ "default": 0,
+ "minimum": 0,
+ "maximum": 2
+ },
+ "retry_errors": {
+ "oneOf": [
+ {
+ "const": "always",
+ "description": "Retry on any failure (default)."
+ },
+ {
+ "const": "unknown_failure",
+ "description": "Retry when the failure reason is unknown."
+ },
+ {
+ "const": "script_failure",
+ "description": "Retry when the script failed."
+ },
+ {
+ "const": "api_failure",
+ "description": "Retry on API failure."
+ },
+ {
+ "const": "stuck_or_timeout_failure",
+ "description": "Retry when the job got stuck or timed out."
+ },
+ {
+ "const": "runner_system_failure",
+ "description": "Retry if there is a runner system failure (for example, job setup failed)."
+ },
+ {
+ "const": "runner_unsupported",
+ "description": "Retry if the runner is unsupported."
+ },
+ {
+ "const": "stale_schedule",
+ "description": "Retry if a delayed job could not be executed."
+ },
+ {
+ "const": "job_execution_timeout",
+ "description": "Retry if the script exceeded the maximum execution time set for the job."
+ },
+ {
+ "const": "archived_failure",
+ "description": "Retry if the job is archived and can’t be run."
+ },
+ {
+ "const": "unmet_prerequisites",
+ "description": "Retry if the job failed to complete prerequisite tasks."
+ },
+ {
+ "const": "scheduler_failure",
+ "description": "Retry if the scheduler failed to assign the job to a runner."
+ },
+ {
+ "const": "data_integrity_failure",
+ "description": "Retry if there is a structural integrity problem detected."
+ }
+ ]
+ },
+ "interruptible": {
+ "type": "boolean",
+ "description": "Interruptible is used to indicate that a job should be canceled if made redundant by a newer pipeline run.",
+ "default": false
+ },
+ "job": {
+ "allOf": [
+ { "$ref": "#/definitions/job_template" },
+ {
+ "anyOf": [
+ { "required": ["script"] },
+ { "required": ["extends"] },
+ { "required": ["trigger"] }
+ ]
+ }
+ ]
+ },
+ "job_template": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "image": { "$ref": "#/definitions/image" },
+ "services": { "$ref": "#/definitions/services" },
+ "before_script": { "$ref": "#/definitions/before_script" },
+ "after_script": { "$ref": "#/definitions/after_script" },
+ "rules": { "$ref": "#/definitions/rules" },
+ "variables": { "$ref": "#/definitions/variables" },
+ "cache": { "$ref": "#/definitions/cache" },
+ "secrets": { "$ref": "#/definitions/secrets" },
+ "script": {
+ "description": "Shell scripts executed by the Runner. The only required property of jobs. Be careful with special characters (e.g. `:`, `{`, `}`, `&`) and use single or double quotes to avoid issues.",
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1
+ },
+ {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "stage": {
+ "type": "string",
+ "description": "Define what stage the job will run in.",
+ "default": "test"
+ },
+ "only": {
+ "$ref": "#/definitions/filter",
+ "description": "Job will run *only* when these filtering options match."
+ },
+ "extends": {
+ "description": "The name of one or more jobs to inherit configuration from.",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "needs": {
+ "description": "The list of jobs in previous stages whose sole completion is needed to start the current job.",
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "job": {
+ "type": "string"
+ },
+ "artifacts": {
+ "type": "boolean"
+ },
+ "optional": {
+ "type": "boolean"
+ }
+ },
+ "required": ["job"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "pipeline": {
+ "type": "string"
+ },
+ "job": {
+ "type": "string"
+ },
+ "artifacts": {
+ "type": "boolean"
+ }
+ },
+ "required": ["job", "pipeline"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "job": {
+ "type": "string"
+ },
+ "project": {
+ "type": "string"
+ },
+ "ref": {
+ "type": "string"
+ },
+ "artifacts": {
+ "type": "boolean"
+ }
+ },
+ "required": ["job", "project", "ref"]
+ }
+ ]
+ }
+ },
+ "except": {
+ "$ref": "#/definitions/filter",
+ "description": "Job will run *except* for when these filtering options match."
+ },
+ "tags": {
+ "$ref": "#/definitions/tags"
+ },
+ "allow_failure": {
+ "$ref": "#/definitions/allow_failure"
+ },
+ "timeout": {
+ "$ref": "#/definitions/timeout"
+ },
+ "when": {
+ "$ref": "#/definitions/when"
+ },
+ "start_in": {
+ "$ref": "#/definitions/start_in"
+ },
+ "dependencies": {
+ "type": "array",
+ "description": "Specify a list of job names from earlier stages from which artifacts should be loaded. By default, all previous artifacts are passed. Use an empty array to skip downloading artifacts.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "artifacts": {
+ "$ref": "#/definitions/artifacts"
+ },
+ "environment": {
+ "description": "Used to associate environment metadata with a deploy. Environment can have a name and URL attached to it, and will be displayed under /environments under the project.",
+ "oneOf": [
+ { "type": "string" },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the environment, e.g. 'qa', 'staging', 'production'.",
+ "minLength": 1
+ },
+ "url": {
+ "type": "string",
+ "description": "When set, this will expose buttons in various places for the current environment in Gitlab, that will take you to the defined URL.",
+ "format": "uri",
+ "pattern": "^(https?://.+|\\$[A-Za-z]+)"
+ },
+ "on_stop": {
+ "type": "string",
+ "description": "The name of a job to execute when the environment is about to be stopped."
+ },
+ "action": {
+ "enum": ["start", "prepare", "stop"],
+ "description": "Specifies what this job will do. 'start' (default) indicates the job will start the deployment. 'prepare' indicates this will not affect the deployment. 'stop' indicates this will stop the deployment.",
+ "default": "start"
+ },
+ "auto_stop_in": {
+ "type": "string",
+ "description": "The amount of time it should take before Gitlab will automatically stop the environment. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'."
+ },
+ "kubernetes": {
+ "type": "object",
+ "description": "Used to configure the kubernetes deployment for this environment. This is currently not supported for kubernetes clusters that are managed by Gitlab.",
+ "properties": {
+ "namespace": {
+ "type": "string",
+ "description": "The kubernetes namespace where this environment should be deployed to.",
+ "minLength": 1
+ }
+ }
+ },
+ "deployment_tier": {
+ "type": "string",
+ "description": "Explicitly specifies the tier of the deployment environment if non-standard environment name is used.",
+ "enum": [
+ "production",
+ "staging",
+ "testing",
+ "development",
+ "other"
+ ]
+ }
+ },
+ "required": ["name"]
+ }
+ ]
+ },
+ "release": {
+ "type": "object",
+ "description": "Indicates that the job creates a Release.",
+ "additionalProperties": false,
+ "properties": {
+ "tag_name": {
+ "type": "string",
+ "description": "The tag_name must be specified. It can refer to an existing Git tag or can be specified by the user.",
+ "minLength": 1
+ },
+ "description": {
+ "type": "string",
+ "description": "Specifies the longer description of the Release.",
+ "minLength": 1
+ },
+ "name": {
+ "type": "string",
+ "description": "The Release name. If omitted, it is populated with the value of release: tag_name."
+ },
+ "ref": {
+ "type": "string",
+ "description": "If the release: tag_name doesn’t exist yet, the release is created from ref. ref can be a commit SHA, another tag name, or a branch name."
+ },
+ "milestones": {
+ "type": "array",
+ "description": "The title of each milestone the release is associated with.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "released_at": {
+ "type": "string",
+ "description": "The date and time when the release is ready. Defaults to the current date and time if not defined. Should be enclosed in quotes and expressed in ISO 8601 format.",
+ "format": "date-time",
+ "pattern": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
+ },
+ "assets": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "links": {
+ "type": "array",
+ "description": "Include asset links in the release.",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the link.",
+ "minLength": 1
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL to download a file.",
+ "minLength": 1
+ },
+ "filepath": {
+ "type": "string",
+ "description": "The redirect link to the url."
+ },
+ "link_type": {
+ "type": "string",
+ "description": "The content kind of what users can download via url.",
+ "enum": [
+ "runbook",
+ "package",
+ "image",
+ "other"
+ ]
+ }
+ },
+ "required": ["name", "url"]
+ },
+ "minItems": 1
+ }
+ },
+ "required": ["links"]
+ }
+ },
+ "required": ["tag_name", "description"]
+ },
+ "coverage": {
+ "type": "string",
+ "description": "Must be a regular expression, optionally but recommended to be quoted, and must be surrounded with '/'. Example: '/Code coverage: \\d+\\.\\d+/'",
+ "format": "regex",
+ "pattern": "^/.+/$"
+ },
+ "retry": {
+ "$ref": "#/definitions/retry"
+ },
+ "parallel": {
+ "description": "Parallel will split up a single job into several, and provide `CI_NODE_INDEX` and `CI_NODE_TOTAL` environment variables for the running jobs.",
+ "oneOf": [
+ {
+ "type": "integer",
+ "description": "Creates N instances of the same job that run in parallel.",
+ "default": 0,
+ "minimum": 2,
+ "maximum": 50
+ },
+ {
+ "type": "object",
+ "properties": {
+ "matrix": {
+ "type": "array",
+ "description": "Defines different variables for jobs that are running in parallel.",
+ "items": {
+ "type": "object",
+ "description": "Defines environment variables for specific job.",
+ "additionalProperties": {
+ "type": ["string", "number", "array"]
+ }
+ },
+ "maxItems": 50
+ }
+ },
+ "additionalProperties": false,
+ "required": ["matrix"]
+ }
+ ]
+ },
+ "interruptible": {
+ "$ref": "#/definitions/interruptible"
+ },
+ "resource_group": {
+ "type": "string",
+ "description": "Limit job concurrency. Can be used to ensure that the Runner will not run certain jobs simultaneously."
+ },
+ "trigger": {
+ "description": "Trigger allows you to define downstream pipeline trigger. When a job created from trigger definition is started by GitLab, a downstream pipeline gets created. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger",
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#simple-trigger-syntax-for-multi-project-pipelines",
+ "additionalProperties": false,
+ "properties": {
+ "project": {
+ "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
+ "type": "string",
+ "pattern": "\\S/\\S"
+ },
+ "branch": {
+ "description": "The branch name that a downstream pipeline will use",
+ "type": "string"
+ },
+ "strategy": {
+ "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend",
+ "type": "string",
+ "enum": ["depend"]
+ }
+ },
+ "required": ["project"],
+ "dependencies": {
+ "branch": ["project"]
+ }
+ },
+ {
+ "type": "object",
+ "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger-syntax-for-child-pipeline",
+ "additionalProperties": false,
+ "properties": {
+ "include": {
+ "oneOf": [
+ {
+ "description": "Relative path from local repository root (`/`) to the local YAML file to define the pipeline configuration.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ },
+ {
+ "type": "array",
+ "description": "References a local file or an artifact from another job to define the pipeline configuration.",
+ "maxItems": 3,
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "local": {
+ "description": "Relative path from local repository root (`/`) to the local YAML file to define the pipeline configuration.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "template": {
+ "description": "Name of the template YAML file to use in the pipeline configuration.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "artifact": {
+ "description": "Relative path to the generated YAML file which is extracted from the artifacts and used as the configuration for triggering the child pipeline.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ },
+ "job": {
+ "description": "Job name which generates the artifact",
+ "type": "string"
+ }
+ },
+ "required": ["artifact", "job"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "project": {
+ "description": "Path to another private project under the same GitLab instance, like `group/project` or `group/sub-group/project`.",
+ "type": "string",
+ "pattern": "\\S/\\S"
+ },
+ "ref": {
+ "description": "Branch/Tag/Commit hash for the target project.",
+ "minLength": 1,
+ "type": "string"
+ },
+ "file": {
+ "description": "Relative path from repository root (`/`) to the pipeline configuration YAML file.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ }
+ },
+ "required": ["project", "file"]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "strategy": {
+ "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend",
+ "type": "string",
+ "enum": ["depend"]
+ }
+ }
+ },
+ {
+ "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
+ "type": "string",
+ "pattern": "\\S/\\S"
+ }
+ ]
+ },
+ "inherit": {
+ "type": "object",
+ "description": "Controls inheritance of globally-defined defaults and variables. Boolean values control inheritance of all default: or variables: keywords. To inherit only a subset of default: or variables: keywords, specify what you wish to inherit. Anything not listed is not inherited.",
+ "properties": {
+ "default": {
+ "description": "Whether to inherit all globally-defined defaults or not. Or subset of inherited defaults",
+ "oneOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "after_script",
+ "artifacts",
+ "before_script",
+ "cache",
+ "image",
+ "interruptible",
+ "retry",
+ "services",
+ "tags",
+ "timeout"
+ ]
+ }
+ }
+ ]
+ },
+ "variables": {
+ "description": "Whether to inherit all globally-defined variables or not. Or subset of inherited variables",
+ "oneOf": [
+ { "type": "boolean" },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ {
+ "properties": {
+ "when": { "enum": ["delayed"] }
+ },
+ "required": ["when", "start_in"]
+ },
+ {
+ "properties": {
+ "when": {
+ "not": {
+ "enum": ["delayed"]
+ }
+ }
+ }
+ }
+ ]
+ },
+ "tags": {
+ "type": "array",
+ "description": "Used to select runners from the list of available runners. A runner must have all tags listed here to run the job.",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue
index 4b7917b4572..8609503e486 100644
--- a/app/assets/javascripts/environments/components/environment_delete.vue
+++ b/app/assets/javascripts/environments/components/environment_delete.vue
@@ -4,17 +4,15 @@
* Used in the environments table.
*/
-import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
+import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
- GlButton,
+ GlDropdownItem,
},
directives: {
- GlTooltip: GlTooltipDirective,
GlModalDirective,
},
props: {
@@ -28,10 +26,8 @@ export default {
isLoading: false,
};
},
- computed: {
- title() {
- return s__('Environments|Delete environment');
- },
+ i18n: {
+ title: s__('Environments|Delete environment'),
},
mounted() {
eventHub.$on('deleteEnvironment', this.onDeleteEnvironment);
@@ -41,7 +37,6 @@ export default {
},
methods: {
onClick() {
- this.$root.$emit(BV_HIDE_TOOLTIP, this.$options.deleteEnvironmentTooltipId);
eventHub.$emit('requestDeleteEnvironment', this.environment);
},
onDeleteEnvironment(environment) {
@@ -50,20 +45,15 @@ export default {
}
},
},
- deleteEnvironmentTooltipId: 'delete-environment-button-tooltip',
};
</script>
<template>
- <gl-button
- v-gl-tooltip="{ id: $options.deleteEnvironmentTooltipId }"
- v-gl-modal-directive="'delete-environment-modal'"
+ <gl-dropdown-item
+ v-gl-modal-directive.delete-environment-modal
:loading="isLoading"
- :title="title"
- :aria-label="title"
- class="gl-display-none gl-md-display-block"
variant="danger"
- category="primary"
- icon="remove"
@click="onClick"
- />
+ >
+ {{ $options.i18n.title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index 793f7bf0681..b8def676e7d 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -18,22 +18,23 @@ export default {
required: true,
},
},
- computed: {
- title() {
- return s__('Environments|Open live environment');
- },
+ i18n: {
+ title: s__('Environments|Open live environment'),
+ open: s__('Environments|Open'),
},
};
</script>
<template>
<gl-button
v-gl-tooltip
- :title="title"
- :aria-label="title"
+ :title="$options.i18n.title"
+ :aria-label="$options.i18n.title"
:href="externalUrl"
class="external-url"
target="_blank"
icon="external-link"
rel="noopener noreferrer nofollow"
- />
+ >
+ {{ $options.i18n.open }}
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index d12863ee742..db01d455b2b 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlDropdown, GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __, s__, sprintf } from '~/locale';
@@ -29,6 +29,7 @@ export default {
ActionsComponent,
CommitComponent,
ExternalUrlComponent,
+ GlDropdown,
GlIcon,
GlLink,
GlSprintf,
@@ -521,6 +522,10 @@ export default {
return this.model.metrics_path || '';
},
+ terminalPath() {
+ return this.model?.terminal_path ?? '';
+ },
+
autoStopUrl() {
return this.model.cancel_auto_stop_path || '';
},
@@ -549,6 +554,15 @@ export default {
tableNameSpacingClass() {
return this.isFolder ? 'section-100' : this.tableData.name.spacing;
},
+ hasExtraActions() {
+ return Boolean(
+ this.canRetry ||
+ this.canShowAutoStopDate ||
+ this.monitoringUrl ||
+ this.terminalPath ||
+ this.canDeleteEnvironment,
+ );
+ },
},
methods: {
@@ -776,13 +790,6 @@ export default {
role="gridcell"
>
<div class="btn-group table-action-buttons" role="group">
- <pin-component
- v-if="canShowAutoStopDate"
- :auto-stop-url="autoStopUrl"
- data-track-action="click_button"
- data-track-label="environment_pin"
- />
-
<external-url-component
v-if="externalURL"
:external-url="externalURL"
@@ -790,13 +797,6 @@ export default {
data-track-label="environment_url"
/>
- <monitoring-button-component
- v-if="monitoringUrl"
- :monitoring-url="monitoringUrl"
- data-track-action="click_button"
- data-track-label="environment_monitoring"
- />
-
<actions-component
v-if="actions.length > 0"
:actions="actions"
@@ -804,35 +804,59 @@ export default {
data-track-label="environment_actions"
/>
- <terminal-button-component
- v-if="model && model.terminal_path"
- :terminal-path="model.terminal_path"
- data-track-action="click_button"
- data-track-label="environment_terminal"
- />
-
- <rollback-component
- v-if="canRetry"
- :environment="model"
- :is-last-deployment="isLastDeployment"
- :retry-url="retryUrl"
- data-track-action="click_button"
- data-track-label="environment_rollback"
- />
-
<stop-component
v-if="canStopEnvironment"
:environment="model"
+ class="gl-z-index-2"
data-track-action="click_button"
data-track-label="environment_stop"
/>
- <delete-component
- v-if="canDeleteEnvironment"
- :environment="model"
- data-track-action="click_button"
- data-track-label="environment_delete"
- />
+ <gl-dropdown
+ v-if="hasExtraActions"
+ icon="ellipsis_v"
+ text-sr-only
+ :text="__('More actions')"
+ category="secondary"
+ no-caret
+ >
+ <rollback-component
+ v-if="canRetry"
+ :environment="model"
+ :is-last-deployment="isLastDeployment"
+ :retry-url="retryUrl"
+ data-track-action="click_button"
+ data-track-label="environment_rollback"
+ />
+
+ <pin-component
+ v-if="canShowAutoStopDate"
+ :auto-stop-url="autoStopUrl"
+ data-track-action="click_button"
+ data-track-label="environment_pin"
+ />
+
+ <monitoring-button-component
+ v-if="monitoringUrl"
+ :monitoring-url="monitoringUrl"
+ data-track-action="click_button"
+ data-track-label="environment_monitoring"
+ />
+
+ <terminal-button-component
+ v-if="terminalPath"
+ :terminal-path="terminalPath"
+ data-track-action="click_button"
+ data-track-label="environment_terminal"
+ />
+
+ <delete-component
+ v-if="canDeleteEnvironment"
+ :environment="model"
+ data-track-action="click_button"
+ data-track-label="environment_delete"
+ />
+ </gl-dropdown>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 7f70433776d..06c7f10223a 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -1,15 +1,12 @@
<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
export default {
components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ GlDropdownItem,
},
props: {
monitoringUrl: {
@@ -17,22 +14,11 @@ export default {
required: true,
},
},
- computed: {
- title() {
- return __('Monitoring');
- },
- },
+ title: __('Monitoring'),
};
</script>
<template>
- <gl-button
- v-gl-tooltip
- :href="monitoringUrl"
- :title="title"
- :aria-label="title"
- class="monitoring-url gl-display-none gl-sm-display-none gl-md-display-block"
- icon="chart"
- rel="noopener noreferrer nofollow"
- variant="default"
- />
+ <gl-dropdown-item :href="monitoringUrl" rel="noopener noreferrer nofollow" target="_blank">
+ {{ $options.title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue
index 52ac7725bde..0b753d53ee3 100644
--- a/app/assets/javascripts/environments/components/environment_pin.vue
+++ b/app/assets/javascripts/environments/components/environment_pin.vue
@@ -3,17 +3,13 @@
* Renders a prevent auto-stop button.
* Used in environments table.
*/
-import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
- GlIcon,
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ GlDropdownItem,
},
props: {
autoStopUrl: {
@@ -26,11 +22,11 @@ export default {
eventHub.$emit('cancelAutoStop', this.autoStopUrl);
},
},
- title: __('Prevent environment from auto-stopping'),
+ title: __('Prevent auto-stopping'),
};
</script>
<template>
- <gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick">
- <gl-icon name="thumbtack" />
- </gl-button>
+ <gl-dropdown-item @click="onPinClick">
+ {{ $options.title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index c0b4e96cea2..00497b3c683 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -5,16 +5,15 @@
*
* Makes a post request when the button is clicked.
*/
-import { GlTooltipDirective, GlModalDirective, GlButton } from '@gitlab/ui';
+import { GlModalDirective, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
- GlButton,
+ GlDropdownItem,
},
directives: {
- GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
props: {
@@ -65,14 +64,7 @@ export default {
};
</script>
<template>
- <gl-button
- v-gl-tooltip
- v-gl-modal.confirm-rollback-modal
- class="gl-display-none gl-md-display-block text-secondary"
- :loading="isLoading"
- :title="title"
- :aria-label="title"
- :icon="isLastDeployment ? 'repeat' : 'redo'"
- @click="onClick"
- />
+ <gl-dropdown-item v-gl-modal.confirm-rollback-modal @click="onClick">
+ {{ title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index dceaf3cacf1..0d4a1e76eb8 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -23,16 +23,15 @@ export default {
required: true,
},
},
+ i18n: {
+ title: s__('Environments|Stop environment'),
+ stop: s__('Environments|Stop'),
+ },
data() {
return {
isLoading: false,
};
},
- computed: {
- title() {
- return s__('Environments|Stop environment');
- },
- },
mounted() {
eventHub.$on('stopEnvironment', this.onStopEnvironment);
},
@@ -58,11 +57,13 @@ export default {
v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }"
v-gl-modal-directive="'stop-environment-modal'"
:loading="isLoading"
- :title="title"
- :aria-label="title"
+ :title="$options.i18n.title"
+ :aria-label="$options.i18n.title"
icon="stop"
- category="primary"
+ category="secondary"
variant="danger"
@click="onClick"
- />
+ >
+ {{ $options.i18n.stop }}
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 4750b8ef01b..0df07f0457f 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -3,15 +3,12 @@
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ GlDropdownItem,
},
props: {
terminalPath: {
@@ -25,22 +22,11 @@ export default {
default: false,
},
},
- computed: {
- title() {
- return __('Terminal');
- },
- },
+ title: __('Terminal'),
};
</script>
<template>
- <a
- v-gl-tooltip
- :title="title"
- :aria-label="title"
- :href="terminalPath"
- :class="{ disabled: disabled }"
- class="btn terminal-button d-none d-md-block text-secondary"
- >
- <gl-icon name="terminal" />
- </a>
+ <gl-dropdown-item :href="terminalPath" :disabled="disabled">
+ {{ $options.title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index f1c728b84fd..7b8b756487b 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -67,7 +67,7 @@ export default {
spacing: 'section-10',
},
autoStop: {
- title: s__('Environments|Auto stop in'),
+ title: s__('Environments|Auto stop'),
spacing: 'section-10',
},
actions: {
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 206381e0b7e..f248e9ec079 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -8,7 +8,7 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export default () => {
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 0a15cb56447..4adbf5362b7 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -128,6 +128,12 @@ export default {
lastReleaseLink() {
return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`;
},
+ firstCommitLink() {
+ return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`;
+ },
+ lastCommitLink() {
+ return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`;
+ },
showStacktrace() {
return Boolean(this.stacktrace?.length);
},
@@ -394,7 +400,7 @@ export default {
<span>{{ error.gitlabIssuePath }}</span>
</gl-link>
</li>
- <li>
+ <li v-if="!error.integrated">
<strong class="bold">{{ __('Sentry event') }}:</strong>
<gl-link
v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)"
@@ -409,15 +415,21 @@ export default {
<li v-if="error.firstReleaseVersion">
<strong class="bold">{{ __('First seen') }}:</strong>
<time-ago-tooltip :time="error.firstSeen" />
- <gl-link :href="firstReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ error.firstReleaseVersion }}</span>
+ <gl-link v-if="error.integrated" :href="firstCommitLink">
+ {{ __('GitLab commit') }}: {{ error.firstReleaseVersion }}
+ </gl-link>
+ <gl-link v-else :href="firstReleaseLink" target="_blank">
+ {{ __('Release') }}: {{ error.firstReleaseVersion }}
</gl-link>
</li>
<li v-if="error.lastReleaseVersion">
<strong class="bold">{{ __('Last seen') }}:</strong>
<time-ago-tooltip :time="error.lastSeen" />
- <gl-link :href="lastReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ error.lastReleaseVersion }}</span>
+ <gl-link v-if="error.integrated" :href="lastCommitLink">
+ {{ __('GitLab commit') }}: {{ error.lastReleaseVersion }}
+ </gl-link>
+ <gl-link v-else :href="lastReleaseLink" target="_blank">
+ {{ __('Release') }}: {{ error.lastReleaseVersion }}
</gl-link>
</li>
<li>
diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql
index 593cbf2ae52..af386528f00 100644
--- a/app/assets/javascripts/error_tracking/queries/details.query.graphql
+++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql
@@ -23,6 +23,7 @@ query errorDetails($fullPath: ID!, $errorId: ID!) {
gitlabCommit
gitlabCommitPath
gitlabIssuePath
+ integrated
}
}
}
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index e12d9cc2b07..4808cd1d1c0 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -1,6 +1,14 @@
<script>
-import { GlButton, GlFormGroup, GlFormCheckbox, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlFormInputGroup,
+} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ErrorTrackingForm from './error_tracking_form.vue';
import ProjectDropdown from './project_dropdown.vue';
@@ -12,7 +20,9 @@ export default {
GlFormGroup,
GlFormRadioGroup,
GlFormRadio,
+ GlFormInputGroup,
ProjectDropdown,
+ ClipboardButton,
},
props: {
initialApiHost: {
@@ -46,6 +56,11 @@ export default {
type: String,
required: true,
},
+ gitlabDsn: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
...mapGetters([
@@ -63,6 +78,9 @@ export default {
'settingsLoading',
'token',
]),
+ showGitlabDsnSetting() {
+ return this.integrated && this.enabled && this.gitlabDsn;
+ },
},
created() {
this.setInitialState({
@@ -119,6 +137,17 @@ export default {
</gl-form-radio>
</gl-form-radio-group>
</gl-form-group>
+ <gl-form-group
+ v-if="showGitlabDsnSetting"
+ :label="__('Paste this DSN into your Sentry SDK')"
+ data-testid="gitlab-dsn-setting-form"
+ >
+ <gl-form-input-group readonly :value="gitlabDsn">
+ <template #append>
+ <clipboard-button :text="gitlabDsn" :title="__('Copy')" />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
<div v-if="!integrated" class="js-sentry-setting-form" data-testid="sentry-setting-form">
<error-tracking-form />
<div class="form-group">
diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js
index 324b3292834..69388329e1c 100644
--- a/app/assets/javascripts/error_tracking_settings/index.js
+++ b/app/assets/javascripts/error_tracking_settings/index.js
@@ -13,6 +13,7 @@ export default () => {
token,
listProjectsEndpoint,
operationsSettingsEndpoint,
+ gitlabDsn,
},
} = formContainerEl;
@@ -29,6 +30,7 @@ export default () => {
initialToken: token,
listProjectsEndpoint,
operationsSettingsEndpoint,
+ gitlabDsn,
},
});
},
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
index 9079c238169..624a04fd7c2 100644
--- a/app/assets/javascripts/experimentation/utils.js
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -1,5 +1,5 @@
// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
-import { get, pick } from 'lodash';
+import { get } from 'lodash';
import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants';
function getExperimentsData() {
@@ -14,12 +14,6 @@ export function getExperimentData(experimentName) {
return getExperimentsData()[experimentName];
}
-export function getExperimentContexts(...experimentNames) {
- return Object.values(pick(getExperimentsData(), experimentNames)).map(
- convertExperimentDataToExperimentContext,
- );
-}
-
export function getAllExperimentContexts() {
return Object.values(getExperimentsData()).map(convertExperimentDataToExperimentContext);
}
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index 05d557db942..2bdc95e798c 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -1,7 +1,7 @@
<script>
import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { sprintf, s__ } from '~/locale';
+import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import FeatureFlagForm from './form.vue';
@@ -10,6 +10,7 @@ export default {
GlAlert,
GlLoadingIcon,
GlToggle,
+ FeatureFlagActions: () => import('ee_component/feature_flags/components/actions.vue'),
FeatureFlagForm,
},
mixins: [glFeatureFlagMixin()],
@@ -28,7 +29,7 @@ export default {
title() {
return this.iid
? `^${this.iid} ${this.name}`
- : sprintf(s__('Edit %{name}'), { name: this.name });
+ : sprintf(this.$options.i18n.editTitle, { name: this.name });
},
},
created() {
@@ -37,6 +38,11 @@ export default {
methods: {
...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']),
},
+ i18n: {
+ editTitle: __('Edit %{name}'),
+ toggleLabel: __('Feature flag status'),
+ submit: __('Save changes'),
+ },
};
</script>
<template>
@@ -51,11 +57,13 @@ export default {
data-track-action="click_button"
data-track-label="feature_flag_toggle"
class="gl-mr-4"
- :label="__('Feature flag status')"
+ :label="$options.i18n.toggleLabel"
label-position="hidden"
@change="toggleActive"
/>
<h3 class="page-title gl-m-0">{{ title }}</h3>
+
+ <feature-flag-actions class="gl-ml-auto" />
</div>
<gl-alert v-if="error.length" variant="warning" class="gl-mb-5" :dismissible="false">
@@ -67,7 +75,7 @@ export default {
:description="description"
:strategies="strategies"
:cancel-path="path"
- :submit-text="__('Save changes')"
+ :submit-text="$options.i18n.submit"
:active="active"
@handleSubmit="(data) => updateFeatureFlag(data)"
/>
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index f7ad2c1f106..29e82289107 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -95,7 +95,7 @@ export default {
return this.formStrategies.filter((s) => !s.shouldBeDestroyed);
},
showRelatedIssues() {
- return this.featureFlagIssuesEndpoint.length > 0;
+ return Boolean(this.featureFlagIssuesEndpoint);
},
},
methods: {
diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
index 858c30649bb..1a470d74b59 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
@@ -17,7 +17,7 @@ export default {
},
},
i18n: {
- percentageDescription: __('Enter an integer number number between 0 and 100'),
+ percentageDescription: __('Enter an integer number between 0 and 100'),
percentageInvalid: __('Percent rollout must be an integer number between 0 and 100'),
percentageLabel: __('Percentage'),
stickinessDescription: __('Consistency guarantee method'),
diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js
index 98dee7c7e97..55dad87ea5b 100644
--- a/app/assets/javascripts/feature_flags/edit.js
+++ b/app/assets/javascripts/feature_flags/edit.js
@@ -15,6 +15,7 @@ export default () => {
environmentsEndpoint,
projectId,
featureFlagIssuesEndpoint,
+ searchPath,
} = el.dataset;
return new Vue({
@@ -26,6 +27,7 @@ export default () => {
environmentsEndpoint,
projectId,
featureFlagIssuesEndpoint,
+ searchPath,
},
render(createElement) {
return createElement(EditFeatureFlag);
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index 545719ee681..9726b2164b7 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -1,6 +1,6 @@
import createFlash from '~/flash';
import { __ } from '~/locale';
-import AjaxFilter from '../droplab/plugins/ajax_filter';
+import AjaxFilter from './droplab/plugins/ajax_filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
import FilteredSearchTokenizer from './filtered_search_tokenizer';
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index a7648a3c463..5adc074b3ce 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,7 +1,7 @@
import createFlash from '~/flash';
import { __ } from '~/locale';
-import Ajax from '../droplab/plugins/ajax';
-import Filter from '../droplab/plugins/filter';
+import Ajax from './droplab/plugins/ajax';
+import Filter from './droplab/plugins/filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 47f350dc6a2..9d29782c9a7 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,5 +1,5 @@
-import Filter from '~/droplab/plugins/filter';
import { __ } from '~/locale';
+import Filter from './droplab/plugins/filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index f78644a3893..ddc3c06a9d1 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,7 +1,7 @@
import createFlash from '~/flash';
import { __ } from '~/locale';
-import Ajax from '../droplab/plugins/ajax';
-import Filter from '../droplab/plugins/filter';
+import Ajax from './droplab/plugins/ajax';
+import Filter from './droplab/plugins/filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
index f933338514a..fb9f25a8c45 100644
--- a/app/assets/javascripts/filtered_search/dropdown_operator.js
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -1,5 +1,5 @@
-import Filter from '~/droplab/plugins/filter';
import { __ } from '~/locale';
+import Filter from './droplab/plugins/filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/filtered_search/droplab/constants.js
index 6451af49d36..6451af49d36 100644
--- a/app/assets/javascripts/droplab/constants.js
+++ b/app/assets/javascripts/filtered_search/droplab/constants.js
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/filtered_search/droplab/drop_down.js
index 05b741af191..05b741af191 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/filtered_search/droplab/drop_down.js
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js
index 6f068aaa800..15c4a4b7c6b 100644
--- a/app/assets/javascripts/droplab/drop_lab.js
+++ b/app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js
@@ -1,3 +1,13 @@
+/**
+ * This library is deprecated and scheduled to be removed once the
+ * filtered_search component is replaced with GitLab's new Pajamas
+ * filter vue component.
+ *
+ * The documentation has been removed from the gitlab codebase but
+ * can still be found in the commit history here:
+ * https://gitlab.com/gitlab-org/gitlab/-/blob/28f20e28/doc/development/fe_guide/droplab/droplab.md
+ */
+
import { DATA_TRIGGER } from './constants';
import HookButton from './hook_button';
import HookInput from './hook_input';
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/filtered_search/droplab/hook.js
index 8a8dcde9f88..8a8dcde9f88 100644
--- a/app/assets/javascripts/droplab/hook.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook.js
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/filtered_search/droplab/hook_button.js
index c51d6167fa3..c51d6167fa3 100644
--- a/app/assets/javascripts/droplab/hook_button.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook_button.js
diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/filtered_search/droplab/hook_input.js
index c523dae347f..c523dae347f 100644
--- a/app/assets/javascripts/droplab/hook_input.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook_input.js
diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/filtered_search/droplab/keyboard.js
index fe1ea2fa6b0..fe1ea2fa6b0 100644
--- a/app/assets/javascripts/droplab/keyboard.js
+++ b/app/assets/javascripts/filtered_search/droplab/keyboard.js
diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax.js
index 77d60454d1a..77d60454d1a 100644
--- a/app/assets/javascripts/droplab/plugins/ajax.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax.js
diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js
index ac4d44adc17..d0f2d205bb6 100644
--- a/app/assets/javascripts/droplab/plugins/ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js
@@ -1,5 +1,6 @@
/* eslint-disable */
-import AjaxCache from '../../lib/utils/ajax_cache';
+
+import AjaxCache from '~/lib/utils/ajax_cache';
const AjaxFilter = {
init: function (hook) {
diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/filter.js
index 06391668928..06391668928 100644
--- a/app/assets/javascripts/droplab/plugins/filter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/filter.js
diff --git a/app/assets/javascripts/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
index 148d9a35b81..148d9a35b81 100644
--- a/app/assets/javascripts/droplab/plugins/input_setter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js
index d7f49bf19d8..d7f49bf19d8 100644
--- a/app/assets/javascripts/droplab/utils.js
+++ b/app/assets/javascripts/filtered_search/droplab/utils.js
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 ebaa3ef98b1..e467e97dda9 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -1,6 +1,6 @@
import { last } from 'lodash';
import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings';
-import DropLab from '~/droplab/drop_lab';
+import DropLab from './droplab/drop_lab_deprecated';
import { DROPDOWN_TYPE } from './constants';
import FilteredSearchContainer from './container';
import DropdownUtils from './dropdown_utils';
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 2f451e8353b..5dac315d345 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,6 +1,6 @@
<script>
/* eslint-disable vue/require-default-prop */
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
@@ -14,6 +14,9 @@ export default {
GlButton,
ProjectAvatar,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
mixins: [trackingMixin],
inject: ['vuexModule'],
props: {
@@ -73,9 +76,9 @@ export default {
<div ref="frequentItemsItemMetadataContainer" class="frequent-items-item-metadata-container">
<div
ref="frequentItemsItemTitle"
+ v-safe-html="highlightedItemName"
:title="itemName"
class="frequent-items-item-title"
- v-html="highlightedItemName /* eslint-disable-line vue/no-v-html */"
></div>
<div
v-if="namespace"
diff --git a/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql
new file mode 100644
index 00000000000..760d78be20d
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql
@@ -0,0 +1,6 @@
+fragment MilestoneFragment on Milestone {
+ expired
+ id
+ state
+ title
+}
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 580c27f6c61..c6590fd8eb3 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -3,6 +3,7 @@ import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
import HeaderSearchScopedItems from './header_search_scoped_items.vue';
@@ -16,6 +17,7 @@ export default {
GlSearchBoxByType,
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
+ HeaderSearchAutocompleteItems,
},
data() {
return {
@@ -41,7 +43,7 @@ export default {
},
},
methods: {
- ...mapActions(['setSearch']),
+ ...mapActions(['setSearch', 'fetchAutocompleteOptions']),
openDropdown() {
this.showDropdown = true;
},
@@ -51,6 +53,13 @@ export default {
submitSearch() {
return visitUrl(this.searchQuery);
},
+ getAutocompleteOptions(searchTerm) {
+ if (!searchTerm) {
+ return;
+ }
+
+ this.fetchAutocompleteOptions();
+ },
},
};
</script>
@@ -64,18 +73,20 @@ export default {
:placeholder="$options.i18n.searchPlaceholder"
@focus="openDropdown"
@click="openDropdown"
+ @input="getAutocompleteOptions"
@keydown.enter="submitSearch"
@keydown.esc="closeDropdown"
/>
<div
v-if="showSearchDropdown"
data-testid="header-search-dropdown-menu"
- class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-left-0 gl-z-index-1 gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
+ class="header-search-dropdown-menu gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
>
<div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
<header-search-default-items v-if="showDefaultItems" />
<template v-else>
<header-search-scoped-items />
+ <header-search-autocomplete-items />
</template>
</div>
</div>
diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
new file mode 100644
index 00000000000..9bea2b280f7
--- /dev/null
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -0,0 +1,74 @@
+<script>
+import {
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlAvatar,
+ GlLoadingIcon,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import highlight from '~/lib/utils/highlight';
+import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
+
+export default {
+ name: 'HeaderSearchAutocompleteItems',
+ components: {
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlAvatar,
+ GlLoadingIcon,
+ },
+ directives: {
+ SafeHtml,
+ },
+ computed: {
+ ...mapState(['search', 'loading']),
+ ...mapGetters(['autocompleteGroupedSearchOptions']),
+ },
+ methods: {
+ highlightedName(val) {
+ return highlight(val, this.search);
+ },
+ avatarSize(data) {
+ if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) {
+ return LARGE_AVATAR_PX;
+ }
+
+ return SMALL_AVATAR_PX;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <template v-if="!loading">
+ <div v-for="option in autocompleteGroupedSearchOptions" :key="option.category">
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(data, index) in option.data"
+ :id="`autocomplete-${option.category}-${index}`"
+ :key="index"
+ tabindex="-1"
+ :href="data.url"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-avatar
+ v-if="data.avatar_url !== undefined"
+ :src="data.avatar_url"
+ :entity-id="data.id"
+ :entity-name="data.label"
+ :size="avatarSize(data)"
+ shape="square"
+ />
+ <span v-safe-html="highlightedName(data.label)"></span>
+ </div>
+ </gl-dropdown-item>
+ </div>
+ </template>
+ <gl-loading-icon v-else size="lg" class="my-4" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index fffed7bcbdb..2fadb1bd1ee 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -15,3 +15,11 @@ export const MSG_IN_ALL_GITLAB = __('in all GitLab');
export const MSG_IN_GROUP = __('in group');
export const MSG_IN_PROJECT = __('in project');
+
+export const GROUPS_CATEGORY = 'Groups';
+
+export const PROJECTS_CATEGORY = 'Projects';
+
+export const LARGE_AVATAR_PX = 32;
+
+export const SMALL_AVATAR_PX = 16;
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index 2d37ee137fc..d7e21f55ea5 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -12,13 +12,13 @@ export const initHeaderSearchApp = () => {
return false;
}
- const { searchPath, issuesPath, mrPath } = el.dataset;
+ const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset;
let { searchContext } = el.dataset;
searchContext = JSON.parse(searchContext);
return new Vue({
el,
- store: createStore({ searchPath, issuesPath, mrPath, searchContext }),
+ store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
render(createElement) {
return createElement(HeaderSearchApp);
},
diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js
index 841aee04029..2c3b1bd4c0f 100644
--- a/app/assets/javascripts/header_search/store/actions.js
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -1,5 +1,19 @@
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
import * as types from './mutation_types';
+export const fetchAutocompleteOptions = ({ commit, getters }) => {
+ commit(types.REQUEST_AUTOCOMPLETE);
+ return axios
+ .get(getters.autocompleteQuery)
+ .then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data))
+ .catch(() => {
+ commit(types.RECEIVE_AUTOCOMPLETE_ERROR);
+ createFlash({ message: __('There was an error fetching search autocomplete suggestions') });
+ });
+};
+
export const setSearch = ({ commit }, value) => {
commit(types.SET_SEARCH, value);
};
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index d1e1fc8ad73..3f4e231ca55 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -23,6 +23,16 @@ export const searchQuery = (state) => {
return `${state.searchPath}?${objectToQuery(query)}`;
};
+export const autocompleteQuery = (state) => {
+ const query = {
+ term: state.search,
+ project_id: state.searchContext.project?.id,
+ project_ref: state.searchContext.ref,
+ };
+
+ return `${state.autocompletePath}?${objectToQuery(query)}`;
+};
+
export const scopedIssuesPath = (state) => {
return (
state.searchContext.project_metadata?.issues_path ||
@@ -133,3 +143,25 @@ export const scopedSearchOptions = (state, getters) => {
return options;
};
+
+export const autocompleteGroupedSearchOptions = (state) => {
+ const groupedOptions = {};
+ const results = [];
+
+ state.autocompleteOptions.forEach((option) => {
+ const category = groupedOptions[option.category];
+
+ if (category) {
+ category.data.push(option);
+ } else {
+ groupedOptions[option.category] = {
+ category: option.category,
+ data: [option],
+ };
+
+ results.push(groupedOptions[option.category]);
+ }
+ });
+
+ return results;
+};
diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js
index 8b74f8662a5..06cca4be8a7 100644
--- a/app/assets/javascripts/header_search/store/index.js
+++ b/app/assets/javascripts/header_search/store/index.js
@@ -7,11 +7,17 @@ import createState from './state';
Vue.use(Vuex);
-export const getStoreConfig = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
+export const getStoreConfig = ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+}) => ({
actions,
getters,
mutations,
- state: createState({ searchPath, issuesPath, mrPath, searchContext }),
+ state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js
index 0bc94ae055f..a2358621ce6 100644
--- a/app/assets/javascripts/header_search/store/mutation_types.js
+++ b/app/assets/javascripts/header_search/store/mutation_types.js
@@ -1 +1,5 @@
+export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE';
+export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS';
+export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR';
+
export const SET_SEARCH = 'SET_SEARCH';
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js
index 5b1438929d4..175b5406540 100644
--- a/app/assets/javascripts/header_search/store/mutations.js
+++ b/app/assets/javascripts/header_search/store/mutations.js
@@ -1,6 +1,18 @@
import * as types from './mutation_types';
export default {
+ [types.REQUEST_AUTOCOMPLETE](state) {
+ state.loading = true;
+ state.autocompleteOptions = [];
+ },
+ [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
+ state.loading = false;
+ state.autocompleteOptions = data;
+ },
+ [types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
+ state.loading = false;
+ state.autocompleteOptions = [];
+ },
[types.SET_SEARCH](state, value) {
state.search = value;
},
diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js
index fb2c83dbbe3..3d4073f0583 100644
--- a/app/assets/javascripts/header_search/store/state.js
+++ b/app/assets/javascripts/header_search/store/state.js
@@ -1,8 +1,11 @@
-const createState = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
+const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }) => ({
searchPath,
issuesPath,
mrPath,
+ autocompletePath,
searchContext,
search: '',
+ autocompleteOptions: [],
+ loading: false,
});
export default createState;
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
index 5a7d7917f8a..5272c4310d8 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
@@ -1,7 +1,11 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapState } from 'vuex';
export default {
+ directives: {
+ SafeHtml,
+ },
computed: {
...mapState(['lastCommitMsg', 'committedStateSvgPath']),
},
@@ -16,7 +20,7 @@ export default {
<div class="gl-mr-3 gl-ml-3">
<div class="text-content text-center">
<h4>{{ __('All changes are committed') }}</h4>
- <p v-html="lastCommitMsg /* eslint-disable-line vue/no-v-html */"></p>
+ <p v-safe-html="lastCommitMsg"></p>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index c142992a9d1..96cb4f3d495 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -44,18 +44,18 @@ export default {
methods: {
...mapActions('pipelines', ['fetchJobLogs', 'setDetailJob']),
scrollDown() {
- if (this.$refs.buildTrace) {
- this.$refs.buildTrace.scrollTo(0, this.$refs.buildTrace.scrollHeight);
+ if (this.$refs.buildJobLog) {
+ this.$refs.buildJobLog.scrollTo(0, this.$refs.buildJobLog.scrollHeight);
}
},
scrollUp() {
- if (this.$refs.buildTrace) {
- this.$refs.buildTrace.scrollTo(0, 0);
+ if (this.$refs.buildJobLog) {
+ this.$refs.buildJobLog.scrollTo(0, 0);
}
},
scrollBuildLog: throttle(function buildLogScrollDebounce() {
- const { scrollTop } = this.$refs.buildTrace;
- const { offsetHeight, scrollHeight } = this.$refs.buildTrace;
+ const { scrollTop } = this.$refs.buildJobLog;
+ const { offsetHeight, scrollHeight } = this.$refs.buildJobLog;
if (scrollTop + offsetHeight === scrollHeight) {
this.scrollPos = scrollPositions.bottom;
@@ -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 mr-3" @scroll="scrollBuildLog">
+ <pre ref="buildJobLog" class="build-log mb-0 h-100 mr-3" @scroll="scrollBuildLog">
<code
v-show="!detailJob.isLoading"
class="bash"
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
index 838c363a6a3..96f9a85c23f 100644
--- a/app/assets/javascripts/ide/components/preview/navigator.vue
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -117,7 +117,7 @@ export default {
class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
@click="refresh"
>
- <gl-icon :size="18" name="retry" use-deprecated-sizes class="m-auto" />
+ <gl-icon :size="16" name="retry" class="m-auto" />
</button>
<div class="position-relative w-100 gl-ml-2">
<input
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index f5e367e16f5..05e3601f381 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -1,14 +1,13 @@
-import { sprintf, n__, __ } from '../../../../locale';
+import { __ } from '../../../../locale';
import { COMMIT_TO_NEW_BRANCH } from './constants';
const BRANCH_SUFFIX_COUNT = 5;
const createTranslatedTextForFiles = (files, text) => {
if (!files.length) return null;
- return sprintf(n__('%{text} %{files}', '%{text} %{files} files', files.length), {
- files: files.reduce((acc, val) => acc.concat(val.path), []).join(', '),
- text,
- });
+ const filesPart = files.reduce((acc, val) => acc.concat(val.path), []).join(', ');
+
+ return `${text} ${filesPart}`;
};
export const discardDraftButtonDisabled = (state) =>
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
index 60561292c9d..9cf8d5a360e 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
@@ -139,6 +139,7 @@ export const receiveJobLogsSuccess = ({ commit }, data) =>
export const fetchJobLogs = ({ dispatch, state }) => {
dispatch('requestJobLogs');
+ // update trace endpoint once BE compeletes trace re-naming in #340626
return axios
.get(`${state.detailJob.path}/trace`, { params: { format: 'json' } })
.then(({ data }) => dispatch('receiveJobLogsSuccess', data))
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 0cef3b98e61..ec661fdb0d6 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -117,7 +117,7 @@ export const createCommitPayload = ({
action: commitActionForFile(f),
file_path: f.path,
previous_path: f.prevPath || undefined,
- content: f.prevPath && !f.changed ? null : content || undefined,
+ content: content || undefined,
encoding: isBlob ? 'base64' : 'text',
last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
};
diff --git a/app/assets/javascripts/import_entities/components/pagination_bar.vue b/app/assets/javascripts/import_entities/components/pagination_bar.vue
new file mode 100644
index 00000000000..33bd3e08bb1
--- /dev/null
+++ b/app/assets/javascripts/import_entities/components/pagination_bar.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
+
+const DEFAULT_PAGE_SIZES = [20, 50, 100];
+
+export default {
+ components: {
+ PaginationLinks,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSprintf,
+ },
+ props: {
+ pageInfo: {
+ required: true,
+ type: Object,
+ },
+ pageSizes: {
+ required: false,
+ type: Array,
+ default: () => DEFAULT_PAGE_SIZES,
+ },
+ itemsCount: {
+ required: true,
+ type: Number,
+ },
+ },
+
+ computed: {
+ humanizedTotal() {
+ return this.pageInfo.total >= 1000 ? __('1000+') : this.pageInfo.total;
+ },
+
+ paginationInfo() {
+ const { page, perPage } = this.pageInfo;
+ const start = (page - 1) * perPage + 1;
+ const end = start + this.itemsCount - 1;
+
+ return { start, end };
+ },
+ },
+
+ methods: {
+ setPage(page) {
+ this.$emit('set-page', page);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" />
+ <gl-dropdown category="tertiary" class="gl-ml-auto">
+ <template #button-content>
+ <span class="gl-font-weight-bold">
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ pageInfo.perPage }}
+ </template>
+ </gl-sprintf>
+ </span>
+ <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" />
+ </template>
+ <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="$emit('set-page-size', size)">
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ size }}
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-ml-2" data-testid="information">
+ <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')">
+ <template #start>
+ {{ paginationInfo.start }}
+ </template>
+ <template #end>
+ {{ paginationInfo.end }}
+ </template>
+ <template #total>
+ {{ humanizedTotal }}
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/constants.js b/app/assets/javascripts/integrations/constants.js
index b74ae209eb7..8a8d38b295c 100644
--- a/app/assets/javascripts/integrations/edit/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -1,5 +1,11 @@
import { s__ } from '~/locale';
+export const TEST_INTEGRATION_EVENT = 'testIntegration';
+export const SAVE_INTEGRATION_EVENT = 'saveIntegration';
+export const GET_JIRA_ISSUE_TYPES_EVENT = 'getJiraIssueTypes';
+export const TOGGLE_INTEGRATION_EVENT = 'toggleIntegration';
+export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm';
+
export const integrationLevels = {
GROUP: 'group',
INSTANCE: 'instance',
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index f7d7f4aa010..9804a9e15f6 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -1,6 +1,7 @@
<script>
import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters } from 'vuex';
+import { TOGGLE_INTEGRATION_EVENT } from '~/integrations/constants';
import eventHub from '../event_hub';
export default {
@@ -26,7 +27,7 @@ export default {
},
methods: {
onChange(e) {
- eventHub.$emit('toggle', e);
+ eventHub.$emit(TOGGLE_INTEGRATION_EVENT, e);
},
},
};
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index 1fd4083b920..f30298676df 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui';
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { mapGetters } from 'vuex';
+import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import eventHub from '../event_hub';
export default {
@@ -121,10 +122,10 @@ export default {
if (this.isNonEmptyPassword) {
this.model = null;
}
- eventHub.$on('validateForm', this.validateForm);
+ eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
beforeDestroy() {
- eventHub.$off('validateForm', this.validateForm);
+ eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
methods: {
validateForm() {
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 63f007170d0..ba1aeb28616 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -2,7 +2,11 @@
import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { integrationLevels } from '../constants';
+import {
+ TEST_INTEGRATION_EVENT,
+ SAVE_INTEGRATION_EVENT,
+ integrationLevels,
+} from '~/integrations/constants';
import eventHub from '../event_hub';
import ActiveCheckbox from './active_checkbox.vue';
@@ -75,11 +79,11 @@ export default {
]),
onSaveClick() {
this.setIsSaving(true);
- eventHub.$emit('saveIntegration');
+ eventHub.$emit(SAVE_INTEGRATION_EVENT);
},
onTestClick() {
this.setIsTesting(true);
- eventHub.$emit('testIntegration');
+ eventHub.$emit(TEST_INTEGRATION_EVENT);
},
onResetClick() {
this.fetchResetIntegration();
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
index 1242493fb57..0521e1eeea5 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,6 +1,10 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import { mapGetters } from 'vuex';
+import {
+ VALIDATE_INTEGRATION_FORM_EVENT,
+ GET_JIRA_ISSUE_TYPES_EVENT,
+} from '~/integrations/constants';
import eventHub from '../event_hub';
import JiraUpgradeCta from './jira_upgrade_cta.vue';
@@ -77,17 +81,17 @@ export default {
},
},
created() {
- eventHub.$on('validateForm', this.validateForm);
+ eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
beforeDestroy() {
- eventHub.$off('validateForm', this.validateForm);
+ eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
methods: {
validateForm() {
this.validated = true;
},
getJiraIssueTypes() {
- eventHub.$emit('getJiraIssueTypes');
+ eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
},
},
};
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 1cc5a185f03..249a3e105b1 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -118,10 +119,10 @@ export default {
},
},
created() {
- eventHub.$on('validateForm', this.validateForm);
+ eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
beforeDestroy() {
- eventHub.$off('validateForm', this.validateForm);
+ eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
methods: {
validateForm() {
diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
index 7b3a067b186..63650400bb7 100644
--- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
+++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
@@ -2,7 +2,7 @@
import { GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
import { mapState } from 'vuex';
import { s__ } from '~/locale';
-import { defaultIntegrationLevel, overrideDropdownDescriptions } from '../constants';
+import { defaultIntegrationLevel, overrideDropdownDescriptions } from '~/integrations/constants';
const dropdownOptions = [
{
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index 801cf3ed27e..f33364d5545 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -1,20 +1,26 @@
-import $ from 'jquery';
import { delay } from 'lodash';
import { __, s__ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import axios from '../lib/utils/axios_utils';
import initForm from './edit';
import eventHub from './edit/event_hub';
+import {
+ TEST_INTEGRATION_EVENT,
+ SAVE_INTEGRATION_EVENT,
+ GET_JIRA_ISSUE_TYPES_EVENT,
+ TOGGLE_INTEGRATION_EVENT,
+ VALIDATE_INTEGRATION_FORM_EVENT,
+} from './constants';
export default class IntegrationSettingsForm {
constructor(formSelector) {
- this.$form = $(formSelector);
+ this.$form = document.querySelector(formSelector);
this.formActive = false;
this.vue = null;
// Form Metadata
- this.testEndPoint = this.$form.data('testUrl');
+ this.testEndPoint = this.$form.dataset.testUrl;
}
init() {
@@ -23,22 +29,19 @@ export default class IntegrationSettingsForm {
document.querySelector('.js-vue-integration-settings'),
document.querySelector('.js-vue-default-integration-settings'),
);
- eventHub.$on('toggle', (active) => {
+ eventHub.$on(TOGGLE_INTEGRATION_EVENT, (active) => {
this.formActive = active;
this.toggleServiceState();
});
- eventHub.$on('testIntegration', () => {
+ eventHub.$on(TEST_INTEGRATION_EVENT, () => {
this.testIntegration();
});
- eventHub.$on('saveIntegration', () => {
+ eventHub.$on(SAVE_INTEGRATION_EVENT, () => {
this.saveIntegration();
});
- eventHub.$on('getJiraIssueTypes', () => {
- // eslint-disable-next-line no-jquery/no-serialize
- this.getJiraIssueTypes(this.$form.serialize());
+ eventHub.$on(GET_JIRA_ISSUE_TYPES_EVENT, () => {
+ this.getJiraIssueTypes(new FormData(this.$form));
});
-
- eventHub.$emit('formInitialized');
}
saveIntegration() {
@@ -47,14 +50,14 @@ export default class IntegrationSettingsForm {
// 2) If this service can be saved
// If both conditions are true, we override form submission
// and save the service using provided configuration.
- const formValid = this.$form.get(0).checkValidity() || this.formActive === false;
+ const formValid = this.$form.checkValidity() || this.formActive === false;
if (formValid) {
delay(() => {
- this.$form.trigger('submit');
+ this.$form.submit();
}, 100);
} else {
- eventHub.$emit('validateForm');
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
this.vue.$store.dispatch('setIsSaving', false);
}
}
@@ -65,11 +68,10 @@ export default class IntegrationSettingsForm {
// 2) If this service can be tested
// If both conditions are true, we override form submission
// and test the service using provided configuration.
- if (this.$form.get(0).checkValidity()) {
- // eslint-disable-next-line no-jquery/no-serialize
- this.testSettings(this.$form.serialize());
+ if (this.$form.checkValidity()) {
+ this.testSettings(new FormData(this.$form));
} else {
- eventHub.$emit('validateForm');
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
this.vue.$store.dispatch('setIsTesting', false);
}
}
@@ -79,9 +81,9 @@ export default class IntegrationSettingsForm {
*/
toggleServiceState() {
if (this.formActive) {
- this.$form.removeAttr('novalidate');
- } else if (!this.$form.attr('novalidate')) {
- this.$form.attr('novalidate', 'novalidate');
+ this.$form.removeAttribute('novalidate');
+ } else if (!this.$form.getAttribute('novalidate')) {
+ this.$form.setAttribute('novalidate', 'novalidate');
}
}
@@ -109,7 +111,7 @@ export default class IntegrationSettingsForm {
},
}) => {
if (error || !issuetypes?.length) {
- eventHub.$emit('validateForm');
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
throw new Error(message);
}
diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
index 707ac946b98..85018f133cb 100644
--- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
+++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
@@ -1,8 +1,8 @@
<script>
-import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
+import { GlLink, GlLoadingIcon, GlPagination, GlTable, GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { DEFAULT_PER_PAGE } from '~/api';
-import createFlash from '~/flash';
import { fetchOverrides } from '~/integrations/overrides/api';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -16,6 +16,7 @@ export default {
GlLoadingIcon,
GlPagination,
GlTable,
+ GlAlert,
ProjectAvatar,
},
props: {
@@ -36,6 +37,7 @@ export default {
overrides: [],
page: 1,
totalItems: 0,
+ errorMessage: null,
};
},
computed: {
@@ -49,6 +51,7 @@ export default {
methods: {
loadOverrides(page = this.page) {
this.isLoading = true;
+ this.errorMessage = null;
fetchOverrides(this.overridesPath, {
page,
@@ -61,11 +64,9 @@ export default {
this.overrides = data;
})
.catch((error) => {
- createFlash({
- message: this.$options.i18n.defaultErrorMessage,
- error,
- captureError: true,
- });
+ this.errorMessage = this.$options.i18n.defaultErrorMessage;
+
+ Sentry.captureException(error);
})
.finally(() => {
this.isLoading = false;
@@ -85,7 +86,11 @@ export default {
<template>
<div>
+ <gl-alert v-if="errorMessage" variant="danger" :dismissible="false">
+ {{ errorMessage }}
+ </gl-alert>
<gl-table
+ v-else
:items="overrides"
:fields="$options.fields"
:busy="isLoading"
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index ab42e8cdfeb..cd0b413265b 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -11,9 +11,10 @@ import {
GlFormInput,
GlFormCheckboxGroup,
} from '@gitlab/ui';
-import { partition, isString } from 'lodash';
+import { partition, isString, unescape } from 'lodash';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { sanitize } from '~/lib/dompurify';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
import {
@@ -293,7 +294,7 @@ export default {
};
},
conditionallyShowToastSuccess(response) {
- const message = responseMessageFromSuccess(response);
+ const message = this.unescapeMsg(responseMessageFromSuccess(response));
if (message === '') {
this.showToastMessageSuccess();
@@ -309,13 +310,17 @@ export default {
this.closeModal();
},
showInvalidFeedbackMessage(response) {
+ const message = this.unescapeMsg(responseMessageFromError(response));
+
this.isLoading = false;
- this.invalidFeedbackMessage =
- responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault;
+ this.invalidFeedbackMessage = message || this.$options.labels.invalidFeedbackMessageDefault;
},
handleMembersTokenSelectClear() {
this.invalidFeedbackMessage = '';
},
+ unescapeMsg(message) {
+ return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
+ },
},
labels: {
members: {
diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js
index b7bc9ea5652..52ec3be3205 100644
--- a/app/assets/javascripts/invite_members/utils/response_message_parser.js
+++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js
@@ -18,7 +18,10 @@ function responseMessageStringForMultiple(message) {
return message.includes(':');
}
function responseMessageStringFirstPart(message) {
- return message.split(' and ')[0];
+ const firstPart = message.split(':')[1];
+ const firstMsg = firstPart.split(/ and [\w-]*$/)[0].trim();
+
+ return firstMsg;
}
export function responseMessageFromError(response) {
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index 1c88f8dfdca..b0af3612e05 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -1,9 +1,14 @@
<script>
import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
import { ISSUABLE_TYPE } from '../constants';
export default {
- name: 'CsvExportModal',
+ i18n: {
+ exportText: __(
+ 'The CSV export will be created in the background. Once finished, it will be sent to %{email} in an attachment.',
+ ),
+ },
components: {
GlButton,
GlModal,
@@ -32,53 +37,39 @@ export default {
required: true,
},
},
- data() {
- return {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
- };
+ computed: {
+ isIssue() {
+ return this.issuableType === ISSUABLE_TYPE.issues;
+ },
+ exportText() {
+ return this.isIssue ? __('Export issues') : __('Export merge requests');
+ },
+ issuableCountText() {
+ return this.isIssue
+ ? n__('1 issue selected', '%d issues selected', this.issuableCount)
+ : n__('1 merge request selected', '%d merge requests selected', this.issuableCount);
+ },
},
- issueableType: ISSUABLE_TYPE,
};
</script>
<template>
- <gl-modal :modal-id="modalId" body-class="gl-p-0!" data-qa-selector="export_issuable_modal">
- <template #modal-title>
- <gl-sprintf :message="__('Export %{name}')">
- <template #name>{{ issuableName }}</template>
- </gl-sprintf>
- </template>
+ <gl-modal
+ :modal-id="modalId"
+ body-class="gl-p-0!"
+ :title="exportText"
+ data-qa-selector="export_issuable_modal"
+ >
<div
- v-if="issuableCount > -1"
class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50"
>
<gl-icon name="check" class="gl-color-green-400" />
- <strong class="gl-m-3">
- <gl-sprintf
- v-if="issuableType === $options.issueableType.issues"
- :message="n__('1 issue selected', '%d issues selected', issuableCount)"
- >
- <template #issuableCount>{{ issuableCount }}</template>
- </gl-sprintf>
- <gl-sprintf
- v-else
- :message="n__('1 merge request selected', '%d merge requests selected', issuableCount)"
- >
- <template #issuableCount>{{ issuableCount }}</template>
- </gl-sprintf>
- </strong>
+ <strong class="gl-m-3">{{ issuableCountText }}</strong>
</div>
<div class="modal-text gl-px-4 gl-py-5">
- <gl-sprintf
- :message="
- __(
- `The CSV export will be created in the background. Once finished, it will be sent to %{strongStart}${email}%{strongEnd} in an attachment.`,
- )
- "
- >
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
+ <gl-sprintf :message="$options.i18n.exportText">
+ <template #email>
+ <strong>{{ email }}</strong>
</template>
</gl-sprintf>
</div>
@@ -92,9 +83,7 @@ export default {
data-track-action="click_button"
:data-track-label="`export_${issuableType}_csv`"
>
- <gl-sprintf :message="__('Export %{name}')">
- <template #name>{{ issuableName }}</template>
- </gl-sprintf>
+ {{ exportText }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
index 4fdd094072c..269f720bac9 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -15,6 +15,8 @@ import CsvImportModal from './csv_import_modal.vue';
export default {
i18n: {
exportAsCsvButtonText: __('Export as CSV'),
+ importCsvText: __('Import CSV'),
+ importFromJiraText: __('Import from Jira'),
importIssuesText: __('Import issues'),
},
name: 'CsvImportExportButtons',
@@ -101,13 +103,16 @@ export default {
:text-sr-only="!showLabel"
:icon="importButtonIcon"
>
- <gl-dropdown-item v-gl-modal="importModalId">{{ __('Import CSV') }}</gl-dropdown-item>
+ <gl-dropdown-item v-gl-modal="importModalId">
+ {{ $options.i18n.importCsvText }}
+ </gl-dropdown-item>
<gl-dropdown-item
v-if="canEdit"
:href="projectImportJiraPath"
data-qa-selector="import_from_jira_link"
- >{{ __('Import from Jira') }}</gl-dropdown-item
>
+ {{ $options.i18n.importFromJiraText }}
+ </gl-dropdown-item>
</gl-dropdown>
</gl-button-group>
<csv-export-modal
diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue
index c85efd60b8b..b72abe14ee1 100644
--- a/app/assets/javascripts/issuable/components/csv_import_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue
@@ -1,23 +1,28 @@
<script>
-import { GlModal, GlSprintf, GlFormGroup, GlButton } from '@gitlab/ui';
+import { GlModal, GlFormGroup } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
-import { ISSUABLE_TYPE } from '../constants';
+import { __, sprintf } from '~/locale';
export default {
- name: 'CsvImportModal',
+ i18n: {
+ maximumFileSizeText: __('The maximum file size allowed is %{size}.'),
+ importIssuesText: __('Import issues'),
+ uploadCsvFileText: __('Upload CSV file'),
+ mainText: __(
+ "Your issues will be imported in the background. Once finished, you'll get a confirmation email.",
+ ),
+ helpText: __(
+ 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.',
+ ),
+ },
+ actionPrimary: {
+ text: __('Import issues'),
+ },
components: {
GlModal,
- GlSprintf,
GlFormGroup,
- GlButton,
},
inject: {
- issuableType: {
- default: '',
- },
- exportCsvPath: {
- default: '',
- },
importCsvIssuesPath: {
default: '',
},
@@ -31,11 +36,10 @@ export default {
required: true,
},
},
- data() {
- return {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
- };
+ computed: {
+ maxFileSizeText() {
+ return sprintf(this.$options.i18n.maximumFileSizeText, { size: this.maxAttachmentSize });
+ },
},
methods: {
submitForm() {
@@ -47,34 +51,22 @@ export default {
</script>
<template>
- <gl-modal :modal-id="modalId" :title="__('Import issues')">
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.importIssuesText"
+ :action-primary="$options.actionPrimary"
+ @primary="submitForm"
+ >
<form ref="form" :action="importCsvIssuesPath" enctype="multipart/form-data" method="post">
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <p>
- {{
- __(
- "Your issues will be imported in the background. Once finished, you'll get a confirmation email.",
- )
- }}
- </p>
- <gl-form-group :label="__('Upload CSV file')" label-for="file">
+ <p>{{ $options.i18n.mainText }}</p>
+ <gl-form-group :label="$options.i18n.uploadCsvFileText" label-for="file">
<input id="file" type="file" name="file" accept=".csv,text/csv" />
</gl-form-group>
<p class="text-secondary">
- {{
- __(
- 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.',
- )
- }}
- <gl-sprintf :message="__('The maximum file size allowed is %{size}.')"
- ><template #size>{{ maxAttachmentSize }}</template></gl-sprintf
- >
+ {{ $options.i18n.helpText }}
+ {{ maxFileSizeText }}
</p>
</form>
- <template #modal-footer>
- <gl-button category="primary" variant="confirm" @click="submitForm">{{
- __('Import issues')
- }}</gl-button>
- </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 5dc49d3ae15..bafc26befda 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -50,20 +50,16 @@ export default class IssuableForm {
this.renderWipExplanation = this.renderWipExplanation.bind(this);
this.resetAutosave = this.resetAutosave.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
- /* eslint-disable @gitlab/require-i18n-strings */
// prettier-ignore
this.draftRegex = new RegExp(
'^\\s*(' + // Line start, then any amount of leading whitespace
- 'draft\\s-\\s' + // Draft_-_ where "_" are *exactly* one whitespace
- '|\\[draft\\]\\s*' + // [Draft] and any following whitespace
+ '\\[draft\\]\\s*' + // [Draft] and any following whitespace
'|draft:\\s*' + // Draft: and any following whitespace
- '|draft\\s+' + // Draft_ where "_" is at least one whitespace
'|\\(draft\\)\\s*' + // (Draft) and any following whitespace
')+' + // At least one repeated match of the preceding parenthetical
'\\s*', // Any amount of trailing whitespace
'i', // Match any case(s)
);
- /* eslint-enable @gitlab/require-i18n-strings */
this.gfmAutoComplete = new GfmAutoComplete(
gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources,
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index df9d5c86a4b..ab04c6a38a5 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
+import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
@@ -15,6 +15,7 @@ export default {
GlIcon,
GlLabel,
GlFormCheckbox,
+ GlSprintf,
IssuableAssignees,
},
directives: {
@@ -82,9 +83,7 @@ export default {
return this.issuable.assignees?.nodes || this.issuable.assignees || [];
},
createdAt() {
- return sprintf(__('created %{timeAgo}'), {
- timeAgo: getTimeago().format(this.issuable.createdAt),
- });
+ return getTimeago().format(this.issuable.createdAt);
},
updatedAt() {
return sprintf(__('updated %{timeAgo}'), {
@@ -164,132 +163,132 @@ export default {
<template>
<li
:id="`issuable_${issuableId}`"
- class="issue gl-px-5!"
+ class="issue gl-display-flex! gl-px-5!"
:class="{ closed: issuable.closedAt, today: createdInPastDay }"
:data-labels="labelIdsString"
>
- <div class="issuable-info-container">
- <div v-if="showCheckbox" class="issue-check">
- <gl-form-checkbox
- class="gl-mr-0"
- :checked="checked"
- :data-id="issuableId"
- @input="$emit('checked-input', $event)"
+ <gl-form-checkbox
+ v-if="showCheckbox"
+ class="issue-check gl-mr-0"
+ :checked="checked"
+ :data-id="issuableId"
+ @input="$emit('checked-input', $event)"
+ >
+ <span class="gl-sr-only">{{ issuable.title }}</span>
+ </gl-form-checkbox>
+ <div class="issuable-main-info">
+ <div data-testid="issuable-title" class="issue-title title">
+ <gl-icon
+ v-if="issuable.confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :title="__('Confidential')"
+ :aria-label="__('Confidential')"
+ />
+ <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps">
+ {{ issuable.title }}
+ <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
+ </gl-link>
+ <span
+ v-if="taskStatus"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ data-testid="task-status"
>
- <span class="gl-sr-only">{{ issuable.title }}</span>
- </gl-form-checkbox>
+ {{ taskStatus }}
+ </span>
</div>
- <div class="issuable-main-info">
- <div data-testid="issuable-title" class="issue-title title">
- <span class="issue-title-text" dir="auto">
- <gl-icon
- v-if="issuable.confidential"
- v-gl-tooltip
- name="eye-slash"
- :title="__('Confidential')"
- :aria-label="__('Confidential')"
- />
- <gl-link :href="webUrl" v-bind="issuableTitleProps">
- {{ issuable.title
- }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
- /></gl-link>
- </span>
- <span
- v-if="taskStatus"
- class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
- data-testid="task-status"
- >
- {{ taskStatus }}
- </span>
- </div>
- <div class="issuable-info">
- <slot v-if="hasSlotContents('reference')" name="reference"></slot>
- <span v-else data-testid="issuable-reference" class="issuable-reference">
- {{ reference }}
- </span>
- <span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
- <span aria-hidden="true">&middot;</span>
- <span
- v-gl-tooltip:tooltipcontainer.bottom
- data-testid="issuable-created-at"
- :title="tooltipTitle(issuable.createdAt)"
- >{{ createdAt }}</span
- >
- {{ __('by') }}
- <slot v-if="hasSlotContents('author')" name="author"></slot>
- <gl-link
- v-else
- :data-user-id="authorId"
- :data-username="author.username"
- :data-name="author.name"
- :data-avatar-url="author.avatarUrl"
- :href="author.webUrl"
- data-testid="issuable-author"
- class="author-link js-user-link"
- >
- <span class="author">{{ author.name }}</span>
- </gl-link>
+ <div class="issuable-info">
+ <slot v-if="hasSlotContents('reference')" name="reference"></slot>
+ <span v-else data-testid="issuable-reference" class="issuable-reference">
+ {{ reference }}
+ </span>
+ <span class="gl-display-none gl-sm-display-inline-block">
+ <span aria-hidden="true">&middot;</span>
+ <span class="issuable-authored gl-mr-3">
+ <gl-sprintf :message="__('created %{timeAgo} by %{author}')">
+ <template #timeAgo>
+ <span
+ v-gl-tooltip.bottom
+ :title="tooltipTitle(issuable.createdAt)"
+ data-testid="issuable-created-at"
+ >
+ {{ createdAt }}
+ </span>
+ </template>
+ <template #author>
+ <slot v-if="hasSlotContents('author')" name="author"></slot>
+ <gl-link
+ v-else
+ :data-user-id="authorId"
+ :data-username="author.username"
+ :data-name="author.name"
+ :data-avatar-url="author.avatarUrl"
+ :href="author.webUrl"
+ data-testid="issuable-author"
+ class="author-link js-user-link"
+ >
+ <span class="author">{{ author.name }}</span>
+ </gl-link>
+ </template>
+ </gl-sprintf>
</span>
<slot name="timeframe"></slot>
- &nbsp;
- <span v-if="labels.length" role="group" :aria-label="__('Labels')">
- <gl-label
- v-for="(label, index) in labels"
- :key="index"
- :background-color="label.color"
- :title="labelTitle(label)"
- :description="label.description"
- :scoped="scopedLabel(label)"
- :target="labelTarget(label)"
- :class="{ 'gl-ml-2': index }"
- size="sm"
- />
- </span>
- </div>
+ </span>
+ &nbsp;
+ <span v-if="labels.length" role="group" :aria-label="__('Labels')">
+ <gl-label
+ v-for="(label, index) in labels"
+ :key="index"
+ :background-color="label.color"
+ :title="labelTitle(label)"
+ :description="label.description"
+ :scoped="scopedLabel(label)"
+ :target="labelTarget(label)"
+ :class="{ 'gl-ml-2': index }"
+ size="sm"
+ />
+ </span>
</div>
- <div class="issuable-meta">
- <ul v-if="showIssuableMeta" class="controls">
- <li v-if="hasSlotContents('status')" class="issuable-status">
- <slot name="status"></slot>
- </li>
- <li v-if="assignees.length" class="gl-display-flex">
- <issuable-assignees
- :assignees="assignees"
- :icon-size="16"
- :max-visible="4"
- img-css-classes="gl-mr-2!"
- class="gl-align-items-center gl-display-flex gl-ml-3"
- />
- </li>
- <slot name="statistics"></slot>
- <li
- v-if="showDiscussions"
- data-testid="issuable-discussions"
- class="issuable-comments gl-display-none gl-sm-display-block"
- >
- <gl-link
- v-gl-tooltip:tooltipcontainer.top
- :title="__('Comments')"
- :href="issuableNotesLink"
- :class="{ 'no-comments': !notesCount }"
- class="gl-reset-color!"
- >
- <gl-icon name="comments" />
- {{ notesCount }}
- </gl-link>
- </li>
- </ul>
- <div
- data-testid="issuable-updated-at"
- class="float-right issuable-updated-at gl-display-none gl-sm-display-inline-block"
+ </div>
+ <div class="issuable-meta">
+ <ul v-if="showIssuableMeta" class="controls">
+ <li v-if="hasSlotContents('status')" class="issuable-status">
+ <slot name="status"></slot>
+ </li>
+ <li v-if="assignees.length">
+ <issuable-assignees
+ :assignees="assignees"
+ :icon-size="16"
+ :max-visible="4"
+ img-css-classes="gl-mr-2!"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ />
+ </li>
+ <slot name="statistics"></slot>
+ <li
+ v-if="showDiscussions"
+ data-testid="issuable-discussions"
+ class="issuable-comments gl-display-none gl-sm-display-block"
>
- <span
- v-gl-tooltip:tooltipcontainer.bottom
- :title="tooltipTitle(issuable.updatedAt)"
- class="issuable-updated-at"
- >{{ updatedAt }}</span
+ <gl-link
+ v-gl-tooltip.top
+ :title="__('Comments')"
+ :href="issuableNotesLink"
+ :class="{ 'no-comments': !notesCount }"
+ class="gl-reset-color!"
>
- </div>
+ <gl-icon name="comments" />
+ {{ notesCount }}
+ </gl-link>
+ </li>
+ </ul>
+ <div
+ v-gl-tooltip.bottom
+ class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block"
+ :title="tooltipTitle(issuable.updatedAt)"
+ data-testid="issuable-updated-at"
+ >
+ {{ updatedAt }}
</div>
</div>
</li>
diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
index 87066a0a0b6..c1082987146 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -284,72 +284,70 @@ export default {
<slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot>
</template>
</issuable-bulk-edit-sidebar>
- <div class="issuables-holder">
- <ul v-if="issuablesLoading" class="content-list">
- <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
- <gl-skeleton-loading />
- </li>
- </ul>
- <template v-else>
- <component
- :is="issuablesWrapper"
- v-if="issuables.length > 0"
- class="content-list issuable-list issues-list"
- :class="{ 'manual-ordering': isManualOrdering }"
- v-bind="$options.vueDraggableAttributes"
- @update="handleVueDraggableUpdate"
+ <ul v-if="issuablesLoading" class="content-list">
+ <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
+ <gl-skeleton-loading />
+ </li>
+ </ul>
+ <template v-else>
+ <component
+ :is="issuablesWrapper"
+ v-if="issuables.length > 0"
+ class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ v-bind="$options.vueDraggableAttributes"
+ @update="handleVueDraggableUpdate"
+ >
+ <issuable-item
+ v-for="issuable in issuables"
+ :key="issuableId(issuable)"
+ :class="{ 'gl-cursor-grab': isManualOrdering }"
+ :issuable-symbol="issuableSymbol"
+ :issuable="issuable"
+ :enable-label-permalinks="enableLabelPermalinks"
+ :label-filter-param="labelFilterParam"
+ :show-checkbox="showBulkEditSidebar"
+ :checked="issuableChecked(issuable)"
+ @checked-input="handleIssuableCheckedInput(issuable, $event)"
>
- <issuable-item
- v-for="issuable in issuables"
- :key="issuableId(issuable)"
- :class="{ 'gl-cursor-grab': isManualOrdering }"
- :issuable-symbol="issuableSymbol"
- :issuable="issuable"
- :enable-label-permalinks="enableLabelPermalinks"
- :label-filter-param="labelFilterParam"
- :show-checkbox="showBulkEditSidebar"
- :checked="issuableChecked(issuable)"
- @checked-input="handleIssuableCheckedInput(issuable, $event)"
- >
- <template #reference>
- <slot name="reference" :issuable="issuable"></slot>
- </template>
- <template #author>
- <slot name="author" :author="issuable.author"></slot>
- </template>
- <template #timeframe>
- <slot name="timeframe" :issuable="issuable"></slot>
- </template>
- <template #status>
- <slot name="status" :issuable="issuable"></slot>
- </template>
- <template #statistics>
- <slot name="statistics" :issuable="issuable"></slot>
- </template>
- </issuable-item>
- </component>
- <slot v-else name="empty-state"></slot>
- </template>
+ <template #reference>
+ <slot name="reference" :issuable="issuable"></slot>
+ </template>
+ <template #author>
+ <slot name="author" :author="issuable.author"></slot>
+ </template>
+ <template #timeframe>
+ <slot name="timeframe" :issuable="issuable"></slot>
+ </template>
+ <template #status>
+ <slot name="status" :issuable="issuable"></slot>
+ </template>
+ <template #statistics>
+ <slot name="statistics" :issuable="issuable"></slot>
+ </template>
+ </issuable-item>
+ </component>
+ <slot v-else name="empty-state"></slot>
+ </template>
- <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
- <gl-keyset-pagination
- :has-next-page="hasNextPage"
- :has-previous-page="hasPreviousPage"
- @next="$emit('next-page')"
- @prev="$emit('previous-page')"
- />
- </div>
- <gl-pagination
- v-else-if="showPaginationControls"
- :per-page="defaultPageSize"
- :total-items="totalItems"
- :value="currentPage"
- :prev-page="previousPage"
- :next-page="nextPage"
- align="center"
- class="gl-pagination gl-mt-3"
- @input="$emit('page-change', $event)"
+ <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
+ <gl-keyset-pagination
+ :has-next-page="hasNextPage"
+ :has-previous-page="hasPreviousPage"
+ @next="$emit('next-page')"
+ @prev="$emit('previous-page')"
/>
</div>
+ <gl-pagination
+ v-else-if="showPaginationControls"
+ :per-page="defaultPageSize"
+ :total-items="totalItems"
+ :value="currentPage"
+ :prev-page="previousPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="$emit('page-change', $event)"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue
index d0642b64e7e..48a5e220abf 100644
--- a/app/assets/javascripts/issuable_suggestions/components/app.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/app.vue
@@ -74,7 +74,7 @@ export default {
:title="$options.helpText"
:aria-label="$options.helpText"
name="question-o"
- class="text-secondary suggestion-help-hover"
+ class="text-secondary gl-cursor-help"
/>
</div>
<div class="col-sm-10">
diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue
index dea7608685a..a01f4f747b9 100644
--- a/app/assets/javascripts/issuable_suggestions/components/item.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/item.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
@@ -26,12 +25,6 @@ export default {
},
},
computed: {
- isOpen() {
- return this.suggestion.state === 'opened';
- },
- isClosed() {
- return this.suggestion.state === 'closed';
- },
counts() {
return [
{
@@ -48,7 +41,13 @@ export default {
},
].filter(({ count }) => count);
},
- stateIcon() {
+ isClosed() {
+ return this.suggestion.state === 'closed';
+ },
+ stateIconClass() {
+ return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500';
+ },
+ stateIconName() {
return this.isClosed ? 'issue-close' : 'issue-open-m';
},
stateTitle() {
@@ -72,7 +71,7 @@ export default {
v-gl-tooltip.bottom
:title="__('Confidential')"
name="eye-slash"
- class="suggestion-help-hover mr-1 suggestion-confidential"
+ class="gl-cursor-help gl-mr-2 gl-text-orange-500"
/>
<gl-link
:href="suggestion.webUrl"
@@ -83,15 +82,7 @@ export default {
</gl-link>
</div>
<div class="text-secondary suggestion-footer">
- <gl-icon
- ref="state"
- :name="stateIcon"
- :class="{
- 'suggestion-state-open': isOpen,
- 'suggestion-state-closed': isClosed,
- }"
- class="suggestion-help-hover"
- />
+ <gl-icon ref="state" :name="stateIconName" :class="stateIconClass" class="gl-cursor-help" />
<gl-tooltip :target="() => $refs.state" placement="bottom">
<span class="d-block">
<span class="bold"> {{ stateTitle }} </span> {{ timeFormatted(closedOrCreatedDate) }}
@@ -102,9 +93,9 @@ export default {
<timeago-tooltip
:time="suggestion.createdAt"
tooltip-placement="bottom"
- class="suggestion-help-hover"
+ class="gl-cursor-help"
/>
- by
+ {{ __('by') }}
<gl-link :href="suggestion.author.webUrl">
<user-avatar-image
:img-src="suggestion.author.avatarUrl"
@@ -122,7 +113,7 @@ export default {
<timeago-tooltip
:time="suggestion.updatedAt"
tooltip-placement="bottom"
- class="suggestion-help-hover"
+ class="gl-cursor-help"
/>
</template>
<span class="suggestion-counts">
@@ -131,7 +122,7 @@ export default {
:key="id"
v-gl-tooltip.bottom
:title="tooltipTitle"
- class="suggestion-help-hover gl-ml-3 text-tertiary"
+ class="gl-cursor-help gl-ml-3 text-tertiary"
>
<gl-icon :name="icon" /> {{ count }}
</span>
diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js
index 8f7f317d6b4..22a99a17741 100644
--- a/app/assets/javascripts/issuable_suggestions/index.js
+++ b/app/assets/javascripts/issuable_suggestions/index.js
@@ -10,7 +10,12 @@ export default function initIssuableSuggestions() {
const issueTitle = document.getElementById('issue_title');
const { projectPath } = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
});
return new Vue({
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
index f3c2a31bd5b..4b99888ae73 100644
--- a/app/assets/javascripts/issue_show/components/locked_warning.vue
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -1,30 +1,33 @@
<script>
-import { __, sprintf } from '~/locale';
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const alertMessage = __(
+ 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.',
+);
export default {
+ alertMessage,
+ components: {
+ GlSprintf,
+ GlLink,
+ },
computed: {
currentPath() {
return window.location.pathname;
},
- alertMessage() {
- return sprintf(
- __(
- 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.',
- ),
- {
- linkStart: `<a href="${this.currentPath}" target="_blank" rel="nofollow">`,
- linkEnd: `</a>`,
- },
- false,
- );
- },
},
};
</script>
<template>
- <div
- class="alert alert-danger"
- v-html="alertMessage /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <div class="alert alert-danger">
+ <gl-sprintf :message="$options.alertMessage">
+ <template #link="{ content }">
+ <gl-link :href="currentPath" target="_blank" rel="nofollow">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
</template>
diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
index a687a58a6ad..4a2f7861492 100644
--- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
@@ -85,7 +85,7 @@ export default {
<span>
<span
v-if="issue.milestone"
- class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ class="issuable-milestone gl-mr-3"
data-testid="issuable-milestone"
>
<gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
@@ -96,7 +96,7 @@ export default {
<span
v-if="issue.dueDate"
v-gl-tooltip
- class="issuable-due-date gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ class="issuable-due-date gl-mr-3"
:class="{ 'gl-text-red-500': showDueDateInRed }"
:title="__('Due date')"
data-testid="issuable-due-date"
@@ -107,21 +107,14 @@ export default {
<span
v-if="timeEstimate"
v-gl-tooltip
- class="gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ class="gl-mr-3"
:title="__('Estimate')"
data-testid="time-estimate"
>
<gl-icon name="timer" />
{{ timeEstimate }}
</span>
- <weight-count
- class="issuable-weight gl-display-none gl-sm-display-inline-block gl-mr-3"
- :weight="issue.weight"
- />
- <issue-health-status
- v-if="showHealthStatus"
- class="gl-display-none gl-sm-display-inline-block"
- :health-status="healthStatus"
- />
+ <weight-count class="issuable-weight gl-mr-3" :weight="issue.weight" />
+ <issue-health-status v-if="showHealthStatus" :health-status="healthStatus" />
</span>
</template>
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index 8e37339fca6..7b51f6ee46a 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -68,13 +68,6 @@ import {
TOKEN_TITLE_TYPE,
TOKEN_TITLE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
-import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
-import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
-import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
-import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
-import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
-import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
-import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
import eventHub from '../eventhub';
import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
import searchIterationsQuery from '../queries/search_iterations.query.graphql';
@@ -82,6 +75,21 @@ import searchLabelsQuery from '../queries/search_labels.query.graphql';
import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
import searchUsersQuery from '../queries/search_users.query.graphql';
import IssueCardTimeInfo from './issue_card_time_info.vue';
+import NewIssueDropdown from './new_issue_dropdown.vue';
+
+const AuthorToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue');
+const EmojiToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
+const EpicToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue');
+const IterationToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue');
+const LabelToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue');
+const MilestoneToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
+const WeightToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue');
export default {
i18n,
@@ -96,6 +104,7 @@ export default {
IssuableByEmail,
IssuableList,
IssueCardTimeInfo,
+ NewIssueDropdown,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
},
directives: {
@@ -120,12 +129,15 @@ export default {
fullPath: {
default: '',
},
- groupEpicsPath: {
+ groupPath: {
default: '',
},
hasAnyIssues: {
default: false,
},
+ hasAnyProjects: {
+ default: false,
+ },
hasBlockedIssuesFeature: {
default: false,
},
@@ -253,6 +265,9 @@ export default {
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
+ showNewIssueDropdown() {
+ return !this.isProject && this.hasAnyProjects;
+ },
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
@@ -363,16 +378,18 @@ export default {
});
}
- if (this.groupEpicsPath) {
+ if (this.groupPath) {
tokens.push({
type: TOKEN_TYPE_EPIC,
title: TOKEN_TITLE_EPIC,
icon: 'epic',
token: EpicToken,
unique: true,
+ symbol: '&',
idProperty: 'id',
useIdValue: true,
- fetchEpics: this.fetchEpics,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`,
+ fullPath: this.groupPath,
});
}
@@ -442,16 +459,6 @@ export default {
fetchEmojis(search) {
return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
},
- async fetchEpics({ search }) {
- const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics');
- if (!search) {
- return epics.slice(0, MAX_LIST_SIZE);
- }
- const number = Number(search);
- return Number.isNaN(number)
- ? fuzzaldrinPlus.filter(epics, search, { key: 'title' })
- : epics.filter((epic) => epic.id === number);
- },
fetchLabels(search) {
return this.$apollo
.query({
@@ -662,6 +669,7 @@ export default {
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
+ <new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
<template #timeframe="{ issuable = {} }">
@@ -765,6 +773,7 @@ export default {
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
+ <new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
</gl-empty-state>
<hr />
diff --git a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue
new file mode 100644
index 00000000000..037fd9be542
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue
@@ -0,0 +1,124 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql';
+import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
+import { __, sprintf } from '~/locale';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+
+export default {
+ i18n: {
+ defaultDropdownText: __('Select project to create issue'),
+ noMatchesFound: __('No matches found'),
+ toggleButtonLabel: __('Toggle project select'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ inject: ['fullPath'],
+ data() {
+ return {
+ projects: [],
+ search: '',
+ selectedProject: {},
+ shouldSkipQuery: true,
+ };
+ },
+ apollo: {
+ projects: {
+ query: searchProjectsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.search,
+ };
+ },
+ update: ({ group }) => group.projects.nodes ?? [],
+ error(error) {
+ createFlash({
+ message: __('An error occurred while loading projects.'),
+ captureError: true,
+ error,
+ });
+ },
+ skip() {
+ return this.shouldSkipQuery;
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ computed: {
+ dropdownHref() {
+ return this.hasSelectedProject
+ ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new')
+ : undefined;
+ },
+ dropdownText() {
+ return this.hasSelectedProject
+ ? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name })
+ : this.$options.i18n.defaultDropdownText;
+ },
+ hasSelectedProject() {
+ return this.selectedProject.id;
+ },
+ showNoSearchResultsText() {
+ return !this.projects.length && this.search;
+ },
+ },
+ methods: {
+ handleDropdownClick() {
+ if (!this.dropdownHref) {
+ this.$refs.dropdown.show();
+ }
+ },
+ handleDropdownShown() {
+ if (this.shouldSkipQuery) {
+ this.shouldSkipQuery = false;
+ }
+ this.$refs.search.focusInput();
+ },
+ selectProject(project) {
+ this.selectedProject = project;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ right
+ split
+ :split-href="dropdownHref"
+ :text="dropdownText"
+ :toggle-text="$options.i18n.toggleButtonLabel"
+ variant="confirm"
+ @click="handleDropdownClick"
+ @shown="handleDropdownShown"
+ >
+ <gl-search-box-by-type ref="search" v-model.trim="search" />
+ <gl-loading-icon v-if="$apollo.queries.projects.loading" />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="project of projects"
+ :key="project.id"
+ @click="selectProject(project)"
+ >
+ {{ project.nameWithNamespace }}
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="showNoSearchResultsText">
+ {{ $options.i18n.noMatchesFound }}
+ </gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index e89e3e8e681..47af20f5271 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -119,8 +119,9 @@ export function mountIssuesListApp() {
emptyStateSvgPath,
exportCsvPath,
fullPath,
- groupEpicsPath,
+ groupPath,
hasAnyIssues,
+ hasAnyProjects,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
@@ -151,8 +152,9 @@ export function mountIssuesListApp() {
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
fullPath,
- groupEpicsPath,
+ groupPath,
hasAnyIssues: parseBoolean(hasAnyIssues),
+ hasAnyProjects: parseBoolean(hasAnyProjects),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
new file mode 100644
index 00000000000..df1f330139a
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
@@ -0,0 +1,12 @@
+query searchProjects($fullPath: ID!, $search: String) {
+ group(fullPath: $fullPath) {
+ projects(search: $search, includeSubgroups: true) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ webUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 059772e8cb9..fe4158a1bd1 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -80,13 +80,13 @@ export default {
'isLoading',
'job',
'isSidebarOpen',
- 'trace',
- 'isTraceComplete',
- 'traceSize',
- 'isTraceSizeVisible',
+ 'jobLog',
+ 'isJobLogComplete',
+ 'jobLogSize',
+ 'isJobLogSizeVisible',
'isScrollBottomDisabled',
'isScrollTopDisabled',
- 'isScrolledToBottomBeforeReceivingTrace',
+ 'isScrolledToBottomBeforeReceivingJobLog',
'hasError',
'selectedStage',
]),
@@ -97,7 +97,7 @@ export default {
'shouldRenderTriggeredLabel',
'hasEnvironment',
'shouldRenderSharedRunnerLimitWarning',
- 'hasTrace',
+ 'hasJobLog',
'emptyStateIllustration',
'isScrollingDown',
'emptyStateAction',
@@ -155,7 +155,7 @@ export default {
this.updateSidebar();
},
beforeDestroy() {
- this.stopPollingTrace();
+ this.stopPollingJobLog();
this.stopPolling();
window.removeEventListener('resize', this.onResize);
window.removeEventListener('scroll', this.updateScroll);
@@ -168,7 +168,7 @@ export default {
'toggleSidebar',
'scrollBottom',
'scrollTop',
- 'stopPollingTrace',
+ 'stopPollingJobLog',
'stopPolling',
'toggleScrollButtons',
'toggleScrollAnimation',
@@ -270,7 +270,7 @@ export default {
<div
v-if="job.archived"
class="gl-mt-3 gl-py-2 gl-px-3 gl-align-items-center gl-z-index-1 gl-m-auto archived-job"
- :class="{ 'sticky-top gl-border-bottom-0': hasTrace }"
+ :class="{ 'sticky-top gl-border-bottom-0': hasJobLog }"
data-testid="archived-job"
>
<gl-icon name="lock" class="gl-vertical-align-bottom" />
@@ -278,8 +278,8 @@ export default {
</div>
<!-- job log -->
<div
- v-if="hasTrace"
- class="build-trace-container gl-relative"
+ v-if="hasJobLog"
+ class="build-log-container gl-relative"
:class="{ 'gl-mt-3': !job.archived }"
>
<log-top-bar
@@ -289,22 +289,22 @@ export default {
'has-archived-block': job.archived,
}"
:erase-path="job.erase_path"
- :size="traceSize"
+ :size="jobLogSize"
:raw-path="job.raw_path"
:is-scroll-bottom-disabled="isScrollBottomDisabled"
:is-scroll-top-disabled="isScrollTopDisabled"
- :is-trace-size-visible="isTraceSizeVisible"
+ :is-job-log-size-visible="isJobLogSizeVisible"
:is-scrolling-down="isScrollingDown"
@scrollJobLogTop="scrollTop"
@scrollJobLogBottom="scrollBottom"
/>
- <log :trace="trace" :is-complete="isTraceComplete" />
+ <log :job-log="jobLog" :is-complete="isJobLogComplete" />
</div>
<!-- EO job log -->
<!-- empty state -->
<empty-state
- v-if="!hasTrace"
+ v-if="!hasJobLog"
:illustration-path="emptyStateIllustration.image"
:illustration-size-class="emptyStateIllustration.size"
:title="emptyStateTitle"
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 957e8243f33..6105299e15c 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -44,7 +44,7 @@ export default {
type: Boolean,
required: true,
},
- isTraceSizeVisible: {
+ isJobLogSizeVisible: {
type: Boolean,
required: true,
},
@@ -73,7 +73,7 @@ export default {
class="truncated-info gl-display-none gl-sm-display-block gl-float-left"
data-testid="log-truncated-info"
>
- <template v-if="isTraceSizeVisible">
+ <template v-if="isJobLogSizeVisible">
{{ jobLogSize }}
<gl-link
v-if="rawPath"
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
index c0d5fac0e8d..757b2e458e9 100644
--- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -17,7 +17,7 @@ export default {
type: Object,
required: true,
},
- traceEndpoint: {
+ jobLogEndpoint: {
type: String,
required: true,
},
@@ -42,7 +42,7 @@ export default {
<log-line-header
:line="section.line"
:duration="badgeDuration"
- :path="traceEndpoint"
+ :path="jobLogEndpoint"
:is-closed="section.isClosed"
@toggleLine="handleOnClickCollapsibleLine(section)"
/>
@@ -53,10 +53,10 @@ export default {
v-if="line.isHeader"
:key="line.line.offset"
:section="line"
- :trace-endpoint="traceEndpoint"
+ :job-log-endpoint="jobLogEndpoint"
@onClickCollapsibleLine="handleOnClickCollapsibleLine"
/>
- <log-line v-else :key="line.offset" :line="line" :path="traceEndpoint" />
+ <log-line v-else :key="line.offset" :line="line" :path="jobLogEndpoint" />
</template>
</template>
<template v-else>
@@ -64,7 +64,7 @@ export default {
v-for="line in section.lines"
:key="line.offset"
:line="line"
- :path="traceEndpoint"
+ :path="jobLogEndpoint"
/>
</template>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index 0134e5dafe8..ef95d79b8ab 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -10,10 +10,10 @@ export default {
},
computed: {
...mapState([
- 'traceEndpoint',
- 'trace',
- 'isTraceComplete',
- 'isScrolledToBottomBeforeReceivingTrace',
+ 'jobLogEndpoint',
+ 'jobLog',
+ 'isJobLogComplete',
+ 'isScrolledToBottomBeforeReceivingJobLog',
]),
},
updated() {
@@ -39,7 +39,7 @@ export default {
* In order to scroll the page down after `v-html` has finished, we need to use setTimeout
*/
handleScrollDown() {
- if (this.isScrolledToBottomBeforeReceivingTrace) {
+ if (this.isScrolledToBottomBeforeReceivingJobLog) {
setTimeout(() => {
this.scrollBottom();
}, 0);
@@ -50,18 +50,18 @@ export default {
</script>
<template>
<code class="job-log d-block" data-qa-selector="job_log_content">
- <template v-for="(section, index) in trace">
+ <template v-for="(section, index) in jobLog">
<collapsible-log-section
v-if="section.isHeader"
:key="`collapsible-${index}`"
:section="section"
- :trace-endpoint="traceEndpoint"
+ :job-log-endpoint="jobLogEndpoint"
@onClickCollapsibleLine="handleOnClickCollapsibleLine"
/>
- <log-line v-else :key="section.offset" :line="section" :path="traceEndpoint" />
+ <log-line v-else :key="section.offset" :line="section" :path="jobLogEndpoint" />
</template>
- <div v-if="!isTraceComplete" class="js-log-animation loader-animation pt-3 pl-3">
+ <div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 6b3a4424a5b..51251c0cacc 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -18,6 +18,7 @@ import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql'
import playJobMutation from '../graphql/mutations/job_play.mutation.graphql';
import retryJobMutation from '../graphql/mutations/job_retry.mutation.graphql';
import unscheduleJobMutation from '../graphql/mutations/job_unschedule.mutation.graphql';
+import { reportMessageToSentry } from '../../../utils';
export default {
ACTIONS_DOWNLOAD_ARTIFACTS,
@@ -34,6 +35,7 @@ export default {
jobPlay: 'jobPlay',
jobUnschedule: 'jobUnschedule',
playJobModalId: 'play-job-modal',
+ name: 'JobActionsCell',
components: {
GlButton,
GlButtonGroup,
@@ -99,15 +101,17 @@ export default {
variables: { id: this.job.id },
});
if (errors.length > 0) {
- this.reportFailure();
+ reportMessageToSentry(this.$options.name, errors.join(', '), {});
+ this.showToastMessage();
} else {
eventHub.$emit('jobActionPerformed');
}
- } catch {
- this.reportFailure();
+ } catch (failure) {
+ reportMessageToSentry(this.$options.name, failure, {});
+ this.showToastMessage();
}
},
- reportFailure() {
+ showToastMessage() {
const toastProps = {
text: this.$options.GENERIC_ERROR,
variant: 'danger',
@@ -136,7 +140,13 @@ export default {
<template>
<gl-button-group>
<template v-if="canReadJob">
- <gl-button v-if="isActive" icon="cancel" :title="$options.CANCEL" @click="cancelJob()" />
+ <gl-button
+ v-if="isActive"
+ data-testid="cancel-button"
+ icon="cancel"
+ :title="$options.CANCEL"
+ @click="cancelJob()"
+ />
<template v-else-if="isScheduled">
<gl-button icon="planning" disabled data-testid="countdown">
<gl-countdown :end-date-string="scheduledAt" />
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 53e3dbbad0d..927ba7c7e1e 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -18,16 +18,16 @@ import * as types from './mutation_types';
export const init = ({ dispatch }, { endpoint, logState, pagePath }) => {
dispatch('setJobEndpoint', endpoint);
- dispatch('setTraceOptions', {
+ dispatch('setJobLogOptions', {
logState,
pagePath,
});
- return Promise.all([dispatch('fetchJob'), dispatch('fetchTrace')]);
+ return Promise.all([dispatch('fetchJob'), dispatch('fetchJobLog')]);
};
export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint);
-export const setTraceOptions = ({ commit }, options) => commit(types.SET_TRACE_OPTIONS, options);
+export const setJobLogOptions = ({ commit }, options) => commit(types.SET_JOB_LOG_OPTIONS, options);
export const hideSidebar = ({ commit }) => commit(types.HIDE_SIDEBAR);
export const showSidebar = ({ commit }) => commit(types.SHOW_SIDEBAR);
@@ -107,7 +107,7 @@ export const receiveJobError = ({ commit }) => {
};
/**
- * Job's Trace
+ * Job Log
*/
export const scrollTop = ({ dispatch }) => {
scrollUp();
@@ -156,59 +156,62 @@ export const toggleScrollAnimation = ({ commit }, toggle) =>
* Responsible to handle automatic scroll
*/
export const toggleScrollisInBottom = ({ commit }, toggle) => {
- commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE, toggle);
+ commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG, toggle);
};
-export const requestTrace = ({ commit }) => commit(types.REQUEST_TRACE);
+export const requestJobLog = ({ commit }) => commit(types.REQUEST_JOB_LOG);
-export const fetchTrace = ({ dispatch, state }) =>
+export const fetchJobLog = ({ dispatch, state }) =>
+ // update trace endpoint once BE compeletes trace re-naming in #340626
axios
- .get(`${state.traceEndpoint}/trace.json`, {
- params: { state: state.traceState },
+ .get(`${state.jobLogEndpoint}/trace.json`, {
+ params: { state: state.jobLogState },
})
.then(({ data }) => {
dispatch('toggleScrollisInBottom', isScrolledToBottom());
- dispatch('receiveTraceSuccess', data);
+ dispatch('receiveJobLogSuccess', data);
if (data.complete) {
- dispatch('stopPollingTrace');
- } else if (!state.traceTimeout) {
- dispatch('startPollingTrace');
+ dispatch('stopPollingJobLog');
+ } else if (!state.jobLogTimeout) {
+ dispatch('startPollingJobLog');
}
})
.catch((e) => {
if (e.response.status === httpStatusCodes.FORBIDDEN) {
- dispatch('receiveTraceUnauthorizedError');
+ dispatch('receiveJobLogUnauthorizedError');
} else {
reportToSentry('job_actions', e);
- dispatch('receiveTraceError');
+ dispatch('receiveJobLogError');
}
});
-export const startPollingTrace = ({ dispatch, commit }) => {
- const traceTimeout = setTimeout(() => {
- commit(types.SET_TRACE_TIMEOUT, 0);
- dispatch('fetchTrace');
+export const startPollingJobLog = ({ dispatch, commit }) => {
+ const jobLogTimeout = setTimeout(() => {
+ commit(types.SET_JOB_LOG_TIMEOUT, 0);
+ dispatch('fetchJobLog');
}, 4000);
- commit(types.SET_TRACE_TIMEOUT, traceTimeout);
+ commit(types.SET_JOB_LOG_TIMEOUT, jobLogTimeout);
};
-export const stopPollingTrace = ({ state, commit }) => {
- clearTimeout(state.traceTimeout);
- commit(types.SET_TRACE_TIMEOUT, 0);
- commit(types.STOP_POLLING_TRACE);
+export const stopPollingJobLog = ({ state, commit }) => {
+ clearTimeout(state.jobLogTimeout);
+ commit(types.SET_JOB_LOG_TIMEOUT, 0);
+ commit(types.STOP_POLLING_JOB_LOG);
};
-export const receiveTraceSuccess = ({ commit }, log) => commit(types.RECEIVE_TRACE_SUCCESS, log);
-export const receiveTraceError = ({ dispatch }) => {
- dispatch('stopPollingTrace');
+export const receiveJobLogSuccess = ({ commit }, log) => commit(types.RECEIVE_JOB_LOG_SUCCESS, log);
+
+export const receiveJobLogError = ({ dispatch }) => {
+ dispatch('stopPollingJobLog');
createFlash({
message: __('An error occurred while fetching the job log.'),
});
};
-export const receiveTraceUnauthorizedError = ({ dispatch }) => {
- dispatch('stopPollingTrace');
+
+export const receiveJobLogUnauthorizedError = ({ dispatch }) => {
+ dispatch('stopPollingJobLog');
createFlash({
message: __('The current user is not authorized to access the job log.'),
});
@@ -248,6 +251,7 @@ export const fetchJobsForStage = ({ dispatch }, stage = {}) => {
};
export const receiveJobsForStageSuccess = ({ commit }, data) =>
commit(types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, data);
+
export const receiveJobsForStageError = ({ commit }) => {
commit(types.RECEIVE_JOBS_FOR_STAGE_ERROR);
createFlash({
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index 6cb96bee07d..9d255822250 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -21,11 +21,12 @@ export const shouldRenderTriggeredLabel = (state) => isString(state.job.started)
export const hasEnvironment = (state) => !isEmpty(state.job.deployment_status);
/**
- * Checks if it the job has trace.
+ * Checks if it the job has a log.
* Used to check if it should render the job log or the empty state
* @returns {Boolean}
*/
-export const hasTrace = (state) =>
+export const hasJobLog = (state) =>
+ // update has_trace once BE compeletes trace re-naming in #340626
state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running');
export const emptyStateIllustration = (state) => state?.job?.status?.illustration || {};
@@ -43,7 +44,7 @@ export const shouldRenderSharedRunnerLimitWarning = (state) =>
!isEmpty(state.job.runners.quota) &&
state.job.runners.quota.used >= state.job.runners.quota.limit;
-export const isScrollingDown = (state) => isScrolledToBottom() && !state.isTraceComplete;
+export const isScrollingDown = (state) => isScrolledToBottom() && !state.isJobLogComplete;
export const hasRunnersForProject = (state) =>
state?.job?.runners?.available && !state?.job?.runners?.online;
diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js
index 6c4f1b5a191..4915a826b84 100644
--- a/app/assets/javascripts/jobs/store/mutation_types.js
+++ b/app/assets/javascripts/jobs/store/mutation_types.js
@@ -1,5 +1,5 @@
export const SET_JOB_ENDPOINT = 'SET_JOB_ENDPOINT';
-export const SET_TRACE_OPTIONS = 'SET_TRACE_OPTIONS';
+export const SET_JOB_LOG_OPTIONS = 'SET_JOB_LOG_OPTIONS';
export const HIDE_SIDEBAR = 'HIDE_SIDEBAR';
export const SHOW_SIDEBAR = 'SHOW_SIDEBAR';
@@ -12,17 +12,17 @@ export const ENABLE_SCROLL_BOTTOM = 'ENABLE_SCROLL_BOTTOM';
export const ENABLE_SCROLL_TOP = 'ENABLE_SCROLL_TOP';
export const TOGGLE_SCROLL_ANIMATION = 'TOGGLE_SCROLL_ANIMATION';
-export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE = 'TOGGLE_IS_SCROLL_IN_BOTTOM';
+export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG = 'TOGGLE_IS_SCROLL_IN_BOTTOM';
export const REQUEST_JOB = 'REQUEST_JOB';
export const RECEIVE_JOB_SUCCESS = 'RECEIVE_JOB_SUCCESS';
export const RECEIVE_JOB_ERROR = 'RECEIVE_JOB_ERROR';
-export const REQUEST_TRACE = 'REQUEST_TRACE';
-export const SET_TRACE_TIMEOUT = 'SET_TRACE_TIMEOUT';
-export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE';
-export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS';
-export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR';
+export const REQUEST_JOB_LOG = 'REQUEST_JOB_LOG';
+export const SET_JOB_LOG_TIMEOUT = 'SET_JOB_LOG_TIMEOUT';
+export const STOP_POLLING_JOB_LOG = 'STOP_POLLING_JOB_LOG';
+export const RECEIVE_JOB_LOG_SUCCESS = 'RECEIVE_JOB_LOG_SUCCESS';
+export const RECEIVE_JOB_LOG_ERROR = 'RECEIVE_JOB_LOG_ERROR';
export const TOGGLE_COLLAPSIBLE_LINE = 'TOGGLE_COLLAPSIBLE_LINE';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 4045d8a0c16..eda2ee0349a 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -1,16 +1,16 @@
import Vue from 'vue';
import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants';
import * as types from './mutation_types';
-import { logLinesParser, logLinesParserLegacy, updateIncrementalTrace } from './utils';
+import { logLinesParser, logLinesParserLegacy, updateIncrementalJobLog } from './utils';
export default {
[types.SET_JOB_ENDPOINT](state, endpoint) {
state.jobEndpoint = endpoint;
},
- [types.SET_TRACE_OPTIONS](state, options = {}) {
- state.traceEndpoint = options.pagePath;
- state.traceState = options.logState;
+ [types.SET_JOB_LOG_OPTIONS](state, options = {}) {
+ state.jobLogEndpoint = options.pagePath;
+ state.jobLogState = options.logState;
},
[types.HIDE_SIDEBAR](state) {
@@ -20,11 +20,11 @@ export default {
state.isSidebarOpen = true;
},
- [types.RECEIVE_TRACE_SUCCESS](state, log = {}) {
+ [types.RECEIVE_JOB_LOG_SUCCESS](state, log = {}) {
const infinitelyCollapsibleSectionsFlag =
gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
if (log.state) {
- state.traceState = log.state;
+ state.jobLogState = log.state;
}
if (log.append) {
@@ -32,52 +32,52 @@ export default {
if (log.lines) {
const parsedResult = logLinesParser(
log.lines,
- state.auxiliaryPartialTraceHelpers,
- state.trace,
+ state.auxiliaryPartialJobLogHelpers,
+ state.jobLog,
);
- state.trace = parsedResult.parsedLines;
- state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers;
+ state.jobLog = parsedResult.parsedLines;
+ state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers;
}
} else {
- state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
+ state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog;
}
- state.traceSize += log.size;
+ state.jobLogSize += log.size;
} else {
- // When the job still does not have a trace
- // the trace response will not have a defined
+ // When the job still does not have a log
+ // the job log response will not have a defined
// html or size. We keep the old value otherwise these
// will be set to `null`
if (infinitelyCollapsibleSectionsFlag) {
const parsedResult = logLinesParser(log.lines);
- state.trace = parsedResult.parsedLines;
- state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers;
+ state.jobLog = parsedResult.parsedLines;
+ state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers;
} else {
- state.trace = log.lines ? logLinesParserLegacy(log.lines) : state.trace;
+ state.jobLog = log.lines ? logLinesParserLegacy(log.lines) : state.jobLog;
}
- state.traceSize = log.size || state.traceSize;
+ state.jobLogSize = log.size || state.jobLogSize;
}
- if (state.traceSize < log.total) {
- state.isTraceSizeVisible = true;
+ if (state.jobLogSize < log.total) {
+ state.isJobLogSizeVisible = true;
} else {
- state.isTraceSizeVisible = false;
+ state.isJobLogSizeVisible = false;
}
- state.isTraceComplete = log.complete || state.isTraceComplete;
+ state.isJobLogComplete = log.complete || state.isJobLogComplete;
},
- [types.SET_TRACE_TIMEOUT](state, id) {
- state.traceTimeout = id;
+ [types.SET_JOB_LOG_TIMEOUT](state, id) {
+ state.jobLogTimeout = id;
},
/**
* Will remove loading animation
*/
- [types.STOP_POLLING_TRACE](state) {
- state.isTraceComplete = true;
+ [types.STOP_POLLING_JOB_LOG](state) {
+ state.isJobLogComplete = true;
},
/**
@@ -137,8 +137,8 @@ export default {
state.isScrollingDown = toggle;
},
- [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE](state, toggle) {
- state.isScrolledToBottomBeforeReceivingTrace = toggle;
+ [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG](state, toggle) {
+ state.isScrolledToBottomBeforeReceivingJobLog = toggle;
},
[types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) {
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 718324c8bad..a1ba64aa71e 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -1,6 +1,6 @@
export default () => ({
jobEndpoint: null,
- traceEndpoint: null,
+ jobLogEndpoint: null,
// sidebar
isSidebarOpen: true,
@@ -14,16 +14,16 @@ export default () => ({
isScrollTopDisabled: true,
// Used to check if we should keep the automatic scroll
- isScrolledToBottomBeforeReceivingTrace: true,
+ isScrolledToBottomBeforeReceivingJobLog: true,
- trace: [],
- isTraceComplete: false,
- traceSize: 0,
- isTraceSizeVisible: false,
- traceTimeout: 0,
+ jobLog: [],
+ isJobLogComplete: false,
+ jobLogSize: 0,
+ isJobLogSizeVisible: false,
+ jobLogTimeout: 0,
- // used as a query parameter to fetch the trace
- traceState: null,
+ // used as a query parameter to fetch the job log
+ jobLogState: null,
// sidebar dropdown & list of jobs
isLoadingJobs: false,
@@ -32,5 +32,5 @@ export default () => ({
jobs: [],
// to parse partial logs
- auxiliaryPartialTraceHelpers: {},
+ auxiliaryPartialJobLogHelpers: {},
});
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index b64734e29f6..8bca448ee11 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -131,17 +131,17 @@ export const logLinesParserLegacy = (lines = [], accumulator = []) =>
[...accumulator],
);
-export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLines = []) => {
- let currentLineCount = previousTraceState?.prevLineCount ?? 0;
- let currentHeader = previousTraceState?.currentHeader;
- let isPreviousLineHeader = previousTraceState?.isPreviousLineHeader ?? false;
+export const logLinesParser = (lines = [], previousJobLogState = {}, prevParsedLines = []) => {
+ let currentLineCount = previousJobLogState?.prevLineCount ?? 0;
+ let currentHeader = previousJobLogState?.currentHeader;
+ let isPreviousLineHeader = previousJobLogState?.isPreviousLineHeader ?? false;
const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : [];
- const sectionsQueue = previousTraceState?.sectionsQueue ?? [];
+ const sectionsQueue = previousJobLogState?.sectionsQueue ?? [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
// First run we can use the current index, later runs we have to retrieve the last number of lines
- currentLineCount = previousTraceState?.prevLineCount ? currentLineCount + 1 : i + 1;
+ currentLineCount = previousJobLogState?.prevLineCount ? currentLineCount + 1 : i + 1;
if (line.section_header && !isPreviousLineHeader) {
// If there's no previous line header that means we're at the root of the log
@@ -198,7 +198,7 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi
return {
parsedLines,
- auxiliaryPartialTraceHelpers: {
+ auxiliaryPartialJobLogHelpers: {
isPreviousLineHeader,
currentHeader,
sectionsQueue,
@@ -241,7 +241,7 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
};
/**
- * When the trace is not complete, backend may send the last received line
+ * When the job log is not complete, backend may send the last received line
* in the new response.
*
* We need to check if that is the case by looking for the offset property
@@ -250,7 +250,7 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
* @param array oldLog
* @param array newLog
*/
-export const updateIncrementalTrace = (newLog = [], oldParsed = []) => {
+export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => {
const parsedLog = findOffsetAndRemove(newLog, oldParsed);
return logLinesParserLegacy(newLog, parsedLog);
diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js
index bb27658369f..a4e695518f1 100644
--- a/app/assets/javascripts/jobs/utils.js
+++ b/app/assets/javascripts/jobs/utils.js
@@ -19,3 +19,12 @@ export const reportToSentry = (component, failureType) => {
Sentry.captureException(failureType);
});
};
+
+export const reportMessageToSentry = (component, message, context) => {
+ Sentry.withScope((scope) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ scope.setContext('Vue data', context);
+ scope.setTag('component', component);
+ Sentry.captureMessage(message);
+ });
+};
diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
new file mode 100644
index 00000000000..ad92bd4de42
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
@@ -0,0 +1,36 @@
+import { Observable } from 'apollo-link';
+import { onError } from 'apollo-link-error';
+import { isNavigatingAway } from '~/lib/utils/is_navigating_away';
+
+/**
+ * Returns an ApolloLink (or null if not enabled) which supresses network
+ * errors when the browser is navigating away.
+ *
+ * @returns {ApolloLink|null}
+ */
+export const getSuppressNetworkErrorsDuringNavigationLink = () => {
+ if (!gon.features?.suppressApolloErrorsDuringNavigation) {
+ return null;
+ }
+
+ return onError(({ networkError }) => {
+ if (networkError && isNavigatingAway()) {
+ // Return an observable that will never notify any subscribers with any
+ // values, errors, or completions. This ensures that requests aborted due
+ // to navigating away do not trigger any failure behaviour.
+ //
+ // See '../utils/suppress_ajax_errors_during_navigation.js' for an axios
+ // interceptor that performs a similar role.
+ return new Observable(() => {});
+ }
+
+ // We aren't suppressing anything here, so simply do nothing.
+ // The onError helper will forward all values/errors/completions from the
+ // underlying request observable to the next link if you return a falsey
+ // value.
+ //
+ // Note that this return statement is technically redundant, but is kept
+ // for explicitness.
+ return undefined;
+ });
+};
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index b96a55fe116..39bf804b54e 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -11,6 +11,7 @@ import csrf from '~/lib/utils/csrf';
import { objectToQuery, queryToObject } from '~/lib/utils/url_utility';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
import { getInstrumentationLink } from './apollo/instrumentation_link';
+import { getSuppressNetworkErrorsDuringNavigationLink } from './apollo/suppress_network_errors_during_navigation_link';
export const fetchPolicies = {
CACHE_FIRST: 'cache-first',
@@ -143,6 +144,7 @@ export default (resolvers = {}, config = {}) => {
new ActionCableLink(),
ApolloLink.from(
[
+ getSuppressNetworkErrorsDuringNavigationLink(),
getInstrumentationLink(),
requestCounterLink,
performanceBarLink,
diff --git a/app/assets/javascripts/lib/logger/hello.js b/app/assets/javascripts/lib/logger/hello.js
index 18fa35ab55b..ccfdfe91e60 100644
--- a/app/assets/javascripts/lib/logger/hello.js
+++ b/app/assets/javascripts/lib/logger/hello.js
@@ -1,15 +1,36 @@
+import { s__, sprintf } from '~/locale';
+
const HANDSHAKE = String.fromCodePoint(0x1f91d);
const MAG = String.fromCodePoint(0x1f50e);
+const ROCKET = String.fromCodePoint(0x1f680);
export const logHello = () => {
// eslint-disable-next-line no-console
console.log(
- `%cWelcome to GitLab!%c
+ `%c${s__('HelloMessage|Welcome to GitLab!')}%c
-Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute!
+${s__(
+ 'HelloMessage|Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute!',
+)}
-${HANDSHAKE} Contribute to GitLab: https://about.gitlab.com/community/contribute/
-${MAG} Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new`,
+${sprintf(s__('HelloMessage|%{handshake_emoji} Contribute to GitLab: %{contribute_link}'), {
+ handshake_emoji: `${HANDSHAKE}`,
+ contribute_link: 'https://about.gitlab.com/community/contribute/',
+})}
+${sprintf(s__('HelloMessage|%{magnifier_emoji} Create a new GitLab issue: %{new_issue_link}'), {
+ magnifier_emoji: `${MAG}`,
+ new_issue_link: 'https://gitlab.com/gitlab-org/gitlab/-/issues/new',
+})}
+${
+ window.gon?.dot_com
+ ? `${sprintf(
+ s__(
+ 'HelloMessage|%{rocket_emoji} We like your curiosity! Help us improve GitLab by joining the team: %{jobs_page_link}',
+ ),
+ { rocket_emoji: `${ROCKET}`, jobs_page_link: 'https://about.gitlab.com/jobs/' },
+ )}`
+ : ''
+}`,
`padding-top: 0.5em; font-size: 2em;`,
'padding-bottom: 0.5em;',
);
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
index 0a26f78e253..de6d85b8a18 100644
--- a/app/assets/javascripts/lib/utils/axios_utils.js
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -2,6 +2,7 @@ import axios from 'axios';
import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor';
import setupAxiosStartupCalls from './axios_startup_calls';
import csrf from './csrf';
+import { isNavigatingAway } from './is_navigating_away';
import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation';
axios.defaults.headers.common[csrf.headerKey] = csrf.token;
@@ -30,16 +31,11 @@ axios.interceptors.response.use(
},
);
-let isUserNavigating = false;
-window.addEventListener('beforeunload', () => {
- isUserNavigating = true;
-});
-
// Ignore AJAX errors caused by requests
// being cancelled due to browser navigation
axios.interceptors.response.use(
(response) => response,
- (err) => suppressAjaxErrorsDuringNavigation(err, isUserNavigating),
+ (err) => suppressAjaxErrorsDuringNavigation(err, isNavigatingAway()),
);
registerCaptchaModalInterceptor(axios);
diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js
index da2c10076b1..66d52051905 100644
--- a/app/assets/javascripts/lib/utils/color_utils.js
+++ b/app/assets/javascripts/lib/utils/color_utils.js
@@ -1,3 +1,22 @@
+const colorValidatorEl = document.createElement('div');
+
+/**
+ * Validates whether the specified color expression
+ * is supported by the browser’s DOM API and has a valid form.
+ *
+ * This utility assigns the color expression to a detached DOM
+ * element’s color property. If the color expression is valid,
+ * the DOM API will accept the value.
+ *
+ * @param {String} color color expression rgba, hex, hsla, etc.
+ */
+export const isValidColorExpression = (colorExpression) => {
+ colorValidatorEl.style.color = '';
+ colorValidatorEl.style.color = colorExpression;
+
+ return colorValidatorEl.style.color.length > 0;
+};
+
/**
* Convert hex color to rgb array
*
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index fd9629499b0..813fd3dbb1e 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -6,6 +6,7 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/util
import $ from 'jquery';
import Cookies from 'js-cookie';
import { isFunction, defer } from 'lodash';
+import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import { convertToCamelCase, convertToSnakeCase } from './text_utility';
import { isObject } from './type_utility';
import { getLocationHash } from './url_utility';
@@ -685,7 +686,7 @@ export const searchBy = (query = '', searchSpace = {}) => {
* @param {Object} label
* @returns Boolean
*/
-export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1;
+export const isScopedLabel = ({ title = '' } = {}) => title.includes(SCOPED_LABEL_DELIMITER);
/**
* Returns the base value of the scoped label
@@ -696,7 +697,8 @@ export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1
* @param {Object} label
* @returns String
*/
-export const scopedLabelKey = ({ title = '' }) => isScopedLabel({ title }) && title.split('::')[0];
+export const scopedLabelKey = ({ title = '' }) =>
+ isScopedLabel({ title }) && title.split(SCOPED_LABEL_DELIMITER)[0];
// Methods to set and get Cookie
export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index e41de72ded4..0e5a23a5cbb 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -20,3 +20,7 @@ export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide';
export const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
+
+// We set the drawer's z-index to 252 to clear flash messages that might
+// be displayed in the page and that have a z-index of 251.
+export const DRAWER_Z_INDEX = 252;
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 0a35efb0ac8..3c446c21865 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -1,6 +1,8 @@
import dateFormat from 'dateformat';
-import { isString, mapValues, reduce, isDate } from 'lodash';
-import { s__, n__, __ } from '../../../locale';
+import { isString, mapValues, reduce, isDate, unescape } from 'lodash';
+import { roundToNearestHalf } from '~/lib/utils/common_utils';
+import { sanitize } from '~/lib/dompurify';
+import { s__, n__, __, sprintf } from '../../../locale';
/**
* Returns i18n month names array.
@@ -361,3 +363,26 @@ export const dateToTimeInputValue = (date) => {
hour12: false,
});
};
+
+export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, months }) => {
+ if (months) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
+ value: roundToNearestHalf(months),
+ });
+ } else if (weeks) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
+ value: roundToNearestHalf(weeks),
+ });
+ } else if (days) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
+ value: roundToNearestHalf(days),
+ });
+ } else if (hours) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
+ } else if (minutes) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
+ } else if (seconds) {
+ return unescape(sanitize(s__('ValueStreamAnalytics|&lt;1m'), { ALLOWED_TAGS: [] }));
+ }
+ return '-';
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js
index a2b161d1446..840cc4600fe 100644
--- a/app/assets/javascripts/lib/utils/datetime_range.js
+++ b/app/assets/javascripts/lib/utils/datetime_range.js
@@ -26,7 +26,17 @@ const isValidDateString = (dateString) => {
return false;
}
- return !Number.isNaN(Date.parse(dateformat(dateString, 'isoUtcDateTime')));
+ let isoFormatted;
+ try {
+ isoFormatted = dateformat(dateString, 'isoUtcDateTime');
+ } catch (e) {
+ if (e instanceof TypeError) {
+ // not a valid date string
+ return false;
+ }
+ throw e;
+ }
+ return !Number.isNaN(Date.parse(isoFormatted));
};
const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => {
diff --git a/app/assets/javascripts/lib/utils/is_navigating_away.js b/app/assets/javascripts/lib/utils/is_navigating_away.js
new file mode 100644
index 00000000000..7df00b45379
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/is_navigating_away.js
@@ -0,0 +1,23 @@
+let navigating = false;
+
+window.addEventListener('beforeunload', () => {
+ navigating = true;
+});
+
+/**
+ * To only be used for testing purposes. Allows the navigating state to be set
+ * to a given value.
+ *
+ * @param {boolean} value The value to set the navigating flag to.
+ */
+export const setNavigatingForTestsOnly = (value) => {
+ navigating = value;
+};
+
+/**
+ * Returns a boolean indicating whether the browser is in the process of
+ * navigating away from the current page.
+ *
+ * @returns {boolean}
+ */
+export const isNavigatingAway = () => navigating;
diff --git a/app/assets/javascripts/lib/utils/regexp.js b/app/assets/javascripts/lib/utils/regexp.js
index 25b60dcd14a..f212bf80bd7 100644
--- a/app/assets/javascripts/lib/utils/regexp.js
+++ b/app/assets/javascripts/lib/utils/regexp.js
@@ -1,6 +1,5 @@
/**
* Regexp utility for the convenience of working with regular expressions.
- *
*/
// Inspired by https://github.com/mishoo/UglifyJS/blob/2bc1d02363db3798d5df41fb5059a19edca9b7eb/lib/parse-js.js#L203
@@ -8,4 +7,9 @@
const unicodeLetters =
'\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u0527\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0\\u08A2-\\u08AC\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0977\\u0979-\\u097F\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16EE-\\u16F0\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191C\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2160-\\u2188\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3007\\u3021-\\u3029\\u3031-\\u3035\\u3038-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA697\\uA6A0-\\uA6EF\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA793\\uA7A0-\\uA7AA\\uA7F8-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA80-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uABC0-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC';
-export default { unicodeLetters };
+/**
+ * A regex that matches all single quotes in a string
+ */
+export const allSingleQuotes = /'/g;
+
+export default { unicodeLetters, allSingleQuotes };
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 5ee00464a8b..419afa0a0a9 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -4,6 +4,7 @@ import {
TRUNCATE_WIDTH_DEFAULT_WIDTH,
TRUNCATE_WIDTH_DEFAULT_FONT_SIZE,
} from '~/lib/utils/constants';
+import { allSingleQuotes } from '~/lib/utils/regexp';
/**
* Adds a , to a string composed by numbers, at every 3 chars.
@@ -479,3 +480,17 @@ export const markdownConfig = {
ALLOWED_ATTR: ['class', 'style', 'href', 'src'],
ALLOW_DATA_ATTR: false,
};
+
+/**
+ * Escapes a string into a shell string, for example
+ * when you want to give a user the command to checkout
+ * a branch.
+ *
+ * It replaces all single-quotes with an escaped "'\''"
+ * that is interpreted by shell as a single-quote. It also
+ * encapsulates the string in single-quotes.
+ *
+ * If the branch is `fix-'bug-behavior'`, that should be
+ * escaped to `'fix-'\''bug-behavior'\'''`.
+ */
+export const escapeShellString = (str) => `'${str.replace(allSingleQuotes, () => "'\\''")}'`;
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index bca0e45d98d..1c22d21a313 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,3 +1,5 @@
+export const DASH_SCOPE = '-';
+
const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
@@ -588,3 +590,30 @@ export function isSameOriginUrl(url) {
return false;
}
}
+
+/**
+ * Returns a URL to WebIDE considering the current user's position in
+ * repository's tree. If not MR `iid` has been passed, the URL is fetched
+ * from the global `gl.webIDEPath`.
+ *
+ * @param sourceProjectFullPath Source project's full path. Used in MRs
+ * @param targetProjectFullPath Target project's full path. Used in MRs
+ * @param iid MR iid
+ * @returns {string}
+ */
+
+export function constructWebIDEPath({
+ sourceProjectFullPath,
+ targetProjectFullPath = '',
+ iid,
+} = {}) {
+ if (!iid || !sourceProjectFullPath) {
+ return window.gl?.webIDEPath;
+ }
+ return mergeUrlParams(
+ {
+ target_project: sourceProjectFullPath !== targetProjectFullPath ? targetProjectFullPath : '',
+ },
+ webIDEUrl(`/${sourceProjectFullPath}/merge_requests/${iid}`),
+ );
+}
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index 3db9fa01629..2a60825a427 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -214,7 +214,7 @@ export default {
<template #items>
<pre
ref="logTrace"
- class="build-trace"
+ class="build-log"
><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
<div class="dot"></div>
<div class="dot"></div>
diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js
index 83080589362..ee17e8ecef2 100644
--- a/app/assets/javascripts/logs/stores/state.js
+++ b/app/assets/javascripts/logs/stores/state.js
@@ -31,7 +31,7 @@ export default () => ({
},
/**
- * Logs including trace
+ * Jobs with logs
*/
logs: {
lines: [],
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index b96a2607552..e422d9b1a32 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -1,5 +1,4 @@
/* global $ */
-/* eslint-disable import/order */
import jQuery from 'jquery';
import Cookies from 'js-cookie';
@@ -15,6 +14,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { initRails } from '~/lib/utils/rails_ujs';
import * as popovers from '~/popovers';
import * as tooltips from '~/tooltips';
+import { initHeaderSearchApp } from '~/header_search';
import initAlertHandler from './alert_handler';
import { removeFlashClickListener } from './flash';
import initTodoToggle from './header';
@@ -36,7 +36,6 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
-import { initHeaderSearchApp } from '~/header_search';
import 'ee_else_ce/main_ee';
import 'jh_else_ce/main_jh';
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index 665e8ee69f7..69137ce615b 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
@@ -42,7 +42,7 @@ export default {
required: false,
default: false,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: Object,
required: false,
default: () => ({}),
@@ -61,7 +61,7 @@ export default {
memberPath: this.memberPath.replace(':id', this.memberId),
memberType: this.memberType,
message: this.message,
- oncallSchedules: this.oncallSchedules,
+ userDeletionObstacles: this.userDeletionObstacles,
};
},
},
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index 0c20f935d50..44d658c90a0 100644
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -1,5 +1,6 @@
<script>
import { s__, sprintf } from '~/locale';
+import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import ActionButtonGroup from './action_button_group.vue';
import LeaveButton from './leave_button.vue';
import RemoveMemberButton from './remove_member_button.vue';
@@ -49,9 +50,11 @@ export default {
},
);
},
- oncallScheduleUserData() {
- const { user: { name, oncallSchedules: schedules } = {} } = this.member;
- return { name, schedules };
+ userDeletionObstaclesUserData() {
+ return {
+ name: this.member.user?.name,
+ obstacles: parseUserDeletionObstacles(this.member.user),
+ };
},
},
};
@@ -65,7 +68,7 @@ export default {
v-else
:member-id="member.id"
:member-type="member.type"
- :oncall-schedules="oncallScheduleUserData"
+ :user-deletion-obstacles="userDeletionObstaclesUserData"
:message="message"
:title="s__('Member|Remove member')"
/>
diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index 44178981136..e39669e17dd 100644
--- a/app/assets/javascripts/members/components/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -3,7 +3,8 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
-import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
+import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
+import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { LEAVE_MODAL_ID } from '../../constants';
export default {
@@ -20,7 +21,7 @@ export default {
csrf,
modalId: LEAVE_MODAL_ID,
modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
- components: { GlModal, GlForm, GlSprintf, OncallSchedulesList },
+ components: { GlModal, GlForm, GlSprintf, UserDeletionObstaclesList },
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -43,11 +44,11 @@ export default {
modalTitle() {
return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
},
- schedules() {
- return this.member.user?.oncallSchedules;
+ obstacles() {
+ return parseUserDeletionObstacles(this.member.user);
},
- isPartOfOnCallSchedules() {
- return this.schedules?.length;
+ hasObstaclesToUserDeletion() {
+ return this.obstacles?.length;
},
},
methods: {
@@ -74,9 +75,9 @@ export default {
</gl-sprintf>
</p>
- <oncall-schedules-list
- v-if="isPartOfOnCallSchedules"
- :schedules="schedules"
+ <user-deletion-obstacles-list
+ v-if="hasObstaclesToUserDeletion"
+ :obstacles="obstacles"
:is-current-user="true"
/>
diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
index 00b6ebf9a73..b82fb0030ff 100644
--- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
@@ -3,7 +3,7 @@ import { GlFormCheckbox, GlModal } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { s__, __ } from '~/locale';
-import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
+import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
export default {
actionCancel: {
@@ -13,7 +13,7 @@ export default {
components: {
GlFormCheckbox,
GlModal,
- OncallSchedulesList,
+ UserDeletionObstaclesList,
},
inject: ['namespace'],
computed: {
@@ -33,8 +33,8 @@ export default {
message(state) {
return state[this.namespace].removeMemberModalData.message;
},
- oncallSchedules(state) {
- return state[this.namespace].removeMemberModalData.oncallSchedules ?? {};
+ userDeletionObstacles(state) {
+ return state[this.namespace].removeMemberModalData.userDeletionObstacles ?? {};
},
removeMemberModalVisible(state) {
return state[this.namespace].removeMemberModalVisible;
@@ -60,11 +60,11 @@ export default {
},
};
},
- showUnassignIssuablesCheckbox() {
+ hasWorkspaceAccess() {
return !this.isAccessRequest && !this.isInvite;
},
- isPartOfOncallSchedules() {
- return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
+ hasObstaclesToUserDeletion() {
+ return this.hasWorkspaceAccess && this.userDeletionObstacles.obstacles?.length;
},
},
methods: {
@@ -95,10 +95,10 @@ export default {
<form ref="form" :action="memberPath" method="post">
<p>{{ message }}</p>
- <oncall-schedules-list
- v-if="isPartOfOncallSchedules"
- :schedules="oncallSchedules.schedules"
- :user-name="oncallSchedules.name"
+ <user-deletion-obstacles-list
+ v-if="hasObstaclesToUserDeletion"
+ :obstacles="userDeletionObstacles.obstacles"
+ :user-name="userDeletionObstacles.name"
/>
<input ref="method" type="hidden" name="_method" value="delete" />
@@ -106,7 +106,7 @@ export default {
<gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
{{ __('Also remove direct user membership from subgroups and projects') }}
</gl-form-checkbox>
- <gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables">
+ <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables">
{{ __('Also unassign this user from related issues and merge requests') }}
</gl-form-checkbox>
</form>
diff --git a/app/assets/javascripts/members/components/table/expires_at.vue b/app/assets/javascripts/members/components/table/expires_at.vue
deleted file mode 100644
index c91de061b50..00000000000
--- a/app/assets/javascripts/members/components/table/expires_at.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<script>
-import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import {
- approximateDuration,
- differenceInSeconds,
- formatDate,
- getDayDifference,
-} from '~/lib/utils/datetime_utility';
-import { DAYS_TO_EXPIRE_SOON } from '../../constants';
-
-export default {
- name: 'ExpiresAt',
- components: { GlSprintf },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- date: {
- type: String,
- required: false,
- default: null,
- },
- },
- computed: {
- noExpirationSet() {
- return this.date === null;
- },
- parsed() {
- return new Date(this.date);
- },
- differenceInSeconds() {
- return differenceInSeconds(new Date(), this.parsed);
- },
- isExpired() {
- return this.differenceInSeconds <= 0;
- },
- inWords() {
- return approximateDuration(this.differenceInSeconds);
- },
- formatted() {
- return formatDate(this.parsed);
- },
- expiresSoon() {
- return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON;
- },
- cssClass() {
- return {
- 'gl-text-red-500': this.isExpired,
- 'gl-text-orange-500': this.expiresSoon,
- };
- },
- },
-};
-</script>
-
-<template>
- <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span>
- <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass">
- <template v-if="isExpired">{{ s__('Members|Expired') }}</template>
- <gl-sprintf v-else :message="s__('Members|in %{time}')">
- <template #time>
- {{ inWords }}
- </template>
- </gl-sprintf>
- </span>
-</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index debc3fc31f6..202f3aa89e1 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -5,12 +5,17 @@ import MembersTableCell from 'ee_else_ce/members/components/table/members_table_
import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
-import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME } from '../../constants';
+import {
+ FIELDS,
+ ACTIVE_TAB_QUERY_PARAM_NAME,
+ MEMBER_STATE_AWAITING,
+ USER_STATE_BLOCKED_PENDING_APPROVAL,
+ BADGE_LABELS_PENDING_OWNER_APPROVAL,
+} from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
import RemoveMemberModal from '../modals/remove_member_modal.vue';
import CreatedAt from './created_at.vue';
import ExpirationDatepicker from './expiration_datepicker.vue';
-import ExpiresAt from './expires_at.vue';
import MemberActionButtons from './member_action_buttons.vue';
import MemberAvatar from './member_avatar.vue';
import MemberSource from './member_source.vue';
@@ -24,7 +29,6 @@ export default {
GlPagination,
MemberAvatar,
CreatedAt,
- ExpiresAt,
MembersTableCell,
MemberSource,
MemberActionButtons,
@@ -131,6 +135,74 @@ export default {
window.location.href,
);
},
+ /**
+ * Returns whether it's a new or existing user
+ *
+ * If memberInviteMetadata doesn't exist, it means we're adding an existing user
+ * to the Group/Project, so `isNewUser` should be false.
+ * If memberInviteMetadata exists but `userState` has content,
+ * the user has registered but is awaiting root approval
+ *
+ * @param {object} memberInviteMetadata - MemberEntity.invite
+ * @see {@link ~/app/serializers/member_entity.rb}
+ * @returns {boolean}
+ */
+ isNewUser(memberInviteMetadata) {
+ return memberInviteMetadata && !memberInviteMetadata.userState;
+ },
+ /**
+ * Returns whether the user is awaiting root approval
+ *
+ * This checks User.state exposed via MemberEntity
+ *
+ * @param {object} memberInviteMetadata - MemberEntity.invite
+ * @see {@link ~/app/serializers/member_entity.rb}
+ * @returns {boolean}
+ */
+ isUserPendingRootApproval(memberInviteMetadata) {
+ return memberInviteMetadata?.userState === USER_STATE_BLOCKED_PENDING_APPROVAL;
+ },
+ /**
+ * Returns whether the member is awaiting owner approval
+ *
+ * This checks Member.state exposed via MemberEntity
+ *
+ * @param {Number} memberState - Member.state exposed via MemberEntity.state
+ * @see {@link ~/ee/app/models/ee/member.rb}
+ * @see {@link ~/app/serializers/member_entity.rb}
+ * @returns {boolean}
+ */
+ isMemberPendingOwnerApproval(memberState) {
+ return memberState === MEMBER_STATE_AWAITING;
+ },
+ isUserAwaiting(memberInviteMetadata, memberState) {
+ return (
+ this.isUserPendingRootApproval(memberInviteMetadata) ||
+ this.isMemberPendingOwnerApproval(memberState)
+ );
+ },
+ shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState) {
+ return (
+ this.isUserAwaiting(memberInviteMetadata, memberState) &&
+ !this.isNewUser(memberInviteMetadata)
+ );
+ },
+ /**
+ * Returns the string to be used in the invite badge
+ *
+ * @param {object} memberInviteMetadata - MemberEntity.invite
+ * @see {@link ~/app/serializers/member_entity.rb}
+ * @param {Number} memberState - Member.state exposed via MemberEntity.state
+ * @see {@link ~/ee/app/models/ee/member.rb}
+ * @returns {string}
+ */
+ inviteBadge(memberInviteMetadata, memberState) {
+ if (this.shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState)) {
+ return BADGE_LABELS_PENDING_OWNER_APPROVAL;
+ }
+
+ return '';
+ },
},
};
</script>
@@ -174,18 +246,17 @@ export default {
<created-at :date="createdAt" :created-by="createdBy" />
</template>
- <template #cell(invited)="{ item: { createdAt, createdBy } }">
+ <template #cell(invited)="{ item: { createdAt, createdBy, invite, state } }">
<created-at :date="createdAt" :created-by="createdBy" />
+ <gl-badge v-if="inviteBadge(invite, state)" data-testid="invited-badge">{{
+ inviteBadge(invite, state)
+ }}</gl-badge>
</template>
<template #cell(requested)="{ item: { createdAt } }">
<created-at :date="createdAt" />
</template>
- <template #cell(expires)="{ item: { expiresAt } }">
- <expires-at :date="expiresAt" />
- </template>
-
<template #cell(maxRole)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member">
<role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" />
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 6f465245d20..f5ca881ab0d 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -38,12 +38,6 @@ export const FIELDS = [
tdClass: 'col-meta',
},
{
- key: 'expires',
- label: __('Access expires'),
- thClass: 'col-meta',
- tdClass: 'col-meta',
- },
- {
key: 'maxRole',
label: __('Max role'),
thClass: 'col-max-role',
@@ -95,6 +89,22 @@ export const TAB_QUERY_PARAM_VALUES = {
accessRequest: 'access_requests',
};
+/**
+ * This user state value comes from the User model
+ * see the state machine in app/models/user.rb
+ */
+export const USER_STATE_BLOCKED_PENDING_APPROVAL = 'blocked_pending_approval';
+
+/**
+ * This and following member state constants' values
+ * come from ee/app/models/ee/member.rb
+ */
+export const MEMBER_STATE_CREATED = 0;
+export const MEMBER_STATE_AWAITING = 1;
+export const MEMBER_STATE_ACTIVE = 2;
+
+export const BADGE_LABELS_PENDING_OWNER_APPROVAL = __('Pending owner approval');
+
export const DAYS_TO_EXPIRE_SOON = 7;
export const LEAVE_MODAL_ID = 'member-leave-modal';
diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
index a856d38c089..87eeb272659 100644
--- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
@@ -35,7 +35,11 @@ export default {
<td :class="lineCssClass(line)" class="diff-line-num header"></td>
<td :class="lineCssClass(line)" class="line_content header">
<strong>{{ line.richText }}</strong>
- <button type="button" @click="handleSelected({ file, line })">
+ <button
+ type="button"
+ class="gl-border-1 gl-border-solid"
+ @click="handleSelected({ file, line })"
+ >
{{ line.buttonTitle }}
</button>
</td>
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
index 2c89b614c87..2c59e7bfa2f 100644
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
@@ -35,7 +35,11 @@ export default {
<td class="diff-line-num header" :class="lineCssClass(line)"></td>
<td class="line_content header" :class="lineCssClass(line)">
<strong>{{ line.richText }}</strong>
- <button type="button" @click="handleSelected({ file, line })">
+ <button
+ type="button"
+ class="gl-border-1 gl-border-solid"
+ @click="handleSelected({ file, line })"
+ >
{{ line.buttonTitle }}
</button>
</td>
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index ed32f26583e..244cf1e150a 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import createFlash from '~/flash';
+import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
import eventHub from '~/vue_merge_request_widget/event_hub';
import axios from './lib/utils/axios_utils';
@@ -136,10 +137,9 @@ MergeRequest.hideCloseButton = function () {
MergeRequest.toggleDraftStatus = function (title, isReady) {
if (isReady) {
- createFlash({
- message: __('Marked as ready. Merging is now allowed.'),
- type: 'notice',
- });
+ toast(__('Marked as ready. Merging is now allowed.'));
+ } else {
+ toast(__('Marked as draft. Can only be merged when marked as ready.'));
}
const titleEl = document.querySelector('.merge-request .detail-page-description .title');
diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js
index 714cf67e0bd..6e46c5d3c1f 100644
--- a/app/assets/javascripts/mr_popover/index.js
+++ b/app/assets/javascripts/mr_popover/index.js
@@ -48,7 +48,12 @@ export default (elements) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
});
const listenerAddedAttr = 'data-mr-listener-added';
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
deleted file mode 100644
index af7a600d1ad..00000000000
--- a/app/assets/javascripts/namespace_select.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import Api from './api';
-import { mergeUrlParams } from './lib/utils/url_utility';
-import { __ } from './locale';
-
-export default class NamespaceSelect {
- constructor(opts) {
- const isFilter = parseBoolean(opts.dropdown.dataset.isFilter);
- const fieldName = opts.dropdown.dataset.fieldName || 'namespace_id';
-
- initDeprecatedJQueryDropdown($(opts.dropdown), {
- filterable: true,
- selectable: true,
- filterRemote: true,
- search: {
- fields: ['path'],
- },
- fieldName,
- toggleLabel(selected) {
- if (selected.id == null) {
- return selected.text;
- }
- return `${selected.kind}: ${selected.full_path}`;
- },
- data(term, dataCallback) {
- return Api.namespaces(term, (namespaces) => {
- if (isFilter) {
- const anyNamespace = {
- text: __('Any namespace'),
- id: null,
- };
- namespaces.unshift(anyNamespace);
- namespaces.splice(1, 0, { type: 'divider' });
- }
- return dataCallback(namespaces);
- });
- },
- text(namespace) {
- if (namespace.id == null) {
- return namespace.text;
- }
- return `${namespace.kind}: ${namespace.full_path}`;
- },
- renderRow: this.renderRow,
- clicked(options) {
- if (!isFilter) {
- const { e } = options;
- e.preventDefault();
- }
- },
- url(namespace) {
- return mergeUrlParams({ [fieldName]: namespace.id }, window.location.href);
- },
- });
- }
-}
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 1384c9c40b3..073b27605bb 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,6 +1,7 @@
<script>
import katex from 'katex';
import marked from 'marked';
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
import { hasContent, markdownConfig } from '~/lib/utils/text_utility';
import Prompt from './prompt.vue';
@@ -138,6 +139,9 @@ export default {
components: {
prompt: Prompt,
},
+ directives: {
+ SafeHtml,
+ },
inject: ['relativeRawPath'],
props: {
cell: {
@@ -150,16 +154,17 @@ export default {
renderer.attachments = this.cell.attachments;
renderer.relativeRawPath = this.relativeRawPath;
- return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig);
+ return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
},
},
+ markdownConfig,
};
</script>
<template>
<div class="cell text-cell">
<prompt />
- <div class="markdown" v-html="markdown /* eslint-disable-line vue/no-v-html */"></div>
+ <div v-safe-html:[$options.markdownConfig]="markdown" class="markdown"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/comment_type_dropdown.vue b/app/assets/javascripts/notes/components/comment_type_dropdown.vue
index 663a912999d..30ea5d3532e 100644
--- a/app/assets/javascripts/notes/components/comment_type_dropdown.vue
+++ b/app/assets/javascripts/notes/components/comment_type_dropdown.vue
@@ -96,7 +96,11 @@ export default {
data-track-action="click_button"
@click="$emit('click')"
>
- <gl-dropdown-item is-check-item :is-checked="isNoteTypeComment" @click="setNoteTypeToComment">
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeComment"
+ @click.stop.prevent="setNoteTypeToComment"
+ >
<strong>{{ $options.i18n.submitButton.comment }}</strong>
<p class="gl-m-0">{{ commentDescription }}</p>
</gl-dropdown-item>
@@ -105,7 +109,7 @@ export default {
is-check-item
:is-checked="isNoteTypeDiscussion"
data-qa-selector="discussion_menu_item"
- @click="setNoteTypeToDiscussion"
+ @click.stop.prevent="setNoteTypeToDiscussion"
>
<strong>{{ $options.i18n.submitButton.startThread }}</strong>
<p class="gl-m-0">{{ startDiscussionDescription }}</p>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 0892276ff3b..6fcfa66ea49 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -47,6 +47,11 @@ export default {
required: false,
default: '',
},
+ isOverviewTab: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters(['userCanReply']),
@@ -127,6 +132,7 @@ export default {
:show-reply-button="userCanReply"
:discussion-root="true"
:discussion-resolve-path="discussion.resolve_path"
+ :is-overview-tab="isOverviewTab"
@handleDeleteNote="$emit('deleteNote')"
@startReplying="$emit('startReplying')"
>
@@ -176,6 +182,7 @@ export default {
:line="diffLine"
:discussion-root="index === 0"
:discussion-resolve-path="discussion.resolve_path"
+ :is-overview-tab="isOverviewTab"
@handleDeleteNote="$emit('deleteNote')"
>
<template #avatar-badge>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 44d0c741d5a..e2a2edd7344 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -257,7 +257,7 @@ export default {
<user-access-role-badge
v-if="isAuthor"
v-gl-tooltip
- class="gl-mx-3 d-none d-md-inline-block"
+ class="gl-mr-3 d-none d-md-inline-block"
:title="displayAuthorBadgeText"
>
{{ __('Author') }}
@@ -265,7 +265,7 @@ export default {
<user-access-role-badge
v-if="accessLevel"
v-gl-tooltip
- class="gl-mx-3"
+ class="gl-mr-3"
:title="displayMemberBadgeText"
>
{{ accessLevel }}
@@ -273,7 +273,7 @@ export default {
<user-access-role-badge
v-else-if="isContributor"
v-gl-tooltip
- class="gl-mx-3"
+ class="gl-mr-3"
:title="displayContributorBadgeText"
>
{{ __('Contributor') }}
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 93f71276120..1ce1696e332 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -51,7 +51,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getDiscussion', 'suggestionsCount']),
+ ...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']),
...mapGetters('diffs', ['suggestionCommitMessage']),
discussion() {
if (!this.note.isDraft) return {};
@@ -74,9 +74,10 @@ export default {
// Please see this issue comment for why these
// are hard-coded to 1:
// https://gitlab.com/gitlab-org/gitlab/-/issues/291027#note_468308022
- const suggestionsCount = 1;
- const filesCount = 1;
- const filePaths = this.file ? [this.file.file_path] : [];
+ const suggestionsCount = this.batchSuggestionsInfo.length || 1;
+ const batchFilePaths = this.getSuggestionsFilePaths();
+ const filePaths = batchFilePaths.length ? batchFilePaths : [this.file.file_path];
+ const filesCount = filePaths.length;
const suggestion = this.suggestionCommitMessage({
file_paths: filePaths.join(', '),
suggestions_count: suggestionsCount,
@@ -131,8 +132,8 @@ export default {
message,
}).then(callback);
},
- applySuggestionBatch({ flashContainer }) {
- return this.submitSuggestionBatch({ flashContainer });
+ applySuggestionBatch({ message, flashContainer }) {
+ return this.submitSuggestionBatch({ message, flashContainer });
},
addSuggestionToBatch(suggestionId) {
const { discussion_id: discussionId, id: noteId } = this.note;
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index a4f06a8d9f5..b05643e5e13 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -348,6 +348,7 @@ export default {
id="note_note"
ref="textarea"
v-model="updatedNoteBody"
+ :disabled="isSubmitting"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 4e686ce8719..0925195d4bb 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,10 +1,16 @@
<script>
-import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlTooltipDirective,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import { mapActions } from 'vuex';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue';
export default {
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
components: {
timeAgoTooltip,
GitlabTeamMemberBadge: () =>
@@ -14,6 +20,7 @@ export default {
UserNameWithStatus,
},
directives: {
+ SafeHtml,
GlTooltip: GlTooltipDirective,
},
props: {
@@ -165,10 +172,10 @@ export default {
<span
v-if="authorStatus"
ref="authorStatus"
+ v-safe-html:[$options.safeHtmlConfig]="authorStatus"
v-on="
authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {}
"
- v-html="authorStatus /* eslint-disable-line vue/no-v-html */"
></span>
<span class="text-nowrap author-username">
<a
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index b99579fb9a7..77f796fe8b0 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -66,6 +66,11 @@ export default {
required: false,
default: '',
},
+ isOverviewTab: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -263,6 +268,7 @@ export default {
:is-expanded="isExpanded"
:line="line"
:should-group-replies="shouldGroupReplies"
+ :is-overview-tab="isOverviewTab"
@startReplying="showReplyForm"
@deleteNote="deleteNoteHandler"
>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3c6ed0a8aac..e35d8d94289 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -84,6 +84,11 @@ export default {
required: false,
default: '',
},
+ isOverviewTab: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -186,6 +191,14 @@ export default {
return fileResolvedFromAvailableSource || null;
},
+ avatarSize() {
+ // Use a different size if shown on a Merge Request Diff
+ if (this.line && !this.isOverviewTab) {
+ return 24;
+ }
+
+ return 40;
+ },
},
created() {
const line = this.note.position?.line_range?.start || this.line;
@@ -391,7 +404,7 @@ export default {
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
- :img-size="40"
+ :img-size="avatarSize"
lazy
>
<template #avatar-badge>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 29c60b96d8a..58570e76795 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -317,6 +317,7 @@ export default {
:key="discussion.id"
:discussion="discussion"
:render-diff-file="true"
+ is-overview-tab
:help-page-path="helpPagePath"
/>
</template>
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 656591e0c32..7eb10f647a0 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -631,7 +631,7 @@ export const submitSuggestion = (
});
};
-export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContainer }) => {
+export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, flashContainer }) => {
const suggestionIds = state.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId);
const resolveAllDiscussions = () =>
@@ -644,7 +644,7 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai
commit(types.SET_RESOLVING_DISCUSSION, true);
dispatch('stopPolling');
- return Api.applySuggestionBatch(suggestionIds)
+ return Api.applySuggestionBatch(suggestionIds, message)
.then(() => Promise.all(resolveAllDiscussions()))
.then(() => commit(types.CLEAR_SUGGESTION_BATCH))
.catch((err) => {
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 956221d69ae..a710ac0ccf5 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -283,3 +283,14 @@ export const suggestionsCount = (state, getters) =>
export const hasDrafts = (state, getters, rootState, rootGetters) =>
Boolean(rootGetters['batchComments/hasDrafts']);
+
+export const getSuggestionsFilePaths = (state) => () =>
+ state.batchSuggestionsInfo.reduce((acc, suggestion) => {
+ const discussion = state.discussions.find((d) => d.id === suggestion.discussionId);
+
+ if (acc.indexOf(discussion?.diff_file?.file_path) === -1) {
+ acc.push(discussion.diff_file.file_path);
+ }
+
+ return acc;
+ }, []);
diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js
index 4f875977d78..f5891c9acb5 100644
--- a/app/assets/javascripts/notifications/constants.js
+++ b/app/assets/javascripts/notifications/constants.js
@@ -31,7 +31,7 @@ export const i18n = {
title: __('Custom notification events'),
bodyTitle: __('Notification events'),
bodyMessage: __(
- 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}.',
+ 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart}notification emails%{notificationLinkEnd}.',
),
},
eventNames: {
diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue
deleted file mode 100644
index 4e99099b0a1..00000000000
--- a/app/assets/javascripts/packages/details/components/additional_metadata.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
-import { PackageType } from '../../shared/constants';
-
-export default {
- i18n: {
- sourceText: s__('PackageRegistry|Source project located at %{link}'),
- licenseText: s__('PackageRegistry|License information located at %{link}'),
- recipeText: s__('PackageRegistry|Recipe: %{recipe}'),
- appGroup: s__('PackageRegistry|App group: %{group}'),
- appName: s__('PackageRegistry|App name: %{name}'),
- },
- components: {
- DetailsRow,
- GlLink,
- GlSprintf,
- },
- props: {
- packageEntity: {
- type: Object,
- required: true,
- },
- },
- computed: {
- showMetadata() {
- const visibilityConditions = {
- [PackageType.NUGET]: this.packageEntity.nuget_metadatum,
- [PackageType.CONAN]: this.packageEntity.conan_metadatum,
- [PackageType.MAVEN]: this.packageEntity.maven_metadatum,
- };
- return visibilityConditions[this.packageEntity.package_type];
- },
- },
-};
-</script>
-
-<template>
- <div v-if="showMetadata">
- <h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3>
-
- <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main">
- <template v-if="packageEntity.nuget_metadatum">
- <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source">
- <gl-sprintf :message="$options.i18n.sourceText">
- <template #link>
- <gl-link :href="packageEntity.nuget_metadatum.project_url" target="_blank">{{
- packageEntity.nuget_metadatum.project_url
- }}</gl-link>
- </template>
- </gl-sprintf>
- </details-row>
- <details-row icon="license" padding="gl-p-4" data-testid="nuget-license">
- <gl-sprintf :message="$options.i18n.licenseText">
- <template #link>
- <gl-link :href="packageEntity.nuget_metadatum.license_url" target="_blank">{{
- packageEntity.nuget_metadatum.license_url
- }}</gl-link>
- </template>
- </gl-sprintf>
- </details-row>
- </template>
-
- <details-row
- v-else-if="packageEntity.conan_metadatum"
- icon="information-o"
- padding="gl-p-4"
- data-testid="conan-recipe"
- >
- <gl-sprintf :message="$options.i18n.recipeText">
- <template #recipe>{{ packageEntity.name }}</template>
- </gl-sprintf>
- </details-row>
-
- <template v-else-if="packageEntity.maven_metadatum">
- <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app">
- <gl-sprintf :message="$options.i18n.appName">
- <template #name>
- <strong>{{ packageEntity.maven_metadatum.app_name }}</strong>
- </template>
- </gl-sprintf>
- </details-row>
- <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group">
- <gl-sprintf :message="$options.i18n.appGroup">
- <template #group>
- <strong>{{ packageEntity.maven_metadatum.app_group }}</strong>
- </template>
- </gl-sprintf>
- </details-row>
- </template>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue
deleted file mode 100644
index bf1e5083e12..00000000000
--- a/app/assets/javascripts/packages/details/components/composer_installation.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'ComposerInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- computed: {
- ...mapState(['composerHelpPath']),
- ...mapGetters(['composerRegistryInclude', 'composerPackageInclude', 'groupExists']),
- },
- i18n: {
- registryInclude: s__('PackageRegistry|Add composer registry'),
- copyRegistryInclude: s__('PackageRegistry|Copy registry include'),
- packageInclude: s__('PackageRegistry|Install package version'),
- copyPackageInclude: s__('PackageRegistry|Copy require package include'),
- infoLine: s__(
- 'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [{ value: 'composer', label: s__('PackageRegistry|Show Composer commands') }],
-};
-</script>
-
-<template>
- <div v-if="groupExists" data-testid="root-node">
- <installation-title package-type="composer" :options="$options.installOptions" />
-
- <code-instruction
- :label="$options.i18n.registryInclude"
- :instruction="composerRegistryInclude"
- :copy-text="$options.i18n.copyRegistryInclude"
- :tracking-action="$options.trackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- data-testid="registry-include"
- />
-
- <code-instruction
- :label="$options.i18n.packageInclude"
- :instruction="composerPackageInclude"
- :copy-text="$options.i18n.copyPackageInclude"
- :tracking-action="$options.trackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- data-testid="package-include"
- />
- <span data-testid="help-text">
- <gl-sprintf :message="$options.i18n.infoLine">
- <template #link="{ content }">
- <gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/conan_installation.vue b/app/assets/javascripts/packages/details/components/conan_installation.vue
deleted file mode 100644
index 1d855f6cf3e..00000000000
--- a/app/assets/javascripts/packages/details/components/conan_installation.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'ConanInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- computed: {
- ...mapState(['conanHelpPath']),
- ...mapGetters(['conanInstallationCommand', 'conanSetupCommand']),
- },
- i18n: {
- helpText: s__(
- 'PackageRegistry|For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [{ value: 'conan', label: s__('PackageRegistry|Show Conan commands') }],
-};
-</script>
-
-<template>
- <div>
- <installation-title package-type="conan" :options="$options.installOptions" />
-
- <code-instruction
- :label="s__('PackageRegistry|Conan Command')"
- :instruction="conanInstallationCommand"
- :copy-text="s__('PackageRegistry|Copy Conan Command')"
- :tracking-action="$options.trackingActions.COPY_CONAN_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
-
- <code-instruction
- :label="s__('PackageRegistry|Add Conan Remote')"
- :instruction="conanSetupCommand"
- :copy-text="s__('PackageRegistry|Copy Conan Setup Command')"
- :tracking-action="$options.trackingActions.COPY_CONAN_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="conanHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/dependency_row.vue b/app/assets/javascripts/packages/details/components/dependency_row.vue
deleted file mode 100644
index 1a2202b23c8..00000000000
--- a/app/assets/javascripts/packages/details/components/dependency_row.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-export default {
- name: 'DependencyRow',
- props: {
- dependency: {
- type: Object,
- required: true,
- },
- },
- computed: {
- showVersion() {
- return Boolean(this.dependency.version_pattern);
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-responsive-table-row">
- <div class="table-section section-50">
- <strong class="gl-text-body">{{ dependency.name }}</strong>
- <span v-if="dependency.target_framework" data-testid="target-framework"
- >({{ dependency.target_framework }})</span
- >
- </div>
-
- <div
- v-if="showVersion"
- class="table-section section-50 gl-display-flex gl-md-justify-content-end"
- data-testid="version-pattern"
- >
- <span class="gl-text-body">{{ dependency.version_pattern }}</span>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/installation_commands.vue b/app/assets/javascripts/packages/details/components/installation_commands.vue
deleted file mode 100644
index ed55d7fe782..00000000000
--- a/app/assets/javascripts/packages/details/components/installation_commands.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<script>
-import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue';
-import { PackageType, TERRAFORM_PACKAGE_TYPE } from '../../shared/constants';
-import ComposerInstallation from './composer_installation.vue';
-import ConanInstallation from './conan_installation.vue';
-import MavenInstallation from './maven_installation.vue';
-import NpmInstallation from './npm_installation.vue';
-import NugetInstallation from './nuget_installation.vue';
-import PypiInstallation from './pypi_installation.vue';
-
-export default {
- name: 'InstallationCommands',
- components: {
- [PackageType.CONAN]: ConanInstallation,
- [PackageType.MAVEN]: MavenInstallation,
- [PackageType.NPM]: NpmInstallation,
- [PackageType.NUGET]: NugetInstallation,
- [PackageType.PYPI]: PypiInstallation,
- [PackageType.COMPOSER]: ComposerInstallation,
- [TERRAFORM_PACKAGE_TYPE]: TerraformInstallation,
- },
- props: {
- packageEntity: {
- type: Object,
- required: true,
- },
- npmPath: {
- type: String,
- required: false,
- default: '',
- },
- npmHelpPath: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- installationComponent() {
- return this.$options.components[this.packageEntity.package_type];
- },
- },
-};
-</script>
-
-<template>
- <div v-if="installationComponent">
- <component
- :is="installationComponent"
- :name="packageEntity.name"
- :registry-url="npmPath"
- :help-url="npmHelpPath"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/installation_title.vue b/app/assets/javascripts/packages/details/components/installation_title.vue
deleted file mode 100644
index 43133bf7825..00000000000
--- a/app/assets/javascripts/packages/details/components/installation_title.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<script>
-import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue';
-
-export default {
- name: 'InstallationTitle',
- components: {
- PersistedDropdownSelection,
- },
- props: {
- packageType: {
- type: String,
- required: true,
- },
- options: {
- type: Array,
- required: true,
- },
- },
- computed: {
- storageKey() {
- return `package_${this.packageType}_installation_instructions`;
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
- <div>
- <persisted-dropdown-selection
- :storage-key="storageKey"
- :options="options"
- @change="$emit('change', $event)"
- />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue
deleted file mode 100644
index 6974de99344..00000000000
--- a/app/assets/javascripts/packages/details/components/maven_installation.vue
+++ /dev/null
@@ -1,153 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'MavenInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- data() {
- return {
- instructionType: 'maven',
- };
- },
- computed: {
- ...mapState(['mavenHelpPath']),
- ...mapGetters([
- 'mavenInstallationXml',
- 'mavenInstallationCommand',
- 'mavenSetupXml',
- 'gradleGroovyInstalCommand',
- 'gradleGroovyAddSourceCommand',
- 'gradleKotlinInstalCommand',
- 'gradleKotlinAddSourceCommand',
- ]),
- showMaven() {
- return this.instructionType === 'maven';
- },
- showGroovy() {
- return this.instructionType === 'groovy';
- },
- },
- i18n: {
- xmlText: s__(
- `PackageRegistry|Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block.`,
- ),
- setupText: s__(
- `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file.`,
- ),
- helpText: s__(
- 'PackageRegistry|For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [
- { value: 'maven', label: s__('PackageRegistry|Maven XML') },
- { value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') },
- { value: 'kotlin', label: s__('PackageRegistry|Gradle Kotlin DSL') },
- ],
-};
-</script>
-
-<template>
- <div>
- <installation-title
- package-type="maven"
- :options="$options.installOptions"
- @change="instructionType = $event"
- />
-
- <template v-if="showMaven">
- <p>
- <gl-sprintf :message="$options.i18n.xmlText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
-
- <code-instruction
- :instruction="mavenInstallationXml"
- :copy-text="s__('PackageRegistry|Copy Maven XML')"
- :tracking-action="$options.trackingActions.COPY_MAVEN_XML"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- multiline
- />
-
- <code-instruction
- :label="s__('PackageRegistry|Maven Command')"
- :instruction="mavenInstallationCommand"
- :copy-text="s__('PackageRegistry|Copy Maven command')"
- :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <h3 class="gl-font-lg">{{ s__('PackageRegistry|Registry setup') }}</h3>
- <p>
- <gl-sprintf :message="$options.i18n.setupText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
- <code-instruction
- :instruction="mavenSetupXml"
- :copy-text="s__('PackageRegistry|Copy Maven registry XML')"
- :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- multiline
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
- <template v-else-if="showGroovy">
- <code-instruction
- class="gl-mb-5"
- :label="s__('PackageRegistry|Gradle Groovy DSL install command')"
- :instruction="gradleGroovyInstalCommand"
- :copy-text="s__('PackageRegistry|Copy Gradle Groovy DSL install command')"
- :tracking-action="$options.trackingActions.COPY_GRADLE_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <code-instruction
- :label="s__('PackageRegistry|Add Gradle Groovy DSL repository command')"
- :instruction="gradleGroovyAddSourceCommand"
- :copy-text="s__('PackageRegistry|Copy add Gradle Groovy DSL repository command')"
- :tracking-action="$options.trackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- multiline
- />
- </template>
- <template v-else>
- <code-instruction
- class="gl-mb-5"
- :label="s__('PackageRegistry|Gradle Kotlin DSL install command')"
- :instruction="gradleKotlinInstalCommand"
- :copy-text="s__('PackageRegistry|Copy Gradle Kotlin DSL install command')"
- :tracking-action="$options.trackingActions.COPY_KOTLIN_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <code-instruction
- :label="s__('PackageRegistry|Add Gradle Kotlin DSL repository command')"
- :instruction="gradleKotlinAddSourceCommand"
- :copy-text="s__('PackageRegistry|Copy add Gradle Kotlin DSL repository command')"
- :tracking-action="$options.trackingActions.COPY_KOTLIN_ADD_TO_SOURCE_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- multiline
- />
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue
deleted file mode 100644
index 6b0fcf5e4fe..00000000000
--- a/app/assets/javascripts/packages/details/components/npm_installation.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { NpmManager, TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'NpmInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- data() {
- return {
- instructionType: 'npm',
- };
- },
- computed: {
- ...mapState(['npmHelpPath']),
- ...mapGetters(['npmInstallationCommand', 'npmSetupCommand']),
- npmCommand() {
- return this.npmInstallationCommand(NpmManager.NPM);
- },
- npmSetup() {
- return this.npmSetupCommand(NpmManager.NPM);
- },
- yarnCommand() {
- return this.npmInstallationCommand(NpmManager.YARN);
- },
- yarnSetupCommand() {
- return this.npmSetupCommand(NpmManager.YARN);
- },
- showNpm() {
- return this.instructionType === 'npm';
- },
- },
- i18n: {
- helpText: s__(
- 'PackageRegistry|You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [
- { value: 'npm', label: s__('PackageRegistry|Show NPM commands') },
- { value: 'yarn', label: s__('PackageRegistry|Show Yarn commands') },
- ],
-};
-</script>
-
-<template>
- <div>
- <installation-title
- package-type="npm"
- :options="$options.installOptions"
- @change="instructionType = $event"
- />
-
- <code-instruction
- v-if="showNpm"
- :instruction="npmCommand"
- :copy-text="s__('PackageRegistry|Copy npm command')"
- :tracking-action="$options.trackingActions.COPY_NPM_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <code-instruction
- v-else
- :instruction="yarnCommand"
- :copy-text="s__('PackageRegistry|Copy yarn command')"
- :tracking-action="$options.trackingActions.COPY_YARN_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
-
- <code-instruction
- v-if="showNpm"
- :instruction="npmSetup"
- :copy-text="s__('PackageRegistry|Copy npm setup command')"
- :tracking-action="$options.trackingActions.COPY_NPM_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <code-instruction
- v-else
- :instruction="yarnSetupCommand"
- :copy-text="s__('PackageRegistry|Copy yarn setup command')"
- :tracking-action="$options.trackingActions.COPY_YARN_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="npmHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/nuget_installation.vue b/app/assets/javascripts/packages/details/components/nuget_installation.vue
deleted file mode 100644
index d5e64722f24..00000000000
--- a/app/assets/javascripts/packages/details/components/nuget_installation.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'NugetInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- computed: {
- ...mapState(['nugetHelpPath']),
- ...mapGetters(['nugetInstallationCommand', 'nugetSetupCommand']),
- },
- i18n: {
- helpText: s__(
- 'PackageRegistry|For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [{ value: 'nuget', label: s__('PackageRegistry|Show Nuget commands') }],
-};
-</script>
-
-<template>
- <div>
- <installation-title package-type="nuget" :options="$options.installOptions" />
-
- <code-instruction
- :label="s__('PackageRegistry|NuGet Command')"
- :instruction="nugetInstallationCommand"
- :copy-text="s__('PackageRegistry|Copy NuGet Command')"
- :tracking-action="$options.trackingActions.COPY_NUGET_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
-
- <code-instruction
- :label="s__('PackageRegistry|Add NuGet Source')"
- :instruction="nugetSetupCommand"
- :copy-text="s__('PackageRegistry|Copy NuGet Setup Command')"
- :tracking-action="$options.trackingActions.COPY_NUGET_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="nugetHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue
deleted file mode 100644
index d02a7b3ec27..00000000000
--- a/app/assets/javascripts/packages/details/components/package_title.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<script>
-/* eslint-disable vue/v-slot-style */
-import { GlIcon, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
-import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { mapState, mapGetters } from 'vuex';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { __ } from '~/locale';
-import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
-import TitleArea from '~/vue_shared/components/registry/title_area.vue';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import PackageTags from '../../shared/components/package_tags.vue';
-
-export default {
- name: 'PackageTitle',
- components: {
- TitleArea,
- GlIcon,
- GlSprintf,
- PackageTags,
- MetadataItem,
- GlBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [timeagoMixin],
- i18n: {
- packageInfo: __('v%{version} published %{timeAgo}'),
- },
- data() {
- return {
- isDesktop: true,
- };
- },
- computed: {
- ...mapState(['packageEntity', 'packageFiles']),
- ...mapGetters(['packageTypeDisplay', 'packagePipeline', 'packageIcon']),
- hasTagsToDisplay() {
- return Boolean(this.packageEntity.tags && this.packageEntity.tags.length);
- },
- totalSize() {
- return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0));
- },
- },
- mounted() {
- this.isDesktop = GlBreakpointInstance.isDesktop();
- },
- methods: {
- dynamicSlotName(index) {
- return `metadata-tag${index}`;
- },
- },
-};
-</script>
-
-<template>
- <title-area :title="packageEntity.name" :avatar="packageIcon" data-qa-selector="package_title">
- <template #sub-header>
- <gl-icon name="eye" class="gl-mr-3" />
- <gl-sprintf :message="$options.i18n.packageInfo">
- <template #version>
- {{ packageEntity.version }}
- </template>
-
- <template #timeAgo>
- <span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)">
- &nbsp;{{ timeFormatted(packageEntity.created_at) }}
- </span>
- </template>
- </gl-sprintf>
- </template>
-
- <template v-if="packageTypeDisplay" #metadata-type>
- <metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" />
- </template>
-
- <template #metadata-size>
- <metadata-item data-testid="package-size" icon="disk" :text="totalSize" />
- </template>
-
- <template v-if="packagePipeline" #metadata-pipeline>
- <metadata-item
- data-testid="pipeline-project"
- icon="review-list"
- :text="packagePipeline.project.name"
- :link="packagePipeline.project.web_url"
- />
- </template>
-
- <template v-if="packagePipeline" #metadata-ref>
- <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" />
- </template>
-
- <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags>
- <package-tags :tag-display-limit="2" :tags="packageEntity.tags" hide-label />
- </template>
-
- <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap -->
- <template
- v-for="(tag, index) in packageEntity.tags"
- v-else-if="hasTagsToDisplay"
- v-slot:[dynamicSlotName(index)]
- >
- <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm">
- {{ tag.name }}
- </gl-badge>
- </template>
-
- <template #right-actions>
- <slot name="delete-button"></slot>
- </template>
- </title-area>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/pypi_installation.vue b/app/assets/javascripts/packages/details/components/pypi_installation.vue
deleted file mode 100644
index fe4709d5feb..00000000000
--- a/app/assets/javascripts/packages/details/components/pypi_installation.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'PyPiInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- computed: {
- ...mapState(['pypiHelpPath']),
- ...mapGetters(['pypiPipCommand', 'pypiSetupCommand']),
- },
- i18n: {
- setupText: s__(
- `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`,
- ),
- helpText: s__(
- 'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }],
-};
-</script>
-
-<template>
- <div>
- <installation-title package-type="pypi" :options="$options.installOptions" />
-
- <code-instruction
- :label="s__('PackageRegistry|Pip Command')"
- :instruction="pypiPipCommand"
- :copy-text="s__('PackageRegistry|Copy Pip command')"
- data-testid="pip-command"
- :tracking-action="$options.trackingActions.COPY_PIP_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
- <p>
- <gl-sprintf :message="$options.i18n.setupText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
-
- <code-instruction
- :instruction="pypiSetupCommand"
- :copy-text="s__('PackageRegistry|Copy .pypirc content')"
- data-testid="pypi-setup-content"
- multiline
- :tracking-action="$options.trackingActions.COPY_PYPI_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="pypiHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js
deleted file mode 100644
index cd34b1ad45a..00000000000
--- a/app/assets/javascripts/packages/details/constants.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { s__ } from '~/locale';
-
-export const TrackingLabels = {
- CODE_INSTRUCTION: 'code_instruction',
- CONAN_INSTALLATION: 'conan_installation',
- MAVEN_INSTALLATION: 'maven_installation',
- NPM_INSTALLATION: 'npm_installation',
- NUGET_INSTALLATION: 'nuget_installation',
- PYPI_INSTALLATION: 'pypi_installation',
- COMPOSER_INSTALLATION: 'composer_installation',
-};
-
-export const TrackingActions = {
- INSTALLATION: 'installation',
- REGISTRY_SETUP: 'registry_setup',
-
- COPY_CONAN_COMMAND: 'copy_conan_command',
- COPY_CONAN_SETUP_COMMAND: 'copy_conan_setup_command',
-
- COPY_MAVEN_XML: 'copy_maven_xml',
- COPY_MAVEN_COMMAND: 'copy_maven_command',
- COPY_MAVEN_SETUP: 'copy_maven_setup_xml',
-
- COPY_NPM_INSTALL_COMMAND: 'copy_npm_install_command',
- COPY_NPM_SETUP_COMMAND: 'copy_npm_setup_command',
-
- COPY_YARN_INSTALL_COMMAND: 'copy_yarn_install_command',
- COPY_YARN_SETUP_COMMAND: 'copy_yarn_setup_command',
-
- COPY_NUGET_INSTALL_COMMAND: 'copy_nuget_install_command',
- COPY_NUGET_SETUP_COMMAND: 'copy_nuget_setup_command',
-
- COPY_PIP_INSTALL_COMMAND: 'copy_pip_install_command',
- COPY_PYPI_SETUP_COMMAND: 'copy_pypi_setup_command',
-
- COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND: 'copy_composer_registry_include_command',
- COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND: 'copy_composer_package_include_command',
-
- COPY_GRADLE_INSTALL_COMMAND: 'copy_gradle_install_command',
- COPY_GRADLE_ADD_TO_SOURCE_COMMAND: 'copy_gradle_add_to_source_command',
-
- COPY_KOTLIN_INSTALL_COMMAND: 'copy_kotlin_install_command',
- COPY_KOTLIN_ADD_TO_SOURCE_COMMAND: 'copy_kotlin_add_to_source_command',
-};
-
-export const NpmManager = {
- NPM: 'npm',
- YARN: 'yarn',
-};
-
-export const FETCH_PACKAGE_VERSIONS_ERROR = s__(
- 'PackageRegistry|Unable to fetch package version information.',
-);
-
-export const HISTORY_PIPELINES_LIMIT = 5;
diff --git a/app/assets/javascripts/packages/details/index.js b/app/assets/javascripts/packages/details/index.js
deleted file mode 100644
index 5b9d58a3860..00000000000
--- a/app/assets/javascripts/packages/details/index.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import PackagesApp from './components/app.vue';
-import createStore from './store';
-
-Vue.use(Translate);
-
-export default () => {
- const el = document.querySelector('#js-vue-packages-detail');
- const { package: packageJson, canDelete: canDeleteStr, ...rest } = el.dataset;
- const packageEntity = JSON.parse(packageJson);
- const canDelete = canDeleteStr === 'true';
-
- const store = createStore({
- packageEntity,
- packageFiles: packageEntity.package_files,
- canDelete,
- ...rest,
- });
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- PackagesApp,
- },
- store,
- render(createElement) {
- return createElement('packages-app');
- },
- });
-};
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
deleted file mode 100644
index ae273e26d6a..00000000000
--- a/app/assets/javascripts/packages/details/store/getters.js
+++ /dev/null
@@ -1,140 +0,0 @@
-import { PackageType } from '../../shared/constants';
-import { getPackageTypeLabel } from '../../shared/utils';
-import { NpmManager } from '../constants';
-
-export const packagePipeline = ({ packageEntity }) => {
- return packageEntity?.pipeline || null;
-};
-
-export const packageTypeDisplay = ({ packageEntity }) => {
- return getPackageTypeLabel(packageEntity.package_type);
-};
-
-export const packageIcon = ({ packageEntity }) => {
- if (packageEntity.package_type === PackageType.NUGET) {
- return packageEntity.nuget_metadatum?.icon_url || null;
- }
-
- return null;
-};
-
-export const conanInstallationCommand = ({ packageEntity }) => {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `conan install ${packageEntity.name} --remote=gitlab`;
-};
-
-export const conanSetupCommand = ({ conanPath }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `conan remote add gitlab ${conanPath}`;
-
-export const mavenInstallationXml = ({ packageEntity = {} }) => {
- const {
- app_group: appGroup = '',
- app_name: appName = '',
- app_version: appVersion = '',
- } = packageEntity.maven_metadatum;
-
- return `<dependency>
- <groupId>${appGroup}</groupId>
- <artifactId>${appName}</artifactId>
- <version>${appVersion}</version>
-</dependency>`;
-};
-
-export const mavenInstallationCommand = ({ packageEntity = {} }) => {
- const {
- app_group: group = '',
- app_name: name = '',
- app_version: version = '',
- } = packageEntity.maven_metadatum;
-
- return `mvn dependency:get -Dartifact=${group}:${name}:${version}`;
-};
-
-export const mavenSetupXml = ({ mavenPath }) => `<repositories>
- <repository>
- <id>gitlab-maven</id>
- <url>${mavenPath}</url>
- </repository>
-</repositories>
-
-<distributionManagement>
- <repository>
- <id>gitlab-maven</id>
- <url>${mavenPath}</url>
- </repository>
-
- <snapshotRepository>
- <id>gitlab-maven</id>
- <url>${mavenPath}</url>
- </snapshotRepository>
-</distributionManagement>`;
-
-export const npmInstallationCommand = ({ packageEntity }) => (type = NpmManager.NPM) => {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- const instruction = type === NpmManager.NPM ? 'npm i' : 'yarn add';
-
- return `${instruction} ${packageEntity.name}`;
-};
-
-export const npmSetupCommand = ({ packageEntity, npmPath }) => (type = NpmManager.NPM) => {
- const scope = packageEntity.name.substring(0, packageEntity.name.indexOf('/'));
-
- if (type === NpmManager.NPM) {
- return `echo ${scope}:registry=${npmPath}/ >> .npmrc`;
- }
-
- return `echo \\"${scope}:registry\\" \\"${npmPath}/\\" >> .yarnrc`;
-};
-
-export const nugetInstallationCommand = ({ packageEntity }) =>
- `nuget install ${packageEntity.name} -Source "GitLab"`;
-
-export const nugetSetupCommand = ({ nugetPath }) =>
- `nuget source Add -Name "GitLab" -Source "${nugetPath}" -UserName <your_username> -Password <your_token>`;
-
-export const pypiPipCommand = ({ pypiPath, packageEntity }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `pip install ${packageEntity.name} --extra-index-url ${pypiPath}`;
-
-export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab]
-repository = ${pypiSetupPath}
-username = __token__
-password = <your personal access token>`;
-
-export const composerRegistryInclude = ({ composerPath, composerConfigRepositoryName }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `composer config repositories.${composerConfigRepositoryName} '{"type": "composer", "url": "${composerPath}"}'`;
-
-export const composerPackageInclude = ({ packageEntity }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `composer req ${[packageEntity.name]}:${packageEntity.version}`;
-
-export const gradleGroovyInstalCommand = ({ packageEntity }) => {
- const {
- app_group: group = '',
- app_name: name = '',
- app_version: version = '',
- } = packageEntity.maven_metadatum;
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `implementation '${group}:${name}:${version}'`;
-};
-
-export const gradleGroovyAddSourceCommand = ({ mavenPath }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `maven {
- url '${mavenPath}'
-}`;
-
-export const gradleKotlinInstalCommand = ({ packageEntity }) => {
- const {
- app_group: group = '',
- app_name: name = '',
- app_version: version = '',
- } = packageEntity.maven_metadatum;
- return `implementation("${group}:${name}:${version}")`;
-};
-
-export const gradleKotlinAddSourceCommand = ({ mavenPath }) => `maven("${mavenPath}")`;
-
-export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0;
diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js
deleted file mode 100644
index 27cc95566d3..00000000000
--- a/app/assets/javascripts/packages/details/utils.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { TrackingActions } from './constants';
-
-export const trackInstallationTabChange = {
- methods: {
- trackInstallationTabChange(tabIndex) {
- const action = tabIndex === 0 ? TrackingActions.INSTALLATION : TrackingActions.REGISTRY_SETUP;
- this.track(action, { label: this.trackingLabel });
- },
- },
-};
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index f15c31b85c1..c284b8358b4 100644
--- a/app/assets/javascripts/packages/shared/constants.js
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -1,5 +1,4 @@
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export const PackageType = {
CONAN: 'conan',
@@ -38,7 +37,7 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
- __('PackageRegistry|Something went wrong while deleting the package file.'),
+ 'PackageRegistry|Something went wrong while deleting the package file.',
);
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
'PackageRegistry|Package file deleted successfully',
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
new file mode 100644
index 00000000000..73fb3656af1
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -0,0 +1,105 @@
+<script>
+import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import {
+ DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ DEPENDENCY_PROXY_DOCS_PATH,
+} from '~/packages_and_registries/settings/group/constants';
+
+import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlAlert,
+ GlFormInputGroup,
+ GlSprintf,
+ ClipboardButton,
+ TitleArea,
+ GlSkeletonLoader,
+ },
+ inject: ['groupPath', 'dependencyProxyAvailable'],
+ i18n: {
+ proxyNotAvailableText: __('Dependency Proxy feature is limited to public groups for now.'),
+ proxyDisabledText: __('Dependency Proxy disabled. To enable it, contact the group owner.'),
+ proxyImagePrefix: __('Dependency Proxy image prefix'),
+ copyImagePrefixText: __('Copy prefix'),
+ blobCountAndSize: __('Contains %{count} blobs of images (%{size})'),
+ },
+ data() {
+ return {
+ group: {},
+ };
+ },
+ apollo: {
+ group: {
+ query: getDependencyProxyDetailsQuery,
+ skip() {
+ return !this.dependencyProxyAvailable;
+ },
+ variables() {
+ return { fullPath: this.groupPath };
+ },
+ },
+ },
+ computed: {
+ infoMessages() {
+ return [
+ {
+ text: DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ link: DEPENDENCY_PROXY_DOCS_PATH,
+ },
+ ];
+ },
+ dependencyProxyEnabled() {
+ return this.group?.dependencyProxySetting?.enabled;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <title-area :title="__('Dependency Proxy')" :info-messages="infoMessages" />
+ <gl-alert
+ v-if="!dependencyProxyAvailable"
+ :dismissible="false"
+ data-testid="proxy-not-available"
+ >
+ {{ $options.i18n.proxyNotAvailableText }}
+ </gl-alert>
+
+ <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" />
+
+ <div v-else-if="dependencyProxyEnabled" data-testid="main-area">
+ <gl-form-group :label="$options.i18n.proxyImagePrefix">
+ <gl-form-input-group
+ readonly
+ :value="group.dependencyProxyImagePrefix"
+ class="gl-layout-w-limited"
+ data-testid="proxy-url"
+ >
+ <template #append>
+ <clipboard-button
+ :text="group.dependencyProxyImagePrefix"
+ :title="$options.i18n.copyImagePrefixText"
+ />
+ </template>
+ </gl-form-input-group>
+ <template #description>
+ <span data-qa-selector="dependency_proxy_count" data-testid="proxy-count">
+ <gl-sprintf :message="$options.i18n.blobCountAndSize">
+ <template #count>{{ group.dependencyProxyBlobCount }}</template>
+ <template #size>{{ group.dependencyProxyTotalSize }}</template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </gl-form-group>
+ </div>
+ <gl-alert v-else :dismissible="false" data-testid="proxy-disabled">
+ {{ $options.i18n.proxyDisabledText }}
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
new file mode 100644
index 00000000000..16152eb81f6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
new file mode 100644
index 00000000000..9058d349bf3
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
@@ -0,0 +1,10 @@
+query getDependencyProxyDetails($fullPath: ID!) {
+ group(fullPath: $fullPath) {
+ dependencyProxyBlobCount
+ dependencyProxyTotalSize
+ dependencyProxyImagePrefix
+ dependencyProxySetting {
+ enabled
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
new file mode 100644
index 00000000000..dc73470e07d
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import app from '~/packages_and_registries/dependency_proxy/app.vue';
+import { apolloProvider } from '~/packages_and_registries/dependency_proxy/graphql';
+import Translate from '~/vue_shared/translate';
+
+Vue.use(Translate);
+
+export const initDependencyProxyApp = () => {
+ const el = document.getElementById('js-dependency-proxy');
+ if (!el) {
+ return null;
+ }
+ const { dependencyProxyAvailable, ...dataset } = el.dataset;
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ dependencyProxyAvailable: parseBoolean(dependencyProxyAvailable),
+ ...dataset,
+ },
+ render(createElement) {
+ return createElement(app);
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
index 59da32e6666..6016757c1b9 100644
--- a/app/assets/javascripts/packages/details/components/app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
@@ -1,6 +1,5 @@
<script>
import {
- GlBadge,
GlButton,
GlModal,
GlModalDirective,
@@ -14,36 +13,30 @@ import { mapActions, mapState } from 'vuex';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { objectToQuery } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
+import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue';
+import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue';
import Tracking from '~/tracking';
-import PackageListRow from '../../shared/components/package_list_row.vue';
-import PackagesListLoader from '../../shared/components/packages_list_loader.vue';
-import { PackageType, TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '../../shared/constants';
-import { packageTypeToTrackCategory } from '../../shared/utils';
-import AdditionalMetadata from './additional_metadata.vue';
-import DependencyRow from './dependency_row.vue';
-import InstallationCommands from './installation_commands.vue';
+import PackageListRow from '~/packages/shared/components/package_list_row.vue';
+import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import { TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import { packageTypeToTrackCategory } from '~/packages/shared/utils';
import PackageFiles from './package_files.vue';
import PackageHistory from './package_history.vue';
export default {
name: 'PackagesApp',
components: {
- GlBadge,
GlButton,
GlEmptyState,
GlModal,
GlTab,
GlTabs,
GlSprintf,
- PackageTitle: () => import('./package_title.vue'),
- TerraformTitle: () =>
- import('~/packages_and_registries/infrastructure_registry/components/details_title.vue'),
+ TerraformTitle,
PackagesListLoader,
PackageListRow,
- DependencyRow,
PackageHistory,
- AdditionalMetadata,
- InstallationCommands,
+ TerraformInstallation,
PackageFiles,
},
directives: {
@@ -51,12 +44,6 @@ export default {
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin()],
- inject: {
- titleComponent: {
- default: 'PackageTitle',
- from: 'titleComponent',
- },
- },
trackingActions: { ...TrackingActions },
data() {
return {
@@ -87,15 +74,6 @@ export default {
hasVersions() {
return this.packageEntity.versions?.length > 0;
},
- packageDependencies() {
- return this.packageEntity.dependency_links || [];
- },
- showDependencies() {
- return this.packageEntity.package_type === PackageType.NUGET;
- },
- showFiles() {
- return this.packageEntity?.package_type !== PackageType.COMPOSER;
- },
},
methods: {
...mapActions(['deletePackage', 'fetchPackageVersions', 'deletePackageFile']),
@@ -167,7 +145,7 @@ export default {
/>
<div v-else class="packages-app">
- <component :is="titleComponent">
+ <terraform-title>
<template #delete-button>
<gl-button
v-if="canDelete"
@@ -180,24 +158,16 @@ export default {
{{ __('Delete') }}
</gl-button>
</template>
- </component>
+ </terraform-title>
<gl-tabs>
<gl-tab :title="__('Detail')">
<div data-qa-selector="package_information_content">
<package-history :package-entity="packageEntity" :project-name="projectName" />
-
- <installation-commands
- :package-entity="packageEntity"
- :npm-path="npmPath"
- :npm-help-path="npmHelpPath"
- />
-
- <additional-metadata :package-entity="packageEntity" />
+ <terraform-installation />
</div>
<package-files
- v-if="showFiles"
:package-files="packageFiles"
:can-delete="canDelete"
@download-file="track($options.trackingActions.PULL_PACKAGE)"
@@ -205,27 +175,6 @@ export default {
/>
</gl-tab>
- <gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab">
- <template #title>
- <span>{{ __('Dependencies') }}</span>
- <gl-badge size="sm" data-testid="dependencies-badge">{{
- packageDependencies.length
- }}</gl-badge>
- </template>
-
- <template v-if="packageDependencies.length > 0">
- <dependency-row
- v-for="(dep, index) in packageDependencies"
- :key="index"
- :dependency="dep"
- />
- </template>
-
- <p v-else class="gl-mt-3" data-testid="no-dependencies-message">
- {{ s__('PackageRegistry|This NuGet package has no dependencies.') }}
- </p>
- </gl-tab>
-
<gl-tab
:title="__('Other versions')"
title-item-class="js-versions-tab"
@@ -254,7 +203,6 @@ export default {
<gl-modal
ref="deleteModal"
- class="js-delete-modal"
modal-id="delete-modal"
:action-primary="$options.modal.packageDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue
index 3e551706ed0..3e551706ed0 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue
diff --git a/app/assets/javascripts/packages/details/components/file_sha.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/file_sha.vue
index a25839be7e1..a25839be7e1 100644
--- a/app/assets/javascripts/packages/details/components/file_sha.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/file_sha.vue
diff --git a/app/assets/javascripts/packages/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
index 0563b612d04..ab4cfccd023 100644
--- a/app/assets/javascripts/packages/details/components/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
@@ -3,10 +3,10 @@ import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@
import { last } from 'lodash';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
-import FileSha from '~/packages/details/components/file_sha.vue';
import Tracking from '~/tracking';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import FileSha from './file_sha.vue';
export default {
name: 'PackageFiles',
diff --git a/app/assets/javascripts/packages/details/components/package_history.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue
index 27d2f208a42..e5be98b87f7 100644
--- a/app/assets/javascripts/packages/details/components/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue
@@ -1,10 +1,9 @@
<script>
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { GlLink, GlSprintf } from '@gitlab/ui';
import { first } from 'lodash';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, n__ } from '~/locale';
-import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants';
+import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants';
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -20,8 +19,6 @@ export default {
combinedUpdateText: s__(
'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}',
),
- archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'),
- archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'),
},
components: {
GlLink,
@@ -57,14 +54,14 @@ export default {
showPipelinesInfo() {
return Boolean(this.firstPipeline?.id);
},
- archiviedLines() {
+ archivedLines() {
return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0);
},
archivedPipelineMessage() {
return n__(
- this.$options.i18n.archivedPipelineMessageSingular,
- this.$options.i18n.archivedPipelineMessagePlural,
- this.archiviedLines,
+ 'PackageRegistry|Package has %{updatesCount} archived update',
+ 'PackageRegistry|Package has %{updatesCount} archived updates',
+ this.archivedLines,
);
},
},
@@ -133,10 +130,10 @@ export default {
</gl-sprintf>
</history-item>
- <history-item v-if="archiviedLines" icon="history" data-testid="archived">
+ <history-item v-if="archivedLines" icon="history" data-testid="archived">
<gl-sprintf :message="archivedPipelineMessage">
- <template #number>
- <strong>{{ archiviedLines }}</strong>
+ <template #updatesCount>
+ <strong>{{ archivedLines }}</strong>
</template>
</gl-sprintf>
</history-item>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue
index c62bf7fb722..c62bf7fb722 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js
new file mode 100644
index 00000000000..c0c67faffba
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js
@@ -0,0 +1,5 @@
+import { s__ } from '~/locale';
+
+export const FETCH_PACKAGE_VERSIONS_ERROR = s__(
+ 'PackageRegistry|Unable to fetch package version information.',
+);
diff --git a/app/assets/javascripts/packages/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
index a03fa8d9d63..a03fa8d9d63 100644
--- a/app/assets/javascripts/packages/details/store/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js
new file mode 100644
index 00000000000..6a17e7aa6d6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js
@@ -0,0 +1,3 @@
+export const packagePipeline = ({ packageEntity }) => {
+ return packageEntity?.pipeline || null;
+};
diff --git a/app/assets/javascripts/packages/details/store/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js
index 15e17bcfaac..15e17bcfaac 100644
--- a/app/assets/javascripts/packages/details/store/index.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js
diff --git a/app/assets/javascripts/packages/details/store/mutation_types.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutation_types.js
index 590f2d9f970..590f2d9f970 100644
--- a/app/assets/javascripts/packages/details/store/mutation_types.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutation_types.js
diff --git a/app/assets/javascripts/packages/details/store/mutations.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutations.js
index 762fd5a4040..762fd5a4040 100644
--- a/app/assets/javascripts/packages/details/store/mutations.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutations.js
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js
index 98942b1e578..32fbc9382fd 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-import PackagesApp from '~/packages/details/components/app.vue';
-import createStore from '~/packages/details/store';
+import PackagesApp from '~/packages_and_registries/infrastructure_registry/details/components/app.vue';
+import createStore from '~/packages_and_registries/infrastructure_registry/details/store';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
index f0da7db6c91..1360b03856f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
@@ -24,7 +24,13 @@ export default {
<template>
<div>
- <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source">
+ <details-row
+ v-if="packageEntity.metadata.projectUrl"
+ icon="project"
+ padding="gl-p-4"
+ dashed
+ data-testid="nuget-source"
+ >
<gl-sprintf :message="$options.i18n.sourceText">
<template #link>
<gl-link :href="packageEntity.metadata.projectUrl" target="_blank">{{
@@ -33,7 +39,12 @@ export default {
</template>
</gl-sprintf>
</details-row>
- <details-row icon="license" padding="gl-p-4" data-testid="nuget-license">
+ <details-row
+ v-if="packageEntity.metadata.licenseUrl"
+ icon="license"
+ padding="gl-p-4"
+ data-testid="nuget-license"
+ >
<gl-sprintf :message="$options.i18n.licenseText">
<template #link>
<gl-link :href="packageEntity.metadata.licenseUrl" target="_blank">{{
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue
index 47081e23318..2448324549e 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlSprintf, GlFormRadioGroup } from '@gitlab/ui';
import { s__ } from '~/locale';
import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
@@ -11,6 +11,8 @@ import {
TRACKING_LABEL_CODE_INSTRUCTION,
NPM_PACKAGE_MANAGER,
YARN_PACKAGE_MANAGER,
+ PROJECT_PACKAGE_ENDPOINT_TYPE,
+ INSTANCE_PACKAGE_ENDPOINT_TYPE,
} from '~/packages_and_registries/package_registry/constants';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
@@ -21,8 +23,9 @@ export default {
CodeInstruction,
GlLink,
GlSprintf,
+ GlFormRadioGroup,
},
- inject: ['npmHelpPath', 'npmPath'],
+ inject: ['npmHelpPath', 'npmPath', 'npmProjectPath'],
props: {
packageEntity: {
type: Object,
@@ -32,6 +35,7 @@ export default {
data() {
return {
instructionType: NPM_PACKAGE_MANAGER,
+ packageEndpointType: INSTANCE_PACKAGE_ENDPOINT_TYPE,
};
},
computed: {
@@ -39,13 +43,13 @@ export default {
return this.npmInstallationCommand(NPM_PACKAGE_MANAGER);
},
npmSetup() {
- return this.npmSetupCommand(NPM_PACKAGE_MANAGER);
+ return this.npmSetupCommand(NPM_PACKAGE_MANAGER, this.packageEndpointType);
},
yarnCommand() {
return this.npmInstallationCommand(YARN_PACKAGE_MANAGER);
},
yarnSetupCommand() {
- return this.npmSetupCommand(YARN_PACKAGE_MANAGER);
+ return this.npmSetupCommand(YARN_PACKAGE_MANAGER, this.packageEndpointType);
},
showNpm() {
return this.instructionType === NPM_PACKAGE_MANAGER;
@@ -58,14 +62,16 @@ export default {
return `${instruction} ${this.packageEntity.name}`;
},
- npmSetupCommand(type) {
+ npmSetupCommand(type, endpointType) {
const scope = this.packageEntity.name.substring(0, this.packageEntity.name.indexOf('/'));
+ const npmPathForEndpoint =
+ endpointType === INSTANCE_PACKAGE_ENDPOINT_TYPE ? this.npmPath : this.npmProjectPath;
if (type === NPM_PACKAGE_MANAGER) {
- return `echo ${scope}:registry=${this.npmPath}/ >> .npmrc`;
+ return `echo ${scope}:registry=${npmPathForEndpoint}/ >> .npmrc`;
}
- return `echo \\"${scope}:registry\\" \\"${this.npmPath}/\\" >> .yarnrc`;
+ return `echo \\"${scope}:registry\\" \\"${npmPathForEndpoint}/\\" >> .yarnrc`;
},
},
packageManagers: {
@@ -87,6 +93,10 @@ export default {
{ value: NPM_PACKAGE_MANAGER, label: s__('PackageRegistry|Show NPM commands') },
{ value: YARN_PACKAGE_MANAGER, label: s__('PackageRegistry|Show Yarn commands') },
],
+ packageEndpointTypeOptions: [
+ { value: INSTANCE_PACKAGE_ENDPOINT_TYPE, text: s__('PackageRegistry|Instance-level') },
+ { value: PROJECT_PACKAGE_ENDPOINT_TYPE, text: s__('PackageRegistry|Project-level') },
+ ],
};
</script>
@@ -116,6 +126,12 @@ export default {
<h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
+ <gl-form-radio-group
+ :options="$options.packageEndpointTypeOptions"
+ :checked="packageEndpointType"
+ @change="packageEndpointType = $event"
+ />
+
<code-instruction
v-if="showNpm"
:instruction="npmSetup"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
index 408bd2e3dfe..af6bd7079ba 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
@@ -1,11 +1,10 @@
<script>
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { GlLink, GlSprintf } from '@gitlab/ui';
import { first } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, n__ } from '~/locale';
-import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants';
+import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants';
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -21,8 +20,6 @@ export default {
combinedUpdateText: s__(
'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}',
),
- archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'),
- archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'),
},
components: {
GlLink,
@@ -58,14 +55,14 @@ export default {
showPipelinesInfo() {
return Boolean(this.firstPipeline?.id);
},
- archiviedLines() {
+ archivedLines() {
return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0);
},
archivedPipelineMessage() {
return n__(
- this.$options.i18n.archivedPipelineMessageSingular,
- this.$options.i18n.archivedPipelineMessagePlural,
- this.archiviedLines,
+ 'PackageRegistry|Package has %{updatesCount} archived update',
+ 'PackageRegistry|Package has %{updatesCount} archived updates',
+ this.archivedLines,
);
},
},
@@ -135,10 +132,10 @@ export default {
</gl-sprintf>
</history-item>
- <history-item v-if="archiviedLines" icon="history" data-testid="archived">
+ <history-item v-if="archivedLines" icon="history" data-testid="archived">
<gl-sprintf :message="archivedPipelineMessage">
- <template #number>
- <strong>{{ archiviedLines }}</strong>
+ <template #updatesCount>
+ <strong>{{ archivedLines }}</strong>
</template>
</gl-sprintf>
</history-item>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
new file mode 100644
index 00000000000..08481ac5655
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
@@ -0,0 +1,134 @@
+<script>
+/*
+ * The following component has several commented lines, this is because we are refactoring them piece by piece on several mrs
+ * For a complete overview of the plan please check: https://gitlab.com/gitlab-org/gitlab/-/issues/330846
+ * This work is behind feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/341136
+ */
+// import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { historyReplaceState } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
+import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
+import {
+ PROJECT_RESOURCE_TYPE,
+ GROUP_RESOURCE_TYPE,
+ LIST_QUERY_DEBOUNCE_TIME,
+} from '~/packages_and_registries/package_registry/constants';
+import PackageTitle from './package_title.vue';
+import PackageSearch from './package_search.vue';
+// import PackageList from './packages_list.vue';
+
+export default {
+ components: {
+ // GlEmptyState,
+ // GlLink,
+ // GlSprintf,
+ // PackageList,
+ PackageTitle,
+ PackageSearch,
+ },
+ inject: [
+ 'packageHelpUrl',
+ 'emptyListIllustration',
+ 'emptyListHelpUrl',
+ 'isGroupPage',
+ 'fullPath',
+ ],
+ data() {
+ return {
+ packages: {},
+ sort: '',
+ filters: {},
+ };
+ },
+ apollo: {
+ packages: {
+ query: getPackagesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data[this.graphqlResource].packages;
+ },
+ debounce: LIST_QUERY_DEBOUNCE_TIME,
+ },
+ },
+ computed: {
+ queryVariables() {
+ return {
+ isGroupPage: this.isGroupPage,
+ fullPath: this.fullPath,
+ sort: this.isGroupPage ? undefined : this.sort,
+ groupSort: this.isGroupPage ? this.sort : undefined,
+ packageName: this.filters?.packageName,
+ packageType: this.filters?.packageType,
+ };
+ },
+ graphqlResource() {
+ return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE;
+ },
+ packagesCount() {
+ return this.packages?.count;
+ },
+ hasFilters() {
+ return this.filters.packageName && this.filters.packageType;
+ },
+ emptyStateTitle() {
+ return this.emptySearch
+ ? this.$options.i18n.emptyPageTitle
+ : this.$options.i18n.noResultsTitle;
+ },
+ },
+ mounted() {
+ this.checkDeleteAlert();
+ },
+ methods: {
+ checkDeleteAlert() {
+ const urlParams = new URLSearchParams(window.location.search);
+ const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
+ if (showAlert) {
+ // to be refactored to use gl-alert
+ createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' });
+ const cleanUrl = window.location.href.split('?')[0];
+ historyReplaceState(cleanUrl);
+ }
+ },
+ handleSearchUpdate({ sort, filters }) {
+ this.sort = sort;
+ this.filters = { ...filters };
+ },
+ },
+ i18n: {
+ widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
+ emptyPageTitle: s__('PackageRegistry|There are no packages yet'),
+ noResultsTitle: s__('PackageRegistry|Sorry, your filter produced no results'),
+ noResultsText: s__(
+ 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <package-title :help-url="packageHelpUrl" :count="packagesCount" />
+ <package-search @update="handleSearchUpdate" />
+
+ <!-- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
+ <template #empty-state>
+ <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
+ <template #description>
+ <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
+ <gl-sprintf v-else :message="$options.i18n.noResultsText">
+ <template #noPackagesLink="{ content }">
+ <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-empty-state>
+ </template>
+ </package-list> -->
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
new file mode 100644
index 00000000000..195ff7af583
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -0,0 +1,151 @@
+<script>
+import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import {
+ PACKAGE_ERROR_STATUS,
+ PACKAGE_DEFAULT_STATUS,
+} from '~/packages_and_registries/package_registry/constants';
+import { getPackageTypeLabel } from '~/packages/shared/utils';
+import PackagePath from '~/packages/shared/components/package_path.vue';
+import PackageTags from '~/packages/shared/components/package_tags.vue';
+import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue';
+import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ name: 'PackageListRow',
+ components: {
+ GlButton,
+ GlLink,
+ GlSprintf,
+ GlTruncate,
+ PackageTags,
+ PackagePath,
+ PublishMethod,
+ ListItem,
+ PackageIconAndName,
+ TimeagoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['isGroupPage'],
+ props: {
+ packageEntity: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ packageType() {
+ return getPackageTypeLabel(this.packageEntity.packageType.toLowerCase());
+ },
+ packageLink() {
+ const { project, id } = this.packageEntity;
+ return `${project?.webUrl}/-/packages/${getIdFromGraphQLId(id)}`;
+ },
+ pipeline() {
+ return this.packageEntity?.pipelines?.nodes[0];
+ },
+ pipelineUser() {
+ return this.pipeline?.user?.name;
+ },
+ showWarningIcon() {
+ return this.packageEntity.status === PACKAGE_ERROR_STATUS;
+ },
+ showTags() {
+ return Boolean(this.packageEntity.tags?.nodes?.length);
+ },
+ disabledRow() {
+ return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS;
+ },
+ },
+ i18n: {
+ erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'),
+ },
+};
+</script>
+
+<template>
+ <list-item data-qa-selector="package_row" :disabled="disabledRow">
+ <template #left-primary>
+ <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
+ <gl-link
+ :href="packageLink"
+ class="gl-text-body gl-min-w-0"
+ data-qa-selector="package_link"
+ :disabled="disabledRow"
+ >
+ <gl-truncate :text="packageEntity.name" />
+ </gl-link>
+
+ <gl-button
+ v-if="showWarningIcon"
+ v-gl-tooltip="{ title: $options.i18n.erroredPackageText }"
+ class="gl-hover-bg-transparent!"
+ icon="warning"
+ category="tertiary"
+ data-testid="warning-icon"
+ :aria-label="__('Warning')"
+ />
+
+ <package-tags
+ v-if="showTags"
+ class="gl-ml-3"
+ :tags="packageEntity.tags.nodes"
+ hide-label
+ :tag-display-limit="1"
+ />
+ </div>
+ </template>
+ <template #left-secondary>
+ <div class="gl-display-flex" data-testid="left-secondary-infos">
+ <span>{{ packageEntity.version }}</span>
+
+ <div v-if="pipelineUser" class="gl-display-none gl-sm-display-flex gl-ml-2">
+ <gl-sprintf :message="s__('PackageRegistry|published by %{author}')">
+ <template #author>{{ pipelineUser }}</template>
+ </gl-sprintf>
+ </div>
+
+ <package-icon-and-name>
+ {{ packageType }}
+ </package-icon-and-name>
+
+ <package-path
+ v-if="isGroupPage"
+ :path="packageEntity.project.fullPath"
+ :disabled="disabledRow"
+ />
+ </div>
+ </template>
+
+ <template #right-primary>
+ <publish-method :pipeline="pipeline" />
+ </template>
+
+ <template #right-secondary>
+ <span>
+ <gl-sprintf :message="__('Created %{timestamp}')">
+ <template #timestamp>
+ <timeago-tooltip :time="packageEntity.createdAt" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+
+ <template v-if="!disabledRow" #right-action>
+ <gl-button
+ data-testid="action-delete"
+ icon="remove"
+ category="secondary"
+ variant="danger"
+ :title="s__('PackageRegistry|Remove package')"
+ :aria-label="s__('PackageRegistry|Remove package')"
+ @click="$emit('packageToDelete', packageEntity)"
+ />
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
index 280d292ce0b..836df59ca58 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
@@ -1,10 +1,14 @@
<script>
-import { mapState, mapActions } from 'vuex';
import { s__ } from '~/locale';
import { sortableFields } from '~/packages/list/utils';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
+import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
+import {
+ FILTERED_SEARCH_TERM,
+ FILTERED_SEARCH_TYPE,
+} from '~/packages_and_registries/shared/constants';
import PackageTypeToken from './tokens/package_type_token.vue';
export default {
@@ -19,21 +23,71 @@ export default {
},
],
components: { RegistrySearch, UrlSync },
+ inject: ['isGroupPage'],
+ data() {
+ return {
+ filters: [],
+ sorting: {
+ orderBy: 'name',
+ sort: 'desc',
+ },
+ mountRegistrySearch: false,
+ };
+ },
computed: {
- ...mapState({
- isGroupPage: (state) => state.config.isGroupPage,
- sorting: (state) => state.sorting,
- filter: (state) => state.filter,
- }),
sortableFields() {
return sortableFields(this.isGroupPage);
},
+ parsedSorting() {
+ const cleanOrderBy = this.sorting?.orderBy.replace('_at', '');
+ return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase();
+ },
+ parsedFilters() {
+ const parsed = {
+ packageName: '',
+ packageType: undefined,
+ };
+
+ return this.filters.reduce((acc, filter) => {
+ if (filter.type === FILTERED_SEARCH_TYPE && filter.value?.data) {
+ return {
+ ...acc,
+ packageType: filter.value.data.toUpperCase(),
+ };
+ }
+
+ if (filter.type === FILTERED_SEARCH_TERM) {
+ return {
+ ...acc,
+ packageName: `${acc.packageName} ${filter.value.data}`.trim(),
+ };
+ }
+
+ return acc;
+ }, parsed);
+ },
+ },
+ mounted() {
+ const queryParams = getQueryParams(window.document.location.search);
+ const { sorting, filters } = extractFilterAndSorting(queryParams);
+ this.updateSorting(sorting);
+ this.updateFilters(filters);
+ this.mountRegistrySearch = true;
+ this.emitUpdate();
},
methods: {
- ...mapActions(['setSorting', 'setFilter']),
+ updateFilters(newValue) {
+ this.filters = newValue;
+ },
updateSorting(newValue) {
- this.setSorting(newValue);
- this.$emit('update');
+ this.sorting = { ...this.sorting, ...newValue };
+ },
+ updateSortingAndEmitUpdate(newValue) {
+ this.updateSorting(newValue);
+ this.emitUpdate();
+ },
+ emitUpdate() {
+ this.$emit('update', { sort: this.parsedSorting, filters: this.parsedFilters });
},
},
};
@@ -43,13 +97,14 @@ export default {
<url-sync>
<template #default="{ updateQuery }">
<registry-search
- :filter="filter"
+ v-if="mountRegistrySearch"
+ :filter="filters"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
- @sorting:changed="updateSorting"
- @filter:changed="setFilter"
- @filter:submit="$emit('update')"
+ @sorting:changed="updateSortingAndEmitUpdate"
+ @filter:changed="updateFilters"
+ @filter:submit="emitUpdate"
@query:changed="updateQuery"
/>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue
deleted file mode 100644
index 75fbdb80192..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue
+++ /dev/null
@@ -1,132 +0,0 @@
-<script>
-import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import createFlash from '~/flash';
-import { historyReplaceState } from '~/lib/utils/common_utils';
-import { s__ } from '~/locale';
-import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
-import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
-import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
-import PackageList from './packages_list.vue';
-
-export default {
- components: {
- GlEmptyState,
- GlLink,
- GlSprintf,
- PackageList,
- PackageTitle: () =>
- import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'),
- PackageSearch: () =>
- import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'),
- InfrastructureTitle: () =>
- import(
- /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'
- ),
- InfrastructureSearch: () =>
- import(
- /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'
- ),
- },
- inject: {
- titleComponent: {
- from: 'titleComponent',
- default: 'PackageTitle',
- },
- searchComponent: {
- from: 'searchComponent',
- default: 'PackageSearch',
- },
- emptyPageTitle: {
- from: 'emptyPageTitle',
- default: s__('PackageRegistry|There are no packages yet'),
- },
- noResultsText: {
- from: 'noResultsText',
- default: s__(
- 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
- ),
- },
- },
- computed: {
- ...mapState({
- emptyListIllustration: (state) => state.config.emptyListIllustration,
- emptyListHelpUrl: (state) => state.config.emptyListHelpUrl,
- filter: (state) => state.filter,
- selectedType: (state) => state.selectedType,
- packageHelpUrl: (state) => state.config.packageHelpUrl,
- packagesCount: (state) => state.pagination?.total,
- }),
- emptySearch() {
- return (
- this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0
- );
- },
-
- emptyStateTitle() {
- return this.emptySearch
- ? this.emptyPageTitle
- : s__('PackageRegistry|Sorry, your filter produced no results');
- },
- },
- mounted() {
- const queryParams = getQueryParams(window.document.location.search);
- const { sorting, filters } = extractFilterAndSorting(queryParams);
- this.setSorting(sorting);
- this.setFilter(filters);
- this.requestPackagesList();
- this.checkDeleteAlert();
- },
- methods: {
- ...mapActions([
- 'requestPackagesList',
- 'requestDeletePackage',
- 'setSelectedType',
- 'setSorting',
- 'setFilter',
- ]),
- onPageChanged(page) {
- return this.requestPackagesList({ page });
- },
- onPackageDeleteRequest(item) {
- return this.requestDeletePackage(item);
- },
- checkDeleteAlert() {
- const urlParams = new URLSearchParams(window.location.search);
- const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
- if (showAlert) {
- // to be refactored to use gl-alert
- createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' });
- const cleanUrl = window.location.href.split('?')[0];
- historyReplaceState(cleanUrl);
- }
- },
- },
- i18n: {
- widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
- },
-};
-</script>
-
-<template>
- <div>
- <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" />
- <component :is="searchComponent" @update="requestPackagesList" />
-
- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
- <template #empty-state>
- <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
- <template #description>
- <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" />
- <gl-sprintf v-else :message="noResultsText">
- <template #noPackagesLink="{ content }">
- <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
- </gl-empty-state>
- </template>
- </package-list>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue
new file mode 100644
index 00000000000..8ecf433f3ab
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ name: 'PublishMethod',
+ components: {
+ ClipboardButton,
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ hasPipeline() {
+ return Boolean(this.pipeline);
+ },
+ packageShaShort() {
+ return this.pipeline?.sha?.substring(0, 8);
+ },
+ },
+ i18n: {
+ COPY_COMMIT_SHA: __('Copy commit SHA'),
+ MANUALLY_PUBLISHED: s__('PackageRegistry|Manually Published'),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <template v-if="hasPipeline">
+ <gl-icon name="git-merge" class="gl-mr-2" />
+ <span data-testid="pipeline-ref" class="gl-mr-2">{{ pipeline.ref }}</span>
+
+ <gl-icon name="commit" class="gl-mr-2" />
+ <gl-link data-testid="pipeline-sha" :href="pipeline.commitPath" class="gl-mr-2">{{
+ packageShaShort
+ }}</gl-link>
+
+ <clipboard-button
+ :text="pipeline.sha"
+ :title="$options.i18n.COPY_COMMIT_SHA"
+ category="tertiary"
+ size="small"
+ />
+ </template>
+
+ <template v-else>
+ <gl-icon name="upload" class="gl-mr-2" />
+ <span data-testid="manually-published">
+ {{ $options.i18n.MANUALLY_PUBLISHED }}
+ </span>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index f023b4481a0..6a88880fa90 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -1,5 +1,4 @@
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export const PACKAGE_TYPE_CONAN = 'CONAN';
export const PACKAGE_TYPE_MAVEN = 'MAVEN';
@@ -71,7 +70,7 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
- __('PackageRegistry|Something went wrong while deleting the package file.'),
+ 'PackageRegistry|Something went wrong while deleting the package file.',
);
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
'PackageRegistry|Package file deleted successfully',
@@ -87,3 +86,10 @@ export const PACKAGE_PROCESSING_STATUS = 'PROCESSING';
export const NPM_PACKAGE_MANAGER = 'npm';
export const YARN_PACKAGE_MANAGER = 'yarn';
+
+export const PROJECT_PACKAGE_ENDPOINT_TYPE = 'project';
+export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance';
+
+export const PROJECT_RESOURCE_TYPE = 'project';
+export const GROUP_RESOURCE_TYPE = 'group';
+export const LIST_QUERY_DEBOUNCE_TIME = 50;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
new file mode 100644
index 00000000000..aaf0eb54aff
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
@@ -0,0 +1,27 @@
+fragment PackageData on Package {
+ id
+ name
+ version
+ packageType
+ createdAt
+ status
+ tags {
+ nodes {
+ name
+ }
+ }
+ pipelines {
+ nodes {
+ sha
+ ref
+ commitPath
+ user {
+ name
+ }
+ }
+ }
+ project {
+ fullPath
+ webUrl
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
new file mode 100644
index 00000000000..74e6de87866
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
@@ -0,0 +1,27 @@
+#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
+
+query getPackages(
+ $fullPath: ID!
+ $isGroupPage: Boolean!
+ $sort: PackageSort
+ $groupSort: PackageGroupSort
+ $packageName: String
+ $packageType: PackageTypeEnum
+) {
+ project(fullPath: $fullPath) @skip(if: $isGroupPage) {
+ packages(sort: $sort, packageName: $packageName, packageType: $packageType) {
+ count
+ nodes {
+ ...PackageData
+ }
+ }
+ }
+ group(fullPath: $fullPath) @include(if: $isGroupPage) {
+ packages(sort: $groupSort, packageName: $packageName, packageType: $packageType) {
+ count
+ nodes {
+ ...PackageData
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
index 1e01b75aabc..d797a0a5327 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
@@ -1,14 +1,22 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
-import PackagesListApp from '../components/list/packages_list_app.vue';
+import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index';
+import PackagesListApp from '../components/list/app.vue';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-vue-packages-list');
+ const isGroupPage = el.dataset.pageType === 'groups';
+
return new Vue({
el,
+ apolloProvider,
+ provide: {
+ ...el.dataset,
+ isGroupPage,
+ },
render(createElement) {
return createElement(PackagesListApp);
},
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
index 5cd8261ac23..9b5a0d221b8 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
@@ -19,6 +19,7 @@ export default () => {
apolloProvider,
provide: {
defaultExpanded: parseBoolean(el.dataset.defaultExpanded),
+ dependencyProxyAvailable: parseBoolean(el.dataset.dependencyProxyAvailable),
groupPath: el.dataset.groupPath,
},
render(createElement) {
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
new file mode 100644
index 00000000000..2dbe36def0e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlToggle, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql';
+import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
+import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+
+import {
+ DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ DEPENDENCY_PROXY_DOCS_PATH,
+} from '~/packages_and_registries/settings/group/constants';
+
+export default {
+ name: 'DependencyProxySettings',
+ components: {
+ GlToggle,
+ GlSprintf,
+ GlLink,
+ SettingsBlock,
+ },
+ i18n: {
+ DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ label: s__('DependencyProxy|Enable Proxy'),
+ },
+ links: {
+ DEPENDENCY_PROXY_DOCS_PATH,
+ },
+ inject: ['defaultExpanded', 'groupPath'],
+ props: {
+ dependencyProxySettings: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ enabled: {
+ get() {
+ return this.dependencyProxySettings.enabled;
+ },
+ set(enabled) {
+ this.updateSettings({ enabled });
+ },
+ },
+ },
+ methods: {
+ async updateSettings(payload) {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: updateDependencyProxySettings,
+ variables: {
+ input: {
+ groupPath: this.groupPath,
+ ...payload,
+ },
+ },
+ update: updateGroupPackageSettings(this.groupPath),
+ optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({
+ ...this.dependencyProxySettings,
+ ...payload,
+ }),
+ });
+
+ if (data.updateDependencyProxySettings?.errors?.length > 0) {
+ throw new Error();
+ } else {
+ this.$emit('success');
+ }
+ } catch {
+ this.$emit('error');
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <settings-block
+ :default-expanded="defaultExpanded"
+ data-qa-selector="dependency_proxy_settings_content"
+ >
+ <template #title> {{ $options.i18n.DEPENDENCY_PROXY_HEADER }} </template>
+ <template #description>
+ <span data-testid="description">
+ <gl-sprintf :message="$options.i18n.DEPENDENCY_PROXY_SETTINGS_DESCRIPTION">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #default>
+ <div>
+ <gl-toggle
+ v-model="enabled"
+ :disabled="isLoading"
+ :label="$options.i18n.label"
+ data-qa-selector="dependency_proxy_setting_toggle"
+ />
+ </div>
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
index d66a30e7e81..b0088838acc 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
@@ -86,6 +86,7 @@ export default {
:label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
label-position="hidden"
:value="duplicatesAllowed"
+ :disabled="loading"
@change="update(modelNames.allowed, $event)"
/>
<div class="gl-ml-5">
@@ -108,6 +109,7 @@ export default {
>
<gl-form-input
id="maven-duplicated-settings-regex-input"
+ :disabled="loading"
:value="duplicateExceptionRegex"
@change="update(modelNames.exception, $event)"
/>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index ec3be43196c..b45cedcdd66 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -1,108 +1,66 @@
<script>
-import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
-import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
-import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
-import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
-import {
- PACKAGE_SETTINGS_HEADER,
- PACKAGE_SETTINGS_DESCRIPTION,
- PACKAGES_DOCS_PATH,
- ERROR_UPDATING_SETTINGS,
- SUCCESS_UPDATING_SETTINGS,
-} from '~/packages_and_registries/settings/group/constants';
-import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
+import { GlAlert } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue';
+import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue';
+
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
-import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
-import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
export default {
name: 'GroupSettingsApp',
- i18n: {
- PACKAGE_SETTINGS_HEADER,
- PACKAGE_SETTINGS_DESCRIPTION,
- },
- links: {
- PACKAGES_DOCS_PATH,
- },
components: {
GlAlert,
- GlSprintf,
- GlLink,
- SettingsBlock,
- MavenSettings,
- GenericSettings,
- DuplicatesSettings,
+ PackagesSettings,
+ DependencyProxySettings,
},
- inject: ['defaultExpanded', 'groupPath'],
+ inject: ['groupPath', 'dependencyProxyAvailable'],
apollo: {
- packageSettings: {
+ group: {
query: getGroupPackagesSettingsQuery,
variables() {
return {
fullPath: this.groupPath,
};
},
- update(data) {
- return data.group?.packageSettings;
- },
},
},
data() {
return {
- packageSettings: {},
- errors: {},
+ group: {},
alertMessage: null,
};
},
computed: {
+ packageSettings() {
+ return this.group?.packageSettings || {};
+ },
+ dependencyProxySettings() {
+ return this.group?.dependencyProxySetting || {};
+ },
isLoading() {
- return this.$apollo.queries.packageSettings.loading;
+ return this.$apollo.queries.group.loading;
},
},
methods: {
dismissAlert() {
this.alertMessage = null;
},
- updateSettings(payload) {
- this.errors = {};
- return this.$apollo
- .mutate({
- mutation: updateNamespacePackageSettings,
- variables: {
- input: {
- namespacePath: this.groupPath,
- ...payload,
- },
- },
- update: updateGroupPackageSettings(this.groupPath),
- optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
- ...this.packageSettings,
- ...payload,
- }),
- })
- .then(({ data }) => {
- if (data.updateNamespacePackageSettings?.errors?.length > 0) {
- this.alertMessage = ERROR_UPDATING_SETTINGS;
- } else {
- this.dismissAlert();
- this.$toast.show(SUCCESS_UPDATING_SETTINGS);
- }
- })
- .catch((e) => {
- if (e.graphQLErrors) {
- e.graphQLErrors.forEach((error) => {
- const [
- {
- path: [key],
- message,
- },
- ] = error.extensions.problems;
- this.errors = { ...this.errors, [key]: message };
- });
- }
- this.alertMessage = ERROR_UPDATING_SETTINGS;
- });
+ handleSuccess(amount = 1) {
+ const successMessage = n__(
+ 'Setting saved successfully',
+ 'Settings saved successfully',
+ amount,
+ );
+ this.$toast.show(successMessage);
+ this.dismissAlert();
+ },
+ handleError(amount = 1) {
+ const errorMessage = n__(
+ 'An error occurred while saving the setting',
+ 'An error occurred while saving the settings',
+ amount,
+ );
+ this.alertMessage = errorMessage;
},
},
};
@@ -114,50 +72,19 @@ export default {
{{ alertMessage }}
</gl-alert>
- <settings-block
- :default-expanded="defaultExpanded"
- data-qa-selector="package_registry_settings_content"
- >
- <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
- <template #description>
- <span data-testid="description">
- <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
- <template #link="{ content }">
- <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </template>
- <template #default>
- <maven-settings data-testid="maven-settings">
- <template #default="{ modelNames }">
- <duplicates-settings
- :duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
- :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
- :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
- :model-names="modelNames"
- :loading="isLoading"
- toggle-qa-selector="allow_duplicates_toggle"
- label-qa-selector="allow_duplicates_label"
- @update="updateSettings"
- />
- </template>
- </maven-settings>
- <generic-settings class="gl-mt-6" data-testid="generic-settings">
- <template #default="{ modelNames }">
- <duplicates-settings
- :duplicates-allowed="packageSettings.genericDuplicatesAllowed"
- :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
- :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
- :model-names="modelNames"
- :loading="isLoading"
- @update="updateSettings"
- />
- </template>
- </generic-settings>
- </template>
- </settings-block>
+ <packages-settings
+ :package-settings="packageSettings"
+ :is-loading="isLoading"
+ @success="handleSuccess(2)"
+ @error="handleError(2)"
+ />
+
+ <dependency-proxy-settings
+ v-if="dependencyProxyAvailable"
+ :dependency-proxy-settings="dependencyProxySettings"
+ :is-loading="isLoading"
+ @success="handleSuccess"
+ @error="handleError"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
new file mode 100644
index 00000000000..b7e88945dbd
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
@@ -0,0 +1,139 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
+import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
+import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
+import {
+ PACKAGE_SETTINGS_HEADER,
+ PACKAGE_SETTINGS_DESCRIPTION,
+ PACKAGES_DOCS_PATH,
+} from '~/packages_and_registries/settings/group/constants';
+import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
+import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
+import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+
+export default {
+ name: 'PackageSettings',
+ i18n: {
+ PACKAGE_SETTINGS_HEADER,
+ PACKAGE_SETTINGS_DESCRIPTION,
+ },
+ links: {
+ PACKAGES_DOCS_PATH,
+ },
+ components: {
+ GlSprintf,
+ GlLink,
+ SettingsBlock,
+ MavenSettings,
+ GenericSettings,
+ DuplicatesSettings,
+ },
+ inject: ['defaultExpanded', 'groupPath'],
+ props: {
+ packageSettings: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ errors: {},
+ };
+ },
+ methods: {
+ async updateSettings(payload) {
+ this.errors = {};
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: updateNamespacePackageSettings,
+ variables: {
+ input: {
+ namespacePath: this.groupPath,
+ ...payload,
+ },
+ },
+ update: updateGroupPackageSettings(this.groupPath),
+ optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
+ ...this.packageSettings,
+ ...payload,
+ }),
+ });
+
+ if (data.updateNamespacePackageSettings?.errors?.length > 0) {
+ throw new Error();
+ } else {
+ this.$emit('success');
+ }
+ } catch (e) {
+ if (e.graphQLErrors) {
+ e.graphQLErrors.forEach((error) => {
+ const [
+ {
+ path: [key],
+ message,
+ },
+ ] = error.extensions.problems;
+ this.errors = { ...this.errors, [key]: message };
+ });
+ }
+ this.$emit('error');
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <settings-block
+ :default-expanded="defaultExpanded"
+ data-qa-selector="package_registry_settings_content"
+ >
+ <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
+ <template #description>
+ <span data-testid="description">
+ <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
+ <template #link="{ content }">
+ <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #default>
+ <maven-settings data-testid="maven-settings">
+ <template #default="{ modelNames }">
+ <duplicates-settings
+ :duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
+ :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
+ :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
+ :model-names="modelNames"
+ :loading="isLoading"
+ toggle-qa-selector="allow_duplicates_toggle"
+ label-qa-selector="allow_duplicates_label"
+ @update="updateSettings"
+ />
+ </template>
+ </maven-settings>
+ <generic-settings class="gl-mt-6" data-testid="generic-settings">
+ <template #default="{ modelNames }">
+ <duplicates-settings
+ :duplicates-allowed="packageSettings.genericDuplicatesAllowed"
+ :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
+ :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
+ :model-names="modelNames"
+ :loading="isLoading"
+ @update="updateSettings"
+ />
+ </template>
+ </generic-settings>
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index d29489a0b33..ee922457993 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -18,9 +18,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Publish packages if their name or version matches this regex.',
);
-export const SUCCESS_UPDATING_SETTINGS = s__('PackageRegistry|Settings saved successfully');
-export const ERROR_UPDATING_SETTINGS = s__(
- 'PackageRegistry|An error occurred while saving the settings',
+export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
+export const DEPENDENCY_PROXY_SETTINGS_DESCRIPTION = s__(
+ 'DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.',
);
// Parameters
@@ -28,3 +28,5 @@ export const ERROR_UPDATING_SETTINGS = s__(
export const PACKAGES_DOCS_PATH = helpPagePath('user/packages');
export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed';
export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex';
+
+export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index');
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql
new file mode 100644
index 00000000000..d24a645fecb
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql
@@ -0,0 +1,8 @@
+mutation updateDependencyProxySettings($input: UpdateDependencyProxySettingsInput!) {
+ updateDependencyProxySettings(input: $input) {
+ dependencyProxySetting {
+ enabled
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
index a1c01300893..d3edebfbe20 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
@@ -1,5 +1,8 @@
query getGroupPackagesSettings($fullPath: ID!) {
group(fullPath: $fullPath) {
+ dependencyProxySetting {
+ enabled
+ }
packageSettings {
mavenDuplicatesAllowed
mavenDuplicateExceptionRegex
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
index fb06f557d66..fe94203f51b 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
@@ -9,9 +9,16 @@ export const updateGroupPackageSettings = (fullPath) => (client, { data: updated
const sourceData = client.readQuery(queryAndParams);
const data = produce(sourceData, (draftState) => {
- draftState.group.packageSettings = {
- ...updatedData.updateNamespacePackageSettings.packageSettings,
- };
+ if (updatedData.updateNamespacePackageSettings) {
+ draftState.group.packageSettings = {
+ ...updatedData.updateNamespacePackageSettings.packageSettings,
+ };
+ }
+ if (updatedData.updateDependencyProxySettings) {
+ draftState.group.dependencyProxySetting = {
+ ...updatedData.updateDependencyProxySettings.dependencyProxySetting,
+ };
+ }
});
client.writeQuery({
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
index f2c8de85bf8..a30d8ca0b81 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
@@ -9,3 +9,15 @@ export const updateGroupPackagesSettingsOptimisticResponse = (changes) => ({
},
},
});
+
+export const updateGroupDependencyProxySettingsOptimisticResponse = (changes) => ({
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ updateDependencyProxySettings: {
+ __typename: 'UpdateDependencyProxySettingsPayload',
+ errors: [],
+ dependencyProxySetting: {
+ ...changes,
+ },
+ },
+});
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index bf286c84d5f..7be3bba7cae 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -95,7 +95,7 @@ export default {
<gl-sprintf
:message="
__(
- 'Save space and find images in the container Registry. remove unneeded tags and keep only the ones you want. %{linkStart}How does cleanup work?%{linkEnd}',
+ 'Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}',
)
"
>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 165c4aae3cb..4d477fbd05d 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -73,6 +73,7 @@ export const OLDER_THAN_OPTIONS = [
{ key: 'SEVEN_DAYS', variable: 7, default: false },
{ key: 'FOURTEEN_DAYS', variable: 14, default: false },
{ key: 'THIRTY_DAYS', variable: 30, default: false },
+ { key: 'SIXTY_DAYS', variable: 60, default: false },
{ key: 'NINETY_DAYS', variable: 90, default: true },
];
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js
index 55b5816cc5a..7d2971bd8c7 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants.js
@@ -1 +1,3 @@
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
+export const FILTERED_SEARCH_TYPE = 'type';
+export const HISTORY_PIPELINES_LIMIT = 5;
diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js
index 4c312a008cb..68849857d0f 100644
--- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js
+++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js
@@ -1,7 +1,7 @@
import { __ } from '~/locale';
export const HELPER_TEXT_SERVICE_PING_DISABLED = __(
- 'To enable Registration Features, make sure "Enable service ping" is checked.',
+ 'To enable Registration Features, first enable Service Ping.',
);
export const HELPER_TEXT_SERVICE_PING_ENABLED = __(
diff --git a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue
new file mode 100644
index 00000000000..c75c031b0b1
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue
@@ -0,0 +1,143 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import Api from '~/api';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ dropdownHeader: __('Namespaces'),
+ searchPlaceholder: __('Search for Namespace'),
+ anyNamespace: __('Any namespace'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ props: {
+ showAny: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: __('Namespace'),
+ },
+ fieldName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ namespaceOptions: [],
+ selectedNamespaceId: null,
+ selectedNamespace: null,
+ searchTerm: '',
+ isLoading: false,
+ };
+ },
+ computed: {
+ selectedNamespaceName() {
+ if (this.selectedNamespaceId === null) {
+ return this.placeholder;
+ }
+ return this.selectedNamespace;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.fetchNamespaces(this.searchTerm);
+ },
+ },
+ mounted() {
+ this.fetchNamespaces();
+ },
+ methods: {
+ fetchNamespaces(filter) {
+ this.isLoading = true;
+ this.namespaceOptions = [];
+ return Api.namespaces(filter, (namespaces) => {
+ this.namespaceOptions = namespaces;
+ this.isLoading = false;
+ });
+ },
+ selectNamespace(key) {
+ this.selectedNamespaceId = this.namespaceOptions[key].id;
+ this.selectedNamespace = this.getNamespaceString(this.namespaceOptions[key]);
+ this.$emit('setNamespace', this.selectedNamespaceId);
+ },
+ selectAnyNamespace() {
+ this.selectedNamespaceId = null;
+ this.selectedNamespace = null;
+ this.$emit('setNamespace', null);
+ },
+ getNamespaceString(namespace) {
+ return `${namespace.kind}: ${namespace.full_path}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex">
+ <input
+ v-if="fieldName"
+ :name="fieldName"
+ :value="selectedNamespaceId"
+ type="hidden"
+ data-testid="hidden-input"
+ />
+ <gl-dropdown
+ :text="selectedNamespaceName"
+ :header-text="$options.i18n.dropdownHeader"
+ toggle-class="dropdown-menu-toggle large"
+ data-testid="namespace-dropdown"
+ :right="true"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ class="namespace-search-box"
+ debounce="250"
+ :placeholder="$options.i18n.searchPlaceholder"
+ />
+ </template>
+
+ <template v-if="showAny">
+ <gl-dropdown-item @click="selectAnyNamespace">
+ {{ $options.i18n.anyNamespace }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+
+ <gl-loading-icon v-if="isLoading" />
+
+ <gl-dropdown-item
+ v-for="(namespace, key) in namespaceOptions"
+ :key="namespace.id"
+ @click="selectNamespace(key)"
+ >
+ {{ getNamespaceString(namespace) }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
+
+<style scoped>
+/* workaround position: relative imposed by .top-area .nav-controls */
+.namespace-search-box >>> input {
+ position: static;
+}
+</style>
diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js
index b07ca815f13..3098d06510b 100644
--- a/app/assets/javascripts/pages/admin/projects/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index.js
@@ -1,8 +1,38 @@
-import NamespaceSelect from '~/namespace_select';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import ProjectsList from '~/projects_list';
+import NamespaceSelect from './components/namespace_select.vue';
new ProjectsList(); // eslint-disable-line no-new
-document
- .querySelectorAll('.js-namespace-select')
- .forEach((dropdown) => new NamespaceSelect({ dropdown }));
+function mountNamespaceSelect() {
+ const el = document.querySelector('.js-namespace-select');
+ if (!el) {
+ return false;
+ }
+
+ const { showAny, fieldName, placeholder, updateLocation } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(NamespaceSelect, {
+ props: {
+ showAny: parseBoolean(showAny),
+ fieldName,
+ placeholder,
+ },
+ on: {
+ setNamespace(newNamespace) {
+ if (fieldName && updateLocation) {
+ window.location = mergeUrlParams({ [fieldName]: newNamespace }, window.location.href);
+ }
+ },
+ },
+ });
+ },
+ });
+}
+
+mountNamespaceSelect();
diff --git a/app/assets/javascripts/pages/admin/serverless/domains/index.js b/app/assets/javascripts/pages/admin/serverless/domains/index.js
deleted file mode 100644
index 4fab7a1d9cb..00000000000
--- a/app/assets/javascripts/pages/admin/serverless/domains/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import initSettingsPanels from '~/settings_panels';
-
-// Initialize expandable settings panels
-initSettingsPanels();
-
-const domainCard = document.querySelector('.js-domain-cert-show');
-const domainForm = document.querySelector('.js-domain-cert-inputs');
-const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn');
-const domainSubmitButton = document.querySelector('.js-serverless-domain-submit');
-
-if (domainReplaceButton && domainCard && domainForm) {
- domainReplaceButton.addEventListener('click', () => {
- domainCard.classList.add('hidden');
- domainForm.classList.remove('hidden');
- domainSubmitButton.removeAttribute('disabled');
- });
-}
diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
new file mode 100644
index 00000000000..c4e05bbd092
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import initFilePickers from '~/file_pickers';
+import ZenMode from '~/zen_mode';
+
+new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
+initFilePickers();
+new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/topics/new/index.js b/app/assets/javascripts/pages/admin/topics/new/index.js
new file mode 100644
index 00000000000..c4e05bbd092
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/new/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import initFilePickers from '~/file_pickers';
+import ZenMode from '~/zen_mode';
+
+new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
+initFilePickers();
+new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/dependency_proxies/index.js b/app/assets/javascripts/pages/groups/dependency_proxies/index.js
index 77c885d3858..862ba468296 100644
--- a/app/assets/javascripts/pages/groups/dependency_proxies/index.js
+++ b/app/assets/javascripts/pages/groups/dependency_proxies/index.js
@@ -1,13 +1,3 @@
-import $ from 'jquery';
-import initDependencyProxy from '~/dependency_proxy';
+import { initDependencyProxyApp } from '~/packages_and_registries/dependency_proxy/';
-initDependencyProxy();
-
-const form = document.querySelector('form.edit_dependency_proxy_group_setting');
-const toggleInput = $('input.js-project-feature-toggle-input');
-
-if (form && toggleInput) {
- toggleInput.on('trigger-change', () => {
- form.submit();
- });
-}
+initDependencyProxyApp();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 0137ff87979..01a371920f8 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -11,7 +11,7 @@ import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import UsersSelect from '~/users_select';
-const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-group-members-list-app'), {
[MEMBER_TYPES.user]: {
diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js
index 1c4a10fd653..95522573b53 100644
--- a/app/assets/javascripts/pages/groups/packages/index/index.js
+++ b/app/assets/javascripts/pages/groups/packages/index/index.js
@@ -1,5 +1,10 @@
-import initPackageList from '~/packages/list/packages_list_app_bundle';
+(async function packageApp() {
+ if (window.gon.features.packageListApollo) {
+ const newPackageList = await import('~/packages_and_registries/package_registry/pages/list');
-if (document.getElementById('js-vue-packages-list')) {
- initPackageList();
-}
+ newPackageList.default();
+ } else {
+ const packageList = await import('~/packages/list/packages_list_app_bundle');
+ packageList.default();
+ }
+})();
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
new file mode 100644
index 00000000000..ec3cf4a8a92
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
@@ -0,0 +1,176 @@
+<script>
+import { GlButton, GlEmptyState, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
+
+import { s__, __ } from '~/locale';
+import createFlash from '~/flash';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { getBulkImportsHistory } from '~/rest_api';
+import ImportStatus from '~/import_entities/components/import_status.vue';
+import PaginationBar from '~/import_entities/components/pagination_bar.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import { DEFAULT_ERROR } from '../utils/error_messages';
+
+const DEFAULT_PER_PAGE = 20;
+const DEFAULT_TH_CLASSES =
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!';
+
+const tableCell = (config) => ({
+ thClass: `${DEFAULT_TH_CLASSES}`,
+ tdClass: (value, key, item) => {
+ return {
+ // eslint-disable-next-line no-underscore-dangle
+ 'gl-border-b-0!': item._showDetails,
+ };
+ },
+ ...config,
+});
+
+export default {
+ components: {
+ GlButton,
+ GlEmptyState,
+ GlLink,
+ GlLoadingIcon,
+ GlTable,
+ PaginationBar,
+ ImportStatus,
+ TimeAgo,
+ },
+
+ data() {
+ return {
+ loading: true,
+ historyItems: [],
+ paginationConfig: {
+ page: 1,
+ perPage: DEFAULT_PER_PAGE,
+ },
+ pageInfo: {},
+ };
+ },
+
+ fields: [
+ tableCell({
+ key: 'source_full_path',
+ label: s__('BulkImport|Source group'),
+ thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`,
+ }),
+ tableCell({
+ key: 'destination_name',
+ label: s__('BulkImport|New group'),
+ thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`,
+ }),
+ tableCell({
+ key: 'created_at',
+ label: __('Date'),
+ }),
+ tableCell({
+ key: 'status',
+ label: __('Status'),
+ tdAttr: { 'data-qa-selector': 'import_status_indicator' },
+ }),
+ ],
+
+ computed: {
+ hasHistoryItems() {
+ return this.historyItems.length > 0;
+ },
+ },
+
+ watch: {
+ paginationConfig: {
+ handler() {
+ this.loadHistoryItems();
+ },
+ deep: true,
+ immediate: true,
+ },
+ },
+
+ methods: {
+ async loadHistoryItems() {
+ try {
+ this.loading = true;
+ const { data: historyItems, headers } = await getBulkImportsHistory({
+ page: this.paginationConfig.page,
+ per_page: this.paginationConfig.perPage,
+ });
+ this.pageInfo = parseIntPagination(normalizeHeaders(headers));
+ this.historyItems = historyItems;
+ } catch (e) {
+ createFlash({ message: DEFAULT_ERROR, captureError: true, error: e });
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ getDestinationUrl({ destination_name: name, destination_namespace: namespace }) {
+ return [namespace, name].filter(Boolean).join('/');
+ },
+
+ getFullDestinationUrl(params) {
+ return joinPaths(gon.relative_url_root || '', this.getDestinationUrl(params));
+ },
+ },
+
+ gitlabLogo: window.gon.gitlab_logo,
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center"
+ >
+ <h1 class="gl-my-0 gl-py-4 gl-font-size-h1">
+ <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
+ {{ s__('BulkImport|Group import history') }}
+ </h1>
+ </div>
+ <gl-loading-icon v-if="loading" size="md" class="gl-mt-5" />
+ <gl-empty-state
+ v-else-if="!hasHistoryItems"
+ :title="s__('BulkImport|No history is available')"
+ :description="s__('BulkImport|Your imported groups will appear here.')"
+ />
+ <template v-else>
+ <gl-table
+ :fields="$options.fields"
+ :items="historyItems"
+ data-qa-selector="import_history_table"
+ class="gl-w-full"
+ >
+ <template #cell(destination_name)="{ item }">
+ <gl-link :href="getFullDestinationUrl(item)" target="_blank">
+ {{ getDestinationUrl(item) }}
+ </gl-link>
+ </template>
+ <template #cell(created_at)="{ value }">
+ <time-ago :time="value" />
+ </template>
+ <template #cell(status)="{ value, item, toggleDetails, detailsShowing }">
+ <import-status :status="value" class="gl-display-inline-block gl-w-13" />
+ <gl-button
+ v-if="item.failures.length"
+ class="gl-ml-3"
+ :selected="detailsShowing"
+ @click="toggleDetails"
+ >{{ __('Details') }}</gl-button
+ >
+ </template>
+ <template #row-details="{ item }">
+ <pre>{{ item.failures }}</pre>
+ </template>
+ </gl-table>
+ <pagination-bar
+ :page-info="pageInfo"
+ :items-count="historyItems.length"
+ class="gl-m-0 gl-mt-3"
+ @set-page="paginationConfig.page = $event"
+ @set-page-size="paginationConfig.perPage = $event"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/index.js b/app/assets/javascripts/pages/import/bulk_imports/history/index.js
new file mode 100644
index 00000000000..5a67aa99baa
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import BulkImportHistoryApp from './components/bulk_imports_history_app.vue';
+
+function mountImportHistoryApp(mountElement) {
+ if (!mountElement) return undefined;
+
+ return new Vue({
+ el: mountElement,
+ render(createElement) {
+ return createElement(BulkImportHistoryApp);
+ },
+ });
+}
+
+mountImportHistoryApp(document.querySelector('#import-history-mount-element'));
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js b/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js
new file mode 100644
index 00000000000..24669e22ade
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js
@@ -0,0 +1,3 @@
+import { __ } from '~/locale';
+
+export const DEFAULT_ERROR = __('Something went wrong on our end.');
diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js
index 80bc32dd43f..6afb3636998 100644
--- a/app/assets/javascripts/pages/profiles/index.js
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
+import initPasswordPrompt from './password_prompt';
// eslint-disable-next-line func-names
$(document).on('input.ssh_key', '#key_key', function () {
@@ -19,3 +20,4 @@ $(document).on('input.ssh_key', '#key_key', function () {
new Profile(); // eslint-disable-line no-new
initSearchSettings();
+initPasswordPrompt();
diff --git a/app/assets/javascripts/pages/profiles/password_prompt/constants.js b/app/assets/javascripts/pages/profiles/password_prompt/constants.js
new file mode 100644
index 00000000000..99b8442c928
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/password_prompt/constants.js
@@ -0,0 +1,9 @@
+import { __, s__ } from '~/locale';
+
+export const I18N_PASSWORD_PROMPT_TITLE = s__('PasswordPrompt|Confirm password to continue');
+export const I18N_PASSWORD_PROMPT_FORM_LABEL = s__(
+ 'PasswordPrompt|Please enter your password to confirm',
+);
+export const I18N_PASSWORD_PROMPT_ERROR_MESSAGE = s__('PasswordPrompt|Password is required');
+export const I18N_PASSWORD_PROMPT_CONFIRM_BUTTON = s__('PasswordPrompt|Confirm password');
+export const I18N_PASSWORD_PROMPT_CANCEL_BUTTON = __('Cancel');
diff --git a/app/assets/javascripts/pages/profiles/password_prompt/index.js b/app/assets/javascripts/pages/profiles/password_prompt/index.js
new file mode 100644
index 00000000000..20645112893
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/password_prompt/index.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import PasswordPromptModal from './password_prompt_modal.vue';
+
+Vue.use(Translate);
+
+const emailFieldSelector = '#user_email';
+const editFormSelector = '.js-password-prompt-form';
+const passwordPromptFieldSelector = '.js-password-prompt-field';
+const passwordPromptBtnSelector = '.js-password-prompt-btn';
+
+const passwordPromptModalId = 'password-prompt-modal';
+
+const getEmailValue = () => document.querySelector(emailFieldSelector).value.trim();
+const passwordPromptButton = document.querySelector(passwordPromptBtnSelector);
+const field = document.querySelector(passwordPromptFieldSelector);
+const form = document.querySelector(editFormSelector);
+
+const handleConfirmPassword = (pw) => {
+ // update the validation_password field
+ field.value = pw;
+ // submit the form
+ form.submit();
+};
+
+export default () => {
+ const passwordPromptModalEl = document.getElementById(passwordPromptModalId);
+
+ if (passwordPromptModalEl && field) {
+ return new Vue({
+ el: passwordPromptModalEl,
+ data() {
+ return {
+ initialEmail: '',
+ };
+ },
+ mounted() {
+ this.initialEmail = getEmailValue();
+ passwordPromptButton.addEventListener('click', this.handleSettingsUpdate);
+ },
+ methods: {
+ handleSettingsUpdate(ev) {
+ const email = getEmailValue();
+ if (email !== this.initialEmail) {
+ ev.preventDefault();
+ this.$root.$emit('bv::show::modal', passwordPromptModalId, passwordPromptBtnSelector);
+ }
+ },
+ },
+ render(createElement) {
+ return createElement(PasswordPromptModal, {
+ props: { handleConfirmPassword },
+ });
+ },
+ });
+ }
+ return null;
+};
diff --git a/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
new file mode 100644
index 00000000000..44728ea9cdf
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlModal, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import {
+ I18N_PASSWORD_PROMPT_TITLE,
+ I18N_PASSWORD_PROMPT_FORM_LABEL,
+ I18N_PASSWORD_PROMPT_ERROR_MESSAGE,
+ I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
+ I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
+} from './constants';
+
+export default {
+ components: {
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ handleConfirmPassword: {
+ type: Function,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ passwordCheck: '',
+ };
+ },
+ computed: {
+ isValid() {
+ return Boolean(this.passwordCheck.length);
+ },
+ primaryProps() {
+ return {
+ text: I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
+ attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.isValid }],
+ };
+ },
+ },
+ methods: {
+ onConfirmPassword() {
+ this.handleConfirmPassword(this.passwordCheck);
+ },
+ },
+ cancelProps: {
+ text: I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
+ },
+ i18n: {
+ title: I18N_PASSWORD_PROMPT_TITLE,
+ formLabel: I18N_PASSWORD_PROMPT_FORM_LABEL,
+ errorMessage: I18N_PASSWORD_PROMPT_ERROR_MESSAGE,
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ data-testid="password-prompt-modal"
+ modal-id="password-prompt-modal"
+ :title="$options.i18n.title"
+ :action-primary="primaryProps"
+ :action-cancel="$options.cancelProps"
+ @primary="onConfirmPassword"
+ >
+ <gl-form @submit.prevent="onConfirmPassword">
+ <gl-form-group
+ :label="$options.i18n.formLabel"
+ label-for="password-prompt-confirmation"
+ :invalid-feedback="$options.i18n.errorMessage"
+ :state="isValid"
+ >
+ <gl-form-input
+ id="password-prompt-confirmation"
+ v-model="passwordCheck"
+ name="password-confirmation"
+ type="password"
+ data-testid="password-prompt-field"
+ />
+ </gl-form-group>
+ </gl-form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/projects/cluster_agents/show/index.js b/app/assets/javascripts/pages/projects/cluster_agents/show/index.js
new file mode 100644
index 00000000000..4ed3e2f7bea
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/cluster_agents/show/index.js
@@ -0,0 +1,3 @@
+import loadClusterAgentVues from '~/clusters/agents';
+
+loadClusterAgentVues();
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
index 2b5451bd18b..a1ba920b322 100644
--- a/app/assets/javascripts/pages/projects/clusters/index/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -1,4 +1,4 @@
-import initClustersListApp from 'ee_else_ce/clusters_list';
+import initClustersListApp from '~/clusters_list';
import PersistentUserCallout from '~/persistent_user_callout';
const callout = document.querySelector('.gcp-signup-offer');
diff --git a/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue
deleted file mode 100644
index ba8858c985a..00000000000
--- a/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue
+++ /dev/null
@@ -1,98 +0,0 @@
-<script>
-import {
- GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlLoadingIcon,
- GlSearchBoxByType,
-} from '@gitlab/ui';
-import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import Tracking from '~/tracking';
-import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
-
-export default {
- components: {
- GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlLoadingIcon,
- GlSearchBoxByType,
- },
- mixins: [Tracking.mixin()],
- apollo: {
- currentUser: {
- query: searchNamespacesWhereUserCanCreateProjectsQuery,
- variables() {
- return {
- search: this.search,
- };
- },
- skip() {
- return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
- },
- debounce: DEBOUNCE_DELAY,
- },
- },
- inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel'],
- data() {
- return {
- currentUser: {},
- search: '',
- selectedNamespace: {
- id: this.namespaceId,
- fullPath: this.namespaceFullPath,
- },
- };
- },
- computed: {
- userGroups() {
- return this.currentUser.groups?.nodes || [];
- },
- userNamespace() {
- return this.currentUser.namespace || {};
- },
- },
- methods: {
- handleClick({ id, fullPath }) {
- this.selectedNamespace = {
- id: getIdFromGraphQLId(id),
- fullPath,
- };
- },
- },
-};
-</script>
-
-<template>
- <gl-button-group class="gl-w-full">
- <gl-button label>{{ rootUrl }}</gl-button>
- <gl-dropdown
- class="gl-w-full"
- :text="selectedNamespace.fullPath"
- toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base!"
- data-qa-selector="select_namespace_dropdown"
- @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
- >
- <gl-search-box-by-type v-model.trim="search" />
- <gl-loading-icon v-if="$apollo.queries.currentUser.loading" />
- <template v-else>
- <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
- <gl-dropdown-item v-for="group of userGroups" :key="group.id" @click="handleClick(group)">
- {{ group.fullPath }}
- </gl-dropdown-item>
- <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="handleClick(userNamespace)">
- {{ userNamespace.fullPath }}
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
-
- <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" />
- </gl-button-group>
-</template>
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index ed816e3be95..d89b4d0e0a3 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -1,66 +1,6 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import initProjectVisibilitySelector from '../../../project_visibility';
-import initProjectNew from '../../../projects/project_new';
-import NewProjectCreationApp from './components/app.vue';
-import NewProjectUrlSelect from './components/new_project_url_select.vue';
-
-function initNewProjectCreation() {
- const el = document.querySelector('.js-new-project-creation');
-
- const {
- pushToCreateProjectCommand,
- workingWithProjectsHelpPath,
- newProjectGuidelines,
- hasErrors,
- isCiCdAvailable,
- } = el.dataset;
-
- const props = {
- hasErrors: parseBoolean(hasErrors),
- isCiCdAvailable: parseBoolean(isCiCdAvailable),
- newProjectGuidelines,
- };
-
- const provide = {
- workingWithProjectsHelpPath,
- pushToCreateProjectCommand,
- };
-
- return new Vue({
- el,
- provide,
- render(h) {
- return h(NewProjectCreationApp, { props });
- },
- });
-}
-
-function initNewProjectUrlSelect() {
- const el = document.querySelector('.js-vue-new-project-url-select');
-
- if (!el) {
- return undefined;
- }
-
- Vue.use(VueApollo);
-
- return new Vue({
- el,
- apolloProvider: new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
- }),
- provide: {
- namespaceFullPath: el.dataset.namespaceFullPath,
- namespaceId: el.dataset.namespaceId,
- rootUrl: el.dataset.rootUrl,
- trackLabel: el.dataset.trackLabel,
- },
- render: (createElement) => createElement(NewProjectUrlSelect),
- });
-}
+import { initNewProjectCreation, initNewProjectUrlSelect } from '~/projects/new';
+import initProjectVisibilitySelector from '~/project_visibility';
+import initProjectNew from '~/projects/project_new';
initProjectVisibilitySelector();
initProjectNew.bindEvents();
diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
index c94782fdf1b..95522573b53 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
@@ -1,3 +1,10 @@
-import initPackageList from '~/packages/list/packages_list_app_bundle';
+(async function packageApp() {
+ if (window.gon.features.packageListApollo) {
+ const newPackageList = await import('~/packages_and_registries/package_registry/pages/list');
-initPackageList();
+ newPackageList.default();
+ } else {
+ const packageList = await import('~/packages/list/packages_list_app_bundle');
+ packageList.default();
+ }
+})();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
index 16c4a6191b2..e92b9b30fa4 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
@@ -27,19 +27,22 @@ export const findTimezoneByIdentifier = (tzList = [], identifier = null) => {
};
export default class TimezoneDropdown {
- constructor({ $dropdownEl, $inputEl, onSelectTimezone, displayFormat } = defaults) {
+ constructor({
+ $dropdownEl,
+ $inputEl,
+ onSelectTimezone,
+ displayFormat,
+ allowEmpty = false,
+ } = defaults) {
this.$dropdown = $dropdownEl;
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
this.$input = $inputEl;
- this.timezoneData = this.$dropdown.data('data');
+ this.timezoneData = this.$dropdown.data('data') || [];
this.onSelectTimezone = onSelectTimezone;
this.displayFormat = displayFormat || defaults.displayFormat;
+ this.allowEmpty = allowEmpty;
- this.initialTimezone =
- findTimezoneByIdentifier(this.timezoneData, this.$input.val()) || defaultTimezone;
-
- this.initDefaultTimezone();
this.initDropdown();
}
@@ -52,24 +55,25 @@ export default class TimezoneDropdown {
search: {
fields: ['name'],
},
- clicked: (cfg) => this.updateInputValue(cfg),
+ clicked: (cfg) => this.handleDropdownChange(cfg),
text: (item) => formatTimezone(item),
});
- this.setDropdownToggle(this.displayFormat(this.initialTimezone));
- }
+ const initialTimezone = findTimezoneByIdentifier(this.timezoneData, this.$input.val());
- initDefaultTimezone() {
- if (!this.$input.val()) {
- this.$input.val(defaultTimezone.name);
+ if (initialTimezone !== null) {
+ this.setDropdownValue(initialTimezone);
+ } else if (!this.allowEmpty) {
+ this.setDropdownValue(defaultTimezone);
}
}
- setDropdownToggle(dropdownText) {
- this.$dropdownToggle.text(dropdownText);
+ setDropdownValue(timezone) {
+ this.$dropdownToggle.text(this.displayFormat(timezone));
+ this.$input.val(timezone.name);
}
- updateInputValue({ selectedObj, e }) {
+ handleDropdownChange({ selectedObj, e }) {
e.preventDefault();
this.$input.val(selectedObj.identifier);
if (this.onSelectTimezone) {
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 0b662c945c6..947bbdacf2c 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -26,7 +26,7 @@ initInviteMembersForm();
new UsersSelect(); // eslint-disable-line no-new
-const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
diff --git a/app/assets/javascripts/pages/projects/wikis/diff/index.js b/app/assets/javascripts/pages/projects/wikis/diff/index.js
new file mode 100644
index 00000000000..73440db761f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/diff/index.js
@@ -0,0 +1,3 @@
+import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
+
+initDiffStatsDropdown();
diff --git a/app/assets/javascripts/pages/projects/wikis/edit/index.js b/app/assets/javascripts/pages/projects/wikis/edit/index.js
new file mode 100644
index 00000000000..b2288c2655c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/edit/index.js
@@ -0,0 +1,3 @@
+import { mountApplications } from '~/pages/shared/wikis/edit';
+
+mountApplications();
diff --git a/app/assets/javascripts/pages/projects/wikis/git_access/index.js b/app/assets/javascripts/pages/projects/wikis/git_access/index.js
new file mode 100644
index 00000000000..b1f3006bc1a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/git_access/index.js
@@ -0,0 +1,3 @@
+import initClonePanel from '~/clone_panel';
+
+initClonePanel();
diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js
index 2c1f9e634ab..83fcd348ddf 100644
--- a/app/assets/javascripts/pages/projects/wikis/index.js
+++ b/app/assets/javascripts/pages/projects/wikis/index.js
@@ -1,5 +1,3 @@
-import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
-import initWikis from '~/pages/shared/wikis';
+import Wikis from '~/pages/shared/wikis/wikis';
-initWikis();
-initDiffStatsDropdown();
+export default new Wikis();
diff --git a/app/assets/javascripts/pages/projects/wikis/show/index.js b/app/assets/javascripts/pages/projects/wikis/show/index.js
new file mode 100644
index 00000000000..c08a10122b6
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/show/index.js
@@ -0,0 +1,3 @@
+import { mountApplications as mountEditApplications } from '~/pages/shared/wikis/async_edit';
+
+mountEditApplications();
diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
index 8d2d5d41f6a..ee48543f0d2 100644
--- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -20,7 +20,7 @@ export default class OAuthRememberMe {
toggleRememberMe(event) {
const rememberMe = $(event.target).is(':checked');
- $('.oauth-login', this.container).each((i, element) => {
+ $('.js-oauth-login', this.container).each((i, element) => {
const $form = $(element).parent('form');
const href = $form.attr('action');
diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
index e83c73edfde..1cb7259be64 100644
--- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js
+++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
@@ -9,7 +9,12 @@ export function initInstallRunner(componentId = 'js-install-runner') {
const installRunnerEl = document.getElementById(componentId);
if (installRunnerEl) {
- const defaultClient = createDefaultClient();
+ const defaultClient = createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ );
const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/pages/shared/wikis/async_edit.js b/app/assets/javascripts/pages/shared/wikis/async_edit.js
new file mode 100644
index 00000000000..4536a076568
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/async_edit.js
@@ -0,0 +1,11 @@
+export const mountApplications = async () => {
+ const el = document.querySelector('.js-wiki-edit-page');
+
+ if (el) {
+ const { mountApplications: mountEditApplications } = await import(
+ /* webpackChunkName: 'wiki_edit' */ './edit'
+ );
+
+ mountEditApplications();
+ }
+};
diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/edit.js
index 42aefe81325..beeabfde1a6 100644
--- a/app/assets/javascripts/pages/shared/wikis/index.js
+++ b/app/assets/javascripts/pages/shared/wikis/edit.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import Vue from 'vue';
-import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
import Translate from '~/vue_shared/translate';
@@ -9,14 +8,8 @@ import ZenMode from '../../../zen_mode';
import deleteWikiModal from './components/delete_wiki_modal.vue';
import wikiAlert from './components/wiki_alert.vue';
import wikiForm from './components/wiki_form.vue';
-import Wikis from './wikis';
const createModalVueApp = () => {
- new Wikis(); // eslint-disable-line no-new
- new ShortcutsWiki(); // eslint-disable-line no-new
- new ZenMode(); // eslint-disable-line no-new
- new GLForm($('.wiki-form')); // eslint-disable-line no-new
-
const deleteWikiModalWrapperEl = document.getElementById('delete-wiki-modal-wrapper');
if (deleteWikiModalWrapperEl) {
@@ -85,7 +78,10 @@ const createWikiFormApp = () => {
}
};
-export default () => {
+export const mountApplications = () => {
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($('.wiki-form')); // eslint-disable-line no-new
+
createModalVueApp();
createAlertVueApp();
createWikiFormApp();
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index 7d0b0c90c8d..8d0105bc681 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -1,6 +1,7 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Tracking from '~/tracking';
import showToast from '~/vue_shared/plugins/global_toast';
+import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki';
const TRACKING_EVENT_NAME = 'view_wiki_page';
const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1';
@@ -20,6 +21,7 @@ export default class Wikis {
Wikis.trackPageView();
Wikis.showToasts();
+ Wikis.initShortcuts();
}
handleToggleSidebar(e) {
@@ -64,4 +66,8 @@ export default class Wikis {
const toasts = document.querySelectorAll('.js-toast-message');
toasts.forEach((toast) => showToast(toast.dataset.message));
}
+
+ static initShortcuts() {
+ new ShortcutsWiki(); // eslint-disable-line no-new
+ }
}
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index f204f0ebfaa..ed30198244f 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,6 +1,7 @@
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
import { glEmojiTag } from '~/emoji';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import AddRequest from './add_request.vue';
@@ -131,6 +132,12 @@ export default {
changeCurrentRequest(newRequestId) {
this.currentRequest = newRequestId;
},
+ flamegraphPath(mode) {
+ return mergeUrlParams(
+ { performance_bar: 'flamegraph', stackprof_mode: mode },
+ window.location.href,
+ );
+ },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
@@ -175,6 +182,20 @@ export default {
s__('PerformanceBar|Download')
}}</a>
</div>
+ <div v-if="currentRequest.details" id="peek-flamegraph" class="view">
+ <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span>
+ <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{
+ s__('PerformanceBar|wall')
+ }}</a>
+ /
+ <a class="gl-text-blue-200" :href="flamegraphPath('cpu')">{{
+ s__('PerformanceBar|cpu')
+ }}</a>
+ /
+ <a class="gl-text-blue-200" :href="flamegraphPath('object')">{{
+ s__('PerformanceBar|object')
+ }}</a>
+ </div>
<a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
s__('PerformanceBar|Stats')
}}</a>
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 8170a1f8443..a7f8704b559 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -9,6 +9,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-registration-enabled-callout',
'.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner',
+ '.js-security-newsletter-callout',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
index f2a0f474bc4..7b8e97b573e 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -9,15 +9,8 @@ export default {
SourceEditor,
},
mixins: [glFeatureFlagMixin()],
- inject: ['ciConfigPath', 'projectPath', 'projectNamespace', 'defaultBranch'],
+ inject: ['ciConfigPath'],
inheritAttrs: false,
- props: {
- commitSha: {
- type: String,
- required: false,
- default: '',
- },
- },
methods: {
onCiConfigUpdate(content) {
this.$emit('updateCiConfig', content);
@@ -27,11 +20,7 @@ export default {
const editorInstance = this.$refs.editor.getEditor();
editorInstance.use(new CiSchemaExtension({ instance: editorInstance }));
- editorInstance.registerCiSchema({
- projectPath: this.projectPath,
- projectNamespace: this.projectNamespace,
- ref: this.commitSha || this.defaultBranch,
- });
+ editorInstance.registerCiSchema();
}
},
},
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
new file mode 100644
index 00000000000..75b1398a3c2
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -0,0 +1,49 @@
+<script>
+import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+
+export default {
+ components: {
+ PipelineMiniGraph,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ pipelinePath() {
+ return this.pipeline.detailedStatus?.detailsPath || '';
+ },
+ pipelineStages() {
+ const stages = this.pipeline.stages?.edges;
+ if (!stages) {
+ return [];
+ }
+
+ return stages.map(({ node }) => {
+ const { name, detailedStatus } = node;
+ return {
+ // TODO: fetch dropdown_path from graphql when available
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/342585
+ dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`,
+ name,
+ path: `${this.pipelinePath}#${name}`,
+ status: {
+ details_path: `${this.pipelinePath}#${name}`,
+ has_details: detailedStatus.hasDetails,
+ ...detailedStatus,
+ },
+ title: `${name}: ${detailedStatus.text}`,
+ };
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="pipelineStages.length > 0" class="stage-cell gl-mr-5">
+ <pipeline-mini-graph class="gl-display-inline" :stages="pipelineStages" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index ec240854be5..a1fa2147994 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -10,6 +10,8 @@ import {
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue';
const POLL_INTERVAL = 10000;
export const i18n = {
@@ -30,7 +32,9 @@ export default {
GlLink,
GlLoadingIcon,
GlSprintf,
+ PipelineEditorMiniGraph,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['projectFullPath'],
props: {
commitSha: {
@@ -55,12 +59,15 @@ export default {
};
},
update(data) {
- const { id, commitPath = '', detailedStatus = {} } = data.project?.pipeline || {};
+ const { id, commitPath = '', detailedStatus = {}, stages, status } =
+ data.project?.pipeline || {};
return {
id,
commitPath,
detailedStatus,
+ stages,
+ status,
};
},
result(res) {
@@ -111,9 +118,7 @@ export default {
</script>
<template>
- <div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-white-space-nowrap gl-max-w-full"
- >
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap">
<template v-if="showLoadingState">
<div>
<gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
@@ -129,19 +134,12 @@ export default {
<template v-else>
<div>
<a :href="status.detailsPath" class="gl-mr-auto">
- <ci-icon :status="status" :size="16" />
+ <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" />
</a>
<span class="gl-font-weight-bold">
<gl-sprintf :message="$options.i18n.pipelineInfo">
<template #id="{ content }">
- <gl-link
- :href="status.detailsPath"
- class="pipeline-id gl-font-weight-normal pipeline-number"
- target="_blank"
- data-testid="pipeline-id"
- >
- {{ content }}{{ pipelineId }}</gl-link
- >
+ <span data-testid="pipeline-id"> {{ content }}{{ pipelineId }} </span>
</template>
<template #status>{{ status.text }}</template>
<template #commit>
@@ -157,8 +155,13 @@ export default {
</gl-sprintf>
</span>
</div>
- <div>
+ <div class="gl-display-flex gl-flex-wrap">
+ <pipeline-editor-mini-graph
+ v-if="glFeatures.pipelineEditorMiniGraph"
+ :pipeline="pipeline"
+ />
<gl-button
+ class="gl-mt-2 gl-md-mt-0"
target="_blank"
category="secondary"
variant="confirm"
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
index fbb66231f16..dcd08c9de8d 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -2,7 +2,6 @@
import { GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -17,17 +16,11 @@ export default {
),
btnText: __('Create new CI/CD pipeline'),
},
- mixins: [glFeatureFlagsMixin()],
inject: {
emptyStateIllustrationPath: {
default: '',
},
},
- computed: {
- showCTAButton() {
- return this.glFeatures.pipelineEditorEmptyStateAction;
- },
- },
methods: {
createEmptyConfigFile() {
this.$emit('createEmptyConfigFile');
@@ -48,12 +41,7 @@ export default {
</template>
</gl-sprintf>
</p>
- <gl-button
- v-if="showCTAButton"
- variant="confirm"
- class="gl-mt-3"
- @click="createEmptyConfigFile"
- >
+ <gl-button variant="confirm" class="gl-mt-3" @click="createEmptyConfigFile">
{{ $options.i18n.btnText }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
index d3a7387ad2d..0c3653a2880 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
@@ -11,6 +11,25 @@ query getPipeline($fullPath: ID!, $sha: String!) {
group
text
}
+ stages {
+ edges {
+ node {
+ id
+ name
+ status
+ detailedStatus {
+ detailsPath
+ group
+ hasDetails
+ icon
+ id
+ label
+ text
+ tooltip
+ }
+ }
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index 4324c64ab3b..ba567023946 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CommitSection from './components/commit/commit_section.vue';
import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
@@ -15,7 +14,6 @@ export default {
PipelineEditorHeader,
PipelineEditorTabs,
},
- mixins: [glFeatureFlagMixin()],
props: {
ciConfigData: {
type: Object,
@@ -44,9 +42,6 @@ export default {
showCommitForm() {
return TABS_WITH_COMMIT_FORM.includes(this.currentTab);
},
- showPipelineDrawer() {
- return this.glFeatures.pipelineEditorDrawer;
- },
},
methods: {
setCurrentTab(tabName) {
@@ -77,6 +72,6 @@ export default {
:commit-sha="commitSha"
v-on="$listeners"
/>
- <pipeline-editor-drawer v-if="showPipelineDrawer" />
+ <pipeline-editor-drawer />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index fd40ca0b9c9..0216b2717ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -52,7 +52,7 @@ export default {
required: true,
},
cssClassJobName: {
- type: [String, Array],
+ type: [String, Array, Object],
required: false,
default: '',
},
@@ -167,9 +167,13 @@ export default {
return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
},
jobClasses() {
- return this.relatedDownstreamHovered || this.relatedDownstreamExpanded
- ? `${this.$options.hoverClass} ${this.cssClassJobName}`
- : this.cssClassJobName;
+ return [
+ {
+ [this.$options.hoverClass]:
+ this.relatedDownstreamHovered || this.relatedDownstreamExpanded,
+ },
+ this.cssClassJobName,
+ ];
},
},
errorCaptured(err, _vm, info) {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
index 3470c963ade..b778fe28e59 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -5,7 +5,6 @@ import {
GlDropdownItem,
GlDropdownSectionHeader,
GlLoadingIcon,
- GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
@@ -13,7 +12,6 @@ import { __, s__ } from '~/locale';
export const i18n = {
artifacts: __('Artifacts'),
- downloadArtifact: __('Download %{name} artifact'),
artifactSectionHeader: __('Download artifacts'),
artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
emptyArtifactsMessage: __('No artifacts found'),
@@ -30,7 +28,6 @@ export default {
GlDropdownItem,
GlDropdownSectionHeader,
GlLoadingIcon,
- GlSprintf,
},
inject: {
artifactsEndpoint: {
@@ -113,9 +110,7 @@ export default {
class="gl-word-break-word"
data-testid="artifact-item"
>
- <gl-sprintf :message="$options.i18n.downloadArtifact">
- <template #name>{{ artifact.name }}</template>
- </gl-sprintf>
+ {{ artifact.name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index 3bd149fc782..ef21673115e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlModal } from '@gitlab/ui';
+import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __, s__, sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -13,6 +13,7 @@ export default {
components: {
GlModal,
GlLink,
+ GlSprintf,
CiIcon,
},
props: {
@@ -33,13 +34,7 @@ export default {
);
},
modalText() {
- return sprintf(
- s__(`Pipeline|You’re about to stop pipeline %{pipelineId}.`),
- {
- pipelineId: `<strong>#${this.pipeline.id}</strong>`,
- },
- false,
- );
+ return s__(`Pipeline|You’re about to stop pipeline #%{pipelineId}.`);
},
hasRef() {
return !isEmpty(this.pipeline.ref);
@@ -71,7 +66,13 @@ export default {
:action-cancel="cancelProps"
@primary="emitSubmit($event)"
>
- <p v-html="modalText /* eslint-disable-line vue/no-v-html */"></p>
+ <p>
+ <gl-sprintf :message="modalText">
+ <template #pipelineId>
+ <strong>{{ pipeline.id }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
<p v-if="pipeline">
<ci-icon
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index 36629d9f1f1..1c7c4d7c704 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -3,8 +3,8 @@ import {
GlAlert,
GlDropdown,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlLoadingIcon,
- GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
@@ -12,7 +12,6 @@ import { __, s__ } from '~/locale';
export const i18n = {
artifacts: __('Artifacts'),
- downloadArtifact: __('Download %{name} artifact'),
artifactSectionHeader: __('Download artifacts'),
artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
noArtifacts: s__('Pipelines|No artifacts available'),
@@ -27,8 +26,8 @@ export default {
GlAlert,
GlDropdown,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlLoadingIcon,
- GlSprintf,
},
inject: {
artifactsEndpoint: {
@@ -92,6 +91,10 @@ export default {
text-sr-only
@show.once="fetchArtifacts"
>
+ <gl-dropdown-section-header>{{
+ $options.i18n.artifactSectionHeader
+ }}</gl-dropdown-section-header>
+
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
{{ $options.i18n.artifactsFetchErrorMessage }}
</gl-alert>
@@ -108,10 +111,9 @@ export default {
:href="artifact.path"
rel="nofollow"
download
+ class="gl-word-break-word"
>
- <gl-sprintf :message="$options.i18n.downloadArtifact">
- <template #name>{{ artifact.name }}</template>
- </gl-sprintf>
+ {{ artifact.name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js
index 02baa76f627..d8f15cfde91 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js
@@ -2,51 +2,51 @@ import { s__ } from '~/locale';
export const PIPELINE_SOURCES = [
{
- text: s__('Pipeline|Source|Push'),
+ text: s__('PipelineSource|Push'),
value: 'push',
},
{
- text: s__('Pipeline|Source|Web'),
+ text: s__('PipelineSource|Web'),
value: 'web',
},
{
- text: s__('Pipeline|Source|Trigger'),
+ text: s__('PipelineSource|Trigger'),
value: 'trigger',
},
{
- text: s__('Pipeline|Source|Schedule'),
+ text: s__('PipelineSource|Schedule'),
value: 'schedule',
},
{
- text: s__('Pipeline|Source|API'),
+ text: s__('PipelineSource|API'),
value: 'api',
},
{
- text: s__('Pipeline|Source|External'),
+ text: s__('PipelineSource|External'),
value: 'external',
},
{
- text: s__('Pipeline|Source|Pipeline'),
+ text: s__('PipelineSource|Pipeline'),
value: 'pipeline',
},
{
- text: s__('Pipeline|Source|Chat'),
+ text: s__('PipelineSource|Chat'),
value: 'chat',
},
{
- text: s__('Pipeline|Source|Web IDE'),
+ text: s__('PipelineSource|Web IDE'),
value: 'webide',
},
{
- text: s__('Pipeline|Source|Merge Request'),
+ text: s__('PipelineSource|Merge Request'),
value: 'merge_request_event',
},
{
- text: s__('Pipeline|Source|External Pull Request'),
+ text: s__('PipelineSource|External Pull Request'),
value: 'external_pull_request_event',
},
{
- text: s__('Pipeline|Source|Parent Pipeline'),
+ text: s__('PipelineSource|Parent Pipeline'),
value: 'parent_pipeline',
},
];
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index c49ade2bbb8..ff9b47cdcd6 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -21,6 +21,7 @@ export default class Profile {
$inputEl: this.$inputEl,
$dropdownEl: $('.js-timezone-dropdown'),
displayFormat: (selectedItem) => formatTimezone(selectedItem),
+ allowEmpty: true,
});
}
diff --git a/app/assets/javascripts/pages/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 6e9efc50be8..6e9efc50be8 100644
--- a/app/assets/javascripts/pages/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
diff --git a/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue
index e42d9154866..e42d9154866 100644
--- a/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue
+++ b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue
diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
new file mode 100644
index 00000000000..bf44ff70562
--- /dev/null
+++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
@@ -0,0 +1,163 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import Tracking from '~/tracking';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ mixins: [Tracking.mixin()],
+ apollo: {
+ currentUser: {
+ query: searchNamespacesWhereUserCanCreateProjectsQuery,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ skip() {
+ return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ inject: [
+ 'namespaceFullPath',
+ 'namespaceId',
+ 'rootUrl',
+ 'trackLabel',
+ 'userNamespaceFullPath',
+ 'userNamespaceId',
+ ],
+ data() {
+ return {
+ currentUser: {},
+ groupToFilterBy: undefined,
+ search: '',
+ selectedNamespace: this.namespaceId
+ ? {
+ id: this.namespaceId,
+ fullPath: this.namespaceFullPath,
+ }
+ : {
+ id: this.userNamespaceId,
+ fullPath: this.userNamespaceFullPath,
+ },
+ };
+ },
+ computed: {
+ userGroups() {
+ return this.currentUser.groups?.nodes || [];
+ },
+ userNamespace() {
+ return this.currentUser.namespace || {};
+ },
+ filteredGroups() {
+ return this.groupToFilterBy
+ ? this.userGroups.filter((group) =>
+ group.fullPath.startsWith(this.groupToFilterBy.fullPath),
+ )
+ : this.userGroups;
+ },
+ hasGroupMatches() {
+ return this.filteredGroups.length;
+ },
+ hasNamespaceMatches() {
+ return (
+ this.userNamespace.fullPath?.toLowerCase().includes(this.search.toLowerCase()) &&
+ !this.groupToFilterBy
+ );
+ },
+ hasNoMatches() {
+ return !this.hasGroupMatches && !this.hasNamespaceMatches;
+ },
+ },
+ created() {
+ eventHub.$on('select-template', this.handleSelectTemplate);
+ },
+ beforeDestroy() {
+ eventHub.$off('select-template', this.handleSelectTemplate);
+ },
+ methods: {
+ focusInput() {
+ this.$refs.search.focusInput();
+ },
+ handleSelectTemplate(groupId) {
+ this.groupToFilterBy = this.userGroups.find(
+ (group) => getIdFromGraphQLId(group.id) === groupId,
+ );
+ if (this.groupToFilterBy) {
+ this.setNamespace(this.groupToFilterBy);
+ }
+ },
+ setNamespace({ id, fullPath }) {
+ this.selectedNamespace = {
+ id: getIdFromGraphQLId(id),
+ fullPath,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button-group class="input-lg">
+ <gl-button class="gl-text-truncate" label :title="rootUrl">{{ rootUrl }}</gl-button>
+ <gl-dropdown
+ :text="selectedNamespace.fullPath"
+ toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
+ data-qa-selector="select_namespace_dropdown"
+ @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
+ @shown="focusInput"
+ >
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ data-qa-selector="select_namespace_dropdown_search_field"
+ />
+ <gl-loading-icon v-if="$apollo.queries.currentUser.loading" />
+ <template v-else>
+ <template v-if="hasGroupMatches">
+ <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="group of filteredGroups"
+ :key="group.id"
+ @click="setNamespace(group)"
+ >
+ {{ group.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <template v-if="hasNamespaceMatches">
+ <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
+ <gl-dropdown-item @click="setNamespace(userNamespace)">
+ {{ userNamespace.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-if="hasNoMatches">{{ __('No matches found') }}</gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+
+ <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" />
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/projects/new/event_hub.js b/app/assets/javascripts/projects/new/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/projects/new/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
new file mode 100644
index 00000000000..572d3276e4f
--- /dev/null
+++ b/app/assets/javascripts/projects/new/index.js
@@ -0,0 +1,66 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import NewProjectCreationApp from './components/app.vue';
+import NewProjectUrlSelect from './components/new_project_url_select.vue';
+
+export function initNewProjectCreation() {
+ const el = document.querySelector('.js-new-project-creation');
+
+ const {
+ pushToCreateProjectCommand,
+ workingWithProjectsHelpPath,
+ newProjectGuidelines,
+ hasErrors,
+ isCiCdAvailable,
+ } = el.dataset;
+
+ const props = {
+ hasErrors: parseBoolean(hasErrors),
+ isCiCdAvailable: parseBoolean(isCiCdAvailable),
+ newProjectGuidelines,
+ };
+
+ const provide = {
+ workingWithProjectsHelpPath,
+ pushToCreateProjectCommand,
+ };
+
+ return new Vue({
+ el,
+ provide,
+ render(h) {
+ return h(NewProjectCreationApp, { props });
+ },
+ });
+}
+
+export function initNewProjectUrlSelect() {
+ const elements = document.querySelectorAll('.js-vue-new-project-url-select');
+
+ if (!elements.length) {
+ return;
+ }
+
+ Vue.use(VueApollo);
+
+ elements.forEach(
+ (el) =>
+ new Vue({
+ el,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ }),
+ provide: {
+ namespaceFullPath: el.dataset.namespaceFullPath,
+ namespaceId: el.dataset.namespaceId,
+ rootUrl: el.dataset.rootUrl,
+ trackLabel: el.dataset.trackLabel,
+ userNamespaceFullPath: el.dataset.userNamespaceFullPath,
+ userNamespaceId: el.dataset.userNamespaceId,
+ },
+ render: (createElement) => createElement(NewProjectUrlSelect),
+ }),
+ );
+}
diff --git a/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
index e16fe5dde49..e16fe5dde49 100644
--- a/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
+++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index ebd20583a1c..b350db0c838 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,5 +1,7 @@
import $ from 'jquery';
+import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
+import axios from '../lib/utils/axios_utils';
import {
convertToTitleCase,
humanize,
@@ -9,6 +11,23 @@ import {
let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
+const invalidInputClass = 'gl-field-error-outline';
+
+const validateImportCredentials = (url, user, password) => {
+ const endpoint = `${gon.relative_url_root}/import/url/validate`;
+ return axios
+ .post(endpoint, {
+ url,
+ user,
+ password,
+ })
+ .then(({ data }) => data)
+ .catch(() => ({
+ // intentionally reporting success in case of validation error
+ // we do not want to block users from trying import in case of validation exception
+ success: true,
+ }));
+};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
const slug = slugify(convertUnicodeToAscii($projectNameInput.val()));
@@ -85,7 +104,10 @@ const bindHowToImport = () => {
const bindEvents = () => {
const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url');
- const $projectImportUrlWarning = $('.js-import-url-warning');
+ const $projectImportUrlUser = $('#project_import_url_user');
+ const $projectImportUrlPassword = $('#project_import_url_password');
+ const $projectImportUrlError = $('.js-import-url-error');
+ const $projectImportForm = $('.project-import form');
const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input');
const $projectFieldsForm = $('.project-fields-form');
@@ -139,12 +161,15 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim());
});
- function updateUrlPathWarningVisibility() {
- const url = $projectImportUrl.val();
- const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/;
- const isUrlValid = URL_PATTERN.test(url);
- $projectImportUrlWarning.toggleClass('hide', isUrlValid);
- }
+ const updateUrlPathWarningVisibility = debounce(async () => {
+ const { success: isUrlValid } = await validateImportCredentials(
+ $projectImportUrl.val(),
+ $projectImportUrlUser.val(),
+ $projectImportUrlPassword.val(),
+ );
+ $projectImportUrl.toggleClass(invalidInputClass, !isUrlValid);
+ $projectImportUrlError.toggleClass('hide', isUrlValid);
+ }, 500);
let isProjectImportUrlDirty = false;
$projectImportUrl.on('blur', () => {
@@ -153,9 +178,22 @@ const bindEvents = () => {
});
$projectImportUrl.on('keyup', () => {
deriveProjectPathFromUrl($projectImportUrl);
- // defer error message till first input blur
- if (isProjectImportUrlDirty) {
- updateUrlPathWarningVisibility();
+ });
+
+ [$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
+ $f.on('input', () => {
+ if (isProjectImportUrlDirty) {
+ updateUrlPathWarningVisibility();
+ }
+ });
+ });
+
+ $projectImportForm.on('submit', (e) => {
+ const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`);
+ if ($invalidFields.length > 0) {
+ $invalidFields[0].focus();
+ e.preventDefault();
+ e.stopPropagation();
}
});
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index a5e53ee3927..7fb7a416dca 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -2,8 +2,8 @@
import { escape, find, countBy } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
import { n__, s__, __, sprintf } from '~/locale';
+import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
export default class AccessDropdown {
@@ -16,9 +16,6 @@ export default class AccessDropdown {
this.accessLevelsData = accessLevelsData.roles;
this.$dropdown = $dropdown;
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
- this.usersPath = '/-/autocomplete/users.json';
- this.groupsPath = '/-/autocomplete/project_groups.json';
- this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
@@ -318,9 +315,9 @@ export default class AccessDropdown {
getData(query, callback) {
if (this.hasLicense) {
Promise.all([
- this.getDeployKeys(query),
- this.getUsers(query),
- this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(),
+ getDeployKeys(query),
+ getUsers(query),
+ this.groupsData ? Promise.resolve(this.groupsData) : getGroups(),
])
.then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.groupsData = groupsResponse;
@@ -332,7 +329,7 @@ export default class AccessDropdown {
createFlash({ message: __('Failed to load groups, users and deploy keys.') });
});
} else {
- this.getDeployKeys(query)
+ getDeployKeys(query)
.then((deployKeysResponse) => callback(this.consolidateData(deployKeysResponse.data)))
.catch(() => createFlash({ message: __('Failed to load deploy keys.') }));
}
@@ -473,46 +470,6 @@ export default class AccessDropdown {
return consolidatedData;
}
- getUsers(query) {
- return axios.get(this.buildUrl(gon.relative_url_root, this.usersPath), {
- params: {
- search: query,
- per_page: 20,
- active: true,
- project_id: gon.current_project_id,
- push_code: true,
- },
- });
- }
-
- getGroups() {
- return axios.get(this.buildUrl(gon.relative_url_root, this.groupsPath), {
- params: {
- project_id: gon.current_project_id,
- },
- });
- }
-
- getDeployKeys(query) {
- return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), {
- params: {
- search: query,
- per_page: 20,
- active: true,
- project_id: gon.current_project_id,
- push_code: true,
- },
- });
- }
-
- buildUrl(urlRoot, url) {
- let newUrl;
- if (urlRoot != null) {
- newUrl = urlRoot.replace(/\/$/, '') + url;
- }
- return newUrl;
- }
-
renderRow(item) {
let criteria = {};
let groupRowEl;
diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
new file mode 100644
index 00000000000..10f6c28a7bf
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
@@ -0,0 +1,45 @@
+import axios from '~/lib/utils/axios_utils';
+
+const USERS_PATH = '/-/autocomplete/users.json';
+const GROUPS_PATH = '/-/autocomplete/project_groups.json';
+const DEPLOY_KEYS_PATH = '/-/autocomplete/deploy_keys_with_owners.json';
+
+const buildUrl = (urlRoot, url) => {
+ let newUrl;
+ if (urlRoot != null) {
+ newUrl = urlRoot.replace(/\/$/, '') + url;
+ }
+ return newUrl;
+};
+
+export const getUsers = (query) => {
+ return axios.get(buildUrl(gon.relative_url_root || '', USERS_PATH), {
+ params: {
+ search: query,
+ per_page: 20,
+ active: true,
+ project_id: gon.current_project_id,
+ push_code: true,
+ },
+ });
+};
+
+export const getGroups = () => {
+ return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), {
+ params: {
+ project_id: gon.current_project_id,
+ },
+ });
+};
+
+export const getDeployKeys = (query) => {
+ return axios.get(buildUrl(gon.relative_url_root || '', DEPLOY_KEYS_PATH), {
+ params: {
+ search: query,
+ per_page: 20,
+ active: true,
+ project_id: gon.current_project_id,
+ push_code: true,
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
new file mode 100644
index 00000000000..9823b0229a0
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -0,0 +1,409 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlAvatar,
+ GlSprintf,
+} from '@gitlab/ui';
+import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
+import createFlash from '~/flash';
+import { __, s__, n__ } from '~/locale';
+import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api';
+import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants';
+
+export const i18n = {
+ selectUsers: s__('ProtectedEnvironment|Select users'),
+ rolesSectionHeader: s__('AccessDropdown|Roles'),
+ groupsSectionHeader: s__('AccessDropdown|Groups'),
+ usersSectionHeader: s__('AccessDropdown|Users'),
+ deployKeysSectionHeader: s__('AccessDropdown|Deploy Keys'),
+ ownedBy: __('Owned by %{image_tag}'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlAvatar,
+ GlSprintf,
+ },
+ props: {
+ accessLevelsData: {
+ type: Array,
+ required: true,
+ },
+ accessLevel: {
+ required: true,
+ type: String,
+ },
+ hasLicense: {
+ required: false,
+ type: Boolean,
+ default: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: i18n.selectUsers,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ preselectedItems: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ initialLoading: false,
+ query: '',
+ users: [],
+ groups: [],
+ roles: [],
+ deployKeys: [],
+ selected: {
+ [LEVEL_TYPES.GROUP]: [],
+ [LEVEL_TYPES.USER]: [],
+ [LEVEL_TYPES.ROLE]: [],
+ [LEVEL_TYPES.DEPLOY_KEY]: [],
+ },
+ };
+ },
+ computed: {
+ preselected() {
+ return groupBy(this.preselectedItems, 'type');
+ },
+ showDeployKeys() {
+ return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length;
+ },
+ toggleLabel() {
+ const counts = Object.entries(this.selected).reduce((acc, [key, value]) => {
+ acc[key] = value.length;
+ return acc;
+ }, {});
+
+ const isOnlyRoleSelected =
+ counts[LEVEL_TYPES.ROLE] === 1 &&
+ [counts[LEVEL_TYPES.USER], counts[LEVEL_TYPES.GROUP], counts[LEVEL_TYPES.DEPLOY_KEY]].every(
+ (count) => count === 0,
+ );
+
+ if (isOnlyRoleSelected) {
+ return this.selected[LEVEL_TYPES.ROLE][0].text;
+ }
+
+ const labelPieces = [];
+
+ if (counts[LEVEL_TYPES.ROLE] > 0) {
+ labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE]));
+ }
+
+ if (counts[LEVEL_TYPES.USER] > 0) {
+ labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER]));
+ }
+
+ if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) {
+ labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY]));
+ }
+
+ if (counts[LEVEL_TYPES.GROUP] > 0) {
+ labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
+ }
+
+ return labelPieces.join(', ') || this.label;
+ },
+ toggleClass() {
+ return this.toggleLabel === this.label ? 'gl-text-gray-500!' : '';
+ },
+ selection() {
+ return [
+ ...this.getDataForSave(LEVEL_TYPES.ROLE, 'access_level'),
+ ...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id'),
+ ...this.getDataForSave(LEVEL_TYPES.USER, 'user_id'),
+ ...this.getDataForSave(LEVEL_TYPES.DEPLOY_KEY, 'deploy_key_id'),
+ ];
+ },
+ },
+ watch: {
+ query: debounce(function debouncedSearch() {
+ return this.getData();
+ }, 500),
+ },
+ created() {
+ this.getData({ initial: true });
+ },
+ methods: {
+ focusInput() {
+ this.$refs.search.focusInput();
+ },
+ getData({ initial = false } = {}) {
+ this.initialLoading = initial;
+ this.loading = true;
+
+ if (this.hasLicense) {
+ Promise.all([
+ getDeployKeys(this.query),
+ getUsers(this.query),
+ this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(),
+ ])
+ .then(([deployKeysResponse, usersResponse, groupsResponse]) => {
+ this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data);
+ this.setSelected({ initial });
+ })
+ .catch(() =>
+ createFlash({ message: __('Failed to load groups, users and deploy keys.') }),
+ )
+ .finally(() => {
+ this.initialLoading = false;
+ this.loading = false;
+ });
+ } else {
+ getDeployKeys(this.query)
+ .then((deployKeysResponse) => {
+ this.consolidateData(deployKeysResponse.data);
+ this.setSelected({ initial });
+ })
+ .catch(() => createFlash({ message: __('Failed to load deploy keys.') }))
+ .finally(() => {
+ this.initialLoading = false;
+ this.loading = false;
+ });
+ }
+ },
+ consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) {
+ // This re-assignment is intentional as level.type property is being used for comparision,
+ // and accessLevelsData is provided by gon.create_access_levels which doesn't have `type` included.
+ // See this discussion https://gitlab.com/gitlab-org/gitlab/merge_requests/1629#note_31285823
+ this.roles = this.accessLevelsData.map((role) => ({ ...role, type: LEVEL_TYPES.ROLE }));
+
+ if (this.hasLicense) {
+ this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP }));
+ this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({
+ id,
+ name,
+ username,
+ avatar_url,
+ type: LEVEL_TYPES.USER,
+ }));
+ }
+
+ this.deployKeys = deployKeysResponse.map((response) => {
+ const {
+ id,
+ fingerprint,
+ title,
+ owner: { avatar_url, name, username },
+ } = response;
+
+ const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
+
+ return {
+ id,
+ title: title.concat(' ', shortFingerprint),
+ avatar_url,
+ fullname: name,
+ username,
+ type: LEVEL_TYPES.DEPLOY_KEY,
+ };
+ });
+ },
+ setSelected({ initial } = {}) {
+ if (initial) {
+ // as all available groups && roles are always visible in the dropdown, we set local selected by looking
+ // for intersection in all roles/groups and initial selected (returned from BE).
+ // It is different for the users - not all the users will be returned on the first data load (another set
+ // will be returned on search, only first 20 are displayed initially).
+ // That is why we set ALL initial selected users (returned from BE) as local selected (not looking
+ // for the intersection with all users data) and later if the selected happens to be in the users list
+ // we filter it out from the list so that not to have duplicates
+ // TODO: we'll need to get back to how to handle deploy keys here but they are out of scope
+ // and will be checked when migrating protected branches access dropdown to the current component
+ // related issue - https://gitlab.com/gitlab-org/gitlab/-/issues/284784
+ const selectedRoles = intersectionWith(
+ this.roles,
+ this.preselectedItems,
+ (role, selected) => {
+ return selected.type === LEVEL_TYPES.ROLE && role.id === selected.access_level;
+ },
+ );
+ this.selected[LEVEL_TYPES.ROLE] = selectedRoles;
+
+ const selectedGroups = intersectionWith(
+ this.groups,
+ this.preselectedItems,
+ (group, selected) => {
+ return selected.type === LEVEL_TYPES.GROUP && group.id === selected.group_id;
+ },
+ );
+ this.selected[LEVEL_TYPES.GROUP] = selectedGroups;
+
+ const selectedDeployKeys = intersectionWith(
+ this.deployKeys,
+ this.preselectedItems,
+ (key, selected) => {
+ return selected.type === LEVEL_TYPES.DEPLOY_KEY && key.id === selected.deploy_key_id;
+ },
+ );
+ this.selected[LEVEL_TYPES.DEPLOY_KEY] = selectedDeployKeys;
+
+ const selectedUsers = this.preselectedItems
+ .filter(({ type }) => type === LEVEL_TYPES.USER)
+ .map(({ user_id, name, username, avatar_url, type }) => ({
+ id: user_id,
+ name,
+ username,
+ avatar_url,
+ type,
+ }));
+
+ this.selected[LEVEL_TYPES.USER] = selectedUsers;
+ }
+
+ this.users = this.users.filter(
+ (user) => !this.selected[LEVEL_TYPES.USER].some((selected) => selected.id === user.id),
+ );
+ this.users.unshift(...this.selected[LEVEL_TYPES.USER]);
+ },
+ getDataForSave(accessType, key) {
+ const selected = this.selected[accessType].map(({ id }) => ({ [key]: id }));
+ const preselected = this.preselected[accessType];
+ const added = differenceBy(selected, preselected, key);
+ const preserved = intersectionBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
+ id,
+ [key]: keyId,
+ }));
+ const removed = differenceBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
+ id,
+ [key]: keyId,
+ _destroy: true,
+ }));
+ return [...added, ...removed, ...preserved];
+ },
+ onItemClick(item) {
+ this.toggleSelection(this.selected[item.type], item);
+ this.emitUpdate();
+ },
+ toggleSelection(arr, item) {
+ const itemIndex = arr.findIndex(({ id }) => id === item.id);
+ if (itemIndex > -1) {
+ arr.splice(itemIndex, 1);
+ } else arr.push(item);
+ },
+ isSelected(item) {
+ return this.selected[item.type].some((selected) => selected.id === item.id);
+ },
+ emitUpdate() {
+ this.$emit('select', this.selection);
+ },
+ onHide() {
+ this.$emit('hidden', this.selection);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :disabled="disabled || initialLoading"
+ :text="toggleLabel"
+ class="gl-min-w-20"
+ :toggle-class="toggleClass"
+ aria-labelledby="allowed-users-label"
+ @shown="focusInput"
+ @hidden="onHide"
+ >
+ <template #header>
+ <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" />
+ </template>
+ <template v-if="roles.length">
+ <gl-dropdown-section-header>{{
+ $options.i18n.rolesSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="role in roles"
+ :key="`${role.id}${role.text}`"
+ data-testid="role-dropdown-item"
+ is-check-item
+ :is-checked="isSelected(role)"
+ @click.native.capture.stop="onItemClick(role)"
+ >
+ {{ role.text }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="groups.length || users.length || showDeployKeys" />
+ </template>
+
+ <template v-if="groups.length">
+ <gl-dropdown-section-header>{{
+ $options.i18n.groupsSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="group in groups"
+ :key="`${group.id}${group.name}`"
+ fingerprint
+ data-testid="group-dropdown-item"
+ :avatar-url="group.avatar_url"
+ is-check-item
+ :is-checked="isSelected(group)"
+ @click.native.capture.stop="onItemClick(group)"
+ >
+ {{ group.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="users.length || showDeployKeys" />
+ </template>
+
+ <template v-if="users.length">
+ <gl-dropdown-section-header>{{
+ $options.i18n.usersSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="user in users"
+ :key="`${user.id}${user.username}`"
+ data-testid="user-dropdown-item"
+ :avatar-url="user.avatar_url"
+ :secondary-text="user.username"
+ is-check-item
+ :is-checked="isSelected(user)"
+ @click.native.capture.stop="onItemClick(user)"
+ >
+ {{ user.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="showDeployKeys" />
+ </template>
+
+ <template v-if="showDeployKeys">
+ <gl-dropdown-section-header>{{
+ $options.i18n.deployKeysSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="key in deployKeys"
+ :key="`${key.id}${key.fingerprint}`"
+ data-testid="deploy_key-dropdown-item"
+ is-check-item
+ :is-checked="isSelected(key)"
+ class="gl-text-truncate"
+ @click.native.capture.stop="onItemClick(key)"
+ >
+ <div class="gl-text-truncate gl-font-weight-bold">{{ key.title }}</div>
+ <div class="gl-text-gray-700 gl-text-truncate">
+ <gl-sprintf :message="$options.i18n.ownedBy">
+ <template #image_tag>
+ <gl-avatar :src="key.avatar_url" :size="24" />
+ </template> </gl-sprintf
+ >{{ key.fullname }} ({{ key.username }})
+ </div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js
new file mode 100644
index 00000000000..11272652b63
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js
@@ -0,0 +1,39 @@
+import * as Sentry from '@sentry/browser';
+import Vue from 'vue';
+import AccessDropdown from './components/access_dropdown.vue';
+
+export const initAccessDropdown = (el, options) => {
+ if (!el) {
+ return false;
+ }
+
+ const { accessLevelsData, accessLevel } = options;
+ const { label, disabled, preselectedItems } = el.dataset;
+ let preselected = [];
+ try {
+ preselected = JSON.parse(preselectedItems);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+
+ return new Vue({
+ el,
+ render(createElement) {
+ const vm = this;
+ return createElement(AccessDropdown, {
+ props: {
+ accessLevel,
+ accessLevelsData: accessLevelsData.roles,
+ preselectedItems: preselected,
+ label,
+ disabled,
+ },
+ on: {
+ select(selected) {
+ vm.$emit('select', selected);
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
index eecb3573046..befbca48736 100644
--- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
+++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
@@ -1,8 +1,16 @@
<script>
-import { GlButton, GlFormGroup, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ GlModalDirective,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
@@ -13,6 +21,8 @@ export default {
GlFormInput,
GlModal,
ClipboardButton,
+ GlSprintf,
+ GlLink,
},
directives: {
'gl-modal': GlModalDirective,
@@ -44,16 +54,6 @@ export default {
data() {
return {
authorizationKey: this.initialAuthorizationKey,
- sectionDescription: sprintf(
- __(
- 'To receive alerts from manually configured Prometheus services, add the following URL and Authorization key to your Prometheus webhook config file. Learn more about %{linkStart}configuring Prometheus%{linkEnd} to send alerts to GitLab.',
- ),
- {
- linkStart: `<a href="${this.learnMoreUrl}" target="_blank" rel="noopener noreferrer">`,
- linkEnd: '</a>',
- },
- false,
- ),
};
},
methods: {
@@ -84,7 +84,17 @@ export default {
</p>
</div>
<div class="col-lg-9">
- <p v-html="sectionDescription /* eslint-disable-line vue/no-v-html */"></p>
+ <gl-sprintf
+ :message="
+ __(
+ 'To receive alerts from manually configured Prometheus services, add the following URL and Authorization key to your Prometheus webhook config file. Learn more about %{linkStart}configuring Prometheus%{linkEnd} to send alerts to GitLab.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="learnMoreUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
<gl-form-group :label="__('URL')" label-for="notify-url" label-class="label-bold">
<div class="input-group">
<gl-form-input id="notify-url" :readonly="true" :value="notifyUrl" />
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
index 45eb2ce51e4..0556fd298aa 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
@@ -1,5 +1,12 @@
<script>
-import { GlFormCheckbox, GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui';
+import {
+ GlFormCheckbox,
+ GlTooltipDirective,
+ GlSprintf,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { n__ } from '~/locale';
@@ -11,22 +18,22 @@ import {
REMOVE_TAG_BUTTON_TITLE,
DIGEST_LABEL,
CREATED_AT_LABEL,
- REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST,
MISSING_MANIFEST_WARNING_TOOLTIP,
NOT_AVAILABLE_TEXT,
NOT_AVAILABLE_SIZE,
+ MORE_ACTIONS_TEXT,
} from '../../constants/index';
-import DeleteButton from '../delete_button.vue';
export default {
components: {
GlSprintf,
GlFormCheckbox,
GlIcon,
- DeleteButton,
+ GlDropdown,
+ GlDropdownItem,
ListItem,
ClipboardButton,
TimeAgoTooltip,
@@ -60,11 +67,11 @@ export default {
REMOVE_TAG_BUTTON_TITLE,
DIGEST_LABEL,
CREATED_AT_LABEL,
- REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST,
MISSING_MANIFEST_WARNING_TOOLTIP,
+ MORE_ACTIONS_TEXT,
},
computed: {
formattedSize() {
@@ -173,15 +180,27 @@ export default {
</span>
</template>
<template #right-action>
- <delete-button
+ <gl-dropdown
:disabled="isDeleteDisabled"
- :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
- :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
- :tooltip-disabled="tag.canDelete"
- data-qa-selector="tag_delete_button"
- data-testid="single-delete-button"
- @delete="$emit('delete')"
- />
+ icon="ellipsis_v"
+ :text="$options.i18n.MORE_ACTIONS_TEXT"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ right
+ :class="{ 'gl-opacity-0 gl-pointer-events-none': isDeleteDisabled }"
+ data-testid="additional-actions"
+ data-qa-selector="more_actions_menu"
+ >
+ <gl-dropdown-item
+ variant="danger"
+ data-testid="single-delete-button"
+ data-qa-selector="tag_delete_button"
+ @click="$emit('delete')"
+ >
+ {{ $options.i18n.REMOVE_TAG_BUTTON_TITLE }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
<template v-if="!isInvalidTag" #details-published>
diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/registry/explorer/constants/common.js
index dc71ef8450b..f7beec2c935 100644
--- a/app/assets/javascripts/registry/explorer/constants/common.js
+++ b/app/assets/javascripts/registry/explorer/constants/common.js
@@ -1,3 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
+export const MORE_ACTIONS_TEXT = __('More actions');
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index 0836260b71e..19e1a75fb2f 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -30,7 +30,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__(
'ContainerRegistry|Configuration digest: %{digest}',
);
-export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
+export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Delete tag');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected tags');
export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
@@ -61,10 +61,6 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
);
-export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__(
- 'ContainerRegistry|Deletion disabled due to missing or insufficient permissions.',
-);
-
export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
'ContainerRegistry|Invalid tag: missing manifest digest',
);
diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js
index f59b9d7a9f5..d21a154d1b8 100644
--- a/app/assets/javascripts/registry/explorer/constants/list.js
+++ b/app/assets/javascripts/registry/explorer/constants/list.js
@@ -5,7 +5,7 @@ import { s__, __ } from '~/locale';
export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
export const CONNECTION_ERROR_MESSAGE = s__(
- `ContainerRegistry|We are having trouble connecting to the Registry, which could be due to an issue with your project name or path. %{docLinkStart}More information%{docLinkEnd}`,
+ `ContainerRegistry|We are having trouble connecting to the Container Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`,
);
export const LIST_INTRO_TEXT = s__(
`ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`,
diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js
index 1f82fd7f238..246a6768593 100644
--- a/app/assets/javascripts/registry/explorer/index.js
+++ b/app/assets/javascripts/registry/explorer/index.js
@@ -36,6 +36,8 @@ export default () => {
isAdmin,
showCleanupPolicyOnAlert,
showUnfinishedTagCleanupCallout,
+ connectionError,
+ invalidPathError,
...config
} = el.dataset;
@@ -67,6 +69,8 @@ export default () => {
isAdmin: parseBoolean(isAdmin),
showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert),
showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout),
+ connectionError: parseBoolean(connectionError),
+ invalidPathError: parseBoolean(invalidPathError),
},
/* eslint-disable @gitlab/require-i18n-strings */
dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`,
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 3c8790fa6e5..73b957f42f2 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -171,6 +171,9 @@ export default {
showDeleteAlert() {
return this.deleteAlertType && this.itemToDelete?.path;
},
+ showConnectionError() {
+ return this.config.connectionError || this.config.invalidPathError;
+ },
deleteImageAlertMessage() {
return this.deleteAlertType === 'success'
? DELETE_IMAGE_SUCCESS_MESSAGE
@@ -292,7 +295,7 @@ export default {
/>
<gl-empty-state
- v-if="config.characterError"
+ v-if="showConnectionError"
:title="$options.i18n.CONNECTION_ERROR_TITLE"
:svg-path="config.containersErrorImage"
>
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index 02929062cee..f936c03c5d3 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -74,6 +74,16 @@ export default {
required: false,
default: false,
},
+ autoCompleteEpics: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autoCompleteIssues: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -177,7 +187,7 @@ export default {
:path-id-separator="pathIdSeparator"
:input-value="inputValue"
:auto-complete-sources="transformedAutocompleteSources"
- :auto-complete-options="{ issues: true, epics: true }"
+ :auto-complete-options="{ issues: autoCompleteIssues, epics: autoCompleteEpics }"
:issuable-type="issuableType"
@pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest"
@formCancel="onFormCancel"
@@ -187,15 +197,15 @@ export default {
<p v-if="hasError" class="gl-field-error">
{{ addRelatedErrorMessage }}
</p>
- <div class="add-issuable-form-actions clearfix">
+ <div class="gl-mt-5 gl-clearfix">
<gl-button
ref="addButton"
category="primary"
- variant="success"
+ variant="confirm"
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
type="submit"
- class="js-add-issuable-form-add-button float-left"
+ class="float-left"
data-qa-selector="add_issue_button"
>
{{ __('Add') }}
diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue
index 9665ed173b9..abbd612d3ec 100644
--- a/app/assets/javascripts/related_issues/components/issue_token.vue
+++ b/app/assets/javascripts/related_issues/components/issue_token.vue
@@ -48,7 +48,7 @@ export default {
<template>
<div
:class="{
- 'issue-token': isCondensed,
+ 'issue-token gl-display-inline-flex gl-align-items-stretch gl-max-w-full gl-line-height-24 gl-white-space-nowrap': isCondensed,
'flex-row issuable-info-container': !isCondensed,
}"
>
@@ -57,7 +57,7 @@ export default {
ref="link"
v-gl-tooltip
:class="{
- 'issue-token-link': isCondensed,
+ 'issue-token-link gl-display-inline-flex gl-min-w-0 gl-text-gray-500': isCondensed,
'issuable-main-info': !isCondensed,
}"
:href="computedPath"
@@ -69,19 +69,19 @@ export default {
v-if="hasTitle"
ref="title"
:class="{
- 'issue-token-title issue-token-end': isCondensed,
+ 'issue-token-title issue-token-end gl-overflow-hidden gl-display-flex gl-align-items-baseline gl-text-gray-500 gl-pl-3': isCondensed,
'issue-title block-truncated': !isCondensed,
- 'issue-token-title-standalone': !canRemove,
+ 'gl-rounded-top-right-small gl-rounded-bottom-right-small gl-pr-3': !canRemove,
}"
class="js-issue-token-title"
>
- <span class="issue-token-title-text">{{ title }}</span>
+ <span class="gl-text-truncate">{{ title }}</span>
</component>
<component
:is="innerComponentType"
ref="reference"
:class="{
- 'issue-token-reference': isCondensed,
+ 'issue-token-reference gl-display-flex gl-align-items-center gl-rounded-top-left-small gl-rounded-bottom-left-small gl-px-3': isCondensed,
'issuable-info': !isCondensed,
}"
>
@@ -103,7 +103,7 @@ export default {
ref="removeButton"
v-gl-tooltip
:class="{
- 'issue-token-remove-button': isCondensed,
+ 'issue-token-remove-button gl-display-flex gl-align-items-center gl-px-3 gl-border-0 gl-rounded-top-right-small gl-rounded-bottom-right-small gl-text-gray-500': isCondensed,
'btn btn-default': !isCondensed,
}"
:title="removeButtonLabel"
@@ -111,7 +111,6 @@ export default {
:disabled="removeDisabled"
data-testid="removeBtn"
type="button"
- class="js-issue-token-remove-button"
@click="onRemoveRequest"
>
<gl-icon name="close" />
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 46b97370d66..270d4632a54 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -107,9 +107,6 @@ export default {
onAutoCompleteToggled(isOpen) {
this.isAutoCompleteOpen = isOpen;
},
- onInputWrapperClick() {
- this.$refs.input.focus();
- },
onInput() {
const { value } = this.$refs.input;
const caretPos = this.$refs.input.selectionStart;
@@ -185,26 +182,23 @@ export default {
<div
ref="issuableFormWrapper"
:class="{ focus: isInputFocused }"
- class="add-issuable-form-input-wrapper form-control gl-field-error-outline"
+ class="add-issuable-form-input-wrapper form-control gl-field-error-outline gl-h-auto gl-p-3 gl-pb-2"
role="button"
@click="onIssuableFormWrapperClick"
>
- <ul class="add-issuable-form-input-token-list">
- <!--
- We need to ensure this key changes any time the pendingReferences array is updated
- else two consecutive pending ref strings in an array with the same name will collide
- and cause odd behavior when one is removed.
- -->
+ <ul
+ class="gl-display-flex gl-flex-wrap gl-align-items-baseline gl-list-style-none gl-m-0 gl-p-0"
+ >
<li
v-for="(reference, index) in references"
- :key="`related-issues-token-${reference}`"
- class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item"
+ :key="reference"
+ class="gl-max-w-full gl-mb-2 gl-mr-2"
>
<issue-token
:id-key="index"
:display-reference="reference.text || reference"
- :can-remove="true"
- :is-condensed="true"
+ can-remove
+ is-condensed
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable"
@pendingIssuableRemoveRequest="
@@ -214,14 +208,15 @@ export default {
"
/>
</li>
- <li class="add-issuable-form-input-list-item">
+ <li class="gl-mb-2 gl-flex-grow-1">
<input
:id="inputId"
ref="input"
:value="inputValue"
:placeholder="inputPlaceholder"
+ :aria-label="inputPlaceholder"
type="text"
- class="js-add-issuable-form-input add-issuable-form-input"
+ class="gl-w-full gl-border-none gl-outline-0"
data-qa-selector="add_issue_field"
autocomplete="off"
@input="onInput"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index c042f0eef5f..94535e1b8c9 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -123,7 +123,7 @@ export default {
</script>
<template>
- <div id="related-issues" class="related-issues-block">
+ <div id="related-issues" class="related-issues-block gl-mt-5">
<div class="card card-slim gl-overflow-hidden">
<div
:class="{ 'panel-empty-heading border-bottom-0': !hasBody }"
@@ -162,7 +162,6 @@ export default {
icon="plus"
:aria-label="__('Add a related issue')"
:class="qaClass"
- class="js-issue-count-badge-add-button"
@click="$emit('toggleAddRelatedIssuesForm', $event)"
/>
</div>
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 8f486fb1b07..a21e294a34a 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -97,11 +97,7 @@ export default {
class="related-issues-token-body bordered-box bg-white"
:class="{ 'sortable-container': canReorder }"
>
- <div
- v-if="isFetching"
- class="related-issues-loading-icon"
- data-qa-selector="related_issues_loading_placeholder"
- >
+ <div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder">
<gl-loading-icon
ref="loadingIcon"
size="sm"
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
index 6fb1d1ed365..05858c7469d 100644
--- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -81,13 +81,13 @@ export default {
{{ __('Related merge requests') }}
</span>
<div v-if="totalCount" class="d-inline-flex lh-100 align-middle">
- <div class="mr-count-badge gl-display-inline-flex">
- <div class="mr-count-badge-count">
- <svg class="s16 mr-1 text-secondary">
- <gl-icon name="merge-request" class="mr-1 text-secondary" />
- </svg>
- <span class="js-items-count">{{ totalCount }}</span>
- </div>
+ <div
+ class="mr-count-badge gl-display-inline-flex gl-align-items-center gl-py-2 gl-px-3"
+ >
+ <svg class="s16 mr-1 text-secondary">
+ <gl-icon name="merge-request" class="mr-1 text-secondary" />
+ </svg>
+ <span class="js-items-count">{{ totalCount }}</span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index 3201ca1f443..b2bd405574f 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -1,4 +1,5 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import { isEmpty } from 'lodash';
import { scrollToElement } from '~/lib/utils/common_utils';
@@ -21,6 +22,9 @@ export default {
ReleaseBlockHeader,
ReleaseBlockMilestoneInfo,
},
+ directives: {
+ SafeHtml,
+ },
mixins: [glFeatureFlagsMixin()],
props: {
release: {
@@ -79,6 +83,7 @@ export default {
$(this.$refs['gfm-content']).renderGFM();
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
@@ -102,10 +107,7 @@ export default {
<evidence-block v-if="hasEvidence" :release="release" />
<div ref="gfm-content" class="card-text gl-mt-3">
- <div
- class="md"
- v-html="release.descriptionHtml /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <div v-safe-html:[$options.safeHtmlConfig]="release.descriptionHtml" class="md"></div>
</div>
</div>
diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js
index 7272880197a..686f9e294b7 100644
--- a/app/assets/javascripts/releases/mount_show.js
+++ b/app/assets/javascripts/releases/mount_show.js
@@ -6,7 +6,12 @@ import ReleaseShowApp from './components/app_show.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
});
export default () => {
diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
index 736c8668a34..59bd54eab60 100644
--- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
+++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
@@ -33,17 +33,20 @@ export default {
issueName() {
return `${this.severityLabel} - ${this.issue.name}`;
},
+ issueSeverity() {
+ return this.issue.severity.toLowerCase();
+ },
isStatusSuccess() {
return this.status === STATUS_SUCCESS;
},
severityClass() {
- return SEVERITY_CLASSES[this.issue.severity] || SEVERITY_CLASSES.unknown;
+ return SEVERITY_CLASSES[this.issueSeverity] || SEVERITY_CLASSES.unknown;
},
severityIcon() {
- return SEVERITY_ICONS[this.issue.severity] || SEVERITY_ICONS.unknown;
+ return SEVERITY_ICONS[this.issueSeverity] || SEVERITY_ICONS.unknown;
},
severityLabel() {
- return this.$options.severityText[this.issue.severity] || this.$options.severityText.unknown;
+ return this.$options.severityText[this.issueSeverity] || this.$options.severityText.unknown;
},
},
severityText: {
diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
index 0e18d0992cd..599e8d35708 100644
--- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
+++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
@@ -55,10 +55,12 @@ export default {
...mapActions(['fetchReports', 'setPaths']),
},
loadingText: sprintf(s__('ciReport|Loading %{reportName} report'), {
- reportName: 'codeclimate',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ reportName: 'Code quality',
}),
errorText: sprintf(s__('ciReport|Failed to load %{reportName} report'), {
- reportName: 'codeclimate',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ reportName: 'Code quality',
}),
};
</script>
diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js
index 3fb8c5be351..4712f8cbefe 100644
--- a/app/assets/javascripts/reports/codequality_report/store/getters.js
+++ b/app/assets/javascripts/reports/codequality_report/store/getters.js
@@ -1,5 +1,5 @@
import { spriteIcon } from '~/lib/utils/common_utils';
-import { sprintf, __, s__, n__ } from '~/locale';
+import { sprintf, s__ } from '~/locale';
import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants';
export const hasCodequalityIssues = (state) =>
@@ -18,27 +18,23 @@ export const codequalityStatus = (state) => {
export const codequalityText = (state) => {
const { newIssues, resolvedIssues } = state;
- const text = [];
-
+ let text;
if (!newIssues.length && !resolvedIssues.length) {
- text.push(s__('ciReport|No changes to code quality'));
- } else {
- text.push(s__('ciReport|Code quality'));
-
- if (resolvedIssues.length) {
- text.push(n__(' improved on %d point', ' improved on %d points', resolvedIssues.length));
- }
-
- if (newIssues.length && resolvedIssues.length) {
- text.push(__(' and'));
- }
-
- if (newIssues.length) {
- text.push(n__(' degraded on %d point', ' degraded on %d points', newIssues.length));
- }
+ text = s__('ciReport|No changes to code quality');
+ } else if (newIssues.length && resolvedIssues.length) {
+ text = sprintf(
+ s__(`ciReport|Code quality scanning detected %{issueCount} changes in merged results`),
+ {
+ issueCount: newIssues.length + resolvedIssues.length,
+ },
+ );
+ } else if (resolvedIssues.length) {
+ text = s__(`ciReport|Code quality improved`);
+ } else if (newIssues.length) {
+ text = s__(`ciReport|Code quality degraded`);
}
- return text.join('');
+ return text;
};
export const codequalityPopover = (state) => {
diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js
index a794f5f0577..417297df43c 100644
--- a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js
+++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js
@@ -1,14 +1,16 @@
-export const parseCodeclimateMetrics = (issues = [], path = '') => {
+export const parseCodeclimateMetrics = (issues = [], blobPath = '') => {
return issues.map((issue) => {
+ // the `file_path` attribute from the artifact is returned as `file` by GraphQL
+ const issuePath = issue.file_path || issue.path;
const parsedIssue = {
name: issue.description,
- path: issue.file_path,
- urlPath: `${path}/${issue.file_path}#L${issue.line}`,
+ path: issuePath,
+ urlPath: `${blobPath}/${issuePath}#L${issue.line}`,
...issue,
};
if (issue?.location?.path) {
- let parseCodeQualityUrl = `${path}/${issue.location.path}`;
+ let parseCodeQualityUrl = `${blobPath}/${issue.location.path}`;
parsedIssue.path = issue.location.path;
if (issue?.location?.lines?.begin) {
diff --git a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
index 82806793401..be49a03a9a5 100644
--- a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
@@ -3,7 +3,6 @@ import { GlButton, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import api from '~/api';
import { sprintf, s__ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GroupedIssuesList from '../components/grouped_issues_list.vue';
import { componentNames } from '../components/issue_body';
import ReportSection from '../components/report_section.vue';
@@ -28,7 +27,6 @@ export default {
GlButton,
GlIcon,
},
- mixins: [glFeatureFlagsMixin()],
props: {
endpoint: {
type: String,
@@ -82,9 +80,7 @@ export default {
methods: {
...mapActions(['setPaths', 'fetchReports', 'closeModal']),
handleToggleEvent() {
- if (this.glFeatures.usageDataITestingSummaryWidgetTotal) {
- api.trackRedisHllUserEvent(this.$options.expandEvent);
- }
+ api.trackRedisHllUserEvent(this.$options.expandEvent);
},
reportText(report) {
const { name, summary } = report || {};
diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js
new file mode 100644
index 00000000000..504efaea8cc
--- /dev/null
+++ b/app/assets/javascripts/repository/commits_service.js
@@ -0,0 +1,65 @@
+import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { normalizeData } from 'ee_else_ce/repository/utils/commit';
+import createFlash from '~/flash';
+import { COMMIT_BATCH_SIZE, I18N_COMMIT_DATA_FETCH_ERROR } from './constants';
+
+let requestedOffsets = [];
+let fetchedBatches = [];
+
+export const isRequested = (offset) => requestedOffsets.includes(offset);
+
+export const resetRequestedCommits = () => {
+ requestedOffsets = [];
+ fetchedBatches = [];
+};
+
+const addRequestedOffset = (offset) => {
+ if (isRequested(offset) || offset < 0) {
+ return;
+ }
+
+ requestedOffsets.push(offset);
+};
+
+const removeLeadingSlash = (path) => path.replace(/^\//, '');
+
+const fetchData = (projectPath, path, ref, offset) => {
+ if (fetchedBatches.includes(offset) || offset < 0) {
+ return [];
+ }
+
+ fetchedBatches.push(offset);
+
+ const url = joinPaths(
+ gon.relative_url_root || '/',
+ projectPath,
+ '/-/refs/',
+ ref,
+ '/logs_tree/',
+ encodeURIComponent(removeLeadingSlash(path)),
+ );
+
+ return axios
+ .get(url, { params: { format: 'json', offset } })
+ .then(({ data }) => normalizeData(data, path))
+ .catch(() => createFlash({ message: I18N_COMMIT_DATA_FETCH_ERROR }));
+};
+
+export const loadCommits = async (projectPath, path, ref, offset) => {
+ if (isRequested(offset)) {
+ return [];
+ }
+
+ // We fetch in batches of 25, so this ensures we don't refetch
+ Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => {
+ addRequestedOffset(offset - i);
+ addRequestedOffset(offset + i);
+ });
+
+ // Since a user could scroll either up or down, we want to support lazy loading in both directions
+ const commitsBatchUp = await fetchData(projectPath, path, ref, offset - COMMIT_BATCH_SIZE);
+ const commitsBatchDown = await fetchData(projectPath, path, ref, offset);
+
+ return commitsBatchUp.concat(commitsBatchDown);
+};
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 1d79818cbe8..7ad9fb56972 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -8,10 +8,12 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import { redirectTo } from '~/lib/utils/url_utility';
import getRefMixin from '../mixins/get_ref';
import blobInfoQuery from '../queries/blob_info.query.graphql';
import BlobButtonGroup from './blob_button_group.vue';
import BlobEdit from './blob_edit.vue';
+import ForkSuggestion from './fork_suggestion.vue';
import { loadViewer, viewerProps } from './blob_viewers';
export default {
@@ -21,6 +23,7 @@ export default {
BlobButtonGroup,
BlobContent,
GlLoadingIcon,
+ ForkSuggestion,
},
mixins: [getRefMixin],
inject: {
@@ -42,9 +45,6 @@ export default {
this.switchViewer(
this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
);
- if (this.hasRichViewer && !this.blobViewer) {
- this.loadLegacyViewer();
- }
},
error() {
this.displayError();
@@ -68,7 +68,9 @@ export default {
},
data() {
return {
+ forkTarget: null,
legacyRichViewer: null,
+ legacySimpleViewer: null,
isBinary: false,
isLoadingLegacyViewer: false,
activeViewerType: SIMPLE_BLOB_VIEWER,
@@ -76,6 +78,8 @@ export default {
userPermissions: {
pushCode: false,
downloadCode: false,
+ createMergeRequestIn: false,
+ forkProject: false,
},
pathLocks: {
nodes: [],
@@ -94,12 +98,14 @@ export default {
path: '',
editBlobPath: '',
ideEditPath: '',
+ forkAndEditPath: '',
+ ideForkAndEditPath: '',
storedExternally: false,
+ canModifyBlob: false,
rawPath: '',
externalStorageUrl: '',
replacePath: '',
deletePath: '',
- forkPath: '',
simpleViewer: {},
richViewer: null,
webPath: '',
@@ -115,7 +121,7 @@ export default {
return isLoggedIn();
},
isLoading() {
- return this.$apollo.queries.project.loading || this.isLoadingLegacyViewer;
+ return this.$apollo.queries.project.loading;
},
isBinaryFileType() {
return this.isBinary || this.blobInfo.simpleViewer?.fileType !== 'text';
@@ -151,24 +157,66 @@ export default {
isLocked() {
return this.project.pathLocks.nodes.some((node) => node.path === this.path);
},
+ showForkSuggestion() {
+ const { createMergeRequestIn, forkProject } = this.project.userPermissions;
+ const { canModifyBlob } = this.blobInfo;
+
+ return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject;
+ },
+ forkPath() {
+ return this.forkTarget === 'ide'
+ ? this.blobInfo.ideForkAndEditPath
+ : this.blobInfo.forkAndEditPath;
+ },
},
methods: {
- loadLegacyViewer() {
+ loadLegacyViewer(type) {
+ if (this.legacyViewerLoaded(type)) {
+ return;
+ }
+
this.isLoadingLegacyViewer = true;
axios
- .get(`${this.blobInfo.webPath}?format=json&viewer=rich`)
+ .get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
.then(({ data: { html, binary } }) => {
- this.legacyRichViewer = html;
+ if (type === 'simple') {
+ this.legacySimpleViewer = html;
+ } else {
+ this.legacyRichViewer = html;
+ }
+
this.isBinary = binary;
this.isLoadingLegacyViewer = false;
})
.catch(() => this.displayError());
},
+ legacyViewerLoaded(type) {
+ return (
+ (type === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) ||
+ (type === RICH_BLOB_VIEWER && this.legacyRichViewer)
+ );
+ },
displayError() {
createFlash({ message: __('An error occurred while loading the file. Please try again.') });
},
switchViewer(newViewer) {
this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
+
+ if (!this.blobViewer) {
+ this.loadLegacyViewer(this.activeViewerType);
+ }
+ },
+ editBlob(target) {
+ if (this.showForkSuggestion) {
+ this.setForkTarget(target);
+ return;
+ }
+
+ const { ideEditPath, editBlobPath } = this.blobInfo;
+ redirectTo(target === 'ide' ? ideEditPath : editBlobPath);
+ },
+ setForkTarget(target) {
+ this.forkTarget = target;
},
},
};
@@ -191,6 +239,8 @@ export default {
:show-edit-button="!isBinaryFileType"
:edit-path="blobInfo.editBlobPath"
:web-ide-path="blobInfo.ideEditPath"
+ :needs-to-fork="showForkSuggestion"
+ @edit="editBlob"
/>
<blob-button-group
v-if="isLoggedIn"
@@ -206,14 +256,20 @@ export default {
/>
</template>
</blob-header>
+ <fork-suggestion
+ v-if="forkTarget && showForkSuggestion"
+ :fork-path="forkPath"
+ @cancel="setForkTarget(null)"
+ />
<blob-content
v-if="!blobViewer"
:rich-viewer="legacyRichViewer"
:blob="blobInfo"
- :content="blobInfo.rawTextBlob"
+ :content="legacySimpleViewer"
:is-raw-content="true"
:active-viewer="viewer"
- :loading="false"
+ :hide-line-numbers="true"
+ :loading="isLoadingLegacyViewer"
/>
<component :is="blobViewer" v-else v-bind="viewerProps" class="blob-viewer" />
</div>
diff --git a/app/assets/javascripts/repository/components/blob_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue
index 30ed4cd57f1..fd377ba1b81 100644
--- a/app/assets/javascripts/repository/components/blob_edit.vue
+++ b/app/assets/javascripts/repository/components/blob_edit.vue
@@ -27,6 +27,16 @@ export default {
type: String,
required: true,
},
+ needsToFork: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ onEdit(target) {
+ this.$emit('edit', target);
+ },
},
};
</script>
@@ -38,7 +48,9 @@ export default {
class="gl-mr-3"
:edit-url="editPath"
:web-ide-url="webIdePath"
+ :needs-to-fork="needsToFork"
:is-blob="true"
+ @edit="onEdit"
/>
<div v-else>
<gl-button
@@ -46,8 +58,8 @@ export default {
class="gl-mr-2"
category="primary"
variant="confirm"
- :href="editPath"
data-testid="edit"
+ @click="onEdit('simple')"
>
{{ $options.i18n.edit }}
</gl-button>
@@ -56,8 +68,8 @@ export default {
class="gl-mr-3"
category="primary"
variant="confirm"
- :href="webIdePath"
data-testid="web-ide"
+ @click="onEdit('ide')"
>
{{ $options.i18n.webIde }}
</gl-button>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 3b4f4eb51fe..c5209d97abb 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -3,11 +3,15 @@ export const loadViewer = (type) => {
case 'empty':
return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue');
case 'text':
- return () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue');
+ return gon.features.refactorTextViewer
+ ? () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue')
+ : null;
case 'download':
return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
case 'image':
return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue');
+ case 'video':
+ return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue');
default:
return null;
}
@@ -29,5 +33,8 @@ export const viewerProps = (type, blob) => {
url: blob.rawPath,
alt: blob.name,
},
+ video: {
+ url: blob.rawPath,
+ },
}[type];
};
diff --git a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
new file mode 100644
index 00000000000..dec0c4802ca
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
@@ -0,0 +1,15 @@
+<script>
+export default {
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-text-center gl-p-7 gl-bg-gray-50">
+ <video :src="url" controls data-testid="video" class="gl-max-w-full"></video>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index db84e2b5912..d3717f10ec7 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -9,11 +9,13 @@ import {
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import UploadBlobModal from './upload_blob_modal.vue';
+import NewDirectoryModal from './new_directory_modal.vue';
const ROW_TYPES = {
header: 'header',
@@ -21,6 +23,7 @@ const ROW_TYPES = {
};
const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
+const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory';
export default {
components: {
@@ -30,6 +33,7 @@ export default {
GlDropdownItem,
GlIcon,
UploadBlobModal,
+ NewDirectoryModal,
},
apollo: {
projectShortPath: {
@@ -54,7 +58,7 @@ export default {
directives: {
GlModal: GlModalDirective,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagsMixin()],
props: {
currentPath: {
type: String,
@@ -121,8 +125,14 @@ export default {
required: false,
default: '',
},
+ newDirPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
+ newDirectoryModalId: NEW_DIRECTORY_MODAL_ID,
data() {
return {
projectShortPath: '',
@@ -160,6 +170,13 @@ export default {
showUploadModal() {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
+ showNewDirectoryModal() {
+ return (
+ this.glFeatures.newDirModal &&
+ this.canEditTree &&
+ !this.$apollo.queries.userPermissions.loading
+ );
+ },
dropdownItems() {
const items = [];
@@ -185,15 +202,26 @@ export default {
text: __('Upload file'),
modalId: UPLOAD_BLOB_MODAL_ID,
},
- {
+ );
+
+ if (this.glFeatures.newDirModal) {
+ items.push({
+ attrs: {
+ href: '#modal-create-new-dir',
+ },
+ text: __('New directory'),
+ modalId: NEW_DIRECTORY_MODAL_ID,
+ });
+ } else {
+ items.push({
attrs: {
href: '#modal-create-new-dir',
'data-target': '#modal-create-new-dir',
'data-toggle': 'modal',
},
text: __('New directory'),
- },
- );
+ });
+ }
} else if (this.canCreateMrFromFork) {
items.push(
{
@@ -306,5 +334,14 @@ export default {
:can-push-code="canPushCode"
:path="uploadPath"
/>
+ <new-directory-modal
+ v-if="showNewDirectoryModal"
+ :can-push-code="canPushCode"
+ :modal-id="$options.newDirectoryModalId"
+ :commit-message="__('Add new directory')"
+ :target-branch="selectedBranch"
+ :original-branch="originalBranch"
+ :path="newDirPath"
+ />
</nav>
</template>
diff --git a/app/assets/javascripts/repository/components/fork_suggestion.vue b/app/assets/javascripts/repository/components/fork_suggestion.vue
new file mode 100644
index 00000000000..c266bea319b
--- /dev/null
+++ b/app/assets/javascripts/repository/components/fork_suggestion.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ message: __(
+ 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.',
+ ),
+ fork: __('Fork'),
+ cancel: __('Cancel'),
+ },
+ components: {
+ GlButton,
+ },
+ props: {
+ forkPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-justify-content-end gl-align-items-center gl-bg-gray-10 gl-px-5 gl-py-2 gl-border-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <span class="gl-mr-6" data-testid="message">{{ $options.i18n.message }}</span>
+
+ <gl-button
+ class="gl-mr-3"
+ category="secondary"
+ variant="confirm"
+ :href="forkPath"
+ data-testid="fork"
+ >
+ {{ $options.i18n.fork }}
+ </gl-button>
+
+ <gl-button data-testid="cancel" @click="$emit('cancel')">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/new_directory_modal.vue b/app/assets/javascripts/repository/components/new_directory_modal.vue
new file mode 100644
index 00000000000..6c5797bf5b2
--- /dev/null
+++ b/app/assets/javascripts/repository/components/new_directory_modal.vue
@@ -0,0 +1,183 @@
+<script>
+import {
+ GlAlert,
+ GlForm,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import {
+ SECONDARY_OPTIONS_TEXT,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+ NEW_BRANCH_IN_FORK,
+} from '../constants';
+
+const MODAL_TITLE = __('Create New Directory');
+const PRIMARY_OPTIONS_TEXT = __('Create directory');
+const DIR_LABEL = __('Directory name');
+const ERROR_MESSAGE = __('Error creating new directory. Please try again.');
+
+export default {
+ components: {
+ GlAlert,
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ },
+ i18n: {
+ DIR_LABEL,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+ NEW_BRANCH_IN_FORK,
+ PRIMARY_OPTIONS_TEXT,
+ ERROR_MESSAGE,
+ },
+ props: {
+ modalTitle: {
+ type: String,
+ default: MODAL_TITLE,
+ required: false,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ primaryBtnText: {
+ type: String,
+ default: PRIMARY_OPTIONS_TEXT,
+ required: false,
+ },
+ commitMessage: {
+ type: String,
+ required: true,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ originalBranch: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dir: null,
+ commit: this.commitMessage,
+ target: this.targetBranch,
+ createNewMr: true,
+ loading: false,
+ };
+ },
+ computed: {
+ primaryOptions() {
+ return {
+ text: this.primaryBtnText,
+ attributes: [
+ {
+ variant: 'confirm',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
+ ],
+ };
+ },
+ cancelOptions() {
+ return {
+ text: SECONDARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ disabled: this.loading,
+ },
+ ],
+ };
+ },
+ showCreateNewMrToggle() {
+ return this.canPushCode;
+ },
+ formCompleted() {
+ return this.dir && this.commit && this.target;
+ },
+ },
+ methods: {
+ submitForm() {
+ this.loading = true;
+
+ const formData = new FormData();
+ formData.append('dir_name', this.dir);
+ formData.append('commit_message', this.commit);
+ formData.append('branch_name', this.target);
+ formData.append('original_branch', this.originalBranch);
+
+ if (this.createNewMr) {
+ formData.append('create_merge_request', this.createNewMr);
+ }
+
+ return axios
+ .post(this.path, formData)
+ .then((response) => {
+ visitUrl(response.data.filePath);
+ })
+ .catch(() => {
+ this.loading = false;
+ createFlash({ message: ERROR_MESSAGE });
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form>
+ <gl-modal
+ :modal-id="modalId"
+ :title="modalTitle"
+ :action-primary="primaryOptions"
+ :action-cancel="cancelOptions"
+ @primary.prevent="submitForm"
+ >
+ <gl-form-group :label="$options.i18n.DIR_LABEL" label-for="dir_name">
+ <gl-form-input v-model="dir" :disabled="loading" name="dir_name" />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
+ <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
+ </gl-form-group>
+ <gl-form-group
+ v-if="canPushCode"
+ :label="$options.i18n.TARGET_BRANCH_LABEL"
+ label-for="branch_name"
+ >
+ <gl-form-input v-model="target" :disabled="loading" name="branch_name" />
+ </gl-form-group>
+ <gl-toggle
+ v-if="showCreateNewMrToggle"
+ v-model="createNewMr"
+ :disabled="loading"
+ :label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
+ />
+ <gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.NEW_BRANCH_IN_FORK }}
+ </gl-alert>
+ </gl-modal>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index 54e67c5ab5c..c6e461b10e0 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlIcon, GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { handleLocationHash } from '~/lib/utils/common_utils';
@@ -22,6 +22,9 @@ export default {
GlLink,
GlLoadingIcon,
},
+ directives: {
+ SafeHtml,
+ },
props: {
blob: {
type: Object,
@@ -59,11 +62,7 @@ export default {
</div>
<div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about">
<gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" />
- <div
- v-else-if="readme"
- ref="readme"
- v-html="readme.html /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <div v-else-if="readme" ref="readme" v-safe-html="readme.html"></div>
</div>
</article>
</template>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 10a30bd44b1..0a2ed753e38 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -1,5 +1,6 @@
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '../../../locale';
import getRefMixin from '../../mixins/get_ref';
import projectPathQuery from '../../queries/project_path.query.graphql';
@@ -15,13 +16,18 @@ export default {
ParentRow,
GlButton,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagMixin()],
apollo: {
projectPath: {
query: projectPathQuery,
},
},
props: {
+ commits: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
path: {
type: String,
required: true,
@@ -48,6 +54,7 @@ export default {
data() {
return {
projectPath: '',
+ rowNumbers: {},
};
},
computed: {
@@ -73,10 +80,38 @@ export default {
return ['', '/'].indexOf(this.path) === -1;
},
},
+ watch: {
+ $route: function routeChange() {
+ this.$options.totalRowsLoaded = -1;
+ },
+ },
+ totalRowsLoaded: -1,
methods: {
showMore() {
this.$emit('showMore');
},
+ generateRowNumber(path, id, index) {
+ const key = `${path}-${id}-${index}`;
+ if (!this.glFeatures.lazyLoadCommits) {
+ return 0;
+ }
+
+ if (!this.rowNumbers[key] && this.rowNumbers[key] !== 0) {
+ this.$options.totalRowsLoaded += 1;
+ this.rowNumbers[key] = this.$options.totalRowsLoaded;
+ }
+
+ return this.rowNumbers[key];
+ },
+ getCommit(fileName, type) {
+ if (!this.glFeatures.lazyLoadCommits) {
+ return {};
+ }
+
+ return this.commits.find(
+ (commitEntry) => commitEntry.fileName === fileName && commitEntry.type === type,
+ );
+ },
},
};
</script>
@@ -87,6 +122,7 @@ export default {
<table
:aria-label="tableCaption"
class="table tree-table"
+ :class="{ 'gl-table-layout-fixed': !showParentRow }"
aria-live="polite"
data-qa-selector="file_tree_table"
>
@@ -115,12 +151,17 @@ export default {
:lfs-oid="entry.lfsOid"
:loading-path="loadingPath"
:total-entries="totalEntries"
+ :row-number="generateRowNumber(entry.flatPath, entry.id, index)"
+ :commit-info="getCommit(entry.name, entry.type)"
+ v-on="$listeners"
/>
</template>
<template v-if="isLoading">
<tr v-for="i in 5" :key="i" aria-hidden="true">
<td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
- <td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
+ <td class="gl-display-none gl-sm-display-block">
+ <gl-skeleton-loading :lines="1" class="h-auto" />
+ </td>
<td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td>
</tr>
</template>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 009dd19b4a5..5010d60f374 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -7,6 +7,8 @@ import {
GlLoadingIcon,
GlIcon,
GlHoverLoadDirective,
+ GlSafeHtmlDirective,
+ GlIntersectionObserver,
} from '@gitlab/ui';
import { escapeRegExp } from 'lodash';
import filesQuery from 'shared_queries/repository/files.query.graphql';
@@ -29,10 +31,12 @@ export default {
GlIcon,
TimeagoTooltip,
FileIcon,
+ GlIntersectionObserver,
},
directives: {
GlTooltip: GlTooltipDirective,
GlHoverLoad: GlHoverLoadDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
apollo: {
commit: {
@@ -46,10 +50,23 @@ export default {
maxOffset: this.totalEntries,
};
},
+ skip() {
+ return this.glFeatures.lazyLoadCommits;
+ },
},
},
mixins: [getRefMixin, glFeatureFlagMixin()],
props: {
+ commitInfo: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ rowNumber: {
+ type: Number,
+ required: false,
+ default: null,
+ },
totalEntries: {
type: Number,
required: true,
@@ -111,9 +128,13 @@ export default {
data() {
return {
commit: null,
+ hasRowAppeared: false,
};
},
computed: {
+ commitData() {
+ return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit;
+ },
refactorBlobViewerEnabled() {
return this.glFeatures.refactorBlobViewer;
},
@@ -146,7 +167,10 @@ export default {
return this.sha.slice(0, 8);
},
hasLockLabel() {
- return this.commit && this.commit.lockLabel;
+ return this.commitData && this.commitData.lockLabel;
+ },
+ showSkeletonLoader() {
+ return !this.commitData && this.hasRowAppeared;
},
},
methods: {
@@ -177,7 +201,21 @@ export default {
apolloQuery(query, variables) {
this.$apollo.query({ query, variables });
},
+ rowAppeared() {
+ this.hasRowAppeared = true;
+
+ if (this.glFeatures.lazyLoadCommits) {
+ this.$emit('row-appear', {
+ rowNumber: this.rowNumber,
+ hasCommit: Boolean(this.commitInfo),
+ });
+ }
+ },
+ rowDisappeared() {
+ this.hasRowAppeared = false;
+ },
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -219,7 +257,7 @@ export default {
<gl-icon
v-if="hasLockLabel"
v-gl-tooltip
- :title="commit.lockLabel"
+ :title="commitData.lockLabel"
name="lock"
:size="12"
class="ml-1"
@@ -227,17 +265,19 @@ export default {
</td>
<td class="d-none d-sm-table-cell tree-commit cursor-default">
<gl-link
- v-if="commit"
- :href="commit.commitPath"
- :title="commit.message"
+ v-if="commitData"
+ v-safe-html:[$options.safeHtmlConfig]="commitData.titleHtml"
+ :href="commitData.commitPath"
+ :title="commitData.message"
class="str-truncated-100 tree-commit-link"
- v-html="commit.titleHtml /* eslint-disable-line vue/no-v-html */"
/>
- <gl-skeleton-loading v-else :lines="1" class="h-auto" />
+ <gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared">
+ <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="h-auto" />
+ </gl-intersection-observer>
</td>
<td class="tree-time-ago text-right cursor-default">
- <timeago-tooltip v-if="commit" :time="commit.committedDate" />
- <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" />
+ <timeago-tooltip v-if="commitData" :time="commitData.committedDate" />
+ <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="ml-auto h-auto w-50" />
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 5a8ead9ae8f..16dfe3cfb14 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -8,6 +8,7 @@ import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../co
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import { readmeFile } from '../utils/readme';
+import { loadCommits, isRequested, resetRequestedCommits } from '../commits_service';
import FilePreview from './preview/index.vue';
import FileTable from './table/index.vue';
@@ -36,6 +37,7 @@ export default {
},
data() {
return {
+ commits: [],
projectPath: '',
nextPageCursor: '',
pagesLoaded: 1,
@@ -81,12 +83,16 @@ export default {
this.entries.submodules = [];
this.entries.blobs = [];
this.nextPageCursor = '';
+ resetRequestedCommits();
this.fetchFiles();
},
},
mounted() {
// We need to wait for `ref` and `projectPath` to be set
- this.$nextTick(() => this.fetchFiles());
+ this.$nextTick(() => {
+ resetRequestedCommits();
+ this.fetchFiles();
+ });
},
methods: {
fetchFiles() {
@@ -152,6 +158,18 @@ export default {
.concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
.find(({ hasNextPage }) => hasNextPage);
},
+ loadCommitData({ rowNumber = 0, hasCommit } = {}) {
+ if (!this.glFeatures.lazyLoadCommits || hasCommit || isRequested(rowNumber)) {
+ return;
+ }
+
+ loadCommits(this.projectPath, this.path, this.ref, rowNumber)
+ .then(this.setCommitData)
+ .catch(() => {});
+ },
+ setCommitData(data) {
+ this.commits = this.commits.concat(data);
+ },
handleShowMore() {
this.clickedShowMore = true;
this.pagesLoaded += 1;
@@ -169,7 +187,9 @@ export default {
:is-loading="isLoadingFiles"
:loading-path="loadingPath"
:has-more="hasShowMore"
+ :commits="commits"
@showMore="handleShowMore"
+ @row-appear="loadCommitData"
/>
<file-preview v-if="readme" :blob="readme" />
</div>
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index df5a5ea6163..0199b893453 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -220,6 +220,7 @@ export default {
class="gl-h-200! gl-mb-4"
single-file-selection
:valid-file-mimetypes="$options.validFileMimetypes"
+ :is-file-valid="() => true"
@change="setFile"
>
<div
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 93032bf17e2..152fabbd7cc 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -4,12 +4,19 @@ export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
+export const COMMIT_BATCH_SIZE = 25; // we request commit data in batches of 25
+
export const SECONDARY_OPTIONS_TEXT = __('Cancel');
export const COMMIT_LABEL = __('Commit message');
export const TARGET_BRANCH_LABEL = __('Target branch');
export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
+export const NEW_BRANCH_IN_FORK = __(
+ 'A new branch will be created in your fork and a new merge request will be started.',
+);
export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52;
export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72;
export const LIMITED_CONTAINER_WIDTH_CLASS = 'limit-container-width';
+
+export const I18N_COMMIT_DATA_FETCH_ERROR = __('An error occurred while fetching commit data.');
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 60a1a0443f7..45e026ad695 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -120,6 +120,7 @@ export default function setupVueRepositoryList() {
forkNewDirectoryPath,
forkUploadBlobPath,
uploadPath,
+ newDirPath,
},
});
},
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index 45f07f7dc58..8e0b5e21ca3 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -4,6 +4,8 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
userPermissions {
pushCode
downloadCode
+ createMergeRequestIn
+ forkProject
}
pathLocks {
nodes {
@@ -23,6 +25,9 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
path
editBlobPath
ideEditPath
+ forkAndEditPath
+ ideForkAndEditPath
+ canModifyBlob
storedExternally
rawPath
replacePath
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index 6637d03a7a4..0a675e14eb5 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -1,7 +1,7 @@
import { escapeRegExp } from 'lodash';
import Vue from 'vue';
import VueRouter from 'vue-router';
-import { joinPaths } from '../lib/utils/url_utility';
+import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility';
import BlobPage from './pages/blob.vue';
import IndexPage from './pages/index.vue';
import TreePage from './pages/tree.vue';
@@ -24,7 +24,7 @@ export default function createRouter(base, baseRef) {
}),
};
- return new VueRouter({
+ const router = new VueRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
routes: [
@@ -59,4 +59,21 @@ export default function createRouter(base, baseRef) {
},
],
});
+
+ router.afterEach((to) => {
+ const needsClosingSlash = !to.name.includes('blobPath');
+ window.gl.webIDEPath = webIDEUrl(
+ joinPaths(
+ '/',
+ base,
+ 'edit',
+ decodeURI(baseRef),
+ '-',
+ to.params.path || '',
+ needsClosingSlash && '/',
+ ),
+ );
+ });
+
+ return router;
}
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 61fe89f4f7e..29642b6633f 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -2,6 +2,7 @@ export * from './api/groups_api';
export * from './api/projects_api';
export * from './api/user_api';
export * from './api/markdown_api';
+export * from './api/bulk_imports_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 23254fcc2eb..381421cdc23 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -111,7 +111,7 @@ Sidebar.prototype.toggleTodo = function (e) {
};
Sidebar.prototype.sidebarCollapseClicked = function (e) {
- if ($(e.currentTarget).hasClass('dont-change-state')) {
+ if ($(e.currentTarget).hasClass('js-dont-change-state')) {
return;
}
const sidebar = e.data;
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index fedd2519958..c8513a0b803 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLink } from '@gitlab/ui';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -6,8 +7,8 @@ import { formatNumber, sprintf, __ } from '~/locale';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
+import RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue';
-import RunnerTypeHelp from '../components/runner_type_help.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
@@ -23,10 +24,11 @@ import { captureException } from '../sentry_utils';
export default {
name: 'AdminRunnersApp',
components: {
+ GlLink,
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
- RunnerTypeHelp,
+ RunnerName,
RunnerPagination,
},
props: {
@@ -124,17 +126,10 @@ export default {
</script>
<template>
<div>
- <div class="row">
- <div class="col-sm-6">
- <runner-type-help />
- </div>
- <div class="col-sm-6">
- <runner-manual-setup-help
- :registration-token="registrationToken"
- :type="$options.INSTANCE_TYPE"
- />
- </div>
- </div>
+ <runner-manual-setup-help
+ :registration-token="registrationToken"
+ :type="$options.INSTANCE_TYPE"
+ />
<runner-filtered-search-bar
v-model="search"
@@ -150,7 +145,13 @@ export default {
{{ __('No runners found') }}
</div>
<template v-else>
- <runner-list :runners="runners.items" :loading="runnersLoading" />
+ <runner-list :runners="runners.items" :loading="runnersLoading">
+ <template #runner-name="{ runner }">
+ <gl-link :href="runner.adminUrl">
+ <runner-name :runner="runner" />
+ </gl-link>
+ </template>
+ </runner-list>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template>
</div>
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
index 863f0ab995f..e26bdbf1aea 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -1,7 +1,6 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
@@ -37,13 +36,6 @@ export default {
};
},
computed: {
- runnerNumericalId() {
- return getIdFromGraphQLId(this.runner.id);
- },
- runnerUrl() {
- // TODO implement using webUrl from the API
- return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
- },
isActive() {
return this.runner.active;
},
@@ -119,7 +111,7 @@ export default {
},
},
awaitRefetchQueries: true,
- refetchQueries: ['getRunners'],
+ refetchQueries: ['getRunners', 'getGroupRunners'],
});
if (errors && errors.length) {
throw new Error(errors.join(' '));
@@ -147,12 +139,20 @@ export default {
<template>
<gl-button-group>
+ <!--
+ This button appears for administratos: those with
+ access to the adminUrl. More advanced permissions policies
+ will allow more granular permissions.
+
+ See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
+ -->
<gl-button
+ v-if="runner.adminUrl"
v-gl-tooltip.hover.viewport
+ :href="runner.adminUrl"
:title="$options.i18n.I18N_EDIT"
:aria-label="$options.i18n.I18N_EDIT"
icon="pencil"
- :href="runnerUrl"
data-testid="edit-runner"
/>
<gl-button
diff --git a/app/assets/javascripts/runner/components/cells/runner_name_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
index 797a3359147..886b5cb29fc 100644
--- a/app/assets/javascripts/runner/components/cells/runner_name_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
@@ -1,12 +1,11 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import RunnerName from '../runner_name.vue';
export default {
components: {
- GlLink,
TooltipOnTruncate,
+ RunnerName,
},
props: {
runner: {
@@ -15,26 +14,18 @@ export default {
},
},
computed: {
- runnerNumericalId() {
- return getIdFromGraphQLId(this.runner.id);
- },
- runnerUrl() {
- // TODO implement using webUrl from the API
- return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
- },
description() {
return this.runner.description;
},
- shortSha() {
- return this.runner.shortSha;
- },
},
};
</script>
<template>
<div>
- <gl-link :href="runnerUrl"> #{{ runnerNumericalId }} ({{ shortSha }})</gl-link>
+ <slot :runner="runner" name="runner-name">
+ <runner-name :runner="runner" />
+ </slot>
<tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child">
<div class="gl-text-truncate">
{{ description }}
diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
index f186a8daf72..c8cb0bf6088 100644
--- a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
@@ -1,11 +1,18 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import { GlTooltipDirective } from '@gitlab/ui';
import RunnerTypeBadge from '../runner_type_badge.vue';
+import RunnerStateLockedBadge from '../runner_state_locked_badge.vue';
+import RunnerStatePausedBadge from '../runner_state_paused_badge.vue';
+import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants';
export default {
components: {
- GlBadge,
RunnerTypeBadge,
+ RunnerStateLockedBadge,
+ RunnerStatePausedBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
runner: {
@@ -24,19 +31,17 @@ export default {
return !this.runner.active;
},
},
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_PAUSED_RUNNER_DESCRIPTION,
+ },
};
</script>
<template>
<div>
<runner-type-badge :type="runnerType" size="sm" />
-
- <gl-badge v-if="locked" variant="warning" size="sm">
- {{ s__('Runners|locked') }}
- </gl-badge>
-
- <gl-badge v-if="paused" variant="danger" size="sm">
- {{ s__('Runners|paused') }}
- </gl-badge>
+ <runner-state-locked-badge v-if="locked" size="sm" />
+ <runner-state-paused-badge v-if="paused" size="sm" />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 69a1f106ca8..3f6ea389288 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -5,7 +5,7 @@ import { formatNumber, __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { RUNNER_JOB_COUNT_LIMIT } from '../constants';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
-import RunnerNameCell from './cells/runner_name_cell.vue';
+import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerTypeCell from './cells/runner_type_cell.vue';
import RunnerTags from './runner_tags.vue';
@@ -35,7 +35,7 @@ export default {
GlSkeletonLoader,
TimeAgo,
RunnerActionsCell,
- RunnerNameCell,
+ RunnerSummaryCell,
RunnerTags,
RunnerTypeCell,
},
@@ -77,7 +77,7 @@ export default {
},
fields: [
tableField({ key: 'type', label: __('Type/State') }),
- tableField({ key: 'name', label: s__('Runners|Runner'), width: 30 }),
+ tableField({ key: 'summary', label: s__('Runners|Runner'), width: 30 }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'ipAddress', label: __('IP Address') }),
tableField({ key: 'projectCount', label: __('Projects'), width: 5 }),
@@ -107,8 +107,12 @@ export default {
<runner-type-cell :runner="item" />
</template>
- <template #cell(name)="{ item }">
- <runner-name-cell :runner="item" />
+ <template #cell(summary)="{ item, index }">
+ <runner-summary-cell :runner="item">
+ <template #runner-name="{ runner }">
+ <slot name="runner-name" :runner="runner" :index="index"></slot>
+ </template>
+ </runner-summary-cell>
</template>
<template #cell(version)="{ item: { version } }">
diff --git a/app/assets/javascripts/runner/components/runner_name.vue b/app/assets/javascripts/runner/components/runner_name.vue
new file mode 100644
index 00000000000..8e495125e03
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_name.vue
@@ -0,0 +1,18 @@
+<script>
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+export default {
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ getIdFromGraphQLId,
+ },
+};
+</script>
+<template>
+ <span>#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_state_locked_badge.vue b/app/assets/javascripts/runner/components/runner_state_locked_badge.vue
new file mode 100644
index 00000000000..458526010bc
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_state_locked_badge.vue
@@ -0,0 +1,25 @@
+<script>
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ },
+};
+</script>
+<template>
+ <gl-badge
+ v-gl-tooltip="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
+ variant="warning"
+ v-bind="$attrs"
+ >
+ {{ s__('Runners|locked') }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_state_paused_badge.vue b/app/assets/javascripts/runner/components/runner_state_paused_badge.vue
new file mode 100644
index 00000000000..d1e6fa05e4d
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_state_paused_badge.vue
@@ -0,0 +1,25 @@
+<script>
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { I18N_PAUSED_RUNNER_DESCRIPTION } from '../constants';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ I18N_PAUSED_RUNNER_DESCRIPTION,
+ },
+};
+</script>
+<template>
+ <gl-badge
+ v-gl-tooltip="$options.i18n.I18N_PAUSED_RUNNER_DESCRIPTION"
+ variant="danger"
+ v-bind="$attrs"
+ >
+ {{ s__('Runners|paused') }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue
index c2f43daa899..1a61b80184b 100644
--- a/app/assets/javascripts/runner/components/runner_type_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_type_badge.vue
@@ -1,20 +1,30 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import {
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ I18N_INSTANCE_RUNNER_DESCRIPTION,
+ I18N_GROUP_RUNNER_DESCRIPTION,
+ I18N_PROJECT_RUNNER_DESCRIPTION,
+} from '../constants';
const BADGE_DATA = {
[INSTANCE_TYPE]: {
variant: 'success',
text: s__('Runners|shared'),
+ tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION,
},
[GROUP_TYPE]: {
variant: 'success',
text: s__('Runners|group'),
+ tooltip: I18N_GROUP_RUNNER_DESCRIPTION,
},
[PROJECT_TYPE]: {
variant: 'info',
text: s__('Runners|specific'),
+ tooltip: I18N_PROJECT_RUNNER_DESCRIPTION,
},
};
@@ -22,6 +32,9 @@ export default {
components: {
GlBadge,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
type: {
type: String,
@@ -40,7 +53,7 @@ export default {
};
</script>
<template>
- <gl-badge v-if="badge" :variant="badge.variant" v-bind="$attrs">
+ <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs">
{{ badge.text }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_help.vue b/app/assets/javascripts/runner/components/runner_type_help.vue
deleted file mode 100644
index 70456b3ab65..00000000000
--- a/app/assets/javascripts/runner/components/runner_type_help.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<script>
-import { GlBadge } from '@gitlab/ui';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
-import RunnerTypeBadge from './runner_type_badge.vue';
-
-export default {
- components: {
- GlBadge,
- RunnerTypeBadge,
- },
- runnerTypes: {
- INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
- },
-};
-</script>
-
-<template>
- <div class="bs-callout">
- <p>{{ __('Runners are processes that pick up and execute CI/CD jobs for GitLab.') }}</p>
- <p>
- {{
- __(
- 'You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.',
- )
- }}
- </p>
-
- <div>
- <span> {{ __('Runners can be:') }}</span>
- <ul>
- <li>
- <runner-type-badge :type="$options.runnerTypes.INSTANCE_TYPE" size="sm" />
- - {{ __('Runs jobs from all unassigned projects.') }}
- </li>
- <li>
- <runner-type-badge :type="$options.runnerTypes.GROUP_TYPE" size="sm" />
- - {{ __('Runs jobs from all unassigned projects in its group.') }}
- </li>
- <li>
- <runner-type-badge :type="$options.runnerTypes.PROJECT_TYPE" size="sm" />
- - {{ __('Runs jobs from assigned projects.') }}
- </li>
- <li>
- <gl-badge variant="warning" size="sm">
- {{ s__('Runners|locked') }}
- </gl-badge>
- - {{ __('Cannot be assigned to other projects.') }}
- </li>
- <li>
- <gl-badge variant="danger" size="sm">
- {{ s__('Runners|paused') }}
- </gl-badge>
- - {{ __('Not available to run jobs.') }}
- </li>
- </ul>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 46e55b322c7..a2fb9d9efd8 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -7,6 +7,14 @@ export const GROUP_RUNNER_COUNT_LIMIT = 1000;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
+export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects');
+export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
+ 'Runners|Available to all projects and subgroups in the group',
+);
+export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
+export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
+export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
+
export const RUNNER_TAG_BADGE_VARIANT = 'info';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
index a601ee8d611..3e5109b1ac4 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
@@ -24,8 +24,11 @@ query getGroupRunners(
search: $search
sort: $sort
) {
- nodes {
- ...RunnerNode
+ edges {
+ webUrl
+ node {
+ ...RunnerNode
+ }
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
index 9f837197558..51a91b9eb96 100644
--- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
@@ -25,6 +25,7 @@ query getRunners(
) {
nodes {
...RunnerNode
+ adminUrl
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
index 42e1a9e1de9..4bb28796dfa 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,13 +1,16 @@
<script>
+import { GlLink } from '@gitlab/ui';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, s__ } from '~/locale';
+
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
+import RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue';
-import RunnerTypeHelp from '../components/runner_type_help.vue';
+
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
import {
@@ -27,10 +30,11 @@ import { captureException } from '../sentry_utils';
export default {
name: 'GroupRunnersApp',
components: {
+ GlLink,
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
- RunnerTypeHelp,
+ RunnerName,
RunnerPagination,
},
props: {
@@ -51,6 +55,7 @@ export default {
return {
search: fromUrlQueryToSearch(),
runners: {
+ webUrls: [],
items: [],
pageInfo: {},
},
@@ -68,8 +73,10 @@ export default {
},
update(data) {
const { runners } = data?.group || {};
+
return {
- items: runners?.nodes || [],
+ webUrls: runners?.edges.map(({ webUrl }) => webUrl) || [],
+ items: runners?.edges.map(({ node }) => node) || [],
pageInfo: runners?.pageInfo || {},
};
},
@@ -137,17 +144,7 @@ export default {
<template>
<div>
- <div class="row">
- <div class="col-sm-6">
- <runner-type-help />
- </div>
- <div class="col-sm-6">
- <runner-manual-setup-help
- :registration-token="registrationToken"
- :type="$options.GROUP_TYPE"
- />
- </div>
- </div>
+ <runner-manual-setup-help :registration-token="registrationToken" :type="$options.GROUP_TYPE" />
<runner-filtered-search-bar
v-model="search"
@@ -163,7 +160,13 @@ export default {
{{ __('No runners found') }}
</div>
<template v-else>
- <runner-list :runners="runners.items" :loading="runnersLoading" />
+ <runner-list :runners="runners.items" :loading="runnersLoading">
+ <template #runner-name="{ runner, index }">
+ <gl-link :href="runners.webUrls[index]">
+ <runner-name :runner="runner" />
+ </gl-link>
+ </template>
+ </runner-list>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template>
</div>
diff --git a/app/assets/javascripts/search_settings/components/search_settings.vue b/app/assets/javascripts/search_settings/components/search_settings.vue
index 116967a62c8..3e23b8a3435 100644
--- a/app/assets/javascripts/search_settings/components/search_settings.vue
+++ b/app/assets/javascripts/search_settings/components/search_settings.vue
@@ -1,7 +1,13 @@
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
-import { uniq } from 'lodash';
-import { EXCLUDED_NODES, HIDE_CLASS, HIGHLIGHT_CLASS, TYPING_DELAY } from '../constants';
+import { uniq, escapeRegExp } from 'lodash';
+import {
+ EXCLUDED_NODES,
+ HIDE_CLASS,
+ HIGHLIGHT_CLASS,
+ NONE_PADDING_CLASS,
+ TYPING_DELAY,
+} from '../constants';
const origExpansions = new Map();
@@ -37,9 +43,13 @@ const resetSections = ({ sectionSelector }) => {
};
const clearHighlights = () => {
- document
- .querySelectorAll(`.${HIGHLIGHT_CLASS}`)
- .forEach((element) => element.classList.remove(HIGHLIGHT_CLASS));
+ document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((element) => {
+ const { parentNode } = element;
+ const textNode = document.createTextNode(element.textContent);
+ parentNode.replaceChild(textNode, element);
+
+ parentNode.normalize();
+ });
};
const hideSectionsExcept = (sectionSelector, visibleSections) => {
@@ -50,17 +60,41 @@ const hideSectionsExcept = (sectionSelector, visibleSections) => {
});
};
-const highlightElements = (elements = []) => {
- elements.forEach((element) => element.classList.add(HIGHLIGHT_CLASS));
+const transformMatchElement = (element, searchTerm) => {
+ const textStr = element.textContent;
+ const escapedSearchTerm = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
+
+ const textList = textStr.split(escapedSearchTerm);
+ const replaceFragment = document.createDocumentFragment();
+ textList.forEach((text) => {
+ let addElement = document.createTextNode(text);
+ if (escapedSearchTerm.test(text)) {
+ addElement = document.createElement('mark');
+ addElement.className = `${HIGHLIGHT_CLASS} ${NONE_PADDING_CLASS}`;
+ addElement.textContent = text;
+ escapedSearchTerm.lastIndex = 0;
+ }
+ replaceFragment.appendChild(addElement);
+ });
+
+ return replaceFragment;
+};
+
+const highlightElements = (elements = [], searchTerm) => {
+ elements.forEach((element) => {
+ const replaceFragment = transformMatchElement(element, searchTerm);
+ element.innerHTML = '';
+ element.appendChild(replaceFragment);
+ });
};
-const displayResults = ({ sectionSelector, expandSection }, matches) => {
+const displayResults = ({ sectionSelector, expandSection, searchTerm }, matches) => {
const elements = matches.map((match) => match.parentElement);
const sections = uniq(elements.map((element) => findSettingsSection(sectionSelector, element)));
hideSectionsExcept(sectionSelector, sections);
sections.forEach(expandSection);
- highlightElements(elements);
+ highlightElements(elements, searchTerm);
};
const clearResults = (params) => {
@@ -116,21 +150,21 @@ export default {
},
methods: {
search(value) {
+ this.searchTerm = value;
const displayOptions = {
sectionSelector: this.sectionSelector,
expandSection: this.expandSection,
collapseSection: this.collapseSection,
isExpanded: this.isExpandedFn,
+ searchTerm: this.searchTerm,
};
- this.searchTerm = value;
-
clearResults(displayOptions);
if (value.length) {
saveExpansionState(document.querySelectorAll(this.sectionSelector), displayOptions);
- displayResults(displayOptions, search(this.searchRoot, value));
+ displayResults(displayOptions, search(this.searchRoot, this.searchTerm));
} else {
restoreExpansionState(displayOptions);
}
diff --git a/app/assets/javascripts/search_settings/constants.js b/app/assets/javascripts/search_settings/constants.js
index 9452d149122..a49351dc7b0 100644
--- a/app/assets/javascripts/search_settings/constants.js
+++ b/app/assets/javascripts/search_settings/constants.js
@@ -7,5 +7,8 @@ export const HIDE_CLASS = 'gl-display-none';
// used to highlight the text that matches the * search term
export const HIGHLIGHT_CLASS = 'gl-bg-orange-100';
+// used to remove padding for text that matches the * search term
+export const NONE_PADDING_CLASS = 'gl-p-0';
+
// How many seconds to wait until the user * stops typing
export const TYPING_DELAY = 400;
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 0ecfdf420db..86afdbfeb8c 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -128,6 +128,7 @@ export default {
variant="confirm"
category="primary"
class="gl-mt-5"
+ :data-qa-selector="`${feature.type}_mr_button`"
/>
<gl-button
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 b7080bb05b8..c2ca87af9ce 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -82,8 +82,12 @@ export default {
</div>
</assignee-avatar-link>
<div v-else>
- <div class="user-list">
- <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item">
+ <div class="gl-display-flex gl-flex-wrap">
+ <div
+ v-for="user in uncollapsedUsers"
+ :key="user.id"
+ class="user-item gl-display-inline-block"
+ >
<assignee-avatar-link :user="user" :issuable-type="issuableType" />
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index 9fdf941579d..d5647619ea3 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -36,6 +36,7 @@ export default {
'allowLabelEdit',
'allowScopedLabels',
'iid',
+ 'fullPath',
'initiallySelectedLabels',
'issuableType',
'labelsFetchPath',
@@ -53,30 +54,32 @@ export default {
handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown');
},
- getUpdateVariables(dropdownLabels) {
- const currentLabelIds = this.selectedLabels.map((label) => label.id);
- const dropdownLabelIds = dropdownLabels.map((label) => label.id);
- const userAddedLabelIds = this.glFeatures.labelsWidget
- ? difference(dropdownLabelIds, currentLabelIds)
- : dropdownLabels.filter((label) => label.set).map((label) => label.id);
- const userRemovedLabelIds = this.glFeatures.labelsWidget
- ? difference(currentLabelIds, dropdownLabelIds)
- : dropdownLabels.filter((label) => !label.set).map((label) => label.id);
+ getUpdateVariables(labels) {
+ let labelIds = [];
- const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds);
+ if (this.glFeatures.labelsWidget) {
+ labelIds = labels.map(({ id }) => toLabelGid(id));
+ } else {
+ const currentLabelIds = this.selectedLabels.map((label) => label.id);
+ const userAddedLabelIds = labels.filter((label) => label.set).map((label) => label.id);
+ const userRemovedLabelIds = labels.filter((label) => !label.set).map((label) => label.id);
+
+ labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds).map(
+ toLabelGid,
+ );
+ }
switch (this.issuableType) {
case IssuableType.Issue:
return {
- addLabelIds: userAddedLabelIds,
iid: this.iid,
projectPath: this.projectPath,
- removeLabelIds: userRemovedLabelIds,
+ labelIds,
};
case IssuableType.MergeRequest:
return {
iid: this.iid,
- labelIds: labelIds.map(toLabelGid),
+ labelIds,
operationMode: MutationOperationMode.Replace,
projectPath: this.projectPath,
};
@@ -143,6 +146,8 @@ export default {
<labels-select-widget
v-if="glFeatures.labelsWidget"
class="block labels js-labels-block"
+ :iid="iid"
+ :full-path="fullPath"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
:footer-create-label-title="__('Create project label')"
@@ -152,8 +157,8 @@ export default {
:labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.variant"
+ :issuable-type="issuableType"
data-qa-selector="labels_block"
- @onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index ad4bfe5b665..4a255a3b916 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -104,11 +104,11 @@ export default {
<gl-loading-icon v-if="loading" size="sm" :inline="true" />
{{ participantLabel }}
</div>
- <div class="participants-list hide-collapsed">
+ <div class="hide-collapsed gl-display-flex gl-flex-wrap">
<div
v-for="participant in visibleParticipants"
:key="participant.id"
- class="participants-author"
+ class="participants-author gl-display-inline-block gl-pr-3 gl-pb-3"
>
<a :href="participant.web_url || participant.webUrl" class="author-link">
<user-avatar-image
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index 87780888c2f..361a082def6 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -77,7 +77,7 @@ export default {
>
<!-- use d-flex so that slot can be appropriately styled -->
<span class="gl-display-flex gl-align-items-center">
- <reviewer-avatar :user="user" :img-size="24" :issuable-type="issuableType" />
+ <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
<slot :user="user"></slot>
</span>
</gl-link>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index c6fef86c6ff..2922008cfb2 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -91,7 +91,10 @@ export default {
data-testid="reviewer"
>
<reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType">
- <div class="gl-ml-3">@{{ user.username }}</div>
+ <div class="gl-ml-3 gl-line-height-normal gl-display-grid">
+ <span>{{ user.name }}</span>
+ <span>@{{ user.username }}</span>
+ </div>
</reviewer-avatar-link>
<gl-icon
v-if="user.approved"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
index 3705d725a15..7b4be659330 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
@@ -83,13 +83,15 @@ export default {
:value="timeRemainingPercent"
:variant="progressBarVariant"
/>
- <div class="compare-display-container">
- <div class="compare-display float-left">
- <span class="compare-label">{{ s__('TimeTracking|Spent') }}</span>
+ <div
+ class="compare-display-container gl-display-flex gl-justify-content-space-between gl-mt-2"
+ >
+ <div class="gl-float-left">
+ <span class="gl-text-gray-400">{{ s__('TimeTracking|Spent') }}</span>
<span class="compare-value spent">{{ timeSpentHumanReadable }}</span>
</div>
- <div class="compare-display estimated float-right">
- <span class="compare-label">{{ s__('TimeTrackingEstimated|Est') }}</span>
+ <div class="estimated gl-float-right">
+ <span class="gl-text-gray-400">{{ s__('TimeTrackingEstimated|Est') }}</span>
<span class="compare-value">{{ timeEstimateHumanReadable }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
index db2197ec65e..4564a48fa2d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
@@ -1,30 +1,31 @@
<script>
-import { sprintf, s__ } from '~/locale';
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+const timeSpent = s__('TimeTracking|%{spentStart}Spent: %{spentEnd}');
export default {
name: 'TimeTrackingSpentOnlyPane',
+ timeSpent,
+ components: {
+ GlSprintf,
+ },
props: {
timeSpentHumanReadable: {
type: String,
required: true,
},
},
- computed: {
- timeSpent() {
- return sprintf(
- s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'),
- {
- startTag: '<span class="gl-font-weight-bold">',
- endTag: '</span>',
- timeSpentHumanReadable: this.timeSpentHumanReadable,
- },
- false,
- );
- },
- },
};
</script>
<template>
- <div data-testid="spentOnlyPane" v-html="timeSpent /* eslint-disable-line vue/no-v-html */"></div>
+ <div data-testid="spentOnlyPane">
+ <gl-sprintf :message="$options.timeSpent">
+ <template #spent="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span
+ >{{ timeSpentHumanReadable }}
+ </template>
+ </gl-sprintf>
+ </div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index f7e76cc2b7f..d5782e4b371 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -41,7 +41,7 @@ export default {
computed: {
buttonClasses() {
return this.collapsed
- ? 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state'
+ ? 'btn-blank btn-todo sidebar-collapsed-icon js-dont-change-state'
: 'gl-button btn btn-default btn-todo issuable-header-btn float-right';
},
buttonLabel() {
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index fd43fb80b7f..e593973da82 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -31,6 +31,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs
import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql';
import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
+import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql';
+import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql';
+import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
+import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql';
import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
@@ -105,6 +109,17 @@ export const referenceQueries = {
},
};
+export const labelsQueries = {
+ [IssuableType.Issue]: {
+ issuableQuery: issueLabelsQuery,
+ workspaceQuery: projectLabelsQuery,
+ },
+ [IssuableType.Epic]: {
+ issuableQuery: epicLabelsQuery,
+ workspaceQuery: groupLabelsQuery,
+ },
+};
+
export const dateTypes = {
start: 'startDate',
due: 'dueDate',
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 10ab80f4ec2..9f5a2f4ebb0 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -241,6 +241,7 @@ function mountMilestoneSelect() {
export function mountSidebarLabels() {
const el = document.querySelector('.js-sidebar-labels');
+ const { fullPath } = getSidebarOptions();
if (!el) {
return false;
@@ -251,6 +252,7 @@ export function mountSidebarLabels() {
apolloProvider,
provide: {
...el.dataset,
+ fullPath,
allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate),
allowLabelEdit: parseBoolean(el.dataset.canEdit),
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue
index 62d95a650da..737a131ce7c 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue
@@ -1,10 +1,14 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
export default {
components: {
MarkdownFieldView,
},
+ directives: {
+ SafeHtml,
+ },
props: {
description: {
type: String,
@@ -12,13 +16,14 @@ export default {
default: '',
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
<markdown-field-view class="snippet-description" data-qa-selector="snippet_description_content">
<div
+ v-safe-html:[$options.safeHtmlConfig]="description"
class="md js-snippet-description"
- v-html="description /* eslint-disable-line vue/no-v-html */"
></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 466b273cae4..a5c98a7ad90 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -11,15 +11,26 @@ import {
GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
+import { isEmpty } from 'lodash';
import CanCreateProjectSnippet from 'shared_queries/snippet/project_permissions.query.graphql';
import CanCreatePersonalSnippet from 'shared_queries/snippet/user_permissions.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
+import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import createFlash, { FLASH_TYPES } from '~/flash';
import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
+export const i18n = {
+ snippetSpamSuccess: sprintf(
+ s__('Snippets|%{spammable_titlecase} was submitted to Akismet successfully.'),
+ { spammable_titlecase: __('Snippet') },
+ ),
+ snippetSpamFailure: s__('Snippets|Error with Akismet. Please check the logs for more info.'),
+};
+
export default {
components: {
GlAvatar,
@@ -54,7 +65,7 @@ export default {
},
},
},
- inject: ['reportAbusePath'],
+ inject: ['reportAbusePath', 'canReportSpam'],
props: {
snippet: {
type: Object,
@@ -63,7 +74,8 @@ export default {
},
data() {
return {
- isDeleting: false,
+ isLoading: false,
+ isSubmittingSpam: false,
errorMessage: '',
canCreateSnippet: false,
};
@@ -105,10 +117,11 @@ export default {
category: 'secondary',
},
{
- condition: this.reportAbusePath,
+ condition: this.canReportSpam && !isEmpty(this.reportAbusePath),
text: __('Submit as spam'),
- href: this.reportAbusePath,
+ click: this.submitAsSpam,
title: __('Submit as spam'),
+ loading: this.isSubmittingSpam,
},
];
},
@@ -157,7 +170,7 @@ export default {
this.$refs.deleteModal.show();
},
deleteSnippet() {
- this.isDeleting = true;
+ this.isLoading = true;
this.$apollo
.mutate({
mutation: DeleteSnippetMutation,
@@ -167,17 +180,34 @@ export default {
if (data?.destroySnippet?.errors.length) {
throw new Error(data?.destroySnippet?.errors[0]);
}
- this.isDeleting = false;
this.errorMessage = undefined;
this.closeDeleteModal();
this.redirectToSnippets();
})
.catch((err) => {
- this.isDeleting = false;
+ this.isLoading = false;
this.errorMessage = err.message;
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ async submitAsSpam() {
+ try {
+ this.isSubmittingSpam = true;
+ await axios.post(this.reportAbusePath);
+ createFlash({
+ message: this.$options.i18n.snippetSpamSuccess,
+ type: FLASH_TYPES.SUCCESS,
});
+ } catch (error) {
+ createFlash({ message: this.$options.i18n.snippetSpamFailure });
+ } finally {
+ this.isSubmittingSpam = false;
+ }
},
},
+ i18n,
};
</script>
<template>
@@ -189,9 +219,7 @@ export default {
:title="snippetVisibilityLevelDescription"
data-container="body"
>
- <span class="sr-only">
- {{ s__(`VisibilityLevel|${visibility}`) }}
- </span>
+ <span class="sr-only">{{ s__(`VisibilityLevel|${visibility}`) }}</span>
<gl-icon :name="visibilityLevelIcon" :size="14" />
</div>
<div class="creator" data-testid="authored-message">
@@ -233,6 +261,7 @@ export default {
>
<gl-button
:disabled="action.disabled"
+ :loading="action.loading"
:variant="action.variant"
:category="action.category"
:class="action.cssClass"
@@ -240,9 +269,8 @@ export default {
data-qa-selector="snippet_action_button"
:data-qa-action="action.text"
@click="action.click ? action.click() : undefined"
+ >{{ action.text }}</gl-button
>
- {{ action.text }}
- </gl-button>
</div>
</template>
</div>
@@ -266,14 +294,14 @@ export default {
<gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title">
<template #modal-title>{{ __('Delete snippet?') }}</template>
- <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{
- errorMessage
- }}</gl-alert>
+ <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">
+ {{ errorMessage }}
+ </gl-alert>
<gl-sprintf :message="__('Are you sure you want to delete %{name}?')">
- <template #name
- ><strong>{{ snippet.title }}</strong></template
- >
+ <template #name>
+ <strong>{{ snippet.title }}</strong>
+ </template>
</gl-sprintf>
<template #modal-footer>
@@ -281,11 +309,11 @@ export default {
<gl-button
variant="danger"
category="primary"
- :disabled="isDeleting"
+ :disabled="isLoading"
data-qa-selector="delete_snippet_button"
@click="deleteSnippet"
>
- <gl-loading-icon v-if="isDeleting" size="sm" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
{{ __('Delete snippet') }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index dec8dcec179..8e7368ef804 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -27,6 +27,7 @@ export default function appFactory(el, Component) {
visibilityLevels = '[]',
selectedLevel,
multipleLevelsRestricted,
+ canReportSpam,
reportAbusePath,
...restDataset
} = el.dataset;
@@ -39,6 +40,7 @@ export default function appFactory(el, Component) {
selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
reportAbusePath,
+ canReportSpam,
},
render(createElement) {
return createElement(Component, {
diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js
index 6a29883290a..8d29a65d705 100644
--- a/app/assets/javascripts/token_access/index.js
+++ b/app/assets/javascripts/token_access/index.js
@@ -6,7 +6,7 @@ import TokenAccess from './components/token_access.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index 062a3404355..2593fbe6ed1 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -22,9 +22,8 @@ export const DEFAULT_SNOWPLOW_OPTIONS = {
export const ACTION_ATTR_SELECTOR = '[data-track-action]';
export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]';
-export const DEPRECATED_EVENT_ATTR_SELECTOR = '[data-track-event]';
-export const DEPRECATED_LOAD_EVENT_ATTR_SELECTOR = '[data-track-event="render"]';
-
export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls';
export const REFERRER_TTL = 24 * 60 * 60 * 1000;
+
+export const GOOGLE_ANALYTICS_ID_COOKIE_NAME = '_ga';
diff --git a/app/assets/javascripts/tracking/get_standard_context.js b/app/assets/javascripts/tracking/get_standard_context.js
index c318029323d..6014f1ba3ee 100644
--- a/app/assets/javascripts/tracking/get_standard_context.js
+++ b/app/assets/javascripts/tracking/get_standard_context.js
@@ -1,4 +1,5 @@
-import { SNOWPLOW_JS_SOURCE } from './constants';
+import { getCookie } from '~/lib/utils/common_utils';
+import { SNOWPLOW_JS_SOURCE, GOOGLE_ANALYTICS_ID_COOKIE_NAME } from './constants';
export default function getStandardContext({ extra = {} } = {}) {
const { schema, data = {} } = { ...window.gl?.snowplowStandardContext };
@@ -8,6 +9,7 @@ export default function getStandardContext({ extra = {} } = {}) {
data: {
...data,
source: SNOWPLOW_JS_SOURCE,
+ google_analytics_id: getCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME) ?? '',
extra: extra || data.extra,
},
};
diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js
index 657e0a79911..c26abc261ed 100644
--- a/app/assets/javascripts/tracking/tracking.js
+++ b/app/assets/javascripts/tracking/tracking.js
@@ -1,4 +1,4 @@
-import { LOAD_ACTION_ATTR_SELECTOR, DEPRECATED_LOAD_EVENT_ATTR_SELECTOR } from './constants';
+import { LOAD_ACTION_ATTR_SELECTOR } from './constants';
import { dispatchSnowplowEvent } from './dispatch_snowplow_event';
import getStandardContext from './get_standard_context';
import {
@@ -105,9 +105,7 @@ export default class Tracking {
return [];
}
- const loadEvents = parent.querySelectorAll(
- `${LOAD_ACTION_ATTR_SELECTOR}, ${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR}`,
- );
+ const loadEvents = parent.querySelectorAll(LOAD_ACTION_ATTR_SELECTOR);
loadEvents.forEach((element) => {
const { action, data } = createEventPayload(element);
@@ -179,9 +177,12 @@ export default class Tracking {
}
const referrers = getReferrersCache();
- const pageLinks = Object.seal({ url: '', referrer: '', originalUrl: window.location.href });
+ const pageLinks = Object.seal({
+ url: pageUrl,
+ referrer: '',
+ originalUrl: window.location.href,
+ });
- pageLinks.url = `${pageUrl}${window.location.hash}`;
window.snowplow('setCustomUrl', pageLinks.url);
if (document.referrer) {
diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js
index 3507872b511..cc0d7e7a44a 100644
--- a/app/assets/javascripts/tracking/utils.js
+++ b/app/assets/javascripts/tracking/utils.js
@@ -4,8 +4,6 @@ import { getExperimentData } from '~/experimentation/utils';
import {
ACTION_ATTR_SELECTOR,
LOAD_ACTION_ATTR_SELECTOR,
- DEPRECATED_EVENT_ATTR_SELECTOR,
- DEPRECATED_LOAD_EVENT_ATTR_SELECTOR,
URLS_CACHE_STORAGE_KEY,
REFERRER_TTL,
} from './constants';
@@ -27,7 +25,6 @@ export const addExperimentContext = (opts) => {
export const createEventPayload = (el, { suffix = '' } = {}) => {
const {
trackAction,
- trackEvent,
trackValue,
trackExtra,
trackExperiment,
@@ -36,7 +33,7 @@ export const createEventPayload = (el, { suffix = '' } = {}) => {
trackProperty,
} = el?.dataset || {};
- const action = (trackAction || trackEvent) + (suffix || '');
+ const action = `${trackAction}${suffix || ''}`;
let value = trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) {
@@ -74,8 +71,7 @@ export const createEventPayload = (el, { suffix = '' } = {}) => {
export const eventHandler = (e, func, opts = {}) => {
const actionSelector = `${ACTION_ATTR_SELECTOR}:not(${LOAD_ACTION_ATTR_SELECTOR})`;
- const deprecatedEventSelector = `${DEPRECATED_EVENT_ATTR_SELECTOR}:not(${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR})`;
- const el = e.target.closest(`${actionSelector}, ${deprecatedEventSelector}`);
+ const el = e.target.closest(actionSelector);
if (!el) {
return;
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 7a7518bcf83..4544373d8aa 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -41,6 +41,7 @@ const populateUserInfo = (user) => {
workInformation: userData.work_information,
websiteUrl: userData.website_url,
pronouns: userData.pronouns,
+ localTime: userData.local_time,
loaded: true,
});
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
index ea73ab416de..0c4a5ee35d9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -35,13 +35,17 @@ export default {
}
if (!this.rulesLeft.length) {
- return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft);
+ return n__(
+ 'Requires %d approval from eligible users.',
+ 'Requires %d approvals from eligible users.',
+ this.approvalsLeft,
+ );
}
return sprintf(
n__(
- 'Requires approval from %{names}.',
- 'Requires %{count} more approvals from %{names}.',
+ 'Requires %{count} approval from %{names}.',
+ 'Requires %{count} approvals from %{names}.',
this.approvalsLeft,
),
{
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
new file mode 100644
index 00000000000..023367a794e
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { sprintf, __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ widget: {
+ type: String,
+ required: true,
+ },
+ tertiaryButtons: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ dropdownLabel() {
+ return sprintf(__('%{widget} options'), { widget: this.widget });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown
+ v-if="tertiaryButtons"
+ :text="dropdownLabel"
+ icon="ellipsis_v"
+ no-caret
+ category="tertiary"
+ right
+ lazy
+ text-sr-only
+ size="small"
+ toggle-class="gl-p-2!"
+ class="gl-display-block gl-md-display-none!"
+ >
+ <gl-dropdown-item
+ v-for="(btn, index) in tertiaryButtons"
+ :key="index"
+ :href="btn.href"
+ :target="btn.target"
+ >
+ {{ btn.text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <template v-if="tertiaryButtons.length">
+ <gl-button
+ v-for="(btn, index) in tertiaryButtons"
+ :key="index"
+ :href="btn.href"
+ :target="btn.target"
+ :class="{ 'gl-mr-3': index > 1 }"
+ category="tertiary"
+ variant="confirm"
+ size="small"
+ class="gl-display-none gl-md-display-block"
+ >
+ {{ btn.text }}
+ </gl-button>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index 0ac98f6c982..298f7c7ad8c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -1,7 +1,18 @@
<script>
-import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlLoadingIcon,
+ GlLink,
+ GlBadge,
+ GlSafeHtmlDirective,
+ GlTooltipDirective,
+ GlIntersectionObserver,
+} from '@gitlab/ui';
+import { sprintf, s__, __ } from '~/locale';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
-import StatusIcon from '../mr_widget_status_icon.vue';
+import { EXTENSION_ICON_CLASS } from '../../constants';
+import StatusIcon from './status_icon.vue';
+import Actions from './actions.vue';
export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading',
@@ -13,14 +24,16 @@ export default {
components: {
GlButton,
GlLoadingIcon,
- GlIcon,
GlLink,
GlBadge,
+ GlIntersectionObserver,
SmartVirtualList,
StatusIcon,
+ Actions,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
+ GlTooltip: GlTooltipDirective,
},
data() {
return {
@@ -28,9 +41,16 @@ export default {
collapsedData: null,
fullData: null,
isCollapsed: true,
+ showFade: false,
};
},
computed: {
+ widgetLabel() {
+ return this.$options.i18n?.label || this.$options.name;
+ },
+ widgetLoadingText() {
+ return this.$options.i18n?.loading || __('Loading...');
+ },
isLoadingSummary() {
return this.loadingState === LOADING_STATES.collapsedLoading;
},
@@ -44,17 +64,22 @@ export default {
return true;
},
+ collapseButtonLabel() {
+ return sprintf(
+ this.isCollapsed
+ ? s__('mrWidget|Show %{widget} details')
+ : s__('mrWidget|Hide %{widget} details'),
+ { widget: this.widgetLabel },
+ );
+ },
statusIconName() {
- if (this.isLoadingSummary) {
- return 'loading';
- }
-
- if (this.loadingState === LOADING_STATES.collapsedError) {
- return 'warning';
- }
+ if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData);
},
+ tertiaryActionsButtons() {
+ return this.tertiaryButtons ? this.tertiaryButtons() : undefined;
+ },
},
watch: {
isCollapsed(newVal) {
@@ -95,32 +120,59 @@ export default {
throw e;
});
},
+ appear(index) {
+ if (index === this.fullData.length - 1) {
+ this.showFade = false;
+ }
+ },
+ disappear(index) {
+ if (index === this.fullData.length - 1) {
+ this.showFade = true;
+ }
+ },
},
+ EXTENSION_ICON_CLASS,
};
</script>
<template>
- <section class="media-section mr-widget-border-top">
+ <section class="media-section" data-testid="widget-extension">
<div class="media gl-p-5">
- <status-icon :status="statusIconName" class="align-self-center" />
- <div class="media-body d-flex flex-align-self-center align-items-center">
- <div class="code-text">
- <template v-if="isLoadingSummary">
- {{ __('Loading...') }}
- </template>
+ <status-icon
+ :name="$options.label || $options.name"
+ :is-loading="isLoadingSummary"
+ :icon-name="statusIconName"
+ />
+ <div class="media-body gl-display-flex gl-flex-direction-row!">
+ <div class="gl-flex-grow-1">
+ <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
<div v-else v-safe-html="summary(collapsedData)"></div>
</div>
- <gl-button
- v-if="isCollapsible"
- size="small"
- class="float-right align-self-center"
- @click="toggleCollapsed"
- >
- {{ isCollapsed ? __('Expand') : __('Collapse') }}
- </gl-button>
+ <actions
+ :widget="$options.label || $options.name"
+ :tertiary-buttons="tertiaryActionsButtons"
+ />
+ <div class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6">
+ <gl-button
+ v-if="isCollapsible"
+ v-gl-tooltip
+ :title="collapseButtonLabel"
+ :aria-expanded="`${!isCollapsed}`"
+ :aria-label="collapseButtonLabel"
+ :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ category="tertiary"
+ data-testid="toggle-button"
+ size="small"
+ @click="toggleCollapsed"
+ />
+ </div>
</div>
</div>
- <div v-if="!isCollapsed" class="mr-widget-grouped-section">
+ <div
+ v-if="!isCollapsed"
+ class="mr-widget-grouped-section gl-relative"
+ data-testid="widget-extension-collapsed-section"
+ >
<div v-if="isLoadingExpanded" class="report-block-container">
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>
@@ -131,27 +183,38 @@ export default {
:size="32"
wtag="ul"
wclass="report-block-list"
- class="report-block-container"
+ class="report-block-container gl-px-5 gl-py-0"
>
- <li v-for="data in fullData" :key="data.id" class="d-flex align-items-center">
- <div v-if="data.icon" :class="data.icon.class" class="d-flex">
- <gl-icon :name="data.icon.name" :size="24" />
- </div>
- <div
- class="gl-mt-2 gl-mb-2 align-content-around align-items-start flex-wrap align-self-center d-flex"
+ <li
+ v-for="(data, index) in fullData"
+ :key="data.id"
+ :class="{
+ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
+ }"
+ class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7"
+ data-testid="extension-list-item"
+ >
+ <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" />
+ <gl-intersection-observer
+ :options="{ rootMargin: '100px', thresholds: 0.1 }"
+ class="gl-flex-wrap gl-align-self-center gl-display-flex"
+ @appear="appear(index)"
+ @disappear="disappear(index)"
>
- <div class="gl-mr-4">
- {{ data.text }}
- </div>
+ <div v-safe-html="data.text" class="gl-mr-4"></div>
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
- </div>
+ </gl-intersection-observer>
</li>
</smart-virtual-list>
+ <div
+ :class="{ show: showFade }"
+ class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7"
+ ></div>
</div>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
index 529160de6a7..b9dfd3bd41e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
@@ -1,4 +1,5 @@
-import { extensions } from './index';
+import { __ } from '~/locale';
+import { registeredExtensions } from './index';
export default {
props: {
@@ -8,20 +9,46 @@ export default {
},
},
render(h) {
+ const { extensions } = registeredExtensions;
+
+ if (extensions.length === 0) return null;
+
return h(
'div',
- {},
- extensions.map((extension) =>
- h(extension, {
- props: extensions[0].props.reduce(
- (acc, key) => ({
- ...acc,
- [key]: this.mr[key],
- }),
- {},
- ),
- }),
- ),
+ {
+ attrs: {
+ role: 'region',
+ 'aria-label': __('Merge request reports'),
+ },
+ },
+ [
+ h(
+ 'ul',
+ {
+ class: 'gl-p-0 gl-m-0 gl-list-style-none',
+ },
+ [
+ ...extensions.map((extension, index) =>
+ h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
+ h(
+ { ...extension },
+ {
+ props: {
+ ...extension.props.reduce(
+ (acc, key) => ({
+ ...acc,
+ [key]: this.mr[key],
+ }),
+ {},
+ ),
+ },
+ },
+ ),
+ ]),
+ ),
+ ],
+ ),
+ ],
);
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
index 9796bb44939..4ca0b660696 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -1,15 +1,17 @@
+import Vue from 'vue';
import ExtensionBase from './base.vue';
// Holds all the currently registered extensions
-export const extensions = [];
+export const registeredExtensions = Vue.observable({ extensions: [] });
export const registerExtension = (extension) => {
// Pushes into the extenions array a dynamically created Vue component
// that gets exteneded from `base.vue`
- extensions.push({
+ registeredExtensions.extensions.push({
extends: ExtensionBase,
name: extension.name,
props: extension.props,
+ i18n: extension.i18n,
computed: {
...Object.keys(extension.computed).reduce(
(acc, computedKey) => ({
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
new file mode 100644
index 00000000000..01d8de132e7
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { EXTENSION_ICON_CLASS, EXTENSION_ICON_NAMES } from '../../constants';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlIcon,
+ },
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ iconName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 16,
+ },
+ },
+ computed: {
+ iconAriaLabel() {
+ return `${capitalizeFirstCharacter(this.iconName)} ${this.name}`;
+ },
+ },
+ EXTENSION_ICON_NAMES,
+ EXTENSION_ICON_CLASS,
+};
+</script>
+
+<template>
+ <div
+ :class="[
+ $options.EXTENSION_ICON_CLASS[iconName],
+ { 'mr-widget-extension-icon': !isLoading && size === 16 },
+ { 'gl-p-2': isLoading || size === 16 },
+ ]"
+ class="gl-rounded-full gl-mr-3 gl-relative gl-p-2"
+ >
+ <gl-loading-icon v-if="isLoading" size="md" inline class="gl-display-block" />
+ <gl-icon
+ v-else
+ :name="$options.EXTENSION_ICON_NAMES[iconName]"
+ :size="size"
+ :aria-label="iconAriaLabel"
+ class="gl-display-block"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 966262944ad..5c67b9c7ab5 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
@@ -10,7 +10,7 @@ import {
GlSafeHtmlDirective as SafeHtml,
GlSprintf,
} from '@gitlab/ui';
-import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility';
+import { constructWebIDEPath } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
@@ -58,15 +58,7 @@ export default {
});
},
webIdePath() {
- return mergeUrlParams(
- {
- target_project:
- this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath
- ? this.mr.targetProjectFullPath
- : '',
- },
- webIDEUrl(`/${this.mr.sourceProjectFullPath}/merge_requests/${this.mr.iid}`),
- );
+ return constructWebIDEPath(this.mr);
},
isFork() {
return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath;
@@ -79,7 +71,7 @@ export default {
};
</script>
<template>
- <div class="d-flex mr-source-target gl-mb-3">
+ <div class="gl-display-flex mr-source-target">
<mr-widget-icon name="git-merge" />
<div class="git-merge-container d-flex">
<div class="normal">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 7532eabee8a..68cff1368af 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -1,6 +1,7 @@
<script>
/* eslint-disable @gitlab/require-i18n-strings */
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
+import { escapeShellString } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -75,20 +76,31 @@ export default {
},
computed: {
mergeInfo1() {
+ const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`);
+
return this.isFork
- ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.sourceBranch}\ngit checkout -b "${this.sourceProjectPath}-${this.sourceBranch}" FETCH_HEAD`
- : `git fetch origin\ngit checkout -b "${this.sourceBranch}" "origin/${this.sourceBranch}"`;
+ ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD`
+ : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`;
},
mergeInfo2() {
return this.isFork
- ? `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceProjectPath}-${this.sourceBranch}"`
- : `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceBranch}"`;
+ ? `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedForkBranch}`
+ : `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedSourceBranch}`;
},
mergeInfo3() {
return this.canMerge
- ? `git push origin "${this.targetBranch}"`
+ ? `git push origin ${this.escapedTargetBranch}`
: __('Note that pushing to GitLab requires write access to this repository.');
},
+ escapedForkBranch() {
+ return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`);
+ },
+ escapedTargetBranch() {
+ return escapeShellString(this.targetBranch);
+ },
+ escapedSourceBranch() {
+ return escapeShellString(this.sourceBranch);
+ },
},
};
</script>
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 a8272002f16..a05e8747a43 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
@@ -88,6 +88,9 @@ export default {
return this.mr.preferredAutoMergeStrategy;
},
+ ciStatus() {
+ return this.isPostMerge ? this.mr?.mergePipeline?.details?.status?.text : this.mr.ciStatus;
+ },
},
};
</script>
@@ -97,7 +100,7 @@ export default {
:pipeline="pipeline"
:pipeline-coverage-delta="mr.pipelineCoverageDelta"
:builds-with-coverage="mr.buildsWithCoverage"
- :ci-status="mr.ciStatus"
+ :ci-status="ciStatus"
:has-ci="mr.hasCI"
:pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds"
:source-branch="branch"
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 a55dba92e16..3ca193514f1 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,12 +1,15 @@
<script>
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSprintf } from '@gitlab/ui';
import { escape } from 'lodash';
-import { __, n__, sprintf, s__ } from '~/locale';
+import { __, n__, s__ } from '~/locale';
+
+const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit');
export default {
+ mergeCommitCount,
components: {
GlButton,
+ GlSprintf,
},
props: {
isSquashEnabled: {
@@ -37,7 +40,7 @@ export default {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
commitsCountMessage() {
- return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount);
+ return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount);
},
modifyLinkMessage() {
if (this.isFastForwardEnabled) return __('Modify commit message');
@@ -47,22 +50,15 @@ export default {
ariaLabel() {
return this.expanded ? __('Collapse') : __('Expand');
},
+ targetBranchEscaped() {
+ return escape(this.targetBranch);
+ },
message() {
- const message = this.isFastForwardEnabled
+ return this.isFastForwardEnabled
? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
: s__(
'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.',
);
-
- return sprintf(
- message,
- {
- commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`,
- mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`,
- targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`,
- },
- false,
- );
},
},
methods: {
@@ -89,10 +85,19 @@ export default {
/>
<span v-if="expanded">{{ __('Collapse') }}</span>
<span v-else>
- <span
- class="vertical-align-middle"
- v-html="message /* eslint-disable-line vue/no-v-html */"
- ></span>
+ <span class="vertical-align-middle">
+ <gl-sprintf :message="message">
+ <template #commitCount>
+ <strong class="commits-count-message">{{ commitsCountMessage }}</strong>
+ </template>
+ <template #mergeCommitCount>
+ <strong>{{ $options.mergeCommitCount }}</strong>
+ </template>
+ <template #targetBranch>
+ <span class="label-branch">{{ targetBranchEscaped }}</span>
+ </template>
+ </gl-sprintf>
+ </span>
<gl-button variant="link" class="modify-message-button">
{{ modifyLinkMessage }}
</gl-button>
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 7df65e995a5..7d4bd4cf1bf 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
@@ -29,6 +29,7 @@ import {
WARNING,
MT_MERGE_STRATEGY,
PIPELINE_FAILED_STATE,
+ STATE_MACHINE,
} from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
@@ -47,6 +48,9 @@ const MERGE_FAILED_STATUS = 'failed';
const MERGE_SUCCESS_STATUS = 'success';
const MERGE_HOOK_VALIDATION_ERROR_STATUS = 'hook_validation_error';
+const { transitions } = STATE_MACHINE;
+const { MERGE, MERGED, MERGE_FAILURE, AUTO_MERGE } = transitions;
+
export default {
name: 'ReadyToMerge',
apollo: {
@@ -99,8 +103,8 @@ export default {
GlDropdownItem,
GlFormCheckbox,
GlSkeletonLoader,
- MergeTrainHelperText: () =>
- import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'),
+ MergeTrainHelperIcon: () =>
+ import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'),
MergeImmediatelyConfirmationDialog: () =>
import(
'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
@@ -234,7 +238,7 @@ export default {
return CONFIRM;
},
iconClass() {
- if (this.shouldRenderMergeTrainHelperText && !this.mr.preventMerge) {
+ if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) {
return PIPELINE_RUNNING_STATE;
}
@@ -361,6 +365,11 @@ export default {
}
this.isMakingRequest = true;
+
+ if (!useAutoMerge) {
+ this.mr.transitionStateMachine({ transition: MERGE });
+ }
+
this.service
.merge(options)
.then((res) => res.data)
@@ -371,10 +380,12 @@ export default {
if (AUTO_MERGE_STRATEGIES.includes(data.status)) {
eventHub.$emit('MRWidgetUpdateRequested');
+ this.mr.transitionStateMachine({ transition: AUTO_MERGE });
} else if (data.status === MERGE_SUCCESS_STATUS) {
this.initiateMergePolling();
} else if (hasError) {
eventHub.$emit('FailedToMerge', data.merge_error);
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
}
if (this.glFeatures.mergeRequestWidgetGraphql) {
@@ -383,6 +394,7 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
createFlash({
message: __('Something went wrong. Please try again.'),
});
@@ -417,6 +429,7 @@ export default {
eventHub.$emit('FetchActionsContent');
MergeRequest.hideCloseButton();
MergeRequest.decreaseCounter();
+ this.mr.transitionStateMachine({ transition: MERGED });
stopPolling();
refreshUserMergeRequestCounts();
@@ -428,6 +441,7 @@ export default {
}
} else if (data.merge_error) {
eventHub.$emit('FailedToMerge', data.merge_error);
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
stopPolling();
} else {
// MR is not merged yet, continue polling until the state becomes 'merged'
@@ -438,6 +452,7 @@ export default {
createFlash({
message: __('Something went wrong while merging this merge request. Please try again.'),
});
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
stopPolling();
});
},
@@ -489,7 +504,7 @@ export default {
</div>
</div>
<template v-else>
- <div class="mr-widget-body media" :class="{ 'gl-pb-3': shouldRenderMergeTrainHelperText }">
+ <div class="mr-widget-body media">
<status-icon :status="iconClass" />
<div class="media-body">
<div class="mr-widget-body-controls gl-display-flex gl-align-items-center">
@@ -560,6 +575,13 @@ export default {
:is-disabled="isSquashReadOnly"
class="gl-mx-3"
/>
+
+ <merge-train-helper-icon
+ v-if="shouldRenderMergeTrainHelperIcon"
+ :merge-train-when-pipeline-succeeds-docs-path="
+ mr.mergeTrainWhenPipelineSucceedsDocsPath
+ "
+ />
</div>
<template v-else>
<div class="bold js-resolve-mr-widget-items-message gl-ml-3">
@@ -590,13 +612,6 @@ export default {
</div>
</div>
</div>
- <merge-train-helper-text
- v-if="shouldRenderMergeTrainHelperText"
- :pipeline-id="pipelineId"
- :pipeline-link="pipeline.path"
- :merge-train-length="stateData.mergeTrainsCount"
- :merge-train-when-pipeline-succeeds-docs-path="mr.mergeTrainWhenPipelineSucceedsDocsPath"
- />
<template v-if="shouldShowMergeControls">
<div
v-if="!shouldShowMergeEdit"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 393c599c7e8..790870ee4c6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui';
import { produce } from 'immer';
import $ from 'jquery';
import createFlash from '~/flash';
+import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
import MergeRequest from '~/merge_request';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -123,10 +124,7 @@ export default {
},
},
}) => {
- createFlash({
- message: __('Marked as ready. Merging is now allowed.'),
- type: 'notice',
- });
+ toast(__('Marked as ready. Merging is now allowed.'));
$('.merge-request .detail-page-description .title').text(title);
},
)
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index f5710f46b7e..b88e83ccb0f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -1,4 +1,5 @@
import { s__ } from '~/locale';
+import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps';
export const SUCCESS = 'success';
export const WARNING = 'warning';
@@ -52,3 +53,99 @@ export const MERGE_ACTIVE_STATUS_PHRASES = [
emoji: 'heart_eyes',
},
];
+
+const STATE_MACHINE = {
+ states: {
+ IDLE: 'IDLE',
+ MERGING: 'MERGING',
+ AUTO_MERGE: 'AUTO_MERGE',
+ },
+ transitions: {
+ MERGE: 'start-merge',
+ AUTO_MERGE: 'start-auto-merge',
+ MERGE_FAILURE: 'merge-failed',
+ MERGED: 'merge-done',
+ },
+};
+const { states, transitions } = STATE_MACHINE;
+
+STATE_MACHINE.definition = {
+ initial: states.IDLE,
+ states: {
+ [states.IDLE]: {
+ on: {
+ [transitions.MERGE]: states.MERGING,
+ [transitions.AUTO_MERGE]: states.AUTO_MERGE,
+ },
+ },
+ [states.MERGING]: {
+ on: {
+ [transitions.MERGED]: states.IDLE,
+ [transitions.MERGE_FAILURE]: states.IDLE,
+ },
+ },
+ [states.AUTO_MERGE]: {
+ on: {
+ [transitions.MERGED]: states.IDLE,
+ [transitions.MERGE_FAILURE]: states.IDLE,
+ },
+ },
+ },
+};
+
+export const stateToTransitionMap = {
+ [stateKey.merging]: transitions.MERGE,
+ [stateKey.merged]: transitions.MERGED,
+ [stateKey.autoMergeEnabled]: transitions.AUTO_MERGE,
+};
+export const stateToComponentMap = {
+ [states.MERGING]: classStateMap[stateKey.merging],
+ [states.AUTO_MERGE]: classStateMap[stateKey.autoMergeEnabled],
+};
+
+export const EXTENSION_ICONS = {
+ failed: 'failed',
+ warning: 'warning',
+ success: 'success',
+ neutral: 'neutral',
+ error: 'error',
+ notice: 'notice',
+ severityCritical: 'severityCritical',
+ severityHigh: 'severityHigh',
+ severityMedium: 'severityMedium',
+ severityLow: 'severityLow',
+ severityInfo: 'severityInfo',
+ severityUnknown: 'severityUnknown',
+};
+
+export const EXTENSION_ICON_NAMES = {
+ failed: 'status-failed',
+ warning: 'status-alert',
+ success: 'status-success',
+ neutral: 'status-neutral',
+ error: 'status-alert',
+ notice: 'status-alert',
+ severityCritical: 'severity-critical',
+ severityHigh: 'severity-high',
+ severityMedium: 'severity-medium',
+ severityLow: 'severity-low',
+ severityInfo: 'severity-info',
+ severityUnknown: 'severity-unknown',
+};
+
+export const EXTENSION_ICON_CLASS = {
+ failed: 'gl-text-red-500',
+ warning: 'gl-text-orange-500',
+ success: 'gl-text-green-500',
+ neutral: 'gl-text-gray-400',
+ error: 'gl-text-red-500',
+ notice: 'gl-text-gray-500',
+ severityCritical: 'gl-text-red-800',
+ severityHigh: 'gl-text-red-600',
+ severityMedium: 'gl-text-orange-400',
+ severityLow: 'gl-text-orange-300',
+ severityInfo: 'gl-text-blue-400',
+ severityUnknown: 'gl-text-gray-400',
+};
+
+export { STATE_MACHINE };
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
index 6c6f5e7fc73..349e9d29355 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -1,4 +1,5 @@
/* eslint-disable */
+import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
@@ -6,20 +7,29 @@ export default {
// Give the extension a name
// Make it easier to track in Vue dev tools
name: 'WidgetIssues',
+ i18n: {
+ label: 'Issues',
+ loading: 'Loading issues...',
+ },
// Add an array of props
// These then get mapped to values stored in the MR Widget store
- props: ['targetProjectFullPath'],
+ props: ['targetProjectFullPath', 'conflictsDocsPath'],
// Add any extra computed props in here
computed: {
// Small summary text to be displayed in the collapsed state
// Receives the collapsed data as an argument
summary(count) {
- return `<strong>${count}</strong> open issue`;
+ return 'Summary text<br/>Second line';
},
// Status icon to be used next to the summary text
// Receives the collapsed data as an argument
statusIcon(count) {
- return count > 0 ? 'warning' : 'success';
+ return EXTENSION_ICONS.warning;
+ },
+ // Tertiary action buttons that will take the user elsewhere
+ // in the GitLab app
+ tertiaryButtons() {
+ return [{ text: 'Full report', href: this.conflictsDocsPath, target: '_blank' }];
},
},
methods: {
@@ -44,16 +54,13 @@ export default {
// Icon to get rendered on the side of each row
icon: {
// Required: Name maps to an icon in GitLabs SVG
- name:
- issue.state === 'closed' ? 'status_failed_borderless' : 'status_success_borderless',
- // Optional: An extra class to be added to the icon for additional styling
- class: issue.state === 'closed' ? 'text-danger' : 'text-success',
+ name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success,
},
// Badges get rendered next to the text on each row
- badge: issue.state === 'closed' && {
- text: 'Closed', // Required: Text to be used inside of the badge
- // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants
- },
+ // badge: issue.state === 'closed' && {
+ // text: 'Closed', // Required: Text to be used inside of the badge
+ // // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants
+ // },
// Each row can have its own link that will take the user elsewhere
// link: {
// href: 'https://google.com', // Required: href for the link
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 9d8e5d12d58..cf6472f2c8c 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
@@ -32,7 +32,7 @@ export default {
isMergeImmediatelyDangerous() {
return false;
},
- shouldRenderMergeTrainHelperText() {
+ shouldRenderMergeTrainHelperIcon() {
return false;
},
pipelineId() {
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 78aa3941bfe..3ac1e881658 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
@@ -4,7 +4,7 @@ import { isEmpty } from 'lodash';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
-import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
+import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import createFlash from '~/flash';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import notify from '~/lib/utils/notify';
@@ -38,7 +38,8 @@ import ReadyToMergeState from './components/states/ready_to_merge.vue';
import ShaMismatch from './components/states/sha_mismatch.vue';
import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue';
import WorkInProgressState from './components/states/work_in_progress.vue';
-// import ExtensionsContainer from './components/extensions/container';
+import ExtensionsContainer from './components/extensions/container';
+import { STATE_MACHINE, stateToComponentMap } from './constants';
import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
@@ -52,7 +53,7 @@ export default {
},
components: {
Loading,
- // ExtensionsContainer,
+ ExtensionsContainer,
'mr-widget-header': WidgetHeader,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
MrWidgetPipelineContainer,
@@ -124,7 +125,9 @@ export default {
mr: store,
state: store && store.state,
service: store && this.createService(store),
+ machineState: store?.machineValue || STATE_MACHINE.definition.initial,
loading: true,
+ recomputeComponentName: 0,
};
},
computed: {
@@ -139,7 +142,7 @@ export default {
return this.mr.state !== 'nothingToMerge';
},
componentName() {
- return stateMaps.stateToComponentMap[this.mr.state];
+ return stateToComponentMap[this.machineState] || classState[this.mr.state];
},
hasPipelineMustSucceedConflict() {
return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
@@ -148,9 +151,9 @@ export default {
return this.mr.hasCI || this.hasPipelineMustSucceedConflict;
},
shouldSuggestPipelines() {
- return (
- !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline
- );
+ const { hasCI, mergeRequestAddCiConfigPath, isDismissedSuggestPipeline } = this.mr;
+
+ return !hasCI && mergeRequestAddCiConfigPath && !isDismissedSuggestPipeline;
},
shouldRenderCodeQuality() {
return this.mr?.codequalityReportsPath;
@@ -204,8 +207,19 @@ export default {
hasAlerts() {
return this.mr.mergeError || this.showMergePipelineForkWarning;
},
+ shouldShowExtension() {
+ return (
+ window.gon?.features?.refactorMrWidgetsExtensions ||
+ window.gon?.features?.refactorMrWidgetsExtensionsUser
+ );
+ },
},
watch: {
+ 'mr.machineValue': {
+ handler(newValue) {
+ this.machineState = newValue;
+ },
+ },
state(newVal, oldVal) {
if (newVal !== oldVal && this.shouldRenderMergedPipeline) {
// init polling
@@ -247,6 +261,8 @@ export default {
this.mr = new MRWidgetStore({ ...window.gl.mrWidgetData, ...data });
}
+ this.machineState = this.mr.machineValue;
+
if (!this.state) {
this.state = this.mr.state;
}
@@ -496,7 +512,7 @@ export default {
</template>
</mr-widget-alert-message>
</div>
- <!-- <extensions-container :mr="mr" /> -->
+ <extensions-container :mr="mr" />
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality"
:head-blob-path="mr.headBlobPath"
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 29e0c867f6b..6628225cd46 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -1,11 +1,21 @@
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { statusBoxState } from '~/issuable/components/status_box.vue';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
-import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants';
+import { machine } from '~/lib/utils/finite_state_machine';
+import {
+ MTWPS_MERGE_STRATEGY,
+ MT_MERGE_STRATEGY,
+ MWPS_MERGE_STRATEGY,
+ STATE_MACHINE,
+ stateToTransitionMap,
+} from '../constants';
import { stateKey } from './state_maps';
const { format } = getTimeago();
+const { states } = STATE_MACHINE;
+const { IDLE } = states;
+
export default class MergeRequestStore {
constructor(data) {
this.sha = data.diff_head_sha;
@@ -16,6 +26,9 @@ export default class MergeRequestStore {
this.apiUnapprovePath = data.api_unapprove_path;
this.hasApprovalsAvailable = data.has_approvals_available;
+ this.stateMachine = machine(STATE_MACHINE.definition);
+ this.machineValue = this.stateMachine.value;
+
this.setPaths(data);
this.setData(data);
@@ -215,10 +228,7 @@ export default class MergeRequestStore {
setState() {
if (this.mergeOngoing) {
this.state = 'merging';
- return;
- }
-
- if (this.isOpen) {
+ } else if (this.isOpen) {
this.state = getStateKey.call(this);
} else {
switch (this.mergeRequestState) {
@@ -232,6 +242,8 @@ export default class MergeRequestStore {
this.state = null;
}
}
+
+ this.translateStateToMachine();
}
setPaths(data) {
@@ -277,7 +289,7 @@ export default class MergeRequestStore {
// Security reports
this.sastComparisonPath = data.sast_comparison_path;
- this.secretScanningComparisonPath = data.secret_scanning_comparison_path;
+ this.secretDetectionComparisonPath = data.secret_detection_comparison_path;
}
get isNothingToMergeState() {
@@ -356,4 +368,32 @@ export default class MergeRequestStore {
(this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed)
);
}
+
+ // Because the state machine doesn't yet handle every state and transition,
+ // some use-cases will need to force a state that can't be reached by
+ // a known transition. This is undesirable long-term (as it subverts
+ // the intent of a state machine), but is necessary until the machine
+ // can handle all possible combinations. (unsafeForce)
+ transitionStateMachine({ transition, state, unsafeForce = false } = {}) {
+ if (unsafeForce && state) {
+ this.stateMachine.value = state;
+ } else {
+ this.stateMachine.send(transition);
+ }
+
+ this.machineValue = this.stateMachine.value;
+ }
+ translateStateToMachine() {
+ const transition = stateToTransitionMap[this.state];
+ let transitionOptions = {
+ state: IDLE,
+ unsafeForce: true,
+ };
+
+ if (transition) {
+ transitionOptions = { transition };
+ }
+
+ this.transitionStateMachine(transitionOptions);
+ }
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
index 04454882666..4cb23407a74 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -1,4 +1,4 @@
-const stateToComponentMap = {
+export const stateToComponentMap = {
merged: 'mr-widget-merged',
closed: 'mr-widget-closed',
merging: 'mr-widget-merging',
@@ -21,7 +21,7 @@ const stateToComponentMap = {
mergeChecksFailed: 'mergeChecksFailed',
};
-const statesToShowHelpWidget = [
+export const statesToShowHelpWidget = [
'merging',
'conflicts',
'workInProgress',
@@ -50,11 +50,7 @@ export const stateKey = {
notAllowedToMerge: 'notAllowedToMerge',
readyToMerge: 'readyToMerge',
rebase: 'rebase',
+ merging: 'merging',
merged: 'merged',
mergeChecksFailed: 'mergeChecksFailed',
};
-
-export default {
- stateToComponentMap,
- statesToShowHelpWidget,
-};
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
index 0c1d55ae707..4cab5e964de 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -27,6 +27,11 @@ export default {
required: false,
default: '',
},
+ hideLineNumbers: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
mounted() {
eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
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 84770dbac6f..40044e518c3 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
@@ -8,8 +8,6 @@ export default {
name: 'SimpleViewer',
components: {
GlIcon,
- SourceEditor: () =>
- import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'),
},
mixins: [ViewerMixin, glFeatureFlagsMixin()],
inject: ['blobHash'],
@@ -22,9 +20,6 @@ export default {
lineNumbers() {
return this.content.split('\n').length;
},
- refactorBlobViewerEnabled() {
- return this.glFeatures.refactorBlobViewer;
- },
},
mounted() {
const { hash } = window.location;
@@ -52,14 +47,8 @@ export default {
</script>
<template>
<div>
- <source-editor
- v-if="isRawContent && refactorBlobViewerEnabled"
- :value="content"
- :file-name="fileName"
- :editor-options="{ readOnly: true }"
- />
- <div v-else class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
- <div class="line-numbers">
+ <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
+ <div v-if="!hideLineNumbers" class="line-numbers">
<a
v-for="line in lineNumbers"
:id="`L${line}`"
diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
index 3c21b14894b..7563c35dfc8 100644
--- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
@@ -81,8 +81,8 @@ export default {
},
},
i18n: {
- fullDescription: __('Choose any color. Or you can choose one of the suggested colors below'),
- shortDescription: __('Choose any color'),
+ fullDescription: __('Enter any color or choose one of the suggested colors below.'),
+ shortDescription: __('Enter any color.'),
},
};
</script>
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 7b88b36aa0f..ea507017caa 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
@@ -97,7 +97,7 @@ export default {
});
})
.catch(() => {
- this.previewContent = __('An error occurred while fetching markdown preview');
+ this.previewContent = __('An error occurred while fetching Markdown preview');
this.isLoading = false;
});
}
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
index c4dfcf93a18..014276c7e36 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -1,13 +1,11 @@
<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlAlert } from '@gitlab/ui';
import { slugifyWithUnderscore } from '~/lib/utils/text_utility';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
export default {
components: {
GlAlert,
- GlSprintf,
- GlLink,
LocalStorageSync,
},
props: {
@@ -15,10 +13,6 @@ export default {
type: String,
required: true,
},
- feedbackLink: {
- type: String,
- required: true,
- },
},
data() {
return {
@@ -44,19 +38,8 @@ export default {
<template>
<div v-show="showAlert">
<local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
- <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert">
- <gl-sprintf
- :message="
- __(
- 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
- )
- "
- >
- <template #featureName>{{ featureName }}</template>
- <template #link="{ content }">
- <gl-link :href="feedbackLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert">
+ <slot></slot>
</gl-alert>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
new file mode 100644
index 00000000000..5d0ed8b0821
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
@@ -0,0 +1,81 @@
+<script>
+import { UP_KEY_CODE, DOWN_KEY_CODE, TAB_KEY_CODE } from '~/lib/utils/keycodes';
+
+export default {
+ model: {
+ prop: 'index',
+ event: 'change',
+ },
+ props: {
+ /* v-model property to manage location in list */
+ index: {
+ type: Number,
+ required: true,
+ },
+ /* Highest index that can be navigated to */
+ max: {
+ type: Number,
+ required: true,
+ },
+ /* Lowest index that can be navigated to */
+ min: {
+ type: Number,
+ required: true,
+ },
+ /* Which index to set v-model to on init */
+ defaultIndex: {
+ type: Number,
+ required: true,
+ },
+ },
+ watch: {
+ max() {
+ // If the max index (list length) changes, reset the index
+ this.$emit('change', this.defaultIndex);
+ },
+ },
+ created() {
+ this.$emit('change', this.defaultIndex);
+ document.addEventListener('keydown', this.handleKeydown);
+ },
+ beforeDestroy() {
+ document.removeEventListener('keydown', this.handleKeydown);
+ },
+ methods: {
+ handleKeydown(event) {
+ if (event.keyCode === DOWN_KEY_CODE) {
+ // Prevents moving scrollbar
+ event.preventDefault();
+ event.stopPropagation();
+ // Moves to next index
+ this.increment(1);
+ } else if (event.keyCode === UP_KEY_CODE) {
+ // Prevents moving scrollbar
+ event.preventDefault();
+ event.stopPropagation();
+ // Moves to previous index
+ this.increment(-1);
+ } else if (event.keyCode === TAB_KEY_CODE) {
+ this.$emit('tab');
+ }
+ },
+ increment(val) {
+ if (this.max === 0) {
+ return;
+ }
+
+ const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
+
+ // Return if the index didn't change
+ if (nextIndex === this.index) {
+ return;
+ }
+
+ this.$emit('change', nextIndex);
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql
new file mode 100644
index 00000000000..9e9bda8ad3e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql
@@ -0,0 +1,15 @@
+fragment EpicNode on Epic {
+ id
+ iid
+ group {
+ fullPath
+ }
+ title
+ state
+ reference
+ referencePath: reference(full: true)
+ webPath
+ webUrl
+ createdAt
+ closedAt
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql
new file mode 100644
index 00000000000..4bb4b586fc9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql
@@ -0,0 +1,16 @@
+#import "./epic.fragment.graphql"
+
+query searchEpics($fullPath: ID!, $search: String, $state: EpicState) {
+ group(fullPath: $fullPath) {
+ epics(
+ search: $search
+ state: $state
+ includeAncestorGroups: true
+ includeDescendantGroups: false
+ ) {
+ nodes {
+ ...EpicNode
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index d1326e96794..cee7c40aa83 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -67,6 +67,11 @@ export default {
required: false,
default: 'id',
},
+ searchBy: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
@@ -112,16 +117,18 @@ export default {
);
},
showDefaultSuggestions() {
- return this.availableDefaultSuggestions.length;
+ return this.availableDefaultSuggestions.length > 0;
},
showRecentSuggestions() {
- return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey;
+ return (
+ this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey
+ );
},
showPreloadedSuggestions() {
- return this.preloadedSuggestions.length && !this.searchKey;
+ return this.preloadedSuggestions.length > 0 && !this.searchKey;
},
showAvailableSuggestions() {
- return this.availableSuggestions.length;
+ return this.availableSuggestions.length > 0;
},
showSuggestions() {
// These conditions must match the template under `#suggestions` slot
@@ -134,13 +141,19 @@ export default {
this.showAvailableSuggestions
);
},
+ searchTerm() {
+ return this.searchBy && this.activeTokenValue
+ ? this.activeTokenValue[this.searchBy]
+ : undefined;
+ },
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.suggestions.length) {
- this.$emit('fetch-suggestions', this.value.data);
+ const search = this.searchTerm ? this.searchTerm : this.value.data;
+ this.$emit('fetch-suggestions', search);
}
},
},
@@ -148,8 +161,10 @@ export default {
methods: {
handleInput: debounce(function debouncedSearch({ data }) {
this.searchKey = data;
- if (!this.suggestionsLoading) {
- this.$emit('fetch-suggestions', data);
+
+ if (!this.suggestionsLoading && !this.activeTokenValue) {
+ const search = this.searchTerm ? this.searchTerm : data;
+ this.$emit('fetch-suggestions', search);
}
}, DEBOUNCE_DELAY),
handleTokenValueSelected(activeTokenValue) {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
index 9f68308808e..9c2f5306654 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -1,22 +1,19 @@
<script>
-import {
- GlDropdownDivider,
- GlFilteredSearchSuggestion,
- GlFilteredSearchToken,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import { debounce } from 'lodash';
+import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
-import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
+import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
+import searchEpicsQuery from '../queries/search_epics.query.graphql';
+
+import BaseToken from './base_token.vue';
export default {
- separator: '::&',
+ prefix: '&',
+ separator: '::',
components: {
- GlDropdownDivider,
- GlFilteredSearchToken,
+ BaseToken,
GlFilteredSearchSuggestion,
- GlLoadingIcon,
},
props: {
config: {
@@ -27,11 +24,15 @@ export default {
type: Object,
required: true,
},
+ active: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
epics: this.config.initialEpics || [],
- loading: true,
+ loading: false,
};
},
computed: {
@@ -56,98 +57,73 @@ export default {
}
return this.defaultEpics;
},
- activeEpic() {
- if (this.currentValue && this.epics.length) {
- // Check if current value is an epic ID.
- if (typeof this.currentValue === 'number') {
- return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
- }
-
- // Current value is a string.
- const [groupPath, idProperty] = this.currentValue?.split(this.$options.separator);
- return this.epics.find(
- (epic) =>
- epic.group_full_path === groupPath &&
- epic[this.idProperty] === parseInt(idProperty, 10),
- );
- }
- return null;
- },
- displayText() {
- return `${this.activeEpic?.title}${this.$options.separator}${this.activeEpic?.iid}`;
- },
- },
- watch: {
- active: {
- immediate: true,
- handler(newValue) {
- if (!newValue && !this.epics.length) {
- this.searchEpics({ data: this.currentValue });
- }
- },
- },
},
methods: {
- fetchEpicsBySearchTerm({ epicPath = '', search = '' }) {
+ fetchEpics(search = '') {
+ return this.$apollo
+ .query({
+ query: searchEpicsQuery,
+ variables: { fullPath: this.config.fullPath, search },
+ })
+ .then(({ data }) => data.group?.epics.nodes);
+ },
+ fetchEpicsBySearchTerm(search) {
this.loading = true;
- this.config
- .fetchEpics({ epicPath, search })
+ this.fetchEpics(search)
.then((response) => {
- this.epics = Array.isArray(response) ? response : response.data;
+ this.epics = Array.isArray(response) ? response : response?.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
this.loading = false;
});
},
- searchEpics: debounce(function debouncedSearch({ data }) {
- let epicPath = this.activeEpic?.web_url;
-
- // When user visits the page with token value already included in filters
- // We don't have any information about selected token except for its
- // group path and iid joined by separator, so we need to manually
- // compose epic path from it.
- if (data.includes?.(this.$options.separator)) {
- const [groupPath, epicIid] = data.split(this.$options.separator);
- epicPath = `/groups/${groupPath}/-/epics/${epicIid}`;
+ getActiveEpic(epics, data) {
+ if (data && epics.length) {
+ return epics.find((epic) => this.getValue(epic) === data);
}
- this.fetchEpicsBySearchTerm({ epicPath, search: data });
- }, DEBOUNCE_DELAY),
-
+ return undefined;
+ },
getValue(epic) {
- return this.config.useIdValue
- ? String(epic[this.idProperty])
- : `${epic.group_full_path}${this.$options.separator}${epic[this.idProperty]}`;
+ return this.getEpicIdProperty(epic).toString();
+ },
+ displayValue(epic) {
+ return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${
+ epic?.title
+ }`;
+ },
+ getEpicIdProperty(epic) {
+ return getIdFromGraphQLId(epic[this.idProperty]);
},
},
};
</script>
<template>
- <gl-filtered-search-token
+ <base-token
:config="config"
- v-bind="{ ...$props, ...$attrs }"
+ :value="value"
+ :active="active"
+ :suggestions-loading="loading"
+ :suggestions="epics"
+ :get-active-token-value="getActiveEpic"
+ :default-suggestions="availableDefaultEpics"
+ :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
+ search-by="title"
+ @fetch-suggestions="fetchEpicsBySearchTerm"
v-on="$listeners"
- @input="searchEpics"
>
- <template #view="{ inputValue }">
- {{ activeEpic ? displayText : inputValue }}
+ <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
+ {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }}
</template>
- <template #suggestions>
+ <template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
- v-for="epic in availableDefaultEpics"
- :key="epic.value"
- :value="epic.value"
+ v-for="epic in suggestions"
+ :key="epic.id"
+ :value="getValue(epic)"
>
- {{ epic.text }}
+ {{ epic.title }}
</gl-filtered-search-suggestion>
- <gl-dropdown-divider v-if="availableDefaultEpics.length" />
- <gl-loading-icon v-if="loading" size="sm" />
- <template v-else>
- <gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)">
- {{ epic.title }}
- </gl-filtered-search-suggestion>
- </template>
</template>
- </gl-filtered-search-token>
+ </base-token>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
index b2f077f5329..5955f31fc70 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -77,7 +77,7 @@ export default {
};
</script>
<template>
- <div class="issue-assignees">
+ <div>
<user-avatar-link
v-for="assignee in assigneesToShow"
:key="assignee.id"
@@ -97,10 +97,9 @@ export default {
</user-avatar-link>
<span
v-if="numHiddenAssignees > 0"
- v-gl-tooltip
+ v-gl-tooltip.bottom
:title="assigneesCounterTooltip"
class="avatar-counter"
- data-placement="bottom"
data-qa-selector="avatar_counter_content"
>{{ assigneeCounterLabel }}</span
>
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 095d1854c8b..8aeff9257a5 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -1,6 +1,12 @@
<script>
import '~/commons/bootstrap';
-import { GlIcon, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlTooltip,
+ GlTooltipDirective,
+ GlButton,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { sprintf } from '~/locale';
import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
@@ -22,6 +28,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml,
},
mixins: [relatedIssuableMixin],
props: {
@@ -84,7 +91,7 @@ export default {
/>
</div>
<gl-tooltip :target="() => $refs.iconElementXL">
- <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html="stateTitle"></span>
</gl-tooltip>
<gl-icon
v-if="confidential"
@@ -110,7 +117,7 @@ export default {
class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
>
<gl-tooltip :target="() => this.$refs.iconElement">
- <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html="stateTitle"></span>
</gl-tooltip>
<span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
itemPath
diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
index d6a20984ad1..ce7cbafb97d 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -1,5 +1,6 @@
<script>
import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
export default {
components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton },
@@ -13,12 +14,26 @@ export default {
type: String,
required: true,
},
+ batchSuggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
message: null,
};
},
+ computed: {
+ dropdownText() {
+ if (this.batchSuggestionsCount <= 1) {
+ return __('Apply suggestion');
+ }
+
+ return n__('Apply %d suggestion', 'Apply %d suggestions', this.batchSuggestionsCount);
+ },
+ },
methods: {
onApply() {
this.$emit('apply', this.message);
@@ -29,10 +44,11 @@ export default {
<template>
<gl-dropdown
- :text="__('Apply suggestion')"
+ :text="dropdownText"
:disabled="disabled"
boundary="window"
right
+ lazy
menu-class="gl-w-full!"
data-qa-selector="apply_suggestion_dropdown"
@shown="$refs.commitMessage.$el.focus()"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 77730ada9bb..86f04c78ebe 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -254,7 +254,7 @@ export default {
.then(() => $(this.$refs['markdown-preview']).renderGFM())
.catch(() =>
createFlash({
- message: __('Error rendering markdown preview'),
+ message: __('Error rendering Markdown preview'),
}),
);
},
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 9c954fce322..7d8d8c0b90e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -54,8 +54,8 @@ export default {
applySuggestion(callback, message) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback, message });
},
- applySuggestionBatch() {
- this.$emit('applyBatch');
+ applySuggestionBatch(message) {
+ this.$emit('applyBatch', message);
},
addSuggestionToBatch() {
this.$emit('addToBatch', this.suggestion.id);
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 5fdef0b1a23..f9ae59567b2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -58,12 +58,19 @@ export default {
isApplyingSingle: false,
};
},
+
computed: {
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
tooltipMessage() {
- return this.canApply ? __('This also resolves this thread') : this.inapplicableReason;
+ if (!this.canApply) {
+ return this.inapplicableReason;
+ }
+
+ return this.batchSuggestionsCount > 1
+ ? __('This also resolves all related threads')
+ : __('This also resolves this thread');
},
isDisableButton() {
return this.isApplying || !this.canApply;
@@ -72,13 +79,30 @@ export default {
if (this.isApplyingSingle || this.batchSuggestionsCount < 2) {
return __('Applying suggestion...');
}
+
return __('Applying suggestions...');
},
isLoggedIn() {
return isLoggedIn();
},
+ showApplySuggestion() {
+ if (!this.isLoggedIn) return false;
+
+ if (this.batchSuggestionsCount >= 1 && !this.isBatched) {
+ return false;
+ }
+
+ return true;
+ },
},
methods: {
+ apply(message) {
+ if (this.batchSuggestionsCount > 1) {
+ this.applySuggestionBatch(message);
+ } else {
+ this.applySuggestion(message);
+ }
+ },
applySuggestion(message) {
if (!this.canApply) return;
this.isApplyingSingle = true;
@@ -88,9 +112,9 @@ export default {
applySuggestionCallback() {
this.isApplyingSingle = false;
},
- applySuggestionBatch() {
+ applySuggestionBatch(message) {
if (!this.canApply) return;
- this.$emit('applyBatch');
+ this.$emit('applyBatch', message);
},
addSuggestionToBatch() {
this.$emit('addToBatch');
@@ -115,45 +139,34 @@ export default {
<gl-loading-icon size="sm" class="d-flex-center mr-2" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
- <div v-else-if="canApply && isBatched" class="d-flex align-items-center">
- <gl-button
- class="btn-inverted js-remove-from-batch-btn btn-grouped"
- :disabled="isApplying"
- @click="removeSuggestionFromBatch"
- >
- {{ __('Remove from batch') }}
- </gl-button>
- <gl-button
- v-gl-tooltip.viewport="__('This also resolves all related threads')"
- class="btn-inverted js-apply-batch-btn btn-grouped"
- data-qa-selector="apply_suggestions_batch_button"
- :disabled="isApplying"
- variant="success"
- @click="applySuggestionBatch"
- >
- {{ __('Apply suggestions') }}
- <span class="badge badge-pill badge-pill-success">
- {{ batchSuggestionsCount }}
- </span>
- </gl-button>
- </div>
- <div v-else class="d-flex align-items-center">
- <gl-button
- v-if="suggestionsCount > 1 && !isDisableButton"
- class="btn-inverted js-add-to-batch-btn btn-grouped"
- data-qa-selector="add_suggestion_batch_button"
- :disabled="isDisableButton"
- @click="addSuggestionToBatch"
- >
- {{ __('Add suggestion to batch') }}
- </gl-button>
+ <div v-else-if="isLoggedIn" class="d-flex align-items-center">
+ <div v-if="isBatched">
+ <gl-button
+ class="btn-inverted js-remove-from-batch-btn btn-grouped"
+ :disabled="isApplying"
+ @click="removeSuggestionFromBatch"
+ >
+ {{ __('Remove from batch') }}
+ </gl-button>
+ </div>
+ <div v-else-if="!isDisableButton && suggestionsCount > 1">
+ <gl-button
+ class="btn-inverted js-add-to-batch-btn btn-grouped"
+ data-qa-selector="add_suggestion_batch_button"
+ :disabled="isDisableButton"
+ @click="addSuggestionToBatch"
+ >
+ {{ __('Add suggestion to batch') }}
+ </gl-button>
+ </div>
<apply-suggestion
- v-if="isLoggedIn"
+ v-if="showApplySuggestion"
v-gl-tooltip.viewport="tooltipMessage"
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
+ :batch-suggestions-count="batchSuggestionsCount"
class="gl-ml-3"
- @apply="applySuggestion"
+ @apply="apply"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 63774c6c498..e36cfb3b275 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -68,6 +68,10 @@ export default {
if (this.suggestionsWatch) {
this.suggestionsWatch();
}
+
+ if (this.defaultCommitMessageWatch) {
+ this.defaultCommitMessageWatch();
+ }
},
methods: {
renderSuggestions() {
@@ -123,12 +127,16 @@ export default {
suggestionDiff.suggestionsCount = this.suggestionsCount;
});
+ this.defaultCommitMessageWatch = this.$watch('defaultCommitMessage', () => {
+ suggestionDiff.defaultCommitMessage = this.defaultCommitMessage;
+ });
+
suggestionDiff.$on('apply', ({ suggestionId, callback, message }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el, message });
});
- suggestionDiff.$on('applyBatch', () => {
- this.$emit('applyBatch', { flashContainer: this.$el });
+ suggestionDiff.$on('applyBatch', (message) => {
+ this.$emit('applyBatch', { message, flashContainer: this.$el });
});
suggestionDiff.$on('addToBatch', (suggestionId) => {
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index d6501a37a35..9ea14ed506c 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -34,12 +34,23 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
computed: {
...mapGetters(['getUserData']),
renderedNote() {
return renderMarkdown(this.note.body);
},
+ avatarSize() {
+ if (this.line) {
+ return 16;
+ }
+ return 40;
+ },
},
};
</script>
@@ -50,7 +61,7 @@ export default {
<user-avatar-link
:link-href="getUserData.path"
:img-src="getUserData.avatar_url"
- :img-size="40"
+ :img-size="avatarSize"
/>
</div>
<div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content">
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
new file mode 100644
index 00000000000..9700117a3da
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
@@ -0,0 +1,34 @@
+import ProjectListItem from './project_list_item.vue';
+
+export default {
+ component: ProjectListItem,
+ title: 'vue_shared/components/project_selector/project_list_item',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { ProjectListItem },
+ props: Object.keys(argTypes),
+ template: '<project-list-item v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ project: {
+ id: '1',
+ name: 'MyProject',
+ name_with_namespace: 'path / to / MyProject',
+ },
+ selected: false,
+};
+
+export const SelectedProject = Template.bind({});
+SelectedProject.args = {
+ ...Default.args,
+ selected: true,
+};
+
+export const MatchedProject = Template.bind({});
+MatchedProject.args = {
+ ...Default.args,
+ matcher: 'proj',
+};
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 36d3696ec36..0bd57c84018 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { isString } from 'lodash';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -8,6 +8,7 @@ import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/def
export default {
name: 'ProjectListItem',
components: { GlIcon, ProjectAvatar, GlButton },
+ directives: { SafeHtml },
props: {
project: {
type: Object,
@@ -58,9 +59,9 @@ export default {
<span v-if="truncatedNamespace" class="text-secondary">/&nbsp;</span>
</div>
<div
+ v-safe-html="highlightedProjectName"
:title="project.name"
class="js-project-name text-truncate"
- v-html="highlightedProjectName /* eslint-disable-line vue/no-v-html */"
></div>
</div>
</gl-button>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
index 5c3a6852219..6538de085b0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
@@ -62,7 +62,7 @@ export default {
<div>
<clipboard-button
v-if="!isLoading"
- css-class="sidebar-collapsed-icon dont-change-state gl-rounded-0! gl-hover-bg-transparent"
+ css-class="sidebar-collapsed-icon js-dont-change-state gl-rounded-0! gl-hover-bg-transparent"
v-bind="clipboardProps"
/>
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 8853dc8b9e3..0ea22eb7aea 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 { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
+import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import { DropdownVariant } from '../constants';
import * as types from './mutation_types';
@@ -66,10 +67,10 @@ export default {
}
if (isScopedLabel(candidateLabel)) {
- const scopedBase = scopedLabelKey(candidateLabel);
- const currentActiveScopedLabel = state.labels.find(({ title }) => {
- return title.startsWith(scopedBase) && title !== '' && title !== candidateLabel.title;
- });
+ const scopedKeyWithDelimiter = `${scopedLabelKey(candidateLabel)}${SCOPED_LABEL_DELIMITER}`;
+ const currentActiveScopedLabel = state.labels.find(
+ ({ title }) => title.startsWith(scopedKeyWithDelimiter) && title !== candidateLabel.title,
+ );
if (currentActiveScopedLabel) {
currentActiveScopedLabel.set = false;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
index 00c54313292..389eb174c0e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
@@ -1,3 +1,5 @@
+export const SCOPED_LABEL_DELIMITER = '::';
+
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index 0fcc67c0ffa..3ee0baf8812 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
-
+import { __, s__, sprintf } from '~/locale';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
-import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils';
+import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils';
export default {
components: {
@@ -48,10 +48,30 @@ export default {
type: String,
required: true,
},
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ isVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
showDropdownContentsCreateView: false,
+ localSelectedLabels: [...this.selectedLabels],
+ isDirty: false,
};
},
computed: {
@@ -64,28 +84,66 @@ export default {
dropdownTitle() {
return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
},
+ buttonText() {
+ if (!this.localSelectedLabels.length) {
+ return this.dropdownButtonText || __('Label');
+ } else if (this.localSelectedLabels.length > 1) {
+ return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
+ firstLabelName: this.localSelectedLabels[0].title,
+ remainingLabelCount: this.localSelectedLabels.length - 1,
+ });
+ }
+ return this.localSelectedLabels[0].title;
+ },
showDropdownFooter() {
- return (
- !this.showDropdownContentsCreateView &&
- (this.isDropdownVariantSidebar(this.variant) ||
- this.isDropdownVariantEmbedded(this.variant))
- );
+ return !this.showDropdownContentsCreateView && !this.isStandalone;
+ },
+ isStandalone() {
+ return isDropdownVariantStandalone(this.variant);
},
},
- methods: {
- showDropdown() {
- this.$refs.dropdown.show();
+ watch: {
+ localSelectedLabels: {
+ handler() {
+ this.isDirty = true;
+ },
+ deep: true,
+ },
+ isVisible(newVal) {
+ if (newVal) {
+ this.$refs.dropdown.show();
+ this.isDirty = false;
+ } else {
+ this.$refs.dropdown.hide();
+ this.setLabels();
+ }
},
+ selectedLabels(newVal) {
+ this.localSelectedLabels = newVal;
+ },
+ },
+ methods: {
toggleDropdownContentsCreateView() {
this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView;
},
toggleDropdownContent() {
this.toggleDropdownContentsCreateView();
// Required to recalculate dropdown position as its size changes
- this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate();
+ if (this.$refs.dropdown?.$refs.dropdown) {
+ this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate();
+ }
+ },
+ setLabels() {
+ if (!this.isDirty) {
+ return;
+ }
+ this.$emit('setLabels', this.localSelectedLabels);
+ },
+ handleDropdownHide() {
+ if (!isDropdownVariantSidebar(this.variant)) {
+ this.setLabels();
+ }
},
- isDropdownVariantSidebar,
- isDropdownVariantEmbedded,
},
};
</script>
@@ -93,14 +151,16 @@ export default {
<template>
<gl-dropdown
ref="dropdown"
- :text="dropdownButtonText"
+ :text="buttonText"
class="gl-w-full gl-mt-2"
data-qa-selector="labels_dropdown_content"
+ @hide="handleDropdownHide"
>
<template #header>
<div
- v-if="isDropdownVariantSidebar(variant) || isDropdownVariantEmbedded(variant)"
+ v-if="!isStandalone"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-header"
>
<gl-button
v-if="showDropdownContentsCreateView"
@@ -119,27 +179,33 @@ export default {
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
+ data-testid="close-button"
@click="$emit('closeDropdown')"
/>
</div>
</template>
- <component
- :is="dropdownContentsView"
- :selected-labels="selectedLabels"
- :allow-multiselect="allowMultiselect"
- @hideCreateView="toggleDropdownContentsCreateView"
- @setLabels="$emit('setLabels', $event)"
- />
+ <template #default>
+ <component
+ :is="dropdownContentsView"
+ v-model="localSelectedLabels"
+ :selected-labels="selectedLabels"
+ :allow-multiselect="allowMultiselect"
+ :issuable-type="issuableType"
+ :full-path="fullPath"
+ :attr-workspace-path="attrWorkspacePath"
+ @hideCreateView="toggleDropdownContentsCreateView"
+ />
+ </template>
<template #footer>
<div v-if="showDropdownFooter" data-testid="dropdown-footer">
<gl-dropdown-item
v-if="allowLabelCreate"
data-testid="create-label-button"
- @click.native.capture.stop="toggleDropdownContent"
+ @click.capture.native.stop="toggleDropdownContent"
>
{{ footerCreateLabelTitle }}
</gl-dropdown-item>
- <gl-dropdown-item :href="labelsManagePath" @click.native.capture.stop>
+ <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop>
{{ footerManageLabelTitle }}
</gl-dropdown-item>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index 2e31b386fdd..a2ed08e6b28 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -2,9 +2,10 @@
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
+import { labelsQueries } from '~/sidebar/constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
-import projectLabelsQuery from './graphql/project_labels.query.graphql';
const errorMessage = __('Error creating label.');
@@ -18,9 +19,19 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: {
- projectPath: {
- default: '',
+ props: {
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: false,
+ default: undefined,
},
},
data() {
@@ -38,6 +49,27 @@ export default {
const colorsMap = gon.suggested_label_colors;
return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
},
+ mutationVariables() {
+ if (this.issuableType === IssuableType.Epic) {
+ return {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ groupPath: this.fullPath,
+ };
+ }
+
+ return this.attrWorkspacePath !== undefined
+ ? {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ groupPath: this.attrWorkspacePath,
+ }
+ : {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ projectPath: this.fullPath,
+ };
+ },
},
methods: {
getColorCode(color) {
@@ -51,8 +83,8 @@ export default {
},
updateLabelsInCache(store, label) {
const sourceData = store.readQuery({
- query: projectLabelsQuery,
- variables: { fullPath: this.projectPath, searchTerm: '' },
+ query: labelsQueries[this.issuableType].workspaceQuery,
+ variables: { fullPath: this.fullPath, searchTerm: '' },
});
const collator = new Intl.Collator('en');
@@ -63,8 +95,8 @@ export default {
});
store.writeQuery({
- query: projectLabelsQuery,
- variables: { fullPath: this.projectPath, searchTerm: '' },
+ query: labelsQueries[this.issuableType].workspaceQuery,
+ variables: { fullPath: this.fullPath, searchTerm: '' },
data,
});
},
@@ -75,11 +107,7 @@ export default {
data: { labelCreate },
} = await this.$apollo.mutate({
mutation: createLabelMutation,
- variables: {
- title: this.labelTitle,
- color: this.selectedColor,
- projectPath: this.projectPath,
- },
+ variables: this.mutationVariables,
update: (
store,
{
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
index 857367a0721..e6a25362ff0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,12 +1,18 @@
<script>
-import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+import {
+ GlDropdownForm,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlIntersectionObserver,
+} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
-import projectLabelsQuery from './graphql/project_labels.query.graphql';
+import { labelsQueries } from '~/sidebar/constants';
import LabelItem from './label_item.vue';
export default {
@@ -15,9 +21,12 @@ export default {
GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
+ GlIntersectionObserver,
LabelItem,
},
- inject: ['projectPath'],
+ model: {
+ prop: 'localSelectedLabels',
+ },
props: {
selectedLabels: {
type: Array,
@@ -27,30 +36,44 @@ export default {
type: Boolean,
required: true,
},
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ localSelectedLabels: {
+ type: Array,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
searchKey: '',
labels: [],
- localSelectedLabels: [...this.selectedLabels],
+ isVisible: false,
};
},
apollo: {
labels: {
- query: projectLabelsQuery,
+ query() {
+ return labelsQueries[this.issuableType].workspaceQuery;
+ },
variables() {
return {
- fullPath: this.projectPath,
+ fullPath: this.fullPath,
searchTerm: this.searchKey,
};
},
skip() {
- return this.searchKey.length === 1;
+ return this.searchKey.length === 1 || !this.isVisible;
},
update: (data) => data.workspace?.labels?.nodes || [],
async result() {
if (this.$refs.searchInput) {
- await this.$nextTick();
+ await this.$nextTick;
this.$refs.searchInput.focusInput();
}
},
@@ -64,7 +87,7 @@ export default {
return this.$apollo.queries.labels.loading;
},
localSelectedLabelsIds() {
- return this.localSelectedLabels.map((label) => label.id);
+ return this.localSelectedLabels.map((label) => getIdFromGraphQLId(label.id));
},
visibleLabels() {
if (this.searchKey) {
@@ -82,7 +105,6 @@ export default {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
beforeDestroy() {
- this.$emit('setLabels', this.localSelectedLabels);
this.debouncedSearchKeyUpdate.cancel();
},
methods: {
@@ -109,16 +131,21 @@ export default {
}
},
updateSelectedLabels(label) {
+ let labels;
if (this.isLabelSelected(label)) {
- this.localSelectedLabels = this.localSelectedLabels.filter(
- ({ id }) => id !== getIdFromGraphQLId(label.id),
+ labels = this.localSelectedLabels.filter(
+ ({ id }) => id !== getIdFromGraphQLId(label.id) && id !== label.id,
);
} else {
- this.localSelectedLabels.push({
- ...label,
- id: getIdFromGraphQLId(label.id),
- });
+ labels = [
+ ...this.localSelectedLabels,
+ {
+ ...label,
+ id: getIdFromGraphQLId(label.id),
+ },
+ ];
}
+ this.$emit('input', labels);
},
handleLabelClick(label) {
this.updateSelectedLabels(label);
@@ -129,46 +156,52 @@ export default {
setSearchKey(value) {
this.searchKey = value;
},
+ onDropdownAppear() {
+ this.isVisible = true;
+ this.$refs.searchInput.focusInput();
+ },
},
};
</script>
<template>
- <gl-dropdown-form class="labels-select-contents-list js-labels-list">
- <gl-search-box-by-type
- ref="searchInput"
- :value="searchKey"
- :disabled="labelsFetchInProgress"
- data-qa-selector="dropdown_input_field"
- data-testid="dropdown-input-field"
- @input="debouncedSearchKeyUpdate"
- />
- <div ref="labelsListContainer" data-testid="dropdown-content">
- <gl-loading-icon
- v-if="labelsFetchInProgress"
- class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full"
- size="md"
+ <gl-intersection-observer @appear="onDropdownAppear">
+ <gl-dropdown-form class="labels-select-contents-list js-labels-list">
+ <gl-search-box-by-type
+ ref="searchInput"
+ :value="searchKey"
+ :disabled="labelsFetchInProgress"
+ data-qa-selector="dropdown_input_field"
+ data-testid="dropdown-input-field"
+ @input="debouncedSearchKeyUpdate"
/>
- <template v-else>
- <gl-dropdown-item
- v-for="label in visibleLabels"
- :key="label.id"
- :is-checked="isLabelSelected(label)"
- :is-check-centered="true"
- :is-check-item="true"
- data-testid="labels-list"
- @click.native.capture.stop="handleLabelClick(label)"
- >
- <label-item :label="label" />
- </gl-dropdown-item>
- <gl-dropdown-item
- v-show="showNoMatchingResultsMessage"
- class="gl-p-3 gl-text-center"
- data-testid="no-results"
- >
- {{ __('No matching results') }}
- </gl-dropdown-item>
- </template>
- </div>
- </gl-dropdown-form>
+ <div ref="labelsListContainer" data-testid="dropdown-content">
+ <gl-loading-icon
+ v-if="labelsFetchInProgress"
+ class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3"
+ size="md"
+ />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="label in visibleLabels"
+ :key="label.id"
+ :is-checked="isLabelSelected(label)"
+ :is-check-centered="true"
+ :is-check-item="true"
+ data-testid="labels-list"
+ @click.native.capture.stop="handleLabelClick(label)"
+ >
+ <label-item :label="label" />
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-show="showNoMatchingResultsMessage"
+ class="gl-p-3 gl-text-center"
+ data-testid="no-results"
+ >
+ {{ __('No matching results') }}
+ </gl-dropdown-item>
+ </template>
+ </div>
+ </gl-dropdown-form>
+ </gl-intersection-observer>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
new file mode 100644
index 00000000000..a2e8579486f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
@@ -0,0 +1,15 @@
+query epicLabels($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ issuable: epic(iid: $iid) {
+ id
+ labels {
+ nodes {
+ id
+ title
+ color
+ description
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql
new file mode 100644
index 00000000000..acc9bcd2015
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql
@@ -0,0 +1,12 @@
+query groupLabels($fullPath: ID!, $searchTerm: String) {
+ workspace: group(fullPath: $fullPath) {
+ labels(searchTerm: $searchTerm, onlyGroupLabels: true) {
+ nodes {
+ id
+ title
+ color
+ description
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index 3c834770563..6bd43da2203 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -1,21 +1,18 @@
<script>
-import Vue from 'vue';
-import Vuex from 'vuex';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { labelsQueries } from '~/sidebar/constants';
import { DropdownVariant } from './constants';
import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
-import issueLabelsQuery from './graphql/issue_labels.query.graphql';
import {
isDropdownVariantSidebar,
isDropdownVariantStandalone,
isDropdownVariantEmbedded,
} from './utils';
-Vue.use(Vuex);
-
export default {
components: {
DropdownValue,
@@ -23,8 +20,21 @@ export default {
DropdownValueCollapsed,
SidebarEditableItem,
},
- inject: ['iid', 'projectPath', 'allowLabelEdit'],
+ inject: {
+ allowLabelEdit: {
+ default: false,
+ },
+ },
props: {
+ iid: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
allowLabelRemove: {
type: Boolean,
required: false,
@@ -90,43 +100,60 @@ export default {
required: false,
default: false,
},
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
contentIsOnViewport: true,
- issueLabels: [],
+ issuableLabels: [],
};
},
+ computed: {
+ isLoading() {
+ return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading;
+ },
+ },
apollo: {
- issueLabels: {
- query: issueLabelsQuery,
+ issuableLabels: {
+ query() {
+ return labelsQueries[this.issuableType].issuableQuery;
+ },
+ skip() {
+ return !isDropdownVariantSidebar(this.variant);
+ },
variables() {
return {
iid: this.iid,
- fullPath: this.projectPath,
+ fullPath: this.fullPath,
};
},
update(data) {
return data.workspace?.issuable?.labels.nodes || [];
},
+ error() {
+ createFlash({ message: __('Error fetching labels.') });
+ },
},
},
methods: {
handleDropdownClose(labels) {
- if (labels.length) this.$emit('updateSelectedLabels', labels);
- this.$emit('onDropdownClose');
+ this.$emit('updateSelectedLabels', labels);
+ this.collapseEditableItem();
},
- collapseDropdown() {
- this.$refs.editable.collapse();
+ collapseEditableItem() {
+ this.$refs.editable?.collapse();
},
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
- showDropdown() {
- this.$nextTick(() => {
- this.$refs.dropdownContents.showDropdown();
- });
- },
isDropdownVariantSidebar,
isDropdownVariantStandalone,
isDropdownVariantEmbedded,
@@ -145,20 +172,19 @@ export default {
<template v-if="isDropdownVariantSidebar(variant)">
<dropdown-value-collapsed
ref="dropdownButtonCollapsed"
- :labels="issueLabels"
+ :labels="issuableLabels"
@onValueClick="handleCollapsedValueClick"
/>
<sidebar-editable-item
ref="editable"
:title="__('Labels')"
- :loading="labelsSelectInProgress"
+ :loading="isLoading"
:can-edit="allowLabelEdit"
- @open="showDropdown"
>
<template #collapsed>
<dropdown-value
:disable-labels="labelsSelectInProgress"
- :selected-labels="issueLabels"
+ :selected-labels="issuableLabels"
:allow-label-remove="allowLabelRemove"
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
@@ -170,7 +196,7 @@ export default {
<template #default="{ edit }">
<dropdown-value
:disable-labels="labelsSelectInProgress"
- :selected-labels="issueLabels"
+ :selected-labels="issuableLabels"
:allow-label-remove="allowLabelRemove"
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
@@ -180,8 +206,6 @@ export default {
<slot></slot>
</dropdown-value>
<dropdown-contents
- v-if="edit"
- ref="dropdownContents"
:dropdown-button-text="dropdownButtonText"
:allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle"
@@ -190,11 +214,30 @@ export default {
:labels-create-title="labelsCreateTitle"
:selected-labels="selectedLabels"
:variant="variant"
- @closeDropdown="collapseDropdown"
+ :issuable-type="issuableType"
+ :is-visible="edit"
+ :full-path="fullPath"
+ :attr-workspace-path="attrWorkspacePath"
@setLabels="handleDropdownClose"
+ @closeDropdown="collapseEditableItem"
/>
</template>
</sidebar-editable-item>
</template>
+ <dropdown-contents
+ v-else
+ ref="dropdownContents"
+ :allow-multiselect="allowMultiselect"
+ :dropdown-button-text="dropdownButtonText"
+ :labels-list-title="labelsListTitle"
+ :footer-create-label-title="footerCreateLabelTitle"
+ :footer-manage-label-title="footerManageLabelTitle"
+ :labels-create-title="labelsCreateTitle"
+ :selected-labels="selectedLabels"
+ :variant="variant"
+ :issuable-type="issuableType"
+ :full-path="fullPath"
+ @setLabels="handleDropdownClose"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
index d2afc02233e..294e5bd9f90 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
@@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue';
export default {
component: TodoButton,
- title: 'vue_shared/components/todo_toggle/todo_button',
+ title: 'vue_shared/components/sidebar/todo_toggle/todo_button',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index afb1ea702fa..0a7a22ed3a8 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -45,7 +45,7 @@ export default {
data() {
return {
dragCounter: 0,
- isDragDataValid: false,
+ isDragDataValid: true,
};
},
computed: {
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js
new file mode 100644
index 00000000000..256db2ea1ce
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js
@@ -0,0 +1,5 @@
+// Types of obstacles to user deletion
+export const OBSTACLE_TYPES = Object.freeze({
+ oncallSchedules: 'ONCALL_SCHEDULE',
+ escalationPolicies: 'ESCALATION_POLICY',
+});
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
new file mode 100644
index 00000000000..d2030c14029
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
@@ -0,0 +1,37 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+import { OBSTACLE_TYPES } from './constants';
+import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue';
+
+export default {
+ component: UserDeletionObstaclesList,
+ title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { UserDeletionObstaclesList },
+ props: Object.keys(argTypes),
+ template: '<user-deletion-obstacles-list v-bind="$props" v-on="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ obstacles: [
+ {
+ type: OBSTACLE_TYPES.oncallSchedules,
+ name: 'APAC',
+ url: 'https://domain.com/group/main-application/oncall_schedules',
+ projectName: 'main-application',
+ projectUrl: 'https://domain.com/group/main-application',
+ },
+ {
+ type: OBSTACLE_TYPES.escalationPolicies,
+ name: 'Engineering On-call',
+ url: 'https://domain.com/group/microservice-backend/escalation_policies',
+ projectName: 'Microservice Backend',
+ projectUrl: 'https://domain.com/group/microservice-backend',
+ },
+ ],
+ userName: 'Thomspon Smith',
+ isCurrentUser: false,
+};
diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue
index e37a663ace3..1eea660d527 100644
--- a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue
@@ -1,6 +1,16 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
+import { OBSTACLE_TYPES } from './constants';
+
+const OBSTACLE_TEXT = {
+ [OBSTACLE_TYPES.oncallSchedules]: s__(
+ 'OnCallSchedules|On-call schedule %{obstacle} in Project %{project}',
+ ),
+ [OBSTACLE_TYPES.escalationPolicies]: s__(
+ 'EscalationPolicies|Escalation policy %{obstacle} in Project %{project}',
+ ),
+};
export default {
components: {
@@ -8,7 +18,7 @@ export default {
GlLink,
},
props: {
- schedules: {
+ obstacles: {
type: Array,
required: true,
},
@@ -45,6 +55,15 @@ export default {
);
},
},
+ methods: {
+ textForObstacle(obstacle) {
+ return OBSTACLE_TEXT[obstacle.type];
+ },
+ urlForObstacle(obstacle) {
+ // Fallback to scheduleUrl for backwards compatibility
+ return obstacle.url || obstacle.scheduleUrl;
+ },
+ },
};
</script>
@@ -52,17 +71,15 @@ export default {
<div>
<p data-testid="title">{{ title }}</p>
- <ul data-testid="schedules-list">
- <li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`">
- <gl-sprintf
- :message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')"
- >
- <template #schedule>
- <gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link>
+ <ul data-testid="obstacles-list">
+ <li v-for="(obstacle, index) in obstacles" :key="`${obstacle.name}-${index}`">
+ <gl-sprintf :message="textForObstacle(obstacle)">
+ <template #obstacle>
+ <gl-link :href="urlForObstacle(obstacle)" target="_blank">{{ obstacle.name }}</gl-link>
</template>
<template #project>
- <gl-link :href="schedule.projectUrl" target="_blank">{{
- schedule.projectName
+ <gl-link :href="obstacle.projectUrl" target="_blank">{{
+ obstacle.projectName
}}</gl-link>
</template>
</gl-sprintf>
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js
new file mode 100644
index 00000000000..502302a1ef2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js
@@ -0,0 +1,19 @@
+import { OBSTACLE_TYPES } from './constants';
+
+const addTypeToObstacles = (obstacles, type) => {
+ if (!obstacles) return [];
+
+ return obstacles?.map((obstacle) => ({ type, ...obstacle }));
+};
+
+// For use with user objects formatted via internal REST API.
+// If the removal/deletion of a user could cause critical
+// problems, return a single array containing all affected
+// associations including their type.
+export const parseUserDeletionObstacles = (user) => {
+ if (!user) return [];
+
+ return Object.keys(OBSTACLE_TYPES).flatMap((type) => {
+ return addTypeToObstacles(user[type], OBSTACLE_TYPES[type]);
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 74616763f8f..05e0c3b0be3 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -93,19 +93,27 @@ export default {
</div>
<div class="gl-text-gray-500">
<div v-if="user.bio" class="gl-display-flex gl-mb-2">
- <gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" />
+ <gl-icon name="profile" class="gl-flex-shrink-0" />
<span ref="bio" class="gl-ml-2 gl-overflow-hidden">{{ user.bio }}</span>
</div>
<div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
- <gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" />
+ <gl-icon name="work" class="gl-flex-shrink-0" />
<span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
</div>
+ <div v-if="user.location" class="gl-display-flex gl-mb-2">
+ <gl-icon name="location" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.location }}</span>
+ </div>
+ <div
+ v-if="user.localTime && !user.bot"
+ class="gl-display-flex gl-mb-2"
+ data-testid="user-popover-local-time"
+ >
+ <gl-icon name="clock" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.localTime }}</span>
+ </div>
</div>
- <div v-if="user.location" class="js-location gl-text-gray-500 gl-display-flex">
- <gl-icon name="location" class="gl-text-gray-400 flex-shrink-0" />
- <span class="gl-ml-2">{{ user.location }}</span>
- </div>
- <div v-if="statusHtml" class="js-user-status gl-mt-3">
+ <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
<div v-if="user.bot" class="gl-text-blue-500">
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index df0981aea7a..6da2d39a95a 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -92,7 +92,10 @@ export default {
const handleOptions = this.needsToFork
? {
href: '#modal-confirm-fork-edit',
- handle: () => this.showModal('#modal-confirm-fork-edit'),
+ handle: () => {
+ this.$emit('edit', 'simple');
+ this.showModal('#modal-confirm-fork-edit');
+ },
}
: { href: this.editUrl };
@@ -128,7 +131,10 @@ export default {
const handleOptions = this.needsToFork
? {
href: '#modal-confirm-fork-webide',
- handle: () => this.showModal('#modal-confirm-fork-webide'),
+ handle: () => {
+ this.$emit('edit', 'ide');
+ this.showModal('#modal-confirm-fork-webide');
+ },
}
: { href: this.webIdeUrl };
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
index 692f2769b88..779b04dc2bd 100644
--- a/app/assets/javascripts/vue_shared/directives/validation.js
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -1,4 +1,3 @@
-import { merge } from 'lodash';
import { s__ } from '~/locale';
/**
@@ -21,8 +20,15 @@ const defaultFeedbackMap = {
},
};
-const getFeedbackForElement = (feedbackMap, el) =>
- Object.values(feedbackMap).find((f) => f.isInvalid(el))?.message || el.validationMessage;
+const getFeedbackForElement = (feedbackMap, el) => {
+ const field = Object.values(feedbackMap).find((f) => f.isInvalid(el));
+ let elMessage = null;
+ if (field) {
+ elMessage = el.getAttribute('validation-message');
+ }
+
+ return field?.message || elMessage || el.validationMessage;
+};
const focusFirstInvalidInput = (e) => {
const { target: formEl } = e;
@@ -68,6 +74,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
/**
* Takes an object that allows to add or change custom feedback messages.
+ * See possibilities here: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
*
* The passed in object will be merged with the built-in feedback
* so it is possible to override a built-in message.
@@ -75,7 +82,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
* @example
* validate({
* tooLong: {
- * check: el => el.validity.tooLong === true,
+ * isInvalid: el => el.validity.tooLong === true,
* message: 'Your custom feedback'
* }
* })
@@ -91,7 +98,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
* @returns {{ inserted: function, update: function }} validateDirective
*/
export default function initValidation(customFeedbackMap = {}) {
- const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap);
+ const feedbackMap = { ...defaultFeedbackMap, ...customFeedbackMap };
const elDataMap = new WeakMap();
return {
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index 0ff858e6afc..42272c222fc 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -100,6 +100,7 @@ export default {
:loading="isLoading"
:variant="variant"
:category="category"
+ :data-qa-selector="`${feature.type}_mr_button`"
@click="mutate"
>{{ $options.i18n.buttonLabel }}</gl-button
>
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index ad40ea6a964..12f2bc71505 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -50,7 +50,7 @@ export default {
required: false,
default: '',
},
- secretScanningComparisonPath: {
+ secretDetectionComparisonPath: {
type: String,
required: false,
default: '',
@@ -149,8 +149,8 @@ export default {
this.canShowCounts = true;
}
- if (this.secretScanningComparisonPath && this.hasSecretDetectionReports) {
- this.setSecretDetectionDiffEndpoint(this.secretScanningComparisonPath);
+ if (this.secretDetectionComparisonPath && this.hasSecretDetectionReports) {
+ this.setSecretDetectionDiffEndpoint(this.secretDetectionComparisonPath);
this.fetchSecretDetectionDiff();
this.canShowCounts = true;
}
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
index 4f92e181f9f..62a51abe038 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
@@ -1,3 +1,4 @@
+import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
import { fetchDiffData } from '../../utils';
import * as types from './mutation_types';
@@ -14,7 +15,7 @@ export const receiveDiffError = ({ commit }, response) =>
export const fetchDiff = ({ state, rootState, dispatch }) => {
dispatch('requestDiff');
- return fetchDiffData(rootState, state.paths.diffEndpoint, 'sast')
+ return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SAST)
.then((data) => {
dispatch('receiveDiffSuccess', data);
})
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
index e3ae5435f5d..722dcce3075 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
@@ -1,3 +1,4 @@
+import { REPORT_TYPE_SECRET_DETECTION } from '~/vue_shared/security_reports/constants';
import { fetchDiffData } from '../../utils';
import * as types from './mutation_types';
@@ -14,7 +15,7 @@ export const receiveDiffError = ({ commit }, response) =>
export const fetchDiff = ({ state, rootState, dispatch }) => {
dispatch('requestDiff');
- return fetchDiffData(rootState, state.paths.diffEndpoint, 'secret_detection')
+ return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SECRET_DETECTION)
.then((data) => {
dispatch('receiveDiffSuccess', data);
})
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index fa5ab590232..8f3b5b3b7cc 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,5 +1,4 @@
@import './pages/branches';
-@import './pages/ci_projects';
@import './pages/clusters';
@import './pages/commits';
@import './pages/deploy_keys';
@@ -10,7 +9,6 @@
@import './pages/groups';
@import './pages/help';
@import './pages/issuable';
-@import './pages/issues/issue_count_badge';
@import './pages/issues';
@import './pages/labels';
@import './pages/login';
diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss
index dae0cd72a8f..f1d7df8c5ed 100644
--- a/app/assets/stylesheets/application_dark.scss
+++ b/app/assets/stylesheets/application_dark.scss
@@ -2,74 +2,4 @@
@import './application';
-@import './themes/theme_helper';
-
-body.gl-dark {
- @include gitlab-theme(
- $gray-900,
- $gray-400,
- $gray-500,
- $gray-800,
- $gray-900,
- $white
- );
-
- .logo-text svg {
- fill: var(--gl-text-color);
- }
-
- .navbar-gitlab {
- background-color: var(--gray-50);
- box-shadow: 0 1px 0 0 var(--gray-100);
-
- .navbar-sub-nav,
- .navbar-nav {
- li {
- > a:hover,
- > a:focus,
- > button:hover,
- > button:focus {
- color: var(--gl-text-color);
- background-color: var(--gray-200);
- }
- }
-
- li.active,
- li.dropdown.show {
- > a,
- > button {
- color: var(--gl-text-color);
- background-color: var(--gray-200);
- }
- }
- }
-
- .header-search {
- background-color: var(--gray-100) !important;
- box-shadow: inset 0 0 0 1px var(--border-color) !important;
-
- &:active,
- &:hover {
- background-color: var(--gray-100) !important;
- box-shadow: inset 0 0 0 1px var(--blue-200) !important;
- }
- }
-
- .search {
- form {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--border-color);
-
- &:active,
- &:hover {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--blue-200);
- }
- }
- }
- }
-
- .md :not(pre.code) > code {
- background-color: $gray-200;
- }
-}
+@import './themes/dark_mode_overrides';
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index c4f292dd05d..27ddff181c5 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -112,7 +112,7 @@ code {
border-radius: $border-radius-default;
.code > &,
- .build-trace & {
+ .build-log & {
background-color: inherit;
padding: unset;
}
diff --git a/app/assets/stylesheets/components/batch_comments/review_bar.scss b/app/assets/stylesheets/components/batch_comments/review_bar.scss
index bcd06974813..6f5c5c5a080 100644
--- a/app/assets/stylesheets/components/batch_comments/review_bar.scss
+++ b/app/assets/stylesheets/components/batch_comments/review_bar.scss
@@ -38,59 +38,6 @@
margin: 0 auto;
}
-.review-preview-dropdown {
- .review-preview-item.menu-item {
- display: flex;
- flex-wrap: wrap;
- padding: 8px 16px;
- cursor: pointer;
-
- &:not(.is-last) {
- border-bottom: 1px solid $list-border;
- }
- }
-
- .dropdown-menu {
- top: auto;
- bottom: 36px;
-
- &.show {
- max-height: 400px;
-
- @include media-breakpoint-down(xs) {
- width: calc(100vw - 32px);
- }
- }
- }
-
- .dropdown-content {
- max-height: 300px;
- }
-
- .dropdown-title {
- padding: $grid-size 25px $gl-padding;
- margin-bottom: 0;
- }
-
- .dropdown-footer {
- margin-top: 0;
- }
-
- .dropdown-menu-close {
- top: 6px;
- }
-}
-
-.review-preview-dropdown-toggle {
- svg.s16 {
- width: 15px;
- height: 15px;
- margin-top: -1px;
- top: 3px;
- margin-left: 4px;
- }
-}
-
.review-preview-item-header {
display: flex;
align-items: center;
diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss
index a013d971efb..7f498b79d33 100644
--- a/app/assets/stylesheets/components/content_editor.scss
+++ b/app/assets/stylesheets/components/content_editor.scss
@@ -1,9 +1,13 @@
.ProseMirror {
+ max-height: 55vh;
+ overflow-y: auto;
+
td,
th,
li,
dd,
- dt {
+ dt,
+ summary {
:first-child {
margin-bottom: 0 !important;
}
@@ -37,6 +41,7 @@
}
}
+
.dl-content {
width: 100%;
@@ -50,6 +55,38 @@
}
}
}
+
+ .details-toggle-icon {
+ cursor: pointer;
+ z-index: 1;
+
+ &::before {
+ content: 'â–¶';
+ display: inline-block;
+ width: $gl-spacing-scale-4;
+ }
+
+ &.is-open::before {
+ content: 'â–¼';
+ }
+ }
+
+ .details-content {
+ width: calc(100% - #{$gl-spacing-scale-4});
+
+ > li {
+ list-style-type: none;
+ margin-left: $gl-spacing-scale-2;
+ }
+
+ > :not(:first-child) {
+ display: none;
+ }
+
+ &.is-open > :not(:first-child) {
+ display: inherit;
+ }
+ }
}
.table-creator-grid-item {
@@ -70,3 +107,17 @@
@include gl-white-space-nowrap;
}
+
+
+.content-editor-color-chip::after {
+ content: ' ';
+ display: inline-block;
+ align-items: center;
+ width: 11px;
+ height: 11px;
+ border-radius: 3px;
+ margin-left: 4px;
+ margin-top: -2px;
+ border: 1px solid $black-transparent;
+ background-color: var(--gl-color-chip-color);
+}
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
index 579a86a94a4..a3cbdb9ae86 100644
--- a/app/assets/stylesheets/components/design_management/design.scss
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -70,11 +70,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
}
}
-.design-presentation-wrapper {
- top: 0;
- left: 0;
-}
-
.design-scaler-wrapper {
bottom: 0;
left: 50%;
diff --git a/app/assets/stylesheets/components/design_management/design_version_dropdown.scss b/app/assets/stylesheets/components/design_management/design_version_dropdown.scss
deleted file mode 100644
index f79d672e238..00000000000
--- a/app/assets/stylesheets/components/design_management/design_version_dropdown.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.design-version-dropdown > button {
- background: inherit;
-}
diff --git a/app/assets/stylesheets/components/project_list_item.scss b/app/assets/stylesheets/components/project_list_item.scss
deleted file mode 100644
index 8e7c2c4398c..00000000000
--- a/app/assets/stylesheets/components/project_list_item.scss
+++ /dev/null
@@ -1,24 +0,0 @@
-.project-list-item {
- &:not(:disabled):not(.disabled) {
- &:focus,
- &:active,
- &:focus:active {
- outline: none;
- box-shadow: none;
- }
- }
-}
-
-// When housed inside a modal, the edge of each item
-// should extend to the edge of the modal.
-.modal-body {
- .project-list-item {
- border-radius: 0;
- margin-left: -$gl-padding;
- margin-right: -$gl-padding;
-
- .project-namespace-name-container {
- overflow: hidden;
- }
- }
-}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 804cc205279..06a8694eb3d 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -9,7 +9,6 @@
@import 'framework/animations';
@import 'framework/vue_transitions';
-@import 'framework/banner';
@import 'framework/blocks';
@import 'framework/buttons';
@import 'framework/badges';
diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss
deleted file mode 100644
index 71bbab2065d..00000000000
--- a/app/assets/stylesheets/framework/banner.scss
+++ /dev/null
@@ -1,40 +0,0 @@
-.banner-callout {
- display: flex;
- position: relative;
- align-items: start;
-
- .banner-close {
- position: absolute;
- top: 10px;
- right: 10px;
- opacity: 1;
-
- .dismiss-icon {
- color: $gl-text-color;
- font-size: $gl-font-size;
- }
- }
-
- .banner-graphic {
- margin: 0 $gl-padding $gl-padding 0;
- }
-
- &.banner-non-empty-state {
- border-bottom: 1px solid $border-color;
- }
-
- @include media-breakpoint-down(xs) {
- justify-content: center;
- flex-direction: column;
- align-items: center;
-
- .banner-title,
- .banner-buttons {
- text-align: center;
- }
-
- .banner-graphic {
- margin-left: $gl-padding;
- }
- }
-}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index a0682eabf01..549289450a4 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -1,9 +1,3 @@
-.centered-light-block {
- text-align: center;
- color: $gl-text-color;
- margin: 20px;
-}
-
.nothing-here-block {
text-align: center;
padding: 16px;
@@ -83,22 +77,6 @@
> p:last-child {
margin-bottom: 0;
}
-
- .block-controls {
- display: flex;
- justify-content: flex-end;
- flex: 1;
-
- .control {
- float: left;
- margin-left: 10px;
- }
- }
-
- &.build-content {
- background-color: $white;
- border-top: 0;
- }
}
.sub-header-block {
@@ -169,31 +147,6 @@
}
}
- &.groups-cover-block {
- background: $white;
- border-bottom: 1px solid $border-color;
- text-align: left;
- padding: 24px 0;
-
- .group-info {
- .cover-title {
- margin-top: 9px;
- }
-
- p {
- margin-bottom: 0;
- }
- }
-
- @include media-breakpoint-down(xs) {
- text-align: center;
-
- .avatar {
- float: none;
- }
- }
- }
-
&.user-cover-block {
padding: 24px 0 0;
@@ -214,19 +167,6 @@
margin-right: auto;
}
}
-
- .group-info {
- h1 {
- display: inline;
- font-weight: $gl-font-weight-normal;
- font-size: 24px;
- color: $gl-text-color;
- }
- }
-}
-
-.block-connector {
- margin-top: -1px;
}
.content-block {
@@ -322,7 +262,7 @@
display: inline-block;
}
- .btn {
+ .btn:not(.split-content-button):not(.dropdown-toggle-split) {
margin: $gl-padding-8 $gl-padding-4;
@include media-breakpoint-down(xs) {
@@ -332,10 +272,6 @@
}
}
-.flex-right {
- margin-left: auto;
-}
-
.code-block {
white-space: pre;
overflow-x: auto;
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index ceccec8c5cb..e458dfd5316 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -247,13 +247,6 @@
}
}
-.btn-terminal {
- svg {
- height: 14px;
- width: $default-icon-size;
- }
-}
-
.btn-lg {
padding: 12px 20px;
}
@@ -281,12 +274,6 @@
}
}
-.btn-align-content {
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
.btn-group {
&.btn-grouped {
@include btn-with-margin;
@@ -347,16 +334,6 @@
}
}
-.btn-static {
- background-color: $gray-light !important;
- border: 1px solid $border-gray-normal;
- cursor: default;
-
- &:active {
- box-shadow: inset 0 0 0 $white;
- }
-}
-
.btn-inverted {
&-secondary {
@include btn-outline($white, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800);
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index a7ce19ffc69..354d2737894 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -175,10 +175,6 @@ p.time {
text-shadow: none;
}
-.thin-area {
- height: 150px;
-}
-
// Fix issue with notes & lists creating a bunch of bottom borders.
li.note {
img { max-width: 100%; }
@@ -298,10 +294,6 @@ img.emoji {
margin: 0;
}
-.space-right {
- margin-right: 10px;
-}
-
.alert {
margin-bottom: $gl-padding;
}
@@ -363,14 +355,6 @@ img.emoji {
}
}
-.outline-0 {
- outline: 0;
-
- &:focus {
- outline: 0;
- }
-}
-
/** COMMON CLASSES **/
/**
🚨 Do not use these classes — they are deprecated and being removed. 🚨
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index f5002a342b6..fa1892903a3 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -140,7 +140,7 @@
@include gl-border-none;
.avatar.s32 {
- @extend .rect-avatar.s32;
+ border-radius: $border-radius-default;
box-shadow: $avatar-box-shadow;
}
}
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index 568182ad796..23dc16b7e7f 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -1007,6 +1007,27 @@ table.code {
}
}
+// Notes tweaks for the Changes tab ONLY
+.diff-tr {
+ .timeline-discussion-body {
+ clear: left;
+
+ .note-body {
+ margin-top: 0 !important;
+ }
+ }
+
+ .timeline-entry img.avatar {
+ margin-top: -2px;
+ margin-right: $gl-padding-8;
+ }
+
+ // tiny adjustment to vertical align with the note header text
+ .discussion-collapsible .timeline-icon {
+ padding-top: 2px;
+ }
+}
+
.files:not([data-can-create-note]) .frame {
cursor: auto;
}
@@ -1125,7 +1146,7 @@ table.code {
}
.discussion-collapsible {
- margin: 0 $gl-padding $gl-padding 71px;
+ margin: 0 $gl-padding $gl-padding;
.notes {
border-radius: $border-radius-default;
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index b05fbfaae6c..7f960e3da51 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -76,6 +76,7 @@
}
.dropdown-toggle,
+.dropdown-menu-toggle,
.confidential-merge-request-fork-group .dropdown-toggle {
padding: 6px 8px 6px 10px;
background-color: $white;
@@ -131,7 +132,6 @@
// This is double classed to solve a specificity issue with the gitlab ui buttons
.dropdown-menu-toggle.dropdown-menu-toggle {
- @extend .dropdown-toggle;
justify-content: flex-start;
overflow: hidden;
padding-right: 25px;
@@ -425,21 +425,10 @@
}
}
-.dropdown-menu-drop-up {
- top: auto;
- bottom: 100%;
-}
-
.dropdown-menu-large {
width: 340px;
}
-.dropdown-menu-no-wrap {
- a {
- white-space: normal;
- }
-}
-
.dropdown-menu-full-width {
width: 100%;
}
@@ -662,13 +651,6 @@
padding-right: 10px;
}
-.dropdown-due-date-footer {
- padding-top: 0;
- margin-left: 10px;
- margin-right: 10px;
- border-top: 0;
-}
-
.dropdown-footer-list {
font-size: 14px;
@@ -742,24 +724,6 @@
}
}
-.dropdown-menu-due-date {
- .dropdown-content {
- max-height: 230px;
- }
-
- .pika-single {
- position: relative !important;
- top: 0 !important;
- border: 0;
- box-shadow: none;
- }
-
- .pika-lendar {
- margin-top: -5px;
- margin-bottom: 0;
- }
-}
-
.dropdown-menu-inner-title {
display: block;
color: $gl-text-color;
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index c366bf80093..2a46e50f0da 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -196,14 +196,6 @@ label {
}
}
-@include media-breakpoint-down(xs) {
- .remember-me {
- .remember-me-checkbox {
- margin-top: 0;
- }
- }
-}
-
.input-icon-wrapper,
.select-wrapper {
position: relative;
diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss
index d48a5116677..f77f64f1d76 100644
--- a/app/assets/stylesheets/framework/job_log.scss
+++ b/app/assets/stylesheets/framework/job_log.scss
@@ -5,10 +5,10 @@
font-size: 13px;
word-break: break-all;
word-wrap: break-word;
- color: color-yiq($builds-trace-bg);
+ color: color-yiq($builds-log-bg);
border-radius: $border-radius-small;
min-height: 42px;
- background-color: $builds-trace-bg;
+ background-color: $builds-log-bg;
}
.log-line {
@@ -42,10 +42,6 @@
}
}
-.log-duration-badge {
- background: $gray-300;
-}
-
.loader-animation {
@include build-loader-animation;
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 7315bce1ed9..ef294635641 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -153,6 +153,10 @@
vertical-align: middle;
margin-bottom: 3px;
}
+
+ .dropdown-chevron {
+ margin-bottom: 0;
+ }
}
@include media-breakpoint-down(xs) {
diff --git a/app/assets/stylesheets/framework/media_object.scss b/app/assets/stylesheets/framework/media_object.scss
index 89c561479cc..b573052c14a 100644
--- a/app/assets/stylesheets/framework/media_object.scss
+++ b/app/assets/stylesheets/framework/media_object.scss
@@ -6,7 +6,3 @@
.media-body {
flex: 1;
}
-
-.media-body-wrap {
- flex-grow: 1;
-}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index fcf86680bb3..33f7aa4dba1 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -239,7 +239,7 @@
/*
* Mixin that handles the container for the job logs (CI/CD and kubernetes pod logs)
*/
-@mixin build-trace($background: $black) {
+@mixin build-log($background: $black) {
background: $background;
color: $gray-darkest;
white-space: pre;
@@ -253,13 +253,13 @@
display: block;
}
- &.build-trace-rounded {
+ &.build-log-rounded {
border-radius: $gl-border-radius-base;
}
}
// Used in EE for Web Terminal
-@mixin build-trace-bar($height) {
+@mixin build-log-bar($height) {
height: $height;
min-height: $height;
background: var(--gray-50, $gray-50);
@@ -268,8 +268,8 @@
padding: $grid-size;
}
-@mixin build-trace-top-bar($height) {
- @include build-trace-bar($height);
+@mixin build-log-top-bar($height) {
+ @include build-log-bar($height);
position: -webkit-sticky;
position: sticky;
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 48a18e0d145..c9b17f5d5c4 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -1,13 +1,3 @@
-.modal-xl {
- max-width: 98%;
-}
-
-.modal-1040 {
- @include media-breakpoint-up(xl) {
- max-width: 1040px;
- }
-}
-
.modal-header {
background-color: $modal-body-bg;
@@ -111,30 +101,3 @@ body.modal-open {
}
}
}
-
-.recaptcha-modal .recaptcha-form {
- display: inline-block;
-
- .recaptcha {
- margin: 0;
- }
-}
-
-.issues-import-modal,
-.issuable-export-modal {
- .modal-body {
- padding: 0;
-
- .modal-subheader {
- justify-content: flex-start;
- align-items: center;
- border-bottom: 1px solid $modal-border-color;
- padding: 14px;
- }
-
- .modal-text {
- padding: $gl-padding-24 $gl-padding;
- min-height: $modal-body-height;
- }
- }
-}
diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss
index d9c93fed1c4..e5b2b853363 100644
--- a/app/assets/stylesheets/framework/panels.scss
+++ b/app/assets/stylesheets/framework/panels.scss
@@ -39,10 +39,6 @@
}
}
-.card-empty-heading {
- border-bottom: 0;
-}
-
.card-body {
padding: $gl-padding;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 06eebb95438..685f1f413e6 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -1,6 +1,6 @@
// For tabbed navigation links, scrolling tabs, etc. For all top/main navigation,
// please check nav.scss
-.nav-links:not(.quick-links) {
+.nav-links {
display: flex;
padding: 0;
margin: 0;
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index d8ce6826fc1..900cf9fa4db 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -50,18 +50,6 @@
}
}
-.namespace-result {
- .namespace-kind {
- color: $gray-300;
- font-weight: $gl-font-weight-normal;
- }
-
- .namespace-path {
- margin-left: 10px;
- font-weight: $gl-font-weight-bold;
- }
-}
-
.ajax-users-dropdown {
min-width: 250px !important;
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 6b3201ba2b0..6c7fc25f2d9 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -16,21 +16,6 @@
transition: padding $sidebar-transition-duration;
}
-.nav-header-btn {
- padding: 10px $gl-sidebar-padding;
- color: inherit;
- transition-duration: 0.3s;
- position: absolute;
- top: 0;
- cursor: pointer;
-
- &:hover,
- &:focus {
- color: $white;
- text-decoration: none;
- }
-}
-
.right-sidebar-collapsed {
padding-right: 0;
diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss
index 47184804353..c59e70c80df 100644
--- a/app/assets/stylesheets/framework/snippets.scss
+++ b/app/assets/stylesheets/framework/snippets.scss
@@ -4,11 +4,6 @@
font-weight: $gl-font-weight-bold;
}
- .snippet-filename {
- color: $gl-text-color-secondary;
- font-weight: normal;
- }
-
.snippet-info {
color: $gl-text-color-secondary;
}
diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss
index 25868061d04..953c42219a9 100644
--- a/app/assets/stylesheets/framework/sortable.scss
+++ b/app/assets/stylesheets/framework/sortable.scss
@@ -36,61 +36,6 @@
}
}
-.related-issues-list-item {
- .card-body,
- .issuable-info-container {
- padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding;
-
- .block-truncated {
- padding: $gl-padding-8 0;
- line-height: $gl-btn-line-height;
- }
-
- @include media-breakpoint-down(md) {
- padding-left: $gl-padding;
-
- .block-truncated {
- flex-direction: column-reverse;
- padding: $gl-padding-4 0;
-
- .text-secondary {
- margin-top: $gl-padding-4;
- }
-
- .issue-token-title-text {
- display: block;
- }
- }
-
- .issue-item-remove-button {
- align-self: baseline;
- }
- }
-
- @include media-breakpoint-only(md) {
- .block-truncated .issue-token-title-text {
- white-space: nowrap;
- }
-
- .issue-item-remove-button {
- align-self: center;
- }
- }
-
- @include media-breakpoint-down(sm) {
- padding-left: $gl-padding-8;
-
- .block-truncated .issue-token-title-text {
- white-space: normal;
- }
- }
- }
-
- &.is-dragging {
- padding: 0;
- }
-}
-
.is-dragging {
// Important because plugin sets inline CSS
opacity: 1 !important;
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 92405c00c5e..c6bc8fa0eac 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -23,6 +23,7 @@ table {
@include gl-text-gray-500;
}
+ .md &:not(.code),
&.table {
margin-bottom: $gl-padding;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index aeb3bb2286f..cb36c4e5767 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -75,6 +75,15 @@
details {
margin-bottom: $gl-padding;
+
+ > *:not(summary) {
+ margin-left: $gl-spacing-scale-5;
+ }
+ }
+
+ summary > * {
+ display: inline-block;
+ margin-bottom: 0;
}
// Single code lines should wrap
@@ -160,8 +169,6 @@
}
table:not(.code) {
- @extend .table;
- @extend .table-bordered;
margin: 16px 0;
color: $gl-text-color;
border: 0;
@@ -172,9 +179,11 @@
tbody {
background-color: $white;
- td {
- border-color: $gray-100;
- }
+ }
+
+ td,
+ th {
+ border: 1px solid $border-color;
}
tr {
@@ -478,6 +487,7 @@
font-size: larger;
}
+ figcaption,
.small {
font-size: smaller;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 099dfa28b9f..026aeeb1e8e 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -697,7 +697,7 @@ $blame-blue: #254e77;
/*
* Builds
*/
-$builds-trace-bg: #111;
+$builds-log-bg: #111;
$job-log-highlight-height: 18px;
$job-log-line-padding: 55px;
$job-line-number-width: 50px;
@@ -759,7 +759,6 @@ $help-shortcut-header-color: #333;
*/
$issues-today-bg: #f3fff2 !default;
$issues-today-border: #e1e8d5 !default;
-$compare-display-color: #888;
/*
* Label
diff --git a/app/assets/stylesheets/framework/zen.scss b/app/assets/stylesheets/framework/zen.scss
index 62abf4a7683..10df532e334 100644
--- a/app/assets/stylesheets/framework/zen.scss
+++ b/app/assets/stylesheets/framework/zen.scss
@@ -40,15 +40,6 @@
border: 0;
}
-.zen-control-full {
- color: $gl-text-color-secondary;
-
- &:hover {
- color: $blue-600;
- text-decoration: none;
- }
-}
-
.zen-control-leave {
display: none;
color: $gl-text-color;
diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss
index 48b8a7230b1..bbc47c5cd5d 100644
--- a/app/assets/stylesheets/page_bundles/_ide_mixins.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss
@@ -4,7 +4,7 @@
height: 100%;
.top-bar {
- @include build-trace-bar(35px);
+ @include build-log-bar(35px);
top: 0;
font-size: 12px;
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
index 7336d555f79..25a565ce2ba 100644
--- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -44,7 +44,7 @@
background-color: var(--ide-background, $badge-bg);
}
- .nav-links:not(.quick-links) li:not(.md-header-toolbar) a,
+ .nav-links li:not(.md-header-toolbar) a,
.gl-tabs-nav li a,
.dropdown-menu-inner-content,
.file-row .file-row-icon svg,
@@ -52,7 +52,7 @@
color: var(--ide-text-color-secondary, $gl-text-color-secondary);
}
- .nav-links:not(.quick-links) li:not(.md-header-toolbar),
+ .nav-links li:not(.md-header-toolbar),
.gl-tabs-nav li {
&:hover a,
&.active a,
@@ -148,7 +148,7 @@
.md blockquote,
.md table:not(.code) tbody td,
.md table:not(.code) tr th,
- .nav-links:not(.quick-links),
+ .nav-links,
.gl-tabs-nav,
.common-note-form .md-area.is-focused .nav-links {
border-color: var(--ide-border-color-alt, $white-dark);
@@ -259,6 +259,7 @@
.dropdown-menu-toggle {
border-color: var(--ide-btn-default-border, $gray-darkest);
+ background-color: var(--ide-input-background, transparent);
&:hover,
&:focus {
@@ -310,7 +311,7 @@
border-color: var(--ide-background, $border-color);
background-color: var(--ide-dropdown-background, $white);
- .nav-links:not(.quick-links) {
+ .nav-links {
background-color: var(--ide-dropdown-hover-background, $white);
border-color: var(--ide-dropdown-hover-background, $border-color);
}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index ec41909beec..ed62e055427 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -1,8 +1,8 @@
@import 'mixins_and_variables_and_functions';
.build-page {
- .build-trace {
- @include build-trace();
+ .build-log {
+ @include build-log();
}
.archived-job {
@@ -18,7 +18,7 @@
}
.top-bar {
- @include build-trace-top-bar(50px);
+ @include build-log-top-bar(50px);
&.has-archived-block {
top: $header-height + 28px;
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 28354b83856..7d1230b0225 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -109,3 +109,12 @@
}
}
}
+
+// TODO: Move to GitLab UI
+.mr-extenson-scrim {
+ background: linear-gradient(to bottom, rgba($gray-light, 0), rgba($gray-light, 1));
+
+ .gl-dark & {
+ background: linear-gradient(to bottom, rgba(#333, 0), rgba(#333, 1));
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index 206c2eb09d0..c8b1b6cf9aa 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -44,14 +44,14 @@
line-height: initial;
}
- .build-trace-row td {
+ .build-log-row td {
border-top: 0;
border-bottom-width: 1px;
border-bottom-style: solid;
padding-top: 0;
}
- .build-trace {
+ .build-log {
width: 100%;
text-align: left;
margin-top: $gl-padding;
@@ -93,7 +93,7 @@
}
.build-state,
- .build-trace-row {
+ .build-log-row {
> td:last-child {
padding-right: 0;
}
@@ -108,12 +108,12 @@
margin-top: 2 * $gl-padding;
}
- .build-trace-container {
+ .build-log-container {
padding-top: $gl-padding;
padding-bottom: $gl-padding;
}
- .build-trace {
+ .build-log {
margin-bottom: 0;
margin-top: 0;
}
@@ -221,8 +221,8 @@
}
.test-reports-table {
- .build-trace {
- @include build-trace();
+ .build-log {
+ @include build-log();
}
}
diff --git a/app/assets/stylesheets/page_bundles/signup.scss b/app/assets/stylesheets/page_bundles/signup.scss
index 57e5d2411d1..4fc671dace8 100644
--- a/app/assets/stylesheets/page_bundles/signup.scss
+++ b/app/assets/stylesheets/page_bundles/signup.scss
@@ -26,14 +26,6 @@
}
}
- .omniauth-btn {
- width: 48%;
-
- @include media-breakpoint-down(md) {
- width: 100%;
- }
- }
-
.decline-page {
width: 350px;
}
diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss
index 1e6567189be..c64e159c648 100644
--- a/app/assets/stylesheets/page_bundles/wiki.scss
+++ b/app/assets/stylesheets/page_bundles/wiki.scss
@@ -152,5 +152,5 @@ ul.wiki-pages-list.content-list {
}
.wiki-form .markdown-area {
- max-height: none;
+ max-height: 55vh;
}
diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss
deleted file mode 100644
index fbe1f3081a0..00000000000
--- a/app/assets/stylesheets/pages/ci_projects.scss
+++ /dev/null
@@ -1,54 +0,0 @@
-.ci-body {
- .project-title {
- margin: 0;
- color: $common-gray-dark;
- font-size: 20px;
- line-height: 1.5;
- }
-
- .builds,
- .projects-table {
- .light {
- border-color: $border-color;
- }
-
- th,
- td {
- padding: 10px $gl-padding;
- }
-
- td {
- color: $gl-text-color;
- vertical-align: middle !important;
-
- a {
- font-weight: $gl-font-weight-normal;
- text-decoration: none;
- }
- }
- }
-
- .commit-info {
- .attr-name {
- margin-right: 5px;
- }
-
- pre.commit-message {
- background: none;
- padding: 0;
- border: 0;
- margin: 20px 0;
- border-radius: 0;
- }
- }
-
- .loading {
- font-size: 20px;
- }
-
- .ci-charts {
- fieldset {
- margin-bottom: 16px;
- }
- }
-}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index d233adbf3d2..de27ca2e5e8 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -1,88 +1,4 @@
-.edit-cluster-form {
- .clipboard-addon {
- background-color: $white;
- }
-}
-
-.cluster-application-row {
- background: $gray-lighter;
-
- &.cluster-application-installed {
- background: none;
- }
-
- .settings-message {
- padding: $gl-vert-padding $gl-padding-8;
- }
-}
-
-@media (min-width: map-get($grid-breakpoints, md)) {
- .cluster-application-list {
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
- }
-
- .cluster-application-row {
- border-bottom: 1px solid $border-color;
- padding: $gl-padding;
-
- &:last-child {
- border-bottom: 0;
- border-bottom-left-radius: calc(#{$border-radius-default} - 1px);
- border-bottom-right-radius: calc(#{$border-radius-default} - 1px);
- }
- }
-}
-
-.cluster-application-logo {
- border: 3px solid $white;
- box-shadow: 0 0 0 1px $gray-normal;
-
- &.avatar:hover {
- border-color: $white;
- }
-}
-
-.cluster-application-warning {
- font-weight: bold;
- text-align: center;
- padding: $gl-padding;
- border-bottom: 1px solid $white-normal;
-
- .svg-container {
- display: inline-block;
- vertical-align: middle;
- margin-right: $gl-padding-8;
- width: 40px;
- height: 40px;
- }
-}
-
-.cluster-application-description {
- flex: 1;
-}
-
-.cluster-application-disabled {
- opacity: 0.5;
-}
-
-.clusters-dropdown-menu {
- max-width: 100%;
-}
-
-.clusters-error-alert {
- width: 100%;
-}
-
.clusters-container {
- .nav-bar-right {
- padding: $gl-padding-top $gl-padding;
- }
-
- .card {
- margin-bottom: $gl-vert-padding;
- }
-
.empty-state .svg-content img {
width: 145px;
}
@@ -100,71 +16,4 @@
@include gl-flex-wrap;
}
}
-
- .top-area .nav-controls > .btn.btn-add-cluster {
- margin-right: 0;
- }
-
- .clusters-table {
- background-color: $gray-light;
- padding: $gl-padding-8;
- }
-
- .badge-light {
- background-color: $white-normal;
- }
-
- .gl-responsive-table-row {
- padding: $gl-padding;
- border: 0;
-
- &.table-row-header {
- // stylelint-disable-next-line
- background-color: none;
- border: 0;
- font-weight: bold;
- color: $gray-500;
- }
- }
-}
-
-.cluster-warning {
- @include alert-variant(theme-color-level('warning', $alert-bg-level), theme-color-level('warning', $alert-border-level), theme-color-level('warning', $alert-color-level));
-}
-
-.gcp-signup-offer {
- border-left-color: $blue-500;
-
- svg {
- fill: $blue-500;
- vertical-align: middle;
- }
-
- .gcp-signup-offer--content {
- display: flex;
-
- h4 {
- font-size: 16px;
- line-height: 24px;
- }
-
- .gcp-signup-offer--icon {
- align-self: flex-start;
- }
- }
-}
-
-.cluster-deployments-warning {
- color: $orange-500;
-}
-
-.badge.pods-badge {
- color: $black;
- font-weight: $gl-font-weight-bold;
-}
-
-.cluster-status-indicator {
- &.disabled {
- background-color: $gray-400;
- }
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index bc4dbf695cf..7f35b8fab43 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -82,11 +82,6 @@
}
}
-.commits-compare-switch {
- float: left;
- margin-right: 9px;
-}
-
.commit-header {
padding: 5px 10px;
background-color: $gray-light;
diff --git a/app/assets/stylesheets/pages/environment_logs.scss b/app/assets/stylesheets/pages/environment_logs.scss
index 03993e5321d..f8f40076142 100644
--- a/app/assets/stylesheets/pages/environment_logs.scss
+++ b/app/assets/stylesheets/pages/environment_logs.scss
@@ -40,8 +40,8 @@
height: 100%;
}
- .build-trace {
- @include build-trace($black);
+ .build-log {
+ @include build-log($black);
}
.gl-infinite-scroll-legend {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 94912b1c641..c597d2dd8da 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -220,21 +220,12 @@
}
.cross-project-reference {
- color: inherit;
-
span {
- white-space: nowrap;
width: 85%;
- overflow: hidden;
- position: relative;
- display: inline-block;
- text-overflow: ellipsis;
}
button {
- float: right;
padding: 1px 5px;
- background-color: $gray-light;
}
}
@@ -563,35 +554,17 @@
}
}
-.participants-list {
- display: flex;
- flex-wrap: wrap;
-}
-
-.user-list {
- display: flex;
- flex-wrap: wrap;
-}
-
.participants-author {
- display: inline-block;
- padding: 0 $gl-padding-8 $gl-padding-8 0;
-
&:nth-of-type(7n) {
padding-right: 0;
}
- .author-link {
- display: block;
- }
-
.avatar.avatar-inline {
margin: 0;
}
}
.user-item {
- display: inline-block;
padding: 5px;
flex-basis: 20%;
@@ -673,40 +646,40 @@
.issuable-info-container {
flex: 1;
display: flex;
+ }
- .issuable-main-info {
- flex: 1 auto;
- margin-right: 10px;
- min-width: 0;
+ .issuable-main-info {
+ flex: 1 auto;
+ margin-right: 10px;
+ min-width: 0;
- .issue-weight-icon,
- .issue-estimate-icon {
- vertical-align: sub;
- }
+ .issue-weight-icon,
+ .issue-estimate-icon {
+ vertical-align: sub;
}
+ }
- .issuable-meta {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- flex: 1 0 auto;
-
- .controls {
- margin-bottom: 2px;
- line-height: 20px;
- padding: 0;
- }
+ .issuable-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ flex: 1 0 auto;
- .issue-updated-at {
- line-height: 20px;
- }
+ .controls {
+ margin-bottom: 2px;
+ line-height: 20px;
+ padding: 0;
}
- @include media-breakpoint-down(xs) {
- .issuable-meta {
- .controls li {
- margin-right: 0;
- }
+ .issue-updated-at {
+ line-height: 20px;
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .issuable-meta {
+ .controls li {
+ margin-right: 0;
}
}
}
@@ -773,38 +746,15 @@
}
}
-.add-issuable-form-input-token-list {
- display: flex;
- flex-wrap: wrap;
- align-items: baseline;
- list-style: none;
- margin-bottom: 0;
- padding-left: 0;
-}
-
-.add-issuable-form-token-list-item {
- max-width: 100%;
- margin-bottom: $gl-vert-padding;
- margin-right: 5px;
-}
-
-.add-issuable-form-input-list-item {
- flex: 1;
- min-width: 200px;
- margin-bottom: $gl-vert-padding;
-}
-
-.add-issuable-form-input {
- width: 100%;
- border: 0;
-
- &:focus {
- outline: none;
+.add-issuable-form-input-wrapper {
+ &.focus {
+ border-color: $blue-300;
+ box-shadow: 0 0 4px $dropdown-input-focus-shadow;
}
-}
-.add-issuable-form-actions {
- margin-top: $gl-padding;
+ .gl-show-field-errors &.form-control:not(textarea) {
+ height: auto;
+ }
}
.time-tracker {
@@ -839,18 +789,7 @@
}
.compare-display-container {
- display: flex;
- justify-content: space-between;
- margin-top: 5px;
-
- .compare-display {
- font-size: 13px;
- color: $compare-display-color;
-
- .compare-value {
- color: $gl-text-color;
- }
- }
+ font-size: 13px;
}
.time-tracking-help-state {
@@ -938,22 +877,6 @@
vertical-align: sub;
}
-.suggestion-confidential {
- color: $orange-500;
-}
-
-.suggestion-state-open {
- color: $green-500;
-}
-
-.suggestion-state-closed {
- color: $blue-500;
-}
-
-.suggestion-help-hover {
- cursor: help;
-}
-
.suggestion-footer {
font-size: 12px;
line-height: 15px;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 461d6a29b3a..880231f5644 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -1,42 +1,62 @@
-.issue-realtime-pre-pulse {
- opacity: 0;
-}
+.issue-token-link {
+ &[href] {
+ color: $blue-600;
+ }
-.issue-realtime-trigger-pulse {
- transition: opacity $fade-in-duration linear;
- opacity: 1;
+ &:hover,
+ &:focus {
+ outline: none;
+ text-decoration: none;
+ }
}
-.check-all-holder {
- line-height: 36px;
- float: left;
- margin-right: 15px;
-}
+.issue-token-reference {
+ margin-right: 1px;
+ background-color: $gray-lighter;
+ transition: background $general-hover-transition-duration $general-hover-transition-curve, color $general-hover-transition-duration $general-hover-transition-curve;
-form.edit-issue {
- margin: 0;
+ .issue-token:hover &,
+ .issue-token-link:focus > & {
+ background-color: $gray-normal;
+ color: $blue-800;
+ text-decoration: none;
+ }
}
-ul.related-merge-requests > li {
- display: flex;
- align-items: center;
+.issue-token-title {
+ background-color: $gray-normal;
+ transition: background $general-hover-transition-duration $general-hover-transition-curve;
- .merge-request-id {
- flex-shrink: 0;
+ .issue-token:hover &,
+ .issue-token-link:focus > & {
+ background-color: $border-gray-normal;
}
+}
- .merge-request-info {
- margin-left: 5px;
- }
+.issue-token-remove-button {
+ background-color: $gray-normal;
+ transition: background $general-hover-transition-duration $general-hover-transition-curve;
- gl-emoji {
- font-size: 1em;
+ &:hover,
+ &:focus,
+ .issue-token:hover &,
+ .issue-token-link:focus + & {
+ background-color: $border-gray-normal;
+ outline: none;
}
}
-.related-branches-title {
- font-size: 16px;
- font-weight: $gl-font-weight-bold;
+.issue-realtime-pre-pulse {
+ opacity: 0;
+}
+
+.issue-realtime-trigger-pulse {
+ transition: opacity $fade-in-duration linear;
+ opacity: 1;
+}
+
+ul.related-merge-requests > li gl-emoji {
+ font-size: 1em;
}
.merge-request-status {
@@ -92,35 +112,12 @@ ul.related-merge-requests > li {
}
}
-.issues-footer {
- padding-top: $gl-padding;
- padding-bottom: 37px;
-}
-
-.issues-nav-controls,
-.new-branch-col {
- font-size: 0;
-}
-
.issues-nav-controls {
.btn-group:empty {
display: none;
}
}
-.email-modal-input-group {
- margin-bottom: 10px;
-
- .form-control {
- background-color: $white;
- }
-
- .btn {
- background-color: $gray-light;
- border: 1px solid $border-gray-normal;
- }
-}
-
.recaptcha {
margin-bottom: 30px;
}
diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
deleted file mode 100644
index f2283e02ad2..00000000000
--- a/app/assets/stylesheets/pages/issues/issue_count_badge.scss
+++ /dev/null
@@ -1,10 +0,0 @@
-.issue-count-badge,
-.mr-count-badge {
- padding: 5px $gl-padding-8;
-}
-
-.issue-count-badge-count,
-.mr-count-badge-count {
- display: inline-flex;
- align-items: center;
-}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 773935f4c76..71ddbf175e9 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -99,11 +99,6 @@
padding: 0;
border: 0;
background: none;
- margin-bottom: $gl-padding;
- }
-
- .omniauth-btn {
- width: 100%;
}
}
@@ -206,6 +201,12 @@
padding: 0;
height: 100%;
+ &.with-system-header {
+ .login-page-broadcast {
+ margin-top: $system-header-height + $header-height;
+ }
+ }
+
// Fixes footer container to bottom of viewport
body {
// offset height of fixed header + 1 to avoid scroll
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 071a5be073f..cec8d8a29cc 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -474,31 +474,6 @@ $tabs-holder-z-index: 250;
}
}
}
-
- .merge-request-labels {
- display: inline-block;
- }
-}
-
-.merge-request-angle {
- text-align: center;
- margin: 0 auto;
- font-size: 2em;
- line-height: 1.1;
-}
-
-// hide mr close link for inline diff comment form
-.diff-file .close-mr-link,
-.diff-file .reopen-mr-link {
- display: none;
-}
-
-.mr-links {
- padding-left: $gl-padding-8 + $status-icon-size + $gl-btn-padding;
-
- &:last-child {
- padding-bottom: $gl-padding;
- }
}
.mr-info-list {
@@ -649,10 +624,6 @@ $tabs-holder-z-index: 250;
}
}
-.target-branch-select-dropdown-container {
- position: relative;
-}
-
.assign-to-me-link {
padding-left: 12px;
white-space: nowrap;
@@ -667,12 +638,6 @@ $tabs-holder-z-index: 250;
}
}
-.merged-buttons {
- .btn {
- float: left;
- }
-}
-
.mr-version-controls {
position: relative;
z-index: $tabs-holder-z-index + 10;
@@ -1040,3 +1005,17 @@ $tabs-holder-z-index: 250;
margin-bottom: 1px;
}
}
+
+.mr-widget-extension-icon::before {
+ @include gl-content-empty;
+ @include gl-absolute;
+ @include gl-left-0;
+ @include gl-top-0;
+ @include gl-opacity-3;
+ @include gl-border-solid;
+ @include gl-border-4;
+ @include gl-rounded-full;
+
+ width: 24px;
+ height: 24px;
+}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 3c0f10eb5cb..1c408f6d985 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -24,12 +24,6 @@
margin: $gl-padding 0 0;
}
- .note-preview-holder {
- > p {
- overflow-x: auto;
- }
- }
-
img {
max-width: 100%;
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 17bc40b4dec..04da75b586f 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -125,18 +125,17 @@ $system-note-svg-size: 16px;
}
}
+ .timeline-discussion-body {
+ margin-top: -$gl-padding-8;
+ }
+
.discussion {
display: block;
position: relative;
.timeline-discussion-body {
- margin-top: -$gl-padding-8;
overflow-x: auto;
overflow-y: hidden;
-
- .note-body {
- margin-top: $gl-padding-8;
- }
}
.diff-content {
@@ -586,17 +585,47 @@ $system-note-svg-size: 16px;
.note-header {
display: flex;
justify-content: space-between;
+ flex-wrap: wrap;
+
+ > .note-header-info,
+ > .note-actions {
+ flex-grow: 1;
+ flex-shrink: 1;
+ }
+}
+
+.note {
+ @include notes-media('max', map-get($grid-breakpoints, sm) - 1) {
+ .note-header {
+ .note-actions {
+ flex-wrap: wrap;
+ margin-bottom: $gl-padding-12;
+
+ > :first-child {
+ margin-left: 0;
+ }
+ }
+ }
+
+ .note-header-author-name {
+ display: block;
+ }
+ }
}
.note-header-info {
min-width: 0;
- padding-bottom: $gl-padding-8;
&.discussion {
padding-bottom: 0;
}
}
+.note-header-info,
+.note-actions {
+ padding-bottom: $gl-padding-8;
+}
+
.system-note .note-header-info {
padding-bottom: 0;
}
@@ -667,7 +696,8 @@ $system-note-svg-size: 16px;
.note-actions {
align-self: flex-start;
- flex-shrink: 0;
+ justify-content: flex-end;
+ flex-shrink: 1;
display: inline-flex;
align-items: center;
margin-left: 10px;
diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss
index 298de33888d..2fd2757cf08 100644
--- a/app/assets/stylesheets/pages/notifications.scss
+++ b/app/assets/stylesheets/pages/notifications.scss
@@ -4,11 +4,6 @@
width: 100%;
}
- .notification-form {
- display: block;
- }
-
- .notifications-btn,
.btn-group {
width: 100%;
}
diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss
index 93caa345f8a..ebaf875ad8f 100644
--- a/app/assets/stylesheets/pages/pages.scss
+++ b/app/assets/stylesheets/pages/pages.scss
@@ -55,16 +55,4 @@
border-bottom-right-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
}
-
- &.floating-status-badge {
- position: absolute;
- right: $gl-padding-24;
- bottom: $gl-padding-4;
- margin-bottom: 0;
- }
-}
-
-.form-control.has-floating-status-badge {
- position: relative;
- padding-right: 120px;
}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index de9e0c6f705..af9f10c9a26 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -1,9 +1,3 @@
-.profile-avatar-form-option {
- hr {
- margin: 10px 0;
- }
-}
-
.avatar-image {
margin-bottom: $grid-size;
@@ -23,41 +17,6 @@
display: inline-block;
}
-.private-tokens-reset div.reset-action:not(:first-child) {
- padding-top: 15px;
-}
-
-.oauth-buttons {
- .btn-group {
- margin-right: 10px;
- }
-
- .btn {
- line-height: 40px;
- height: 42px;
- padding: 0 12px;
-
- img {
- width: 32px;
- height: 32px;
- }
- }
-}
-
-// Profile > Account > Two Factor Authentication
-.two-factor-new {
- .manual-instructions {
- h3 {
- margin-top: 0;
- }
-
- // Slightly increase the size of the details so they're easier to read
- dl {
- font-size: 1.1em;
- }
- }
-}
-
.account-well {
padding: 10px;
background-color: $gray-light;
@@ -191,10 +150,6 @@
}
}
-.personal-access-tokens-never-expires-label {
- color: $note-disabled-comment-color;
-}
-
.created-personal-access-token-container {
.btn-clipboard {
border: 1px solid $border-color;
@@ -266,8 +221,7 @@
}
}
-table.u2f-registrations,
-.webauthn-registrations {
+table.u2f-registrations {
th:not(:last-child),
td:not(:last-child) {
border-right: solid 1px transparent;
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss
index 12386fa66ec..b583d40de79 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/pages/profiles/preferences.scss
@@ -1,19 +1,3 @@
-.multi-file-editor-options {
- label {
- margin-right: 20px;
- text-align: center;
- }
-
- .preview {
- font-size: 0;
-
- img {
- border: 1px solid $border-color-settings;
- border-radius: 4px;
- }
- }
-}
-
.application-theme {
$ui-dark-bg: #2e2e2e;
$ui-light-bg: #dfdfdf;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index c330e0709a6..6b4d7c2520c 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -622,12 +622,6 @@ pre.light-well {
}
}
- .star-button {
- .icon {
- top: 0;
- }
- }
-
.icon-container {
@include media-breakpoint-down(xs) {
margin-right: $gl-padding-8;
@@ -938,16 +932,6 @@ pre.light-well {
}
}
-.project-ci-linter {
- .ci-editor {
- height: 400px;
- }
-
- .ci-template pre {
- white-space: pre-wrap;
- }
-}
-
.project-badge {
opacity: 0.9;
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index a3b6cbdff25..71cbd7d9613 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -96,11 +96,6 @@
padding: $gl-padding-8;
}
-.prometheus-graph-embed {
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
-}
-
.alert-current-setting {
max-width: 240px;
@@ -110,233 +105,6 @@
}
}
-.prometheus-graph-cursor {
- position: absolute;
- background: $gray-400;
- width: 1px;
-}
-
-.prometheus-graph-flag {
- display: block;
- min-width: 160px;
- border: 0;
- box-shadow: 0 1px 4px 0 $black-transparent;
-
- h5 {
- padding: 0;
- margin: 0;
- font-size: 14px;
- line-height: 1.2;
- }
-
- .deploy-meta-content {
- border-bottom: 1px solid $white-dark;
-
- svg {
- height: 15px;
- vertical-align: bottom;
- }
- }
-
- &.popover {
- padding: 0;
-
- &.left {
- left: auto;
- right: 0;
- margin-right: 10px;
-
- > .arrow {
- right: -14px;
- border-left-color: $border-color;
- }
-
- > .arrow::after {
- border-top: 6px solid transparent;
- border-bottom: 6px solid transparent;
- border-left: 4px solid $gray-10;
- }
-
- .arrow-shadow {
- right: -3px;
- box-shadow: 1px 0 9px 0 $black-transparent;
- }
- }
-
- &.right {
- left: 0;
- right: auto;
- margin-left: 10px;
-
- > .arrow {
- left: -7px;
- border-right-color: $border-color;
- }
-
- > .arrow::after {
- border-top: 6px solid transparent;
- border-bottom: 6px solid transparent;
- border-right: 4px solid $gray-10;
- }
-
- .arrow-shadow {
- left: -3px;
- box-shadow: 1px 0 8px 0 $black-transparent;
- }
- }
-
- > .arrow {
- top: 10px;
- margin: 0;
- }
-
- .arrow-shadow {
- content: '';
- position: absolute;
- width: 7px;
- height: 7px;
- background-color: transparent;
- transform: rotate(45deg);
- top: 13px;
- }
-
- > .popover-title,
- > .popover-content,
- > .popover-header,
- > .popover-body {
- padding: 8px;
- white-space: nowrap;
- position: relative;
- }
-
- > .popover-title {
- background-color: $gray-10;
- border-radius: $border-radius-default $border-radius-default 0 0;
- }
- }
-
- strong {
- font-weight: 600;
- }
-}
-
-.prometheus-table {
- border-collapse: collapse;
- padding: 0;
- margin: 0;
-
- td {
- vertical-align: middle;
-
- + td {
- padding-left: 8px;
- vertical-align: top;
- }
- }
-
- .legend-metric-title {
- font-size: 12px;
- vertical-align: middle;
- }
-}
-
-.prometheus-svg-container {
- position: relative;
- height: 0;
- width: 100%;
- padding: 0;
- padding-bottom: 100%;
-
- .text-metric-usage {
- fill: $black;
- font-weight: $gl-font-weight-normal;
- font-size: 12px;
- }
-
- > svg {
- position: absolute;
- height: 100%;
- width: 100%;
- left: 0;
- top: 0;
-
- text {
- fill: $gl-text-color;
- stroke-width: 0;
- }
-
- .text-metric-bold {
- font-weight: $gl-font-weight-bold;
- }
-
- .label-axis-text {
- fill: $black;
- font-weight: $gl-font-weight-normal;
- font-size: 10px;
- }
-
- .legend-axis-text {
- fill: $black;
- }
-
- .tick {
- > line {
- stroke: $gray-darker;
- }
-
- > text {
- fill: $gray-400;
- font-size: 10px;
- }
- }
-
- .y-label-text,
- .x-label-text {
- fill: $gray-darkest;
- }
-
- .axis-tick {
- stroke: $gray-darker;
- }
-
- .deploy-info-text {
- dominant-baseline: text-before-edge;
- font-size: 12px;
- }
-
- .deploy-info-text-link {
- font-family: $monospace-font;
- fill: $blue-600;
-
- &:hover {
- fill: $blue-800;
- }
- }
-
- @include media-breakpoint-down(sm) {
- .label-axis-text,
- .text-metric-usage,
- .legend-axis-text {
- font-size: 8px;
- }
-
- .tick > text {
- font-size: 8px;
- }
- }
- }
-}
-
-.prometheus-table-row-highlight {
- background-color: $gray-100;
-}
-
-.prometheus-graph-overlay {
- fill: none;
- opacity: 0;
- pointer-events: all;
-}
-
.prometheus-panel-builder {
.preview-date-time-picker {
// same as in .dropdown-menu-toggle
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index aa9ebfe2968..37e272cfff7 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -154,17 +154,6 @@
}
}
-.token-token-container {
- #impersonation-token-token {
- width: 80%;
- display: inline;
- }
-
- .btn-clipboard {
- margin-left: 5px;
- }
-}
-
.visibility-level-setting {
.form-check {
margin-bottom: 10px;
@@ -203,22 +192,6 @@
}
}
-.initialize-with-readme-setting {
- .form-check {
- margin-bottom: 10px;
-
- .option-title {
- font-weight: $gl-font-weight-normal;
- display: inline-block;
- color: $gl-text-color;
- }
-
- .option-description {
- color: $project-option-descr-color;
- }
- }
-}
-
.nested-settings {
padding-left: 20px;
}
@@ -326,10 +299,6 @@
}
}
-.personal-access-tokens-never-expires-label {
- color: $note-disabled-comment-color;
-}
-
.created-deploy-token-container {
.deploy-token-field {
width: 90%;
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 5765156f26c..33c66648718 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -223,11 +223,6 @@
min-height: 200px;
}
-.upload-link {
- font-weight: $gl-font-weight-normal;
- color: $blue-600;
-}
-
.repo-charts {
.sub-header {
margin: 20px 0;
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index b7958cdf4a3..d436c328921 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -5,20 +5,17 @@
body.gl-dark {
--gray-50: #303030;
--gray-100: #404040;
+ --gray-900: #fafafa;
--gray-950: #fff;
--green-100: #0d532a;
--green-400: #108548;
--green-700: #91d4a8;
--blue-400: #1f75cb;
--orange-400: #ab6100;
- --purple-100: #2f2a6b;
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
--black: #fff;
}
-.nav-sidebar li.active {
- box-shadow: none;
-}
:root {
--white: #333;
}
@@ -198,22 +195,6 @@ h1 {
.dropdown {
position: relative;
}
-.dropdown-menu-toggle {
- white-space: nowrap;
-}
-.dropdown-menu-toggle::after {
- display: inline-block;
- margin-left: 0.255em;
- vertical-align: 0.255em;
- content: "";
- border-top: 0.3em solid;
- border-right: 0.3em solid transparent;
- border-bottom: 0;
- border-left: 0.3em solid transparent;
-}
-.dropdown-menu-toggle:empty::after {
- margin-left: 0;
-}
.dropdown-menu {
position: absolute;
top: 100%;
@@ -331,6 +312,9 @@ h1 {
padding-left: 0.6em;
border-radius: 10rem;
}
+.bg-transparent {
+ background-color: transparent !important;
+}
.rounded-circle {
border-radius: 50% !important;
}
@@ -375,6 +359,20 @@ h1 {
.m-auto {
margin: auto !important;
}
+.gl-badge {
+ display: inline-flex;
+ align-items: center;
+ font-size: 0.75rem;
+ font-weight: 400;
+ line-height: 1rem;
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+.gl-button .gl-badge {
+ top: 0;
+}
.gl-form-input,
.gl-form-input.form-control {
background-color: #333;
@@ -466,9 +464,6 @@ a {
.hide {
display: none;
}
-.dropdown-menu-toggle::after {
- display: none;
-}
.badge:not(.gl-badge) {
padding: 4px 5px;
font-size: 12px;
@@ -548,7 +543,7 @@ body {
border-radius: 0.25rem;
white-space: nowrap;
}
-.no-outline.dropdown-menu-toggle {
+.dropdown-menu-toggle.no-outline {
outline: 0;
}
.dropdown-menu-toggle.dropdown-menu-toggle {
@@ -875,6 +870,12 @@ input {
.navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count {
background-color: var(--blue-400, #1f75cb);
}
+.title-container .canary-badge .badge,
+.navbar-nav .canary-badge .badge {
+ font-size: 12px;
+ line-height: 16px;
+ padding: 0 0.5rem;
+}
@media (max-width: 575.98px) {
.navbar-gitlab .container-fluid {
font-size: 18px;
@@ -1280,6 +1281,7 @@ input {
a
.avatar-container.rect-avatar
.avatar.s32 {
+ border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
@@ -1304,6 +1306,7 @@ input {
a
.avatar-container.rect-avatar
.avatar.s32 {
+ border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items > li .badge.badge-pill {
@@ -1601,19 +1604,98 @@ svg.s16 {
.rect-avatar.s16 {
border-radius: 2px;
}
-.rect-avatar.s32,
-.nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32,
-.sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
+.rect-avatar.s32 {
border-radius: 4px;
}
+body.gl-dark {
+ --gray-10: #1f1f1f;
+ --gray-50: #303030;
+ --gray-100: #404040;
+ --gray-200: #525252;
+ --gray-300: #5e5e5e;
+ --gray-400: #868686;
+ --gray-500: #999;
+ --gray-600: #bfbfbf;
+ --gray-700: #dbdbdb;
+ --gray-800: #f0f0f0;
+ --gray-900: #fafafa;
+ --gray-950: #fff;
+ --green-50: #0a4020;
+ --green-100: #0d532a;
+ --green-200: #24663b;
+ --green-300: #217645;
+ --green-400: #108548;
+ --green-500: #2da160;
+ --green-600: #52b87a;
+ --green-700: #91d4a8;
+ --green-800: #c3e6cd;
+ --green-900: #ecf4ee;
+ --green-950: #f1fdf6;
+ --blue-50: #033464;
+ --blue-100: #064787;
+ --blue-200: #0b5cad;
+ --blue-300: #1068bf;
+ --blue-400: #1f75cb;
+ --blue-500: #428fdc;
+ --blue-600: #63a6e9;
+ --blue-700: #9dc7f1;
+ --blue-800: #cbe2f9;
+ --blue-900: #e9f3fc;
+ --blue-950: #f2f9ff;
+ --orange-50: #5c2900;
+ --orange-100: #703800;
+ --orange-200: #8f4700;
+ --orange-300: #9e5400;
+ --orange-400: #ab6100;
+ --orange-500: #c17d10;
+ --orange-600: #d99530;
+ --orange-700: #e9be74;
+ --orange-800: #f5d9a8;
+ --orange-900: #fdf1dd;
+ --orange-950: #fff4e1;
+ --red-50: #660e00;
+ --red-100: #8d1300;
+ --red-200: #ae1800;
+ --red-300: #c91c00;
+ --red-400: #dd2b0e;
+ --red-500: #ec5941;
+ --red-600: #f57f6c;
+ --red-700: #fcb5aa;
+ --red-800: #fdd4cd;
+ --red-900: #fcf1ef;
+ --red-950: #fff4f3;
+ --indigo-50: #1a1a40;
+ --indigo-100: #292961;
+ --indigo-200: #393982;
+ --indigo-300: #4b4ba3;
+ --indigo-400: #5b5bbd;
+ --indigo-500: #6666c4;
+ --indigo-600: #7c7ccc;
+ --indigo-700: #a6a6de;
+ --indigo-800: #d1d1f0;
+ --indigo-900: #ebebfa;
+ --indigo-950: #f7f7ff;
+ --indigo-900-alpha-008: rgba(235, 235, 250, 0.08);
+ --purple-50: #232150;
+ --purple-100: #2f2a6b;
+ --purple-200: #453894;
+ --purple-300: #5943b6;
+ --purple-400: #694cc0;
+ --purple-500: #7b58cf;
+ --purple-600: #9475db;
+ --purple-700: #ac93e6;
+ --purple-800: #cbbbf2;
+ --purple-900: #e1d8f9;
+ --purple-950: #f4f0ff;
+ --gl-text-color: #fafafa;
+ --border-color: #4f4f4f;
+ --white: #333;
+ --black: #fff;
+ --svg-status-bg: #333;
+}
+.nav-sidebar li.active {
+ box-shadow: none;
+}
body.gl-dark .navbar-gitlab {
background-color: #fafafa;
}
@@ -1703,8 +1785,8 @@ body.gl-dark .nav-sidebar li.active > a {
body.gl-dark .nav-sidebar .fly-out-top-item a,
body.gl-dark .nav-sidebar .fly-out-top-item.active a,
body.gl-dark .nav-sidebar .fly-out-top-item .fly-out-top-item-container {
- background-color: var(--purple-100, #e1d8f9);
- color: var(--black, #333);
+ background-color: var(--gray-100, #303030);
+ color: var(--gray-900, #fafafa);
}
body.gl-dark .logo-text svg {
fill: var(--gl-text-color);
@@ -1823,9 +1905,6 @@ body.gl-dark {
--black: #fff;
--svg-status-bg: #333;
}
-.nav-sidebar li.active {
- box-shadow: none;
-}
.tab-width-8 {
-moz-tab-size: 8;
tab-size: 8;
@@ -1841,9 +1920,18 @@ body.gl-dark {
white-space: nowrap;
width: 1px;
}
+.gl-bg-green-500 {
+ background-color: #2da160;
+}
.gl-border-none\! {
border-style: none !important;
}
+.gl-rounded-pill {
+ border-radius: 0.75rem;
+}
+.gl-text-white {
+ color: #333;
+}
.gl-display-none {
display: none;
}
@@ -1862,6 +1950,10 @@ body.gl-dark {
.gl-pr-2 {
padding-right: 0.25rem;
}
+.gl-py-1 {
+ padding-top: 0.125rem;
+ padding-bottom: 0.125rem;
+}
.gl-ml-3 {
margin-left: 0.5rem;
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 2c79b819899..40026c95a15 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -178,22 +178,6 @@ h1 {
.dropdown {
position: relative;
}
-.dropdown-menu-toggle {
- white-space: nowrap;
-}
-.dropdown-menu-toggle::after {
- display: inline-block;
- margin-left: 0.255em;
- vertical-align: 0.255em;
- content: "";
- border-top: 0.3em solid;
- border-right: 0.3em solid transparent;
- border-bottom: 0;
- border-left: 0.3em solid transparent;
-}
-.dropdown-menu-toggle:empty::after {
- margin-left: 0;
-}
.dropdown-menu {
position: absolute;
top: 100%;
@@ -311,6 +295,9 @@ h1 {
padding-left: 0.6em;
border-radius: 10rem;
}
+.bg-transparent {
+ background-color: transparent !important;
+}
.rounded-circle {
border-radius: 50% !important;
}
@@ -355,6 +342,20 @@ h1 {
.m-auto {
margin: auto !important;
}
+.gl-badge {
+ display: inline-flex;
+ align-items: center;
+ font-size: 0.75rem;
+ font-weight: 400;
+ line-height: 1rem;
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+.gl-button .gl-badge {
+ top: 0;
+}
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
@@ -446,9 +447,6 @@ a {
.hide {
display: none;
}
-.dropdown-menu-toggle::after {
- display: none;
-}
.badge:not(.gl-badge) {
padding: 4px 5px;
font-size: 12px;
@@ -528,7 +526,7 @@ body {
border-radius: 0.25rem;
white-space: nowrap;
}
-.no-outline.dropdown-menu-toggle {
+.dropdown-menu-toggle.no-outline {
outline: 0;
}
.dropdown-menu-toggle.dropdown-menu-toggle {
@@ -855,6 +853,12 @@ input {
.navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count {
background-color: var(--blue-400, #428fdc);
}
+.title-container .canary-badge .badge,
+.navbar-nav .canary-badge .badge {
+ font-size: 12px;
+ line-height: 16px;
+ padding: 0 0.5rem;
+}
@media (max-width: 575.98px) {
.navbar-gitlab .container-fluid {
font-size: 18px;
@@ -1260,6 +1264,7 @@ input {
a
.avatar-container.rect-avatar
.avatar.s32 {
+ border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
@@ -1284,6 +1289,7 @@ input {
a
.avatar-container.rect-avatar
.avatar.s32 {
+ border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items > li .badge.badge-pill {
@@ -1581,17 +1587,7 @@ svg.s16 {
.rect-avatar.s16 {
border-radius: 2px;
}
-.rect-avatar.s32,
-.nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32,
-.sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
+.rect-avatar.s32 {
border-radius: 4px;
}
@@ -1610,9 +1606,18 @@ svg.s16 {
white-space: nowrap;
width: 1px;
}
+.gl-bg-green-500 {
+ background-color: #108548;
+}
.gl-border-none\! {
border-style: none !important;
}
+.gl-rounded-pill {
+ border-radius: 0.75rem;
+}
+.gl-text-white {
+ color: #fff;
+}
.gl-display-none {
display: none;
}
@@ -1631,6 +1636,10 @@ svg.s16 {
.gl-pr-2 {
padding-right: 0.25rem;
}
+.gl-py-1 {
+ padding-top: 0.125rem;
+ padding-bottom: 0.125rem;
+}
.gl-ml-3 {
margin-left: 0.5rem;
}
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 013ad3fac87..8d7531d6c9c 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -258,21 +258,6 @@ fieldset:disabled a.btn {
align-items: center;
justify-content: space-between;
}
-.d-block {
- display: block !important;
-}
-.d-flex {
- display: flex !important;
-}
-.flex-wrap {
- flex-wrap: wrap !important;
-}
-.justify-content-between {
- justify-content: space-between !important;
-}
-.align-items-center {
- align-items: center !important;
-}
.fixed-top {
position: fixed;
top: 0;
@@ -280,9 +265,6 @@ fieldset:disabled a.btn {
left: 0;
z-index: 1030;
}
-.ml-2 {
- margin-left: 0.5rem !important;
-}
.mt-3 {
margin-top: 1rem !important;
}
@@ -349,6 +331,15 @@ fieldset:disabled a.btn {
font-size: 0.875rem;
border-radius: 0.25rem;
}
+.gl-button.gl-button .gl-button-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding-top: 1px;
+ padding-bottom: 1px;
+ margin-top: -1px;
+ margin-bottom: -1px;
+}
.gl-button.gl-button .gl-button-icon {
height: 1rem;
width: 1rem;
@@ -637,10 +628,6 @@ svg {
padding: 0;
border: 0;
background: none;
- margin-bottom: 16px;
-}
-.login-page .omniauth-container .omniauth-btn {
- width: 100%;
}
.login-page .new-session-tabs {
display: flex;
@@ -771,21 +758,18 @@ svg {
.gl-align-items-center {
align-items: center;
}
+.gl-flex-wrap {
+ flex-wrap: wrap;
+}
.gl-w-full {
width: 100%;
}
-.gl-p-2 {
- padding: 0.25rem;
-}
.gl-p-4 {
padding: 0.75rem;
}
.gl-mt-2 {
margin-top: 0.25rem;
}
-.gl-mb-2 {
- margin-bottom: 0.25rem;
-}
.gl-mb-3 {
margin-bottom: 0.5rem;
}
@@ -797,8 +781,8 @@ svg {
margin-top: 0;
}
}
-.gl-text-left {
- text-align: left;
+.gl-font-weight-bold {
+ font-weight: 600;
}
@import "startup/cloaking";
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index f12b2ee2591..c79816e3579 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -202,7 +202,8 @@ body.gl-dark {
&.btn-info,
&.btn-success,
&.btn-danger,
- &.btn-warning {
+ &.btn-warning,
+ &.btn-confirm {
&-tertiary {
mix-blend-mode: screen;
}
@@ -256,53 +257,3 @@ $line-removed-dark: $red-200;
$well-expand-item: $gray-200;
$well-inner-border: $gray-200;
-
-// Misc component overrides that should live elsewhere
-.gl-label {
- filter: brightness(0.9) contrast(1.1);
-
- // This applies to the gl-label markups
- // rendered and cached in the backend (labels_helper.rb)
- &.gl-label-scoped {
- .gl-label-text-scoped,
- .gl-label-close {
- color: $gray-900;
- }
- }
-}
-
-// white-ish text for light labels
-.gl-label-text-light.gl-label-text-light {
- color: $gray-900;
-}
-
-.gl-label-text-dark.gl-label-text-dark {
- color: $gray-10;
-}
-
-// This applies to "gl-labels" from "gitlab-ui"
-.gl-label.gl-label-scoped.gl-label-text-dark,
-.gl-label.gl-label-scoped.gl-label-text-light {
- .gl-label-text-scoped,
- .gl-label-close {
- color: $gray-900;
- }
-}
-
-// duplicated class as the original .atwho-view style is added later
-.atwho-view.atwho-view {
- background-color: $white;
- color: $gray-900;
- border-color: $gray-800;
-}
-
-.nav-sidebar {
- li.active {
- box-shadow: none;
- }
-
- .sidebar-sub-level-items.fly-out-list {
- box-shadow: none;
- border: 1px solid $border-color;
- }
-}
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
new file mode 100644
index 00000000000..b77048174c9
--- /dev/null
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -0,0 +1,116 @@
+@import './themes/dark';
+@import 'page_bundles/mixins_and_variables_and_functions';
+@import './themes/theme_helper';
+
+// Some hacks and overrides for things that don't properly support dark mode
+.gl-label {
+ filter: brightness(0.9) contrast(1.1);
+
+ // This applies to the gl-label markups
+ // rendered and cached in the backend (labels_helper.rb)
+ &.gl-label-scoped {
+ .gl-label-text-scoped,
+ .gl-label-close {
+ color: $gray-900;
+ }
+ }
+}
+
+// white-ish text for light labels
+.gl-label-text-light.gl-label-text-light {
+ color: $gray-900;
+}
+
+.gl-label-text-dark.gl-label-text-dark {
+ color: $gray-10;
+}
+
+// This applies to "gl-labels" from "gitlab-ui"
+.gl-label.gl-label-scoped.gl-label-text-dark,
+.gl-label.gl-label-scoped.gl-label-text-light {
+ .gl-label-text-scoped,
+ .gl-label-close {
+ color: $gray-900;
+ }
+}
+
+// duplicated class as the original .atwho-view style is added later
+.atwho-view.atwho-view {
+ background-color: $white;
+ color: $gray-900;
+ border-color: $gray-800;
+}
+
+.nav-sidebar {
+ li.active {
+ box-shadow: none;
+ }
+
+ .sidebar-sub-level-items.fly-out-list {
+ box-shadow: none;
+ border: 1px solid $border-color;
+ }
+}
+
+body.gl-dark {
+ @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-800, $gray-900, $white);
+
+ .logo-text svg {
+ fill: var(--gl-text-color);
+ }
+
+ .navbar-gitlab {
+ background-color: var(--gray-50);
+ box-shadow: 0 1px 0 0 var(--gray-100);
+
+ .navbar-sub-nav,
+ .navbar-nav {
+ li {
+ > a:hover,
+ > a:focus,
+ > button:hover,
+ > button:focus {
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
+ }
+ }
+
+ li.active,
+ li.dropdown.show {
+ > a,
+ > button {
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
+ }
+ }
+ }
+
+ .header-search {
+ background-color: var(--gray-100) !important;
+ box-shadow: inset 0 0 0 1px var(--border-color) !important;
+
+ &:active,
+ &:hover {
+ background-color: var(--gray-100) !important;
+ box-shadow: inset 0 0 0 1px var(--blue-200) !important;
+ }
+ }
+
+ .search {
+ form {
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--border-color);
+
+ &:active,
+ &:hover {
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--blue-200);
+ }
+ }
+ }
+ }
+
+ .md :not(pre.code) > code {
+ background-color: $gray-200;
+ }
+}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index a9e8b238d78..1332686a906 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -212,8 +212,8 @@
a:hover,
&.active a,
.fly-out-top-item-container {
- background-color: var(--purple-100, $purple-900);
- color: var(--black, $white);
+ background-color: var(--gray-100, $gray-50);
+ color: var(--gray-900, $gray-900);
}
}
}
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index ba24e3e619b..d12ccfc7423 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -15,7 +15,14 @@ class Admin::DashboardController < Admin::ApplicationController
@groups = Group.order_id_desc.with_route.limit(10)
@notices = Gitlab::ConfigChecker::PumaRuggedChecker.check
@notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check
- @redis_versions = [Gitlab::Redis::Queues, Gitlab::Redis::SharedState, Gitlab::Redis::Cache, Gitlab::Redis::TraceChunks].map(&:version).uniq
+ @redis_versions = [
+ Gitlab::Redis::Queues,
+ Gitlab::Redis::SharedState,
+ Gitlab::Redis::Cache,
+ Gitlab::Redis::TraceChunks,
+ Gitlab::Redis::RateLimiting,
+ Gitlab::Redis::Sessions
+ ].map(&:version).uniq
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb
index 88ca2c88aab..5567ffbdc84 100644
--- a/app/controllers/admin/instance_review_controller.rb
+++ b/app/controllers/admin/instance_review_controller.rb
@@ -3,7 +3,7 @@ class Admin::InstanceReviewController < Admin::ApplicationController
feature_category :devops_reports
def index
- redirect_to("#{::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL}/instance_review?#{instance_review_params}")
+ redirect_to("#{Gitlab::SubscriptionPortal.subscriptions_instance_review_url}?#{instance_review_params}")
end
def instance_review_params
diff --git a/app/controllers/admin/serverless/domains_controller.rb b/app/controllers/admin/serverless/domains_controller.rb
deleted file mode 100644
index 49cd9f7a36d..00000000000
--- a/app/controllers/admin/serverless/domains_controller.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-class Admin::Serverless::DomainsController < Admin::ApplicationController
- before_action :check_feature_flag
- before_action :domain, only: [:update, :verify, :destroy]
-
- feature_category :serverless
-
- def index
- @domain = PagesDomain.instance_serverless.first_or_initialize
- end
-
- def create
- if PagesDomain.instance_serverless.exists?
- return redirect_to admin_serverless_domains_path, notice: _('An instance-level serverless domain already exists.')
- end
-
- @domain = PagesDomain.instance_serverless.create(create_params)
-
- if @domain.persisted?
- redirect_to admin_serverless_domains_path, notice: _('Domain was successfully created.')
- else
- render 'index'
- end
- end
-
- def update
- if domain.update(update_params)
- redirect_to admin_serverless_domains_path, notice: _('Domain was successfully updated.')
- else
- render 'index'
- end
- end
-
- def destroy
- if domain.serverless_domain_clusters.exists?
- return redirect_to admin_serverless_domains_path,
- status: :conflict,
- notice: _('Domain cannot be deleted while associated to one or more clusters.')
- end
-
- domain.destroy!
-
- redirect_to admin_serverless_domains_path,
- status: :found,
- notice: _('Domain was successfully deleted.')
- end
-
- def verify
- result = VerifyPagesDomainService.new(domain).execute
-
- if result[:status] == :success
- flash[:notice] = _('Successfully verified domain ownership')
- else
- flash[:alert] = _('Failed to verify domain ownership')
- end
-
- redirect_to admin_serverless_domains_path
- end
-
- private
-
- def domain
- @domain = PagesDomain.instance_serverless.find(params[:id])
- end
-
- def check_feature_flag
- render_404 unless Feature.enabled?(:serverless_domain)
- end
-
- def update_params
- params.require(:pages_domain).permit(:user_provided_certificate, :user_provided_key)
- end
-
- def create_params
- params.require(:pages_domain).permit(:domain, :user_provided_certificate, :user_provided_key)
- end
-end
diff --git a/app/controllers/admin/topics/avatars_controller.rb b/app/controllers/admin/topics/avatars_controller.rb
new file mode 100644
index 00000000000..7acdec424b4
--- /dev/null
+++ b/app/controllers/admin/topics/avatars_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Admin::Topics::AvatarsController < Admin::ApplicationController
+ feature_category :projects
+
+ def destroy
+ @topic = Projects::Topic.find(params[:topic_id])
+
+ @topic.remove_avatar!
+ @topic.save
+
+ redirect_to edit_admin_topic_path(@topic), status: :found
+ end
+end
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
new file mode 100644
index 00000000000..ccc38ba7cd5
--- /dev/null
+++ b/app/controllers/admin/topics_controller.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class Admin::TopicsController < Admin::ApplicationController
+ include SendFileUpload
+ include PreviewMarkdown
+
+ before_action :topic, only: [:edit, :update]
+
+ feature_category :projects
+
+ def index
+ @topics = Projects::TopicsFinder.new(params: params.permit(:search)).execute.page(params[:page]).without_count
+ end
+
+ def new
+ @topic = Projects::Topic.new
+ end
+
+ def edit
+ end
+
+ def create
+ @topic = Projects::Topic.new(topic_params)
+
+ if @topic.save
+ redirect_to edit_admin_topic_path(@topic), notice: _('Topic %{topic_name} was successfully created.') % { topic_name: @topic.name }
+ else
+ render "new"
+ end
+ end
+
+ def update
+ if @topic.update(topic_params)
+ redirect_to edit_admin_topic_path(@topic), notice: _('Topic was successfully updated.')
+ else
+ render "edit"
+ end
+ end
+
+ private
+
+ def topic
+ @topic ||= Projects::Topic.find(params[:id])
+ end
+
+ def topic_params
+ params.require(:projects_topic).permit(allowed_topic_params)
+ end
+
+ def allowed_topic_params
+ [
+ :avatar,
+ :description,
+ :name
+ ]
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index a83458f3260..b22167a3952 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -21,7 +21,7 @@ class ApplicationController < ActionController::Base
include Impersonation
include Gitlab::Logging::CloudflareHelper
include Gitlab::Utils::StrongMemoize
- include ::Gitlab::WithFeatureCategory
+ include ::Gitlab::EndpointAttributes
include FlocOptOut
before_action :authenticate_user!, except: [:route_not_found]
@@ -70,6 +70,10 @@ class ApplicationController < ActionController::Base
# concerns due to caching private data.
DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store"
+ def self.endpoint_id_for_action(action_name)
+ "#{self.name}##{action_name}"
+ end
+
rescue_from Encoding::CompatibilityError do |exception|
log_exception(exception)
render "errors/encoding", layout: "errors", status: :internal_server_error
@@ -104,6 +108,12 @@ class ApplicationController < ActionController::Base
head :forbidden, retry_after: Gitlab::Auth::UniqueIpsLimiter.config.unique_ips_limit_time_window
end
+ rescue_from RateLimitedService::RateLimitedError do |e|
+ e.log_request(request, current_user)
+ response.headers.merge!(e.headers)
+ render plain: e.message, status: :too_many_requests
+ end
+
def redirect_back_or_default(default: root_path, options: {})
redirect_back(fallback_location: default, **options)
end
@@ -131,6 +141,14 @@ class ApplicationController < ActionController::Base
end
end
+ def feature_category
+ self.class.feature_category_for_action(action_name).to_s
+ end
+
+ def urgency
+ self.class.urgency_for_action(action_name)
+ end
+
protected
def workhorse_excluded_content_types
@@ -457,7 +475,7 @@ class ApplicationController < ActionController::Base
user: -> { context_user },
project: -> { @project if @project&.persisted? },
namespace: -> { @group if @group&.persisted? },
- caller_id: caller_id,
+ caller_id: self.class.endpoint_id_for_action(action_name),
remote_ip: request.ip,
feature_category: feature_category
)
@@ -543,14 +561,6 @@ class ApplicationController < ActionController::Base
auth_user if strong_memoized?(:auth_user)
end
- def caller_id
- "#{self.class.name}##{action_name}"
- end
-
- def feature_category
- self.class.feature_category_for_action(action_name).to_s
- end
-
def required_signup_info
return unless current_user
return unless current_user.role_required?
diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb
index d076c62c707..35c1f358a77 100644
--- a/app/controllers/concerns/group_tree.rb
+++ b/app/controllers/concerns/group_tree.rb
@@ -38,8 +38,13 @@ module GroupTree
#
# Pagination needs to be applied before loading the ancestors to
# make sure ancestors are not cut off by pagination.
- Gitlab::ObjectHierarchy.new(Group.where(id: filtered_groups.select(:id)))
- .base_and_ancestors
+ filtered_groups_relation = Group.where(id: filtered_groups.select(:id))
+
+ if Feature.enabled?(:linear_group_tree_ancestor_scopes, current_user, default_enabled: :yaml)
+ filtered_groups_relation.self_and_ancestors
+ else
+ Gitlab::ObjectHierarchy.new(filtered_groups_relation).base_and_ancestors
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 7ee680db7f9..e1e662a1968 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -158,8 +158,10 @@ module IssuableActions
discussions = Discussion.build_collection(notes, issuable)
- if issuable.is_a?(MergeRequest) && Feature.enabled?(:merge_request_discussion_cache, issuable.target_project, default_enabled: :yaml)
- render_cached(discussions, with: discussion_serializer, context: self)
+ if issuable.is_a?(MergeRequest)
+ cache_context = [current_user&.cache_key, project.team.human_max_access(current_user&.id)].join(':')
+
+ render_cached(discussions, with: discussion_serializer, cache_context: -> (_) { cache_context }, context: self)
else
render json: discussion_serializer.represent(discussions, context: self)
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 2d8168af2e3..c2ee735a2b5 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -62,7 +62,7 @@ module NotesActions
json.merge!(note_json(@note))
end
- if @note.errors.present? && @note.errors.keys != [:commands_only]
+ if @note.errors.present? && @note.errors.attribute_names != [:commands_only]
render json: json, status: :unprocessable_entity
else
render json: json
diff --git a/app/controllers/concerns/one_trust_csp.rb b/app/controllers/concerns/one_trust_csp.rb
new file mode 100644
index 00000000000..4e98ec586ca
--- /dev/null
+++ b/app/controllers/concerns/one_trust_csp.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module OneTrustCSP
+ extend ActiveSupport::Concern
+
+ included do
+ content_security_policy do |policy|
+ next if policy.directives.blank?
+
+ default_script_src = policy.directives['script-src'] || policy.directives['default-src']
+ script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org https://*.onetrust.com']
+ policy.script_src(*script_src_values)
+
+ default_connect_src = policy.directives['connect-src'] || policy.directives['default-src']
+ connect_src_values = Array.wrap(default_connect_src) | ['https://cdn.cookielaw.org']
+ policy.connect_src(*connect_src_values)
+ end
+ end
+end
diff --git a/app/controllers/concerns/registry/connection_errors_handler.rb b/app/controllers/concerns/registry/connection_errors_handler.rb
new file mode 100644
index 00000000000..2b24f3b5b31
--- /dev/null
+++ b/app/controllers/concerns/registry/connection_errors_handler.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Registry
+ module ConnectionErrorsHandler
+ extend ActiveSupport::Concern
+
+ included do
+ rescue_from ContainerRegistry::Path::InvalidRegistryPathError, with: :invalid_registry_path
+ rescue_from Faraday::Error, with: :connection_error
+
+ before_action :ping_container_registry
+ end
+
+ private
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ # These instance variables are only read by a view helper to pass
+ # them to the frontend
+ # See app/views/projects/registry/repositories/index.html.haml
+ # app/views/groups/registry/repositories/index.html.haml
+ def invalid_registry_path
+ @invalid_path_error = true
+
+ render :index
+ end
+
+ def connection_error
+ @connection_error = true
+
+ render :index
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def ping_container_registry
+ ContainerRegistry::Client.registry_info
+ end
+ end
+end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 74ad78ff4c1..d861ef646f8 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -36,7 +36,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def starred
@projects = load_projects(params.merge(starred: true))
- .includes(:forked_from_project, :topics, :topics_acts_as_taggable)
+ .includes(:forked_from_project, :topics)
@groups = []
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 515fbd7b482..0722a712b5c 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class GraphqlController < ApplicationController
+ extend ::Gitlab::Utils::Override
+
# Unauthenticated users have access to the API for public data
skip_before_action :authenticate_user!
@@ -35,6 +37,7 @@ class GraphqlController < ApplicationController
# callback execution order here
around_action :sessionless_bypass_admin_mode!, if: :sessionless_user?
+ # The default feature category is overridden to read from request
feature_category :not_owned
def execute
@@ -64,6 +67,11 @@ class GraphqlController < ApplicationController
render_error(exception.message, status: :unprocessable_entity)
end
+ override :feature_category
+ def feature_category
+ ::Gitlab::FeatureCategories.default.from_request(request) || super
+ end
+
private
def disallow_mutations_for_get
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 60708c13b85..e8e6a7e5c1a 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -11,6 +11,7 @@ class Groups::BoardsController < Groups::ApplicationController
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
+ push_frontend_feature_flag(:labels_widget, group, default_enabled: :yaml)
end
feature_category :boards
diff --git a/app/controllers/groups/dependency_proxy/application_controller.rb b/app/controllers/groups/dependency_proxy/application_controller.rb
index fd9db41f748..18a6ff93e15 100644
--- a/app/controllers/groups/dependency_proxy/application_controller.rb
+++ b/app/controllers/groups/dependency_proxy/application_controller.rb
@@ -21,8 +21,14 @@ module Groups
authenticate_with_http_token do |token, _|
@authentication_result = EMPTY_AUTH_RESULT
- found_user = user_from_token(token)
- sign_in(found_user) if found_user.is_a?(User)
+ user_or_deploy_token = ::DependencyProxy::AuthTokenService.user_or_deploy_token_from_jwt(token)
+
+ if user_or_deploy_token.is_a?(User)
+ @authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :user, [])
+ sign_in(user_or_deploy_token)
+ elsif user_or_deploy_token.is_a?(DeployToken)
+ @authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :deploy_token, [])
+ end
end
request_bearer_token! unless authenticated_user
@@ -39,28 +45,6 @@ module Groups
response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header
render plain: '', status: :unauthorized
end
-
- def user_from_token(token)
- token_payload = ::DependencyProxy::AuthTokenService.decoded_token_payload(token)
-
- if token_payload['user_id']
- token_user = User.find(token_payload['user_id'])
- return unless token_user
-
- @authentication_result = Gitlab::Auth::Result.new(token_user, nil, :user, [])
- return token_user
- elsif token_payload['deploy_token']
- deploy_token = DeployToken.active.find_by_token(token_payload['deploy_token'])
- return unless deploy_token
-
- @authentication_result = Gitlab::Auth::Result.new(deploy_token, nil, :deploy_token, [])
- return deploy_token
- end
-
- nil
- rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature
- nil
- end
end
end
end
diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
index f7dc552bd3e..e19b8ae35f8 100644
--- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -5,11 +5,15 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy
include DependencyProxy::GroupAccess
include SendFileUpload
include ::PackagesHelper # for event tracking
+ include WorkhorseRequest
before_action :ensure_group
- before_action :ensure_token_granted!
+ before_action :ensure_token_granted!, only: [:blob, :manifest]
before_action :ensure_feature_enabled!
+ before_action :verify_workhorse_api!, only: [:authorize_upload_blob, :upload_blob]
+ skip_before_action :verify_authenticity_token, only: [:authorize_upload_blob, :upload_blob]
+
attr_reader :token
feature_category :dependency_proxy
@@ -38,6 +42,8 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy
end
def blob
+ return blob_via_workhorse if Feature.enabled?(:dependency_proxy_workhorse, group, default_enabled: :yaml)
+
result = DependencyProxy::FindOrCreateBlobService
.new(group, image, token, params[:sha]).execute
@@ -50,11 +56,47 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy
end
end
+ def authorize_upload_blob
+ set_workhorse_internal_api_content_type
+
+ render json: DependencyProxy::FileUploader.workhorse_authorize(has_length: false)
+ end
+
+ def upload_blob
+ @group.dependency_proxy_blobs.create!(
+ file_name: blob_file_name,
+ file: params[:file],
+ size: params[:file].size
+ )
+
+ event_name = tracking_event_name(object_type: :blob, from_cache: false)
+ track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user)
+
+ head :ok
+ end
+
private
+ def blob_via_workhorse
+ blob = @group.dependency_proxy_blobs.find_by_file_name(blob_file_name)
+
+ if blob.present?
+ event_name = tracking_event_name(object_type: :blob, from_cache: true)
+ track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user)
+
+ send_upload(blob.file)
+ else
+ send_dependency(token, DependencyProxy::Registry.blob_url(image, params[:sha]), blob_file_name)
+ end
+ end
+
+ def blob_file_name
+ @blob_file_name ||= params[:sha].sub('sha256:', '') + '.gz'
+ end
+
def group
strong_memoize(:group) do
- Group.find_by_full_path(params[:group_id], follow_redirects: request.get?)
+ Group.find_by_full_path(params[:group_id], follow_redirects: true)
end
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 9b8d5cfe476..6e59f159636 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -25,19 +25,15 @@ class Groups::GroupMembersController < Groups::ApplicationController
def index
@sort = params[:sort].presence || sort_value_name
- @members = GroupMembersFinder
- .new(@group, current_user, params: filter_params)
- .execute(include_relations: requested_relations)
-
if can?(current_user, :admin_group_member, @group)
@skip_groups = @group.related_group_ids
- @invited_members = @members.invite
+ @invited_members = invited_members
@invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present?
@invited_members = present_invited_members(@invited_members)
end
- @members = present_group_members(@members.non_invite)
+ @members = present_group_members(non_invited_members)
@requesters = present_members(
AccessRequestsFinder.new(@group).execute(current_user)
@@ -51,6 +47,20 @@ class Groups::GroupMembersController < Groups::ApplicationController
private
+ def group_members
+ @group_members ||= GroupMembersFinder
+ .new(@group, current_user, params: filter_params)
+ .execute(include_relations: requested_relations)
+ end
+
+ def invited_members
+ group_members.invite.with_invited_user_state
+ end
+
+ def non_invited_members
+ group_members.non_invite
+ end
+
def present_invited_members(invited_members)
present_members(invited_members
.page(params[:invited_members_page])
diff --git a/app/controllers/groups/packages_controller.rb b/app/controllers/groups/packages_controller.rb
index 47f1816cc4c..d02a8262948 100644
--- a/app/controllers/groups/packages_controller.rb
+++ b/app/controllers/groups/packages_controller.rb
@@ -6,6 +6,10 @@ module Groups
feature_category :package_registry
+ before_action do
+ push_frontend_feature_flag(:package_list_apollo, default_enabled: :yaml)
+ end
+
private
def verify_packages_enabled!
diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb
index 3aaaf6ade6b..549a148bfb8 100644
--- a/app/controllers/groups/registry/repositories_controller.rb
+++ b/app/controllers/groups/registry/repositories_controller.rb
@@ -3,6 +3,7 @@ module Groups
module Registry
class RepositoriesController < Groups::ApplicationController
include PackagesHelper
+ include ::Registry::ConnectionErrorsHandler
before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image!
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index f37c08da22a..5c21c7b023c 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -10,8 +10,10 @@ class Groups::RunnersController < Groups::ApplicationController
feature_category :runner
def index
- finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group })
- @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000)
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do
+ finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group })
+ @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000)
+ end
end
def runner_list_group_view_vue_ui_enabled
@@ -61,9 +63,11 @@ class Groups::RunnersController < Groups::ApplicationController
private
def runner
- @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute
- .except(:limit, :offset)
- .find(params[:id])
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do
+ @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute
+ .except(:limit, :offset)
+ .find(params[:id])
+ end
end
def runner_params
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index a290ef9b5e7..e125385f841 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -23,6 +23,11 @@ module Groups
@group_runners = runners_finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
@sort = runners_finder.sort_key
+
+ # Allow sql generated by the two relations above, @all_group_runners and @group_runners
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do
+ render
+ end
end
def update
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index 99b0b775217..071378f266e 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -16,6 +16,8 @@ class HealthController < ActionController::Base
Gitlab::HealthChecks::Redis::QueuesCheck,
Gitlab::HealthChecks::Redis::SharedStateCheck,
Gitlab::HealthChecks::Redis::TraceChunksCheck,
+ Gitlab::HealthChecks::Redis::RateLimitingCheck,
+ Gitlab::HealthChecks::Redis::SessionsCheck,
Gitlab::HealthChecks::GitalyCheck
].freeze
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index a1fb74cf277..0ad7478584f 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -72,7 +72,6 @@ class HelpController < ApplicationController
end
def redirect_to_documentation_website?
- return false unless Feature.enabled?(:help_page_documentation_redirect)
return false unless Gitlab::UrlSanitizer.valid_web?(documentation_url)
true
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index da936215ad4..bec26cb547d 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -22,13 +22,16 @@ class Import::BulkImportsController < ApplicationController
def status
respond_to do |format|
format.json do
- data = importable_data
+ data = ::BulkImports::GetImportableDataService.new(params, query_params, credentials).execute
pagination_headers.each do |header|
- response.set_header(header, data.headers[header])
+ response.set_header(header, data[:response].headers[header])
end
- render json: { importable_data: serialized_data(data.parsed_response) }
+ json_response = { importable_data: serialized_data(data[:response].parsed_response) }
+ json_response[:version_validation] = data[:version_validation]
+
+ render json: json_response
end
format.html do
@source_url = session[url_key]
@@ -37,7 +40,7 @@ class Import::BulkImportsController < ApplicationController
end
def create
- response = BulkImportService.new(current_user, create_params, credentials).execute
+ response = ::BulkImports::CreateService.new(current_user, create_params, credentials).execute
if response.success?
render json: response.payload.to_json(only: [:id])
@@ -66,10 +69,6 @@ class Import::BulkImportsController < ApplicationController
@serializer ||= BaseSerializer.new(current_user: current_user)
end
- def importable_data
- client.get('groups', query_params)
- end
-
# Default query string params used to fetch groups from GitLab source instance
#
# top_level_only: fetch only top level groups (subgroups are fetched during import itself)
@@ -85,15 +84,6 @@ class Import::BulkImportsController < ApplicationController
query_params
end
- def client
- @client ||= BulkImports::Clients::HTTP.new(
- url: session[url_key],
- token: session[access_token_key],
- per_page: params[:per_page],
- page: params[:page]
- )
- end
-
def configure_params
params.permit(access_token_key, url_key)
end
diff --git a/app/controllers/import/url_controller.rb b/app/controllers/import/url_controller.rb
new file mode 100644
index 00000000000..4e4b6ad125e
--- /dev/null
+++ b/app/controllers/import/url_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class Import::UrlController < ApplicationController
+ feature_category :importers
+
+ def validate
+ result = Import::ValidateRemoteGitEndpointService.new(validate_params).execute
+ if result.success?
+ render json: { success: true }
+ else
+ render json: { success: false, message: result.message }
+ end
+ end
+
+ private
+
+ def validate_params
+ params.permit(:user, :password, :url)
+ end
+end
diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb
index 74fac6ff9bb..e96242c7052 100644
--- a/app/controllers/jira_connect/app_descriptor_controller.rb
+++ b/app/controllers/jira_connect/app_descriptor_controller.rb
@@ -32,6 +32,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
apiVersion: 1,
apiMigrations: {
'context-qsh': true,
+ 'signed-install': signed_install_active?,
gdpr: true
}
}
diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb
index 352e78d6255..ecb23c326fe 100644
--- a/app/controllers/jira_connect/application_controller.rb
+++ b/app/controllers/jira_connect/application_controller.rb
@@ -74,4 +74,8 @@ class JiraConnect::ApplicationController < ApplicationController
params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last
end
end
+
+ def signed_install_active?
+ Feature.enabled?(:jira_connect_asymmetric_jwt)
+ end
end
diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb
index fe66e742c44..76ac15f7631 100644
--- a/app/controllers/jira_connect/events_controller.rb
+++ b/app/controllers/jira_connect/events_controller.rb
@@ -3,13 +3,18 @@
class JiraConnect::EventsController < JiraConnect::ApplicationController
# See https://developer.atlassian.com/cloud/jira/software/app-descriptor/#lifecycle
- skip_before_action :verify_atlassian_jwt!, only: :installed
- before_action :verify_qsh_claim!, only: :uninstalled
+ skip_before_action :verify_atlassian_jwt!
+ before_action :verify_asymmetric_atlassian_jwt!, if: :signed_install_active?
+
+ before_action :verify_atlassian_jwt!, only: :uninstalled, unless: :signed_install_active?
+ before_action :verify_qsh_claim!, only: :uninstalled, unless: :signed_install_active?
def installed
- return head :ok if atlassian_jwt_valid?
+ return head :ok if !signed_install_active? && atlassian_jwt_valid?
+
+ return head :ok if current_jira_installation
- installation = JiraConnectInstallation.new(install_params)
+ installation = JiraConnectInstallation.new(event_params)
if installation.save
head :ok
@@ -28,7 +33,23 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController
private
- def install_params
+ def event_params
params.permit(:clientKey, :sharedSecret, :baseUrl).transform_keys(&:underscore)
end
+
+ def verify_asymmetric_atlassian_jwt!
+ asymmetric_jwt = Atlassian::JiraConnect::AsymmetricJwt.new(auth_token, jwt_verification_claims)
+
+ return head :unauthorized unless asymmetric_jwt.valid?
+
+ @current_jira_installation = JiraConnectInstallation.find_by_client_key(asymmetric_jwt.iss_claim)
+ end
+
+ def jwt_verification_claims
+ {
+ aud: jira_connect_base_url(protocol: 'https'),
+ iss: event_params[:client_key],
+ qsh: Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url)
+ }
+ end
end
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index a0c307a0a03..d3dea2ce159 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -7,6 +7,7 @@ class MetricsController < ActionController::Base
def index
response = if Gitlab::Metrics.prometheus_metrics_enabled?
+ Gitlab::Metrics::RailsSlis.initialize_request_slis_if_needed!
metrics_service.metrics_text
else
help_page = help_page_url('administration/monitoring/prometheus/gitlab_metrics',
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index c8c2dd1c7d6..5eb0f80ddc9 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -15,17 +15,11 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end
def create
- unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password])
+ unless @user.password_automatically_set || @user.valid_password?(user_params[:password])
redirect_to new_profile_password_path, alert: _('You must provide a valid current password')
return
end
- password_attributes = {
- password: user_params[:password],
- password_confirmation: user_params[:password_confirmation],
- password_automatically_set: false
- }
-
result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute
if result[:status] == :success
@@ -41,12 +35,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end
def update
- password_attributes = user_params.select do |key, value|
- %w(password password_confirmation).include?(key.to_s)
- end
- password_attributes[:password_automatically_set] = false
-
- unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password])
+ unless @user.password_automatically_set || @user.valid_password?(user_params[:password])
handle_invalid_current_password_attempt!
redirect_to edit_profile_password_path, alert: _('You must provide a valid current password')
@@ -94,6 +83,14 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end
def user_params
- params.require(:user).permit(:current_password, :password, :password_confirmation)
+ params.require(:user).permit(:password, :new_password, :password_confirmation)
+ end
+
+ def password_attributes
+ {
+ password: user_params[:new_password],
+ password_confirmation: user_params[:password_confirmation],
+ password_automatically_set: false
+ }
end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index de22a0e47d5..e0b5d6be155 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -237,8 +237,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
end
def ensure_verified_primary_email
- return unless Feature.enabled?(:ensure_verified_primary_email_for_2fa, default_enabled: :yaml)
-
unless current_user.two_factor_enabled? || current_user.primary_email_verified?
redirect_to profile_emails_path, notice: s_('You need to verify your primary email first before enabling Two-Factor Authentication.')
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 29ae268ef67..69257081cc9 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -18,7 +18,7 @@ class ProfilesController < Profiles::ApplicationController
def update
respond_to do |format|
- result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute
+ result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute(check_password: true)
if result[:status] == :success
message = s_("Profiles|Profile was successfully updated")
@@ -129,6 +129,7 @@ class ProfilesController < Profiles::ApplicationController
:job_title,
:pronouns,
:pronunciation,
+ :validation_password,
status: [:emoji, :message, :availability]
]
end
diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb
index db5d91308db..95b403faf55 100644
--- a/app/controllers/projects/alerting/notifications_controller.rb
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -3,6 +3,8 @@
module Projects
module Alerting
class NotificationsController < Projects::ApplicationController
+ include ActionController::HttpAuthentication::Basic
+
respond_to :json
skip_before_action :verify_authenticity_token
@@ -27,9 +29,19 @@ module Projects
end
def extract_alert_manager_token(request)
+ extract_bearer_token(request) || extract_basic_auth_token(request)
+ end
+
+ def extract_bearer_token(request)
Doorkeeper::OAuth::Token.from_bearer_authorization(request)
end
+ def extract_basic_auth_token(request)
+ _username, token = user_name_and_password(request)
+
+ token
+ end
+
def notify_service
notify_service_class.new(project, notification_payload)
end
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index f75ab5cdbf2..0cd59c136e5 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -24,7 +24,10 @@ class Projects::BadgesController < Projects::ApplicationController
.new(project, params[:ref], opts: {
job: params[:job],
key_text: params[:key_text],
- key_width: params[:key_width]
+ key_width: params[:key_width],
+ min_good: params[:min_good],
+ min_acceptable: params[:min_acceptable],
+ min_medium: params[:min_medium]
})
render_badge coverage_report
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index acf6b6116b8..17fd28ee06a 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -43,6 +43,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 316582f3994..834e4baa7dd 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -11,6 +11,7 @@ class Projects::BoardsController < Projects::ApplicationController
push_frontend_feature_flag(:issue_boards_filtered_search, project, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
+ push_frontend_feature_flag(:labels_widget, project, default_enabled: :yaml)
end
feature_category :boards
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 3be10559e80..b75effc52d1 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -33,6 +33,11 @@ class Projects::BranchesController < Projects::ApplicationController
Gitlab::GitalyClient.allow_n_plus_1_calls do
render
end
+ rescue Gitlab::Git::CommandError => e
+ Gitlab::ErrorTracking.track_exception(e)
+
+ @gitaly_unavailable = true
+ render
end
format.json do
branches = BranchesFinder.new(@repository, params).execute
diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
index fee216da492..b2b5e096105 100644
--- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
+++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
@@ -4,7 +4,7 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
before_action :authorize_read_build_report_results!
before_action :validate_param_type!
- feature_category :continuous_integration
+ feature_category :code_testing
def index
respond_to do |format|
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 550877548e1..22cd247644d 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -3,8 +3,7 @@
class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
- push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:pipeline_editor_drawer, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:pipeline_editor_mini_graph, @project, default_enabled: :yaml)
push_frontend_feature_flag(:schema_linting, @project, default_enabled: :yaml)
end
diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb
new file mode 100644
index 00000000000..e7fbe93131d
--- /dev/null
+++ b/app/controllers/projects/cluster_agents_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Projects::ClusterAgentsController < Projects::ApplicationController
+ before_action :authorize_can_read_cluster_agent!
+
+ feature_category :kubernetes_management
+
+ def show
+ @agent_name = params[:name]
+ end
+
+ private
+
+ def authorize_can_read_cluster_agent!
+ return if can?(current_user, :admin_cluster, project)
+
+ access_denied!
+ end
+end
diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb
new file mode 100644
index 00000000000..d185457aeb3
--- /dev/null
+++ b/app/controllers/projects/google_cloud_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class Projects::GoogleCloudController < Projects::ApplicationController
+ before_action :authorize_can_manage_google_cloud_deployments!
+
+ feature_category :release_orchestration
+
+ def index
+ end
+
+ private
+
+ def authorize_can_manage_google_cloud_deployments!
+ access_denied! unless can?(current_user, :manage_project_google_cloud, project)
+ end
+end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index f885ff9b45b..fd508d5f127 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -37,7 +37,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_download_code!, only: [:related_branches]
# Limit the amount of issues created per minute
- before_action :create_rate_limit, only: [:create]
+ before_action :create_rate_limit, only: [:create], if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) }
before_action do
push_frontend_feature_flag(:tribute_autocomplete, @project)
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 778623a05c6..994be5c2b5c 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -44,7 +44,7 @@ class Projects::JobsController < Projects::ApplicationController
render json: BuildSerializer
.new(project: @project, current_user: @current_user)
- .represent(@build, {}, BuildDetailsEntity)
+ .represent(@build.present(current_user: current_user), {}, BuildDetailsEntity)
end
end
end
@@ -120,7 +120,7 @@ class Projects::JobsController < Projects::ApplicationController
def status
render json: BuildSerializer
.new(project: @project, current_user: @current_user)
- .represent_status(@build)
+ .represent_status(@build.present(current_user: current_user))
end
def erase
@@ -225,7 +225,6 @@ class Projects::JobsController < Projects::ApplicationController
def find_job_as_build
@build = project.builds.find(params[:id])
- .present(current_user: current_user)
end
def find_job_as_processable
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index cb68aaf4583..46df514abcb 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -37,10 +37,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
push_frontend_feature_flag(:paginated_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml)
push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:mr_changes_fluid_layout, project, default_enabled: :yaml)
# Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
@@ -192,15 +192,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
Gitlab::PollingInterval.set_header(response, interval: 10_000)
- render json: {
- pipelines: PipelineSerializer
- .new(project: @project, current_user: @current_user)
- .with_pagination(request, response)
- .represent(@pipelines),
- count: {
- all: @pipelines.count
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do
+ render json: {
+ pipelines: PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(@pipelines),
+ count: {
+ all: @pipelines.count
+ }
}
- }
+ end
end
def sast_reports
diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb
index 5de71466c10..dd7c2ad3cbd 100644
--- a/app/controllers/projects/packages/packages_controller.rb
+++ b/app/controllers/projects/packages/packages_controller.rb
@@ -7,6 +7,10 @@ module Projects
feature_category :package_registry
+ before_action do
+ push_frontend_feature_flag(:package_list_apollo, default_enabled: :yaml)
+ end
+
def show
@package = project.packages.find(params[:id])
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index b979276437c..e8074f7d793 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -19,16 +19,12 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@group_links = @project.project_group_links
@group_links = @group_links.search(params[:search_groups]) if params[:search_groups].present?
- project_members = MembersFinder
- .new(@project, current_user, params: filter_params)
- .execute(include_relations: requested_relations)
-
if can?(current_user, :admin_project_member, @project)
- @invited_members = present_members(project_members.invite)
+ @invited_members = present_members(invited_members)
@requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user))
end
- @project_members = present_members(project_members.non_invite.page(params[:page]))
+ @project_members = present_members(non_invited_members.page(params[:page]))
@project_member = @project.project_members.new
end
@@ -55,6 +51,20 @@ class Projects::ProjectMembersController < Projects::ApplicationController
private
+ def members
+ @members ||= MembersFinder
+ .new(@project, current_user, params: filter_params)
+ .execute(include_relations: requested_relations)
+ end
+
+ def invited_members
+ members.invite.with_invited_user_state
+ end
+
+ def non_invited_members
+ members.non_invite
+ end
+
def filter_params
params.permit(:search).merge(sort: @sort)
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 8acebd89033..ad3b2bc98e7 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -4,6 +4,7 @@ module Projects
module Registry
class RepositoriesController < ::Projects::Registry::ApplicationController
include PackagesHelper
+ include ::Registry::ConnectionErrorsHandler
before_action :authorize_update_container_image!, only: [:destroy]
@@ -48,8 +49,6 @@ module Projects
repository.save! if repository.has_tags?
end
end
- rescue ContainerRegistry::Path::InvalidRegistryPathError
- @character_error = true
end
end
end
diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb
index 19de157357a..444f4783a19 100644
--- a/app/controllers/projects/security/configuration_controller.rb
+++ b/app/controllers/projects/security/configuration_controller.rb
@@ -5,7 +5,7 @@ module Projects
class ConfigurationController < Projects::ApplicationController
include SecurityAndCompliancePermissions
- feature_category :static_application_security_testing
+ feature_category :static_application_security_testing, [:show]
def show
render_403 unless can?(current_user, :read_security_configuration, project)
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
index 4168880001c..3fc379a135a 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -5,7 +5,7 @@ module Projects
class FunctionsController < Projects::ApplicationController
before_action :authorize_read_cluster!
- feature_category :serverless
+ feature_category :not_owned
def index
respond_to do |format|
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 960c0beb244..3033dac8246 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -25,6 +25,11 @@ module Projects
@project.triggers, current_user: current_user, project: @project
).to_json
end
+
+ # @assignable_runners is using ci_owned_runners
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336436') do
+ render
+ end
end
def update
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index 94b0473e1f3..02d36c3353d 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -18,17 +18,21 @@ class Projects::TagsController < Projects::ApplicationController
params[:sort] = params[:sort].presence || sort_value_recently_updated
@sort = params[:sort]
- @tags = TagsFinder.new(@repository, params).execute
- @tags = Kaminari.paginate_array(@tags).page(params[:page])
+ @tags, @tags_loading_error = TagsFinder.new(@repository, params).execute
+
+ @tags = Kaminari.paginate_array(@tags).page(params[:page])
tag_names = @tags.map(&:name)
@tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names)
+
@releases = project.releases.where(tag: tag_names)
@tag_pipeline_statuses = Ci::CommitStatusesFinder.new(@project, @repository, current_user, @tags).execute
respond_to do |format|
- format.html
- format.atom { render layout: 'xml.atom' }
+ status = @tags_loading_error ? :service_unavailable : :ok
+
+ format.html { render status: status }
+ format.atom { render layout: 'xml.atom', status: status }
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 6fd4c632dd3..a76d45411dd 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -16,7 +16,9 @@ class Projects::TreeController < Projects::ApplicationController
before_action :authorize_edit_tree!, only: [:create_dir]
before_action do
+ push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
end
feature_category :source_code_management
diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb
index 179c7fc8db1..103e1cc596a 100644
--- a/app/controllers/projects/usage_quotas_controller.rb
+++ b/app/controllers/projects/usage_quotas_controller.rb
@@ -9,6 +9,7 @@ class Projects::UsageQuotasController < Projects::ApplicationController
feature_category :utilization
def index
+ @hide_search_settings = true
@storage_app_data = {
project_path: @project.full_path,
usage_quotas_help_page_path: help_page_path('user/usage_quotas'),
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index de4e51a3a2f..26da0436dd8 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -33,9 +33,12 @@ class ProjectsController < Projects::ApplicationController
before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export]
before_action do
+ push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
end
layout :determine_layout
@@ -72,6 +75,13 @@ class ProjectsController < Projects::ApplicationController
@project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
+ experiment(:new_project_sast_enabled, user: current_user).track(:created,
+ property: active_new_project_tab,
+ checked: Gitlab::Utils.to_boolean(project_params[:initialize_with_sast]),
+ project: @project,
+ namespace: @project.namespace
+ )
+
redirect_to(
project_path(@project, custom_import_params),
notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name }
@@ -283,9 +293,9 @@ class ProjectsController < Projects::ApplicationController
end
if find_tags && @repository.tag_count.nonzero?
- tags = TagsFinder.new(@repository, params).execute.take(100).map(&:name)
+ tags, _ = TagsFinder.new(@repository, params).execute
- options['Tags'] = tags
+ options['Tags'] = tags.take(100).map(&:name)
end
# If reference is commit id - we should add it to branch/tag selectbox
@@ -435,6 +445,7 @@ class ProjectsController < Projects::ApplicationController
:template_name,
:template_project_id,
:merge_method,
+ :initialize_with_sast,
:initialize_with_readme,
:autoclose_referenced_issues,
:suggestion_commit_message,
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index fe800de5dd8..450c12a233b 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -5,6 +5,7 @@ class RegistrationsController < Devise::RegistrationsController
include AcceptsPendingInvitations
include RecaptchaHelper
include InvisibleCaptchaOnSignup
+ include OneTrustCSP
layout 'devise'
@@ -45,6 +46,11 @@ class RegistrationsController < Devise::RegistrationsController
end
def destroy
+ if current_user.required_terms_not_accepted?
+ redirect_to profile_account_path, status: :see_other, alert: s_('Profiles|You must accept the Terms of Service in order to perform this action.')
+ return
+ end
+
if destroy_confirmation_valid?
current_user.delete_async(deleted_by: current_user)
session.try(:destroy)
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index e51bfe6a37e..c3c6a51239d 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -11,6 +11,9 @@ module Repositories
rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404_with_exception
rescue_from Gitlab::GitAccessProject::CreationError, with: :render_422_with_exception
rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503_with_exception
+ rescue_from GRPC::Unavailable do |e|
+ render_503_with_exception(e, message: 'The git server, Gitaly, is not available at this time. Please contact your administrator.')
+ end
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
@@ -71,8 +74,8 @@ module Repositories
render plain: exception.message, status: :unprocessable_entity
end
- def render_503_with_exception(exception)
- render plain: exception.message, status: :service_unavailable
+ def render_503_with_exception(exception, message: nil)
+ render plain: message || exception.message, status: :service_unavailable
end
def update_fetch_statistics
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 5f1b3750e41..0a18559fc81 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -12,6 +12,7 @@ class SearchController < ApplicationController
around_action :allow_gitaly_ref_name_caching
before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch
+ before_action :strip_surrounding_whitespace_from_search, except: :opensearch
skip_before_action :authenticate_user!
requires_cross_project_access if: -> do
search_term_present = params[:search].present? || params[:term].present?
@@ -23,6 +24,7 @@ class SearchController < ApplicationController
layout 'search'
feature_category :global_search
+ urgency :high, [:opensearch]
def show
@project = search_service.project
@@ -196,6 +198,10 @@ class SearchController < ApplicationController
def count_action_name?
action_name.to_sym == :count
end
+
+ def strip_surrounding_whitespace_from_search
+ %i(term search).each { |param| params[param]&.strip! }
+ end
end
SearchController.prepend_mod_with('SearchController')
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 4fcf82c605b..bbd7e5d5725 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -9,6 +9,7 @@ class SessionsController < Devise::SessionsController
include RendersLdapServers
include KnownSignIn
include Gitlab::Utils::StrongMemoize
+ include OneTrustCSP
skip_before_action :check_two_factor_requirement, only: [:destroy]
skip_before_action :check_password_expiration, only: [:destroy]
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index d040ac7f76c..d7eb3ccd274 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -13,6 +13,7 @@ class UploadsController < ApplicationController
"group" => Group,
"appearance" => Appearance,
"personal_snippet" => PersonalSnippet,
+ "projects/topic" => Projects::Topic,
nil => PersonalSnippet
}.freeze
@@ -54,6 +55,8 @@ class UploadsController < ApplicationController
!secret? || can?(current_user, :update_user, model)
when Appearance
true
+ when Projects::Topic
+ true
else
permission = "read_#{model.class.underscore}".to_sym
@@ -85,7 +88,7 @@ class UploadsController < ApplicationController
def cache_settings
case model
- when User, Appearance
+ when User, Appearance, Projects::Topic
[5.minutes, { public: true, must_revalidate: false }]
when Project, Group
[5.minutes, { private: true, must_revalidate: true }]
diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb
new file mode 100644
index 00000000000..1ab86d70134
--- /dev/null
+++ b/app/experiments/new_project_sast_enabled_experiment.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class NewProjectSastEnabledExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+ def publish(_result = nil)
+ super
+
+ publish_to_database
+ end
+
+ def candidate_behavior
+ end
+
+ def free_indicator_behavior
+ end
+end
diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb
index 5d794c0903a..9476c30f525 100644
--- a/app/finders/ci/pipelines_for_merge_request_finder.rb
+++ b/app/finders/ci/pipelines_for_merge_request_finder.rb
@@ -5,6 +5,8 @@ module Ci
class PipelinesForMergeRequestFinder
include Gitlab::Utils::StrongMemoize
+ COMMITS_LIMIT = 100
+
def initialize(merge_request, current_user)
@merge_request = merge_request
@current_user = current_user
@@ -12,7 +14,7 @@ module Ci
attr_reader :merge_request, :current_user
- delegate :commit_shas, :target_project, :source_project, :source_branch, to: :merge_request
+ delegate :recent_diff_head_shas, :commit_shas, :target_project, :source_project, :source_branch, to: :merge_request
# Fetch all pipelines that the user can read.
def execute
@@ -35,7 +37,7 @@ module Ci
pipelines =
if merge_request.persisted?
- pipelines_using_cte
+ all_pipelines_for_merge_request
else
triggered_for_branch.for_sha(commit_shas)
end
@@ -79,6 +81,17 @@ module Ci
pipelines.joins(shas_table) # rubocop: disable CodeReuse/ActiveRecord
end
+ def all_pipelines_for_merge_request
+ if Feature.enabled?(:decomposed_ci_query_in_pipelines_for_merge_request_finder, target_project, default_enabled: :yaml)
+ pipelines_for_merge_request = triggered_by_merge_request
+ pipelines_for_branch = triggered_for_branch.for_sha(recent_diff_head_shas(COMMITS_LIMIT))
+
+ Ci::Pipeline.from_union([pipelines_for_merge_request, pipelines_for_branch])
+ else
+ pipelines_using_cte
+ end
+ end
+
# NOTE: this method returns only parent merge request pipelines.
# Child merge request pipelines have a different source.
def triggered_by_merge_request
diff --git a/app/finders/clusters/agents_finder.rb b/app/finders/clusters/agents_finder.rb
new file mode 100644
index 00000000000..136bbf16981
--- /dev/null
+++ b/app/finders/clusters/agents_finder.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Clusters
+ class AgentsFinder
+ def initialize(project, current_user, params: {})
+ @project = project
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return ::Clusters::Agent.none unless can_read_cluster_agents?
+
+ agents = project.cluster_agents
+ agents = agents.with_name(params[:name]) if params[:name].present?
+
+ agents.ordered_by_name
+ end
+
+ private
+
+ attr_reader :project, :current_user, :params
+
+ def can_read_cluster_agents?
+ current_user.can?(:read_cluster, project)
+ end
+ end
+end
diff --git a/app/finders/concerns/packages/finder_helper.rb b/app/finders/concerns/packages/finder_helper.rb
index d2784a1d270..0ae99782cd3 100644
--- a/app/finders/concerns/packages/finder_helper.rb
+++ b/app/finders/concerns/packages/finder_helper.rb
@@ -54,6 +54,12 @@ module Packages
packages.search_by_name(params[:package_name])
end
+ def filter_by_exact_package_name(packages)
+ return packages unless params[:package_name].present?
+
+ packages.with_name(params[:package_name])
+ end
+
def filter_by_package_version(packages)
return packages unless params[:package_version].present?
diff --git a/app/finders/error_tracking/errors_finder.rb b/app/finders/error_tracking/errors_finder.rb
index d83a0c487e6..c361d6e2fc2 100644
--- a/app/finders/error_tracking/errors_finder.rb
+++ b/app/finders/error_tracking/errors_finder.rb
@@ -15,8 +15,7 @@ module ErrorTracking
collection = by_status(collection)
collection = sort(collection)
- # Limit collection until pagination implemented.
- limit(collection)
+ collection.keyset_paginate(cursor: params[:cursor], per_page: limit)
end
private
@@ -39,9 +38,9 @@ module ErrorTracking
params[:sort] ? collection.sort_by_attribute(params[:sort]) : collection.order_id_desc
end
- def limit(collection)
+ def limit
# Restrict the maximum limit at 100 records.
- collection.limit([(params[:limit] || 20).to_i, 100].min)
+ [(params[:limit] || 20).to_i, 100].min
end
end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index cf706a8f98e..7b0cd17a761 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -194,8 +194,7 @@ class IssuableFinder
def use_cte_for_search?
strong_memoize(:use_cte_for_search) do
next false unless search
- # Only simple unsorted & simple sorts can use CTE
- next false if params[:sort].present? && !params[:sort].in?(klass.simple_sorts.keys)
+ next false unless default_or_simple_sort?
attempt_group_search_optimizations? || attempt_project_search_optimizations?
end
@@ -244,6 +243,10 @@ class IssuableFinder
klass.all
end
+ def default_or_simple_sort?
+ params[:sort].blank? || params[:sort].to_s.in?(klass.simple_sorts.keys)
+ end
+
def attempt_group_search_optimizations?
params[:attempt_group_search_optimizations]
end
diff --git a/app/finders/issuables/label_filter.rb b/app/finders/issuables/label_filter.rb
index 2bbc963aa90..f4712fa6879 100644
--- a/app/finders/issuables/label_filter.rb
+++ b/app/finders/issuables/label_filter.rb
@@ -89,17 +89,25 @@ module Issuables
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def find_label_ids(label_names)
- group_labels = Label
- .where(project_id: nil)
- .where(title: label_names)
- .where(group_id: root_namespace.self_and_descendant_ids)
+ find_label_ids_uncached(label_names)
+ end
+ # Avoid repeating label queries times when the finder is instantiated multiple times during the request.
+ request_cache(:find_label_ids) { root_namespace.id }
- project_labels = Label
- .where(group_id: nil)
- .where(title: label_names)
- .where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
+ # This returns an array of label IDs per label name. It is possible for a label name
+ # to have multiple IDs because we allow labels with the same name if they are on a different
+ # project or group.
+ #
+ # For example, if we pass in `['bug', 'feature']`, this will return something like:
+ # `[ [1, 2], [3] ]`
+ #
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_label_ids_uncached(label_names)
+ return [] if label_names.empty?
+
+ group_labels = group_labels_for_root_namespace.where(title: label_names)
+ project_labels = project_labels_for_root_namespace.where(title: label_names)
Label
.from_union([group_labels, project_labels], remove_duplicates: false)
@@ -109,8 +117,18 @@ module Issuables
.values
.map { |labels| labels.map(&:last) }
end
- # Avoid repeating label queries times when the finder is instantiated multiple times during the request.
- request_cache(:find_label_ids) { root_namespace.id }
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def group_labels_for_root_namespace
+ Label.where(project_id: nil).where(group_id: root_namespace.self_and_descendant_ids)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def project_labels_for_root_namespace
+ Label.where(group_id: nil).where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
+ end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
@@ -153,3 +171,5 @@ module Issuables
end
end
end
+
+Issuables::LabelFilter.prepend_mod
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index abf0c180d6b..21a19aa22a1 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -91,6 +91,12 @@ class IssuesFinder < IssuableFinder
by_issue_types(issues)
end
+ # Negates all params found in `negatable_params`
+ def filter_negated_items(items)
+ issues = super
+ by_negated_issue_types(issues)
+ end
+
def by_confidential(items)
return items if params[:confidential].nil?
@@ -122,6 +128,13 @@ class IssuesFinder < IssuableFinder
items.with_issue_type(params[:issue_types])
end
+
+ def by_negated_issue_types(items)
+ issue_type_params = Array(not_params[:issue_types]).map(&:to_s) & WorkItem::Type.base_types.keys
+ return items if issue_type_params.blank?
+
+ items.without_issue_type(issue_type_params)
+ end
end
IssuesFinder.prepend_mod_with('IssuesFinder')
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index ea101cf1dcd..0faafa6df9c 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -70,11 +70,16 @@ class MembersFinder
end
def project_invited_groups
- invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy
- .new(project.invited_groups)
- .base_and_ancestors
- .public_or_visible_to_user(current_user)
- .select(:id)
+ invited_groups_and_ancestors = if ::Feature.enabled?(:linear_members_finder_ancestor_scopes, current_user, default_enabled: :yaml)
+ project.invited_groups
+ .self_and_ancestors
+ else
+ Gitlab::ObjectHierarchy
+ .new(project.invited_groups)
+ .base_and_ancestors
+ end
+
+ invited_groups_ids_including_ancestors = invited_groups_and_ancestors.public_or_visible_to_user(current_user).select(:id)
GroupMember.with_source_id(invited_groups_ids_including_ancestors).non_minimal_access
end
diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb
index e753fa4d455..3ac5f00d518 100644
--- a/app/finders/packages/group_packages_finder.rb
+++ b/app/finders/packages/group_packages_finder.rb
@@ -4,7 +4,7 @@ module Packages
class GroupPackagesFinder
include ::Packages::FinderHelper
- def initialize(current_user, group, params = { exclude_subgroups: false, order_by: 'created_at', sort: 'asc' })
+ def initialize(current_user, group, params = { exclude_subgroups: false, exact_name: false, order_by: 'created_at', sort: 'asc' })
@current_user = current_user
@group = group
@params = params
@@ -30,7 +30,7 @@ module Packages
packages = filter_with_version(packages)
packages = filter_by_package_type(packages)
- packages = filter_by_package_name(packages)
+ packages = (params[:exact_name] ? filter_by_exact_package_name(packages) : filter_by_package_name(packages))
packages = filter_by_package_version(packages)
installable_only ? packages.installable : filter_by_status(packages)
end
diff --git a/app/finders/projects/members/effective_access_level_finder.rb b/app/finders/projects/members/effective_access_level_finder.rb
index c1e3842a9e4..d238679f2fb 100644
--- a/app/finders/projects/members/effective_access_level_finder.rb
+++ b/app/finders/projects/members/effective_access_level_finder.rb
@@ -99,7 +99,7 @@ module Projects
end
def include_membership_from_project_group_shares?
- project.allowed_to_share_with_group? && project.project_group_links.any?
+ !project.namespace.share_with_group_lock && project.project_group_links.any?
end
# methods for `select` options
diff --git a/app/finders/projects/topics_finder.rb b/app/finders/projects/topics_finder.rb
new file mode 100644
index 00000000000..7c3abc27cf7
--- /dev/null
+++ b/app/finders/projects/topics_finder.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# Used to filter project topics by a set of params
+#
+# Arguments:
+# params:
+# search: string
+module Projects
+ class TopicsFinder
+ def initialize(params: {})
+ @params = params
+ end
+
+ def execute
+ topics = Projects::Topic.order_by_total_projects_count
+ by_search(topics)
+ end
+
+ private
+
+ attr_reader :current_user, :params
+
+ def by_search(topics)
+ return topics unless params[:search].present?
+
+ topics.search(params[:search]).reorder_by_similarity(params[:search])
+ end
+ end
+end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 5537058cc79..7245bb36ac9 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -182,8 +182,8 @@ class ProjectsFinder < UnionFinder
def by_topics(items)
return items unless params[:topic].present?
- topics = params[:topic].instance_of?(String) ? params[:topic].strip.split(/\s*,\s*/) : params[:topic]
- topics.each do |topic|
+ topics = params[:topic].instance_of?(String) ? params[:topic].split(',') : params[:topic]
+ topics.map(&:strip).uniq.reject(&:empty?).each do |topic|
items = items.with_topic(topic)
end
diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb
index d9848d027cf..0ccbbdc1b87 100644
--- a/app/finders/tags_finder.rb
+++ b/app/finders/tags_finder.rb
@@ -7,6 +7,9 @@ class TagsFinder < GitRefsFinder
def execute
tags = repository.tags_sorted_by(sort)
- by_search(tags)
+
+ [by_search(tags), nil]
+ rescue Gitlab::Git::CommandError => e
+ [[], e]
end
end
diff --git a/app/graphql/mutations/ci/runner/delete.rb b/app/graphql/mutations/ci/runner/delete.rb
index 8d9a5f15505..88dc426398b 100644
--- a/app/graphql/mutations/ci/runner/delete.rb
+++ b/app/graphql/mutations/ci/runner/delete.rb
@@ -28,7 +28,7 @@ module Mutations
def authenticate_delete_runner!(runner)
return if current_user.can_admin_all_resources?
- "Runner #{runner.to_global_id} associated with more than one project" if runner.projects.count > 1
+ "Runner #{runner.to_global_id} associated with more than one project" if runner.runner_projects.count > 1
end
def find_object(id)
diff --git a/app/graphql/mutations/clusters/agent_tokens/create.rb b/app/graphql/mutations/clusters/agent_tokens/create.rb
new file mode 100644
index 00000000000..07bf2536065
--- /dev/null
+++ b/app/graphql/mutations/clusters/agent_tokens/create.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Clusters
+ module AgentTokens
+ class Create < BaseMutation
+ graphql_name 'ClusterAgentTokenCreate'
+
+ authorize :create_cluster
+
+ ClusterAgentID = ::Types::GlobalIDType[::Clusters::Agent]
+
+ argument :cluster_agent_id,
+ ClusterAgentID,
+ required: true,
+ description: 'Global ID of the cluster agent that will be associated with the new token.'
+
+ argument :description,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Description of the token.'
+
+ argument :name,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Name of the token.'
+
+ field :secret,
+ GraphQL::Types::String,
+ null: true,
+ description: "Token secret value. Make sure you save it - you won't be able to access it again."
+
+ field :token,
+ Types::Clusters::AgentTokenType,
+ null: true,
+ description: 'Token created after mutation.'
+
+ def resolve(args)
+ cluster_agent = authorized_find!(id: args[:cluster_agent_id])
+
+ result = ::Clusters::AgentTokens::CreateService
+ .new(
+ container: cluster_agent.project,
+ current_user: current_user,
+ params: args.merge(agent_id: cluster_agent.id)
+ )
+ .execute
+
+ payload = result.payload
+
+ {
+ secret: payload[:secret],
+ token: payload[:token],
+ errors: Array.wrap(result.message)
+ }
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ClusterAgentID.coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/clusters/agent_tokens/delete.rb b/app/graphql/mutations/clusters/agent_tokens/delete.rb
new file mode 100644
index 00000000000..603b6b30910
--- /dev/null
+++ b/app/graphql/mutations/clusters/agent_tokens/delete.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Clusters
+ module AgentTokens
+ class Delete < BaseMutation
+ graphql_name 'ClusterAgentTokenDelete'
+
+ authorize :admin_cluster
+
+ TokenID = ::Types::GlobalIDType[::Clusters::AgentToken]
+
+ argument :id, TokenID,
+ required: true,
+ description: 'Global ID of the cluster agent token that will be deleted.'
+
+ def resolve(id:)
+ token = authorized_find!(id: id)
+ token.destroy
+
+ { errors: errors_on_object(token) }
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = TokenID.coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/clusters/agents/create.rb b/app/graphql/mutations/clusters/agents/create.rb
new file mode 100644
index 00000000000..0896cc7b203
--- /dev/null
+++ b/app/graphql/mutations/clusters/agents/create.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Clusters
+ module Agents
+ class Create < BaseMutation
+ include FindsProject
+
+ authorize :create_cluster
+
+ graphql_name 'CreateClusterAgent'
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the associated project for this cluster agent.'
+
+ argument :name, GraphQL::Types::String,
+ required: true,
+ description: 'Name of the cluster agent.'
+
+ field :cluster_agent,
+ Types::Clusters::AgentType,
+ null: true,
+ description: 'Cluster agent created after mutation.'
+
+ def resolve(project_path:, name:)
+ project = authorized_find!(project_path)
+ result = ::Clusters::Agents::CreateService.new(project, current_user).execute(name: name)
+
+ {
+ cluster_agent: result[:cluster_agent],
+ errors: Array.wrap(result[:message])
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/clusters/agents/delete.rb b/app/graphql/mutations/clusters/agents/delete.rb
new file mode 100644
index 00000000000..9ada1f31f60
--- /dev/null
+++ b/app/graphql/mutations/clusters/agents/delete.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Clusters
+ module Agents
+ class Delete < BaseMutation
+ graphql_name 'ClusterAgentDelete'
+
+ authorize :admin_cluster
+
+ AgentID = ::Types::GlobalIDType[::Clusters::Agent]
+
+ argument :id, AgentID,
+ required: true,
+ description: 'Global ID of the cluster agent that will be deleted.'
+
+ def resolve(id:)
+ cluster_agent = authorized_find!(id: id)
+ result = ::Clusters::Agents::DeleteService
+ .new(container: cluster_agent.project, current_user: current_user)
+ .execute(cluster_agent)
+
+ {
+ errors: Array.wrap(result.message)
+ }
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = AgentID.coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/customer_relations/contacts/create.rb b/app/graphql/mutations/customer_relations/contacts/create.rb
new file mode 100644
index 00000000000..77b4864468b
--- /dev/null
+++ b/app/graphql/mutations/customer_relations/contacts/create.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Mutations
+ module CustomerRelations
+ module Contacts
+ class Create < BaseMutation
+ include ResolvesIds
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ graphql_name 'CustomerRelationsContactCreate'
+
+ field :contact,
+ Types::CustomerRelations::ContactType,
+ null: true,
+ description: 'Contact after the mutation.'
+
+ argument :group_id, ::Types::GlobalIDType[::Group],
+ required: true,
+ description: 'Group for the contact.'
+
+ argument :organization_id, ::Types::GlobalIDType[::CustomerRelations::Organization],
+ required: false,
+ description: 'Organization for the contact.'
+
+ argument :first_name, GraphQL::Types::String,
+ required: true,
+ description: 'First name of the contact.'
+
+ argument :last_name, GraphQL::Types::String,
+ required: true,
+ description: 'Last name of the contact.'
+
+ argument :phone, GraphQL::Types::String,
+ required: false,
+ description: 'Phone number of the contact.'
+
+ argument :email, GraphQL::Types::String,
+ required: false,
+ description: 'Email address of the contact.'
+
+ argument :description, GraphQL::Types::String,
+ required: false,
+ description: 'Description of or notes for the contact.'
+
+ authorize :admin_contact
+
+ def resolve(args)
+ group = authorized_find!(id: args[:group_id])
+
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml)
+
+ set_organization!(args)
+ result = ::CustomerRelations::Contacts::CreateService.new(group: group, current_user: current_user, params: args).execute
+ { contact: result.payload, errors: result.errors }
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::Group)
+ end
+
+ def set_organization!(args)
+ return unless args[:organization_id]
+
+ args[:organization_id] = resolve_ids(args[:organization_id], ::Types::GlobalIDType[::CustomerRelations::Organization])[0]
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/customer_relations/contacts/update.rb b/app/graphql/mutations/customer_relations/contacts/update.rb
new file mode 100644
index 00000000000..e9e7c9b6abd
--- /dev/null
+++ b/app/graphql/mutations/customer_relations/contacts/update.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Mutations
+ module CustomerRelations
+ module Contacts
+ class Update < Mutations::BaseMutation
+ include ResolvesIds
+
+ graphql_name 'CustomerRelationsContactUpdate'
+
+ authorize :admin_contact
+
+ field :contact,
+ Types::CustomerRelations::ContactType,
+ null: true,
+ description: 'Contact after the mutation.'
+
+ argument :id, ::Types::GlobalIDType[::CustomerRelations::Contact],
+ required: true,
+ description: 'Global ID of the contact.'
+
+ argument :organization_id, ::Types::GlobalIDType[::CustomerRelations::Organization],
+ required: false,
+ description: 'Organization of the contact.'
+
+ argument :first_name, GraphQL::Types::String,
+ required: false,
+ description: 'First name of the contact.'
+
+ argument :last_name, GraphQL::Types::String,
+ required: false,
+ description: 'Last name of the contact.'
+
+ argument :phone, GraphQL::Types::String,
+ required: false,
+ description: 'Phone number of the contact.'
+
+ argument :email, GraphQL::Types::String,
+ required: false,
+ description: 'Email address of the contact.'
+
+ argument :description, GraphQL::Types::String,
+ required: false,
+ description: 'Description of or notes for the contact.'
+
+ def resolve(args)
+ contact = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(args.delete(:id), expected_type: ::CustomerRelations::Contact))
+ raise_resource_not_available_error! unless contact
+
+ group = contact.group
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml)
+
+ authorize!(group)
+
+ result = ::CustomerRelations::Contacts::UpdateService.new(group: group, current_user: current_user, params: args).execute(contact)
+ { contact: result.payload, errors: result.errors }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/customer_relations/organizations/create.rb b/app/graphql/mutations/customer_relations/organizations/create.rb
index 3fa7b0327ca..bb02e1f7346 100644
--- a/app/graphql/mutations/customer_relations/organizations/create.rb
+++ b/app/graphql/mutations/customer_relations/organizations/create.rb
@@ -31,7 +31,7 @@ module Mutations
argument :description,
GraphQL::Types::String,
required: false,
- description: 'Description or notes for the organization.'
+ description: 'Description of or notes for the organization.'
authorize :admin_organization
diff --git a/app/graphql/mutations/customer_relations/organizations/update.rb b/app/graphql/mutations/customer_relations/organizations/update.rb
index c6ae62193f9..d8eb55d77e9 100644
--- a/app/graphql/mutations/customer_relations/organizations/update.rb
+++ b/app/graphql/mutations/customer_relations/organizations/update.rb
@@ -32,7 +32,7 @@ module Mutations
argument :description,
GraphQL::Types::String,
required: false,
- description: 'Description or notes for the organization.'
+ description: 'Description of or notes for the organization.'
def resolve(args)
organization = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(args.delete(:id), expected_type: ::CustomerRelations::Organization))
diff --git a/app/graphql/mutations/dependency_proxy/group_settings/update.rb b/app/graphql/mutations/dependency_proxy/group_settings/update.rb
new file mode 100644
index 00000000000..d10e43cde29
--- /dev/null
+++ b/app/graphql/mutations/dependency_proxy/group_settings/update.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DependencyProxy
+ module GroupSettings
+ class Update < Mutations::BaseMutation
+ include Mutations::ResolvesGroup
+
+ graphql_name 'UpdateDependencyProxySettings'
+
+ authorize :admin_dependency_proxy
+
+ argument :group_path,
+ GraphQL::Types::ID,
+ required: true,
+ description: 'Group path for the group dependency proxy.'
+
+ argument :enabled,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: copy_field_description(Types::DependencyProxy::ImageTtlGroupPolicyType, :enabled)
+
+ field :dependency_proxy_setting,
+ Types::DependencyProxy::GroupSettingType,
+ null: true,
+ description: 'Group dependency proxy settings after mutation.'
+
+ def resolve(group_path:, **args)
+ group = authorized_find!(group_path: group_path)
+
+ result = ::DependencyProxy::GroupSettings::UpdateService
+ .new(container: group, current_user: current_user, params: args)
+ .execute
+
+ {
+ dependency_proxy_setting: result.payload[:dependency_proxy_setting],
+ errors: result.errors
+ }
+ end
+
+ private
+
+ def find_object(group_path:)
+ resolve_group(full_path: group_path)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb
index 32f96f1bfe6..70a8f539ccf 100644
--- a/app/graphql/mutations/issues/create.rb
+++ b/app/graphql/mutations/issues/create.rb
@@ -71,7 +71,7 @@ module Mutations
def resolve(project_path:, **attributes)
project = authorized_find!(project_path)
- params = build_create_issue_params(attributes.merge(author_id: current_user.id))
+ params = build_create_issue_params(attributes.merge(author_id: current_user.id), project)
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
issue = ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute
@@ -88,7 +88,8 @@ module Mutations
private
- def build_create_issue_params(params)
+ # _project argument is unused here, but it is necessary on the EE version of the method
+ def build_create_issue_params(params, _project)
params[:milestone_id] &&= params[:milestone_id]&.model_id
params[:assignee_ids] &&= params[:assignee_ids].map { |assignee_id| assignee_id&.model_id }
params[:label_ids] &&= params[:label_ids].map { |label_id| label_id&.model_id }
diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb
index 7c85dd8fb9b..d70acdf7ca0 100644
--- a/app/graphql/resolvers/board_list_issues_resolver.rb
+++ b/app/graphql/resolvers/board_list_issues_resolver.rb
@@ -18,11 +18,8 @@ module Resolvers
filter_params = filters.merge(board_id: list.board.id, id: list.id)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
- pagination_connections = Gitlab::Graphql::Pagination::Keyset::Connection.new(service.execute)
- ::Boards::Issues::ListService.initialize_relative_positions(list.board, current_user, pagination_connections.items)
-
- pagination_connections
+ service.execute
end
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681
diff --git a/app/graphql/resolvers/board_list_resolver.rb b/app/graphql/resolvers/board_list_resolver.rb
new file mode 100644
index 00000000000..d853846b674
--- /dev/null
+++ b/app/graphql/resolvers/board_list_resolver.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class BoardListResolver < BaseResolver.single
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ include BoardItemFilterable
+
+ type Types::BoardListType, null: true
+ description 'Find an issue board list.'
+
+ authorize :read_issue_board_list
+
+ argument :id, Types::GlobalIDType[List],
+ required: true,
+ description: 'Global ID of the list.'
+
+ argument :issue_filters, Types::Boards::BoardIssueInputType,
+ required: false,
+ description: 'Filters applied when getting issue metadata in the board list.'
+
+ def resolve(id: nil, issue_filters: {})
+ context.scoped_set!(:issue_filters, item_filters(issue_filters))
+
+ Gitlab::Graphql::Lazy.with_value(find_list(id: id)) do |list|
+ list if authorized_resource?(list)
+ end
+ end
+
+ private
+
+ def find_list(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::List)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb
new file mode 100644
index 00000000000..5ae19700fd5
--- /dev/null
+++ b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Clusters
+ class AgentTokensResolver < BaseResolver
+ type Types::Clusters::AgentTokenType, null: true
+
+ alias_method :agent, :object
+
+ delegate :project, to: :agent
+
+ def resolve(**args)
+ return ::Clusters::AgentToken.none unless can_read_agent_tokens?
+
+ agent.last_used_agent_tokens
+ end
+
+ private
+
+ def can_read_agent_tokens?
+ current_user.can?(:admin_cluster, project)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/clusters/agents_resolver.rb b/app/graphql/resolvers/clusters/agents_resolver.rb
new file mode 100644
index 00000000000..9b8cea52e3b
--- /dev/null
+++ b/app/graphql/resolvers/clusters/agents_resolver.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Clusters
+ class AgentsResolver < BaseResolver
+ include LooksAhead
+
+ type Types::Clusters::AgentType.connection_type, null: true
+
+ extras [:lookahead]
+
+ when_single do
+ argument :name, GraphQL::Types::String,
+ required: true,
+ description: 'Name of the cluster agent.'
+ end
+
+ alias_method :project, :object
+
+ def resolve_with_lookahead(**args)
+ apply_lookahead(
+ ::Clusters::AgentsFinder
+ .new(project, current_user, params: args)
+ .execute
+ )
+ end
+
+ private
+
+ def preloads
+ { tokens: :last_used_agent_tokens }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index 9de36b5b7d1..855877110e5 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -4,6 +4,7 @@ module IssueResolverArguments
extend ActiveSupport::Concern
prepended do
+ include SearchArguments
include LooksAhead
argument :iid, GraphQL::Types::String,
@@ -49,9 +50,6 @@ module IssueResolverArguments
argument :closed_after, Types::TimeType,
required: false,
description: 'Issues closed after this date.'
- argument :search, GraphQL::Types::String,
- required: false,
- description: 'Search query for issue title or description.'
argument :types, [Types::IssueTypeEnum],
as: :issue_types,
description: 'Filter issues by the given issue types.',
@@ -62,6 +60,10 @@ module IssueResolverArguments
argument :my_reaction_emoji, GraphQL::Types::String,
required: false,
description: 'Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported.'
+ argument :confidential,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: 'Filter for confidential issues. If "false", excludes confidential issues. If "true", returns only confidential issues.'
argument :not, Types::Issues::NegatedIssueFilterInputType,
description: 'Negated arguments.',
prepare: ->(negated_args, ctx) { negated_args.to_h },
@@ -91,6 +93,7 @@ module IssueResolverArguments
params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args)
params_not_mutually_exclusive(args, mutually_exclusive_milestone_args)
params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args)
+ validate_anonymous_search_access! if args[:search].present?
super
end
diff --git a/app/graphql/resolvers/concerns/search_arguments.rb b/app/graphql/resolvers/concerns/search_arguments.rb
new file mode 100644
index 00000000000..7f480f9d0b6
--- /dev/null
+++ b/app/graphql/resolvers/concerns/search_arguments.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module SearchArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search query for title or description.'
+ end
+
+ def validate_anonymous_search_access!
+ return if current_user.present? || Feature.disabled?(:disable_anonymous_search, type: :ops)
+
+ raise ::Gitlab::Graphql::Errors::ArgumentError,
+ "User must be authenticated to include the `search` argument."
+ end
+end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 47e4e3c0b32..b556964ae0c 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -47,7 +47,8 @@ module Resolvers
alert_management_alert: [:alert_management_alert],
labels: [:labels],
assignees: [:assignees],
- timelogs: [:timelogs]
+ timelogs: [:timelogs],
+ customer_relations_contacts: { customer_relations_contacts: [:group] }
}
end
diff --git a/app/graphql/resolvers/kas/agent_configurations_resolver.rb b/app/graphql/resolvers/kas/agent_configurations_resolver.rb
new file mode 100644
index 00000000000..238dae0bf12
--- /dev/null
+++ b/app/graphql/resolvers/kas/agent_configurations_resolver.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Kas
+ class AgentConfigurationsResolver < BaseResolver
+ type Types::Kas::AgentConfigurationType, null: true
+
+ # Calls Gitaly via KAS
+ calls_gitaly!
+
+ alias_method :project, :object
+
+ def resolve
+ return [] unless can_read_agent_configuration?
+
+ kas_client.list_agent_config_files(project: project)
+ rescue GRPC::BadStatus => e
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, e.class.name
+ end
+
+ private
+
+ def can_read_agent_configuration?
+ current_user.can?(:admin_cluster, project)
+ end
+
+ def kas_client
+ @kas_client ||= Gitlab::Kas::Client.new
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/kas/agent_connections_resolver.rb b/app/graphql/resolvers/kas/agent_connections_resolver.rb
new file mode 100644
index 00000000000..8b7c4003598
--- /dev/null
+++ b/app/graphql/resolvers/kas/agent_connections_resolver.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Kas
+ class AgentConnectionsResolver < BaseResolver
+ type Types::Kas::AgentConnectionType, null: true
+
+ alias_method :agent, :object
+
+ delegate :project, to: :agent
+
+ def resolve
+ return [] unless can_read_connected_agents?
+
+ BatchLoader::GraphQL.for(agent.id).batch(key: project, default_value: []) do |agent_ids, loader|
+ agents = get_connected_agents.group_by(&:agent_id).slice(*agent_ids)
+
+ agents.each do |agent_id, connections|
+ loader.call(agent_id, connections)
+ end
+ end
+ end
+
+ private
+
+ def can_read_connected_agents?
+ current_user.can?(:admin_cluster, project)
+ end
+
+ def get_connected_agents
+ kas_client.get_connected_agents(project: project)
+ rescue GRPC::BadStatus => e
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, e.class.name
+ end
+
+ def kas_client
+ @kas_client ||= Gitlab::Kas::Client.new
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb
index ce4b6ac6b0c..5acd7f95606 100644
--- a/app/graphql/resolvers/project_pipeline_resolver.rb
+++ b/app/graphql/resolvers/project_pipeline_resolver.rb
@@ -2,6 +2,8 @@
module Resolvers
class ProjectPipelineResolver < BaseResolver
+ include LooksAhead
+
type ::Types::Ci::PipelineType, null: true
alias_method :project, :object
@@ -14,7 +16,7 @@ module Resolvers
required: false,
description: 'SHA of the Pipeline. For example, "dyd0f15ay83993f5ab66k927w28673882x99100b".'
- def ready?(iid: nil, sha: nil)
+ def ready?(iid: nil, sha: nil, **args)
unless iid.present? ^ sha.present?
raise Gitlab::Graphql::Errors::ArgumentError, 'Provide one of an IID or SHA'
end
@@ -22,18 +24,21 @@ module Resolvers
super
end
- def resolve(iid: nil, sha: nil)
+ # the preloads are defined on ee/app/graphql/ee/resolvers/project_pipeline_resolver.rb
+ def resolve(iid: nil, sha: nil, **args)
+ self.lookahead = args.delete(:lookahead)
+
if iid
- BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args|
+ BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader|
finder = ::Ci::PipelinesFinder.new(project, current_user, iids: iids)
- finder.execute.each { |pipeline| loader.call(pipeline.iid.to_s, pipeline) }
+ apply_lookahead(finder.execute).each { |pipeline| loader.call(pipeline.iid.to_s, pipeline) }
end
else
- BatchLoader::GraphQL.for(sha).batch(key: project) do |shas, loader, args|
+ BatchLoader::GraphQL.for(sha).batch(key: project) do |shas, loader|
finder = ::Ci::PipelinesFinder.new(project, current_user, sha: shas)
- finder.execute.each { |pipeline| loader.call(pipeline.sha.to_s, pipeline) }
+ apply_lookahead(finder.execute).each { |pipeline| loader.call(pipeline.sha.to_s, pipeline) }
end
end
end
diff --git a/app/graphql/resolvers/project_pipelines_resolver.rb b/app/graphql/resolvers/project_pipelines_resolver.rb
index 0171473a77f..5a1e92efc96 100644
--- a/app/graphql/resolvers/project_pipelines_resolver.rb
+++ b/app/graphql/resolvers/project_pipelines_resolver.rb
@@ -26,3 +26,5 @@ module Resolvers
end
end
# rubocop: enable Graphql/ResolverType
+
+Resolvers::ProjectPipelinesResolver.prepend_mod
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 9c27f0f8138..93e17ea6dfc 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -9,7 +9,6 @@ module Types
DEFAULT_COMPLEXITY = 1
attr_reader :deprecation, :doc_reference
- attr_writer :max_page_size # Can be removed with :performance_roadmap feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/337198
def initialize(**kwargs, &block)
@calls_gitaly = !!kwargs.delete(:calls_gitaly)
@@ -21,6 +20,7 @@ module Types
@feature_flag = kwargs[:feature_flag]
kwargs = check_feature_flag(kwargs)
@deprecation = gitlab_deprecation(kwargs)
+ after_connection_extensions = kwargs.delete(:late_extensions) || []
super(**kwargs, &block)
@@ -28,6 +28,8 @@ module Types
extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env?
extension ::Gitlab::Graphql::Present::FieldExtension
extension ::Gitlab::Graphql::Authorize::ConnectionFilterExtension
+
+ after_connection_extensions.each { extension _1 } if after_connection_extensions.any?
end
def may_call_gitaly?
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
index 762e03973d9..8c67803e39e 100644
--- a/app/graphql/types/board_list_type.rb
+++ b/app/graphql/types/board_list_type.rb
@@ -10,8 +10,10 @@ module Types
alias_method :list, :object
- field :id, GraphQL::Types::ID, null: false,
+ field :id, GraphQL::Types::ID,
+ null: false,
description: 'ID (global ID) of the list.'
+
field :title, GraphQL::Types::String, null: false,
description: 'Title of the list.'
field :list_type, GraphQL::Types::String, null: false,
@@ -27,6 +29,7 @@ module Types
field :issues, ::Types::IssueType.connection_type, null: true,
description: 'Board issues.',
+ late_extensions: [Gitlab::Graphql::Board::IssuesConnectionExtension],
resolver: ::Resolvers::BoardListIssuesResolver
def issues_count
@@ -46,6 +49,16 @@ module Types
.metadata
end
end
+
+ # board lists have a data dependency on label - so we batch load them here
+ def title
+ BatchLoader::GraphQL.for(object).batch do |lists, callback|
+ ActiveRecord::Associations::Preloader.new.preload(lists, :label) # rubocop: disable CodeReuse/ActiveRecord
+
+ # all list titles are preloaded at this point
+ lists.each { |list| callback.call(list, list.title) }
+ end
+ end
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/runner_status_enum.rb b/app/graphql/types/ci/runner_status_enum.rb
index ad69175e44a..8501ce20204 100644
--- a/app/graphql/types/ci/runner_status_enum.rb
+++ b/app/graphql/types/ci/runner_status_enum.rb
@@ -6,8 +6,21 @@ module Types
graphql_name 'CiRunnerStatus'
::Ci::Runner::AVAILABLE_STATUSES.each do |status|
+ description = case status
+ when 'active'
+ "A runner that is not paused."
+ when 'online'
+ "A runner that contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}."
+ when 'offline'
+ "A runner that has not contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}."
+ when 'not_connected'
+ "A runner that has never contacted this instance."
+ else
+ "A runner that is #{status.to_s.tr('_', ' ')}."
+ end
+
value status.to_s.upcase,
- description: "A runner that is #{status.to_s.tr('_', ' ')}.",
+ description: description,
value: status.to_sym
end
end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index e2c8070af0c..9bf98aa7e86 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -3,10 +3,13 @@
module Types
module Ci
class RunnerType < BaseObject
+ edge_type_class(RunnerWebUrlEdge)
graphql_name 'CiRunner'
authorize :read_runner
present_using ::Ci::RunnerPresenter
+ expose_permissions Types::PermissionTypes::Ci::Runner
+
JOB_COUNT_LIMIT = 1000
alias_method :runner, :object
@@ -46,12 +49,18 @@ module Types
description: 'Number of projects that the runner is associated with.'
field :job_count, GraphQL::Types::Int, null: true,
description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)."
+ field :admin_url, GraphQL::Types::String, null: true,
+ description: 'Admin URL of the runner. Only available for adminstrators.'
def job_count
# We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT
runner.builds.limit(JOB_COUNT_LIMIT + 1).count
end
+ def admin_url
+ Gitlab::Routing.url_helpers.admin_runner_url(runner) if can_admin_runners?
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def project_count
BatchLoader::GraphQL.for(runner.id).batch(key: :runner_project_count) do |ids, loader, args|
@@ -68,6 +77,12 @@ module Types
end
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def can_admin_runners?
+ context[:current_user]&.can_admin_all_resources?
+ end
end
end
end
diff --git a/app/graphql/types/ci/runner_web_url_edge.rb b/app/graphql/types/ci/runner_web_url_edge.rb
new file mode 100644
index 00000000000..3b9fdfd1571
--- /dev/null
+++ b/app/graphql/types/ci/runner_web_url_edge.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class RunnerWebUrlEdge < GraphQL::Types::Relay::BaseEdge
+ include FindClosest
+
+ field :web_url, GraphQL::Types::String, null: true,
+ description: 'Web URL of the runner. The value depends on where you put this field in the query. You can use it for projects or groups.',
+ extras: [:parent]
+
+ def initialize(node, connection)
+ super
+
+ @runner = node.node
+ end
+
+ def web_url(parent:)
+ owner = closest_parent([::Types::ProjectType, ::Types::GroupType], parent)
+
+ case owner
+ when ::Group
+ Gitlab::Routing.url_helpers.group_runner_url(owner, @runner)
+ when ::Project
+ Gitlab::Routing.url_helpers.project_runner_url(owner, @runner)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/clusters/agent_token_type.rb b/app/graphql/types/clusters/agent_token_type.rb
new file mode 100644
index 00000000000..94c5fc46a5d
--- /dev/null
+++ b/app/graphql/types/clusters/agent_token_type.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Types
+ module Clusters
+ class AgentTokenType < BaseObject
+ graphql_name 'ClusterAgentToken'
+
+ authorize :admin_cluster
+
+ connection_type_class(Types::CountableConnectionType)
+
+ field :cluster_agent,
+ Types::Clusters::AgentType,
+ description: 'Cluster agent this token is associated with.',
+ null: true
+
+ field :created_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the token was created.'
+
+ field :created_by_user,
+ Types::UserType,
+ null: true,
+ description: 'User who created the token.'
+
+ field :description,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Description of the token.'
+
+ field :last_used_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the token was last used.'
+
+ field :id,
+ ::Types::GlobalIDType[::Clusters::AgentToken],
+ null: false,
+ description: 'Global ID of the token.'
+
+ field :name,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Name given to the token.'
+
+ def cluster_agent
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(::Clusters::Agent, object.agent_id).find
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb
new file mode 100644
index 00000000000..ce748f6e8ae
--- /dev/null
+++ b/app/graphql/types/clusters/agent_type.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Types
+ module Clusters
+ class AgentType < BaseObject
+ graphql_name 'ClusterAgent'
+
+ authorize :admin_cluster
+
+ connection_type_class(Types::CountableConnectionType)
+
+ field :created_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the cluster agent was created.'
+
+ field :created_by_user,
+ Types::UserType,
+ null: true,
+ description: 'User object, containing information about the person who created the agent.'
+
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'ID of the cluster agent.'
+
+ field :name,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Name of the cluster agent.'
+
+ field :project, Types::ProjectType,
+ description: 'Project this cluster agent is associated with.',
+ null: true,
+ authorize: :read_project
+
+ field :tokens, Types::Clusters::AgentTokenType.connection_type,
+ description: 'Tokens associated with the cluster agent.',
+ null: true,
+ resolver: ::Resolvers::Clusters::AgentTokensResolver
+
+ field :updated_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the cluster agent was updated.'
+
+ field :web_path,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Web path of the cluster agent.'
+
+ field :connections,
+ Types::Kas::AgentConnectionType.connection_type,
+ null: true,
+ description: 'Active connections for the cluster agent',
+ complexity: 5,
+ resolver: ::Resolvers::Kas::AgentConnectionsResolver
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
+ end
+
+ def web_path
+ ::Gitlab::Routing.url_helpers.project_cluster_agent_path(object.project, object.name)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/concerns/find_closest.rb b/app/graphql/types/concerns/find_closest.rb
index 1d76e872364..3064db19ea0 100644
--- a/app/graphql/types/concerns/find_closest.rb
+++ b/app/graphql/types/concerns/find_closest.rb
@@ -1,11 +1,15 @@
# frozen_string_literal: true
module FindClosest
- # Find the closest node of a given type above this node, and return the domain object
- def closest_parent(type, parent)
- parent = parent.try(:parent) while parent && parent.object.class != type
- return unless parent
+ # Find the closest node which has any of the given types above this node, and return the domain object
+ def closest_parent(types, parent)
+ while parent
- parent.object.object
+ if types.any? {|type| parent.object.instance_of? type}
+ return parent.object.object
+ else
+ parent = parent.try(:parent)
+ end
+ end
end
end
diff --git a/app/graphql/types/container_expiration_policy_older_than_enum.rb b/app/graphql/types/container_expiration_policy_older_than_enum.rb
index 7364910f8cd..9c32d767caf 100644
--- a/app/graphql/types/container_expiration_policy_older_than_enum.rb
+++ b/app/graphql/types/container_expiration_policy_older_than_enum.rb
@@ -6,6 +6,7 @@ module Types
'7d': 'SEVEN_DAYS',
'14d': 'FOURTEEN_DAYS',
'30d': 'THIRTY_DAYS',
+ '60d': 'SIXTY_DAYS',
'90d': 'NINETY_DAYS'
}.freeze
diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb
index 1a9f57e701f..8190cc9bc25 100644
--- a/app/graphql/types/container_repository_details_type.rb
+++ b/app/graphql/types/container_repository_details_type.rb
@@ -17,5 +17,11 @@ module Types
def can_delete
Ability.allowed?(current_user, :destroy_container_image, object)
end
+
+ def tags
+ object.tags
+ rescue Faraday::Error
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'We are having trouble connecting to the Container Registry. If this error persists, please review the troubleshooting documentation.'
+ end
end
end
diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb
index 67093f57862..1fe5cf112f0 100644
--- a/app/graphql/types/container_repository_type.rb
+++ b/app/graphql/types/container_repository_type.rb
@@ -28,5 +28,11 @@ module Types
def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
end
+
+ def tags_count
+ object.tags_count
+ rescue Faraday::Error
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'We are having trouble connecting to the Container Registry. If this error persists, please review the troubleshooting documentation.'
+ end
end
end
diff --git a/app/graphql/types/customer_relations/contact_type.rb b/app/graphql/types/customer_relations/contact_type.rb
index 35b5bf45698..b5224a3e239 100644
--- a/app/graphql/types/customer_relations/contact_type.rb
+++ b/app/graphql/types/customer_relations/contact_type.rb
@@ -39,7 +39,7 @@ module Types
field :description,
GraphQL::Types::String,
null: true,
- description: 'Description or notes for the contact.'
+ description: 'Description of or notes for the contact.'
field :created_at,
Types::TimeType,
diff --git a/app/graphql/types/customer_relations/organization_type.rb b/app/graphql/types/customer_relations/organization_type.rb
index 0e091d4a9a3..9b22fa35b11 100644
--- a/app/graphql/types/customer_relations/organization_type.rb
+++ b/app/graphql/types/customer_relations/organization_type.rb
@@ -25,7 +25,7 @@ module Types
field :description,
GraphQL::Types::String,
null: true,
- description: 'Description or notes for the organization.'
+ description: 'Description of or notes for the organization.'
field :created_at,
Types::TimeType,
diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
index 79e789d3f8b..826ae61a1a3 100644
--- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
+++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
@@ -13,6 +13,9 @@ module Types
field :id, GraphQL::Types::ID,
null: false,
description: 'ID (global ID) of the error.'
+ field :integrated, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Error tracking backend.'
field :sentry_id, GraphQL::Types::String,
method: :id,
null: false,
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 8fe4ba557ea..b1bbabcdaed 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -234,6 +234,10 @@ module Types
)
end
+ def dependency_proxy_setting
+ group.dependency_proxy_setting || group.create_dependency_proxy_setting
+ end
+
private
def group
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index c8db2b84ff2..3b0f93d8dc1 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -136,6 +136,9 @@ module Types
field :project_id, GraphQL::Types::Int, null: false, method: :project_id,
description: 'ID of the issue project.'
+ field :customer_relations_contacts, Types::CustomerRelations::ContactType.connection_type, null: true,
+ description: 'Customer relations contacts of the issue.'
+
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb
index 4f620a5b3d9..c8b7cdaa68e 100644
--- a/app/graphql/types/issues/negated_issue_filter_input_type.rb
+++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb
@@ -29,6 +29,10 @@ module Types
argument :my_reaction_emoji, GraphQL::Types::String,
required: false,
description: 'Filter by reaction emoji applied by the current user.'
+ argument :types, [Types::IssueTypeEnum],
+ as: :issue_types,
+ description: 'Filters out issues by the given issue types.',
+ required: false
end
end
end
diff --git a/app/graphql/types/kas/agent_configuration_type.rb b/app/graphql/types/kas/agent_configuration_type.rb
new file mode 100644
index 00000000000..397a5739671
--- /dev/null
+++ b/app/graphql/types/kas/agent_configuration_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ module Kas
+ # rubocop: disable Graphql/AuthorizeTypes
+ class AgentConfigurationType < BaseObject
+ graphql_name 'AgentConfiguration'
+ description 'Configuration details for an Agent'
+
+ field :agent_name,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Name of the agent.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/kas/agent_connection_type.rb b/app/graphql/types/kas/agent_connection_type.rb
new file mode 100644
index 00000000000..9c6321bece9
--- /dev/null
+++ b/app/graphql/types/kas/agent_connection_type.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Types
+ module Kas
+ # rubocop: disable Graphql/AuthorizeTypes
+ class AgentConnectionType < BaseObject
+ graphql_name 'ConnectedAgent'
+ description 'Connection details for an Agent'
+
+ field :connected_at,
+ Types::TimeType,
+ null: true,
+ description: 'When the connection was established.'
+
+ field :connection_id,
+ GraphQL::Types::BigInt,
+ null: true,
+ description: 'ID of the connection.'
+
+ field :metadata,
+ Types::Kas::AgentMetadataType,
+ method: :agent_meta,
+ null: true,
+ description: 'Information about the Agent.'
+
+ def connected_at
+ Time.at(object.connected_at.seconds)
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/kas/agent_metadata_type.rb b/app/graphql/types/kas/agent_metadata_type.rb
new file mode 100644
index 00000000000..4a3bb09b9e1
--- /dev/null
+++ b/app/graphql/types/kas/agent_metadata_type.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Types
+ module Kas
+ # rubocop: disable Graphql/AuthorizeTypes
+ class AgentMetadataType < BaseObject
+ graphql_name 'AgentMetadata'
+ description 'Information about a connected Agent'
+
+ field :version,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Agent version tag.'
+
+ field :commit,
+ GraphQL::Types::String,
+ method: :commit_id,
+ null: true,
+ description: 'Agent version commit.'
+
+ field :pod_namespace,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Namespace of the pod running the Agent.'
+
+ field :pod_name,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Name of the pod running the Agent.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/merge_requests/interacts_with_merge_request.rb b/app/graphql/types/merge_requests/interacts_with_merge_request.rb
index d685ac4d3c9..d4a1f2faa8d 100644
--- a/app/graphql/types/merge_requests/interacts_with_merge_request.rb
+++ b/app/graphql/types/merge_requests/interacts_with_merge_request.rb
@@ -14,7 +14,7 @@ module Types
end
def merge_request_interaction(parent:)
- merge_request = closest_parent(::Types::MergeRequestType, parent)
+ merge_request = closest_parent([::Types::MergeRequestType], parent)
return unless merge_request
Users::MergeRequestInteraction.new(user: object, merge_request: merge_request)
diff --git a/app/graphql/types/milestone_wildcard_id_enum.rb b/app/graphql/types/milestone_wildcard_id_enum.rb
index 12e8e07fb05..ad9651a26dc 100644
--- a/app/graphql/types/milestone_wildcard_id_enum.rb
+++ b/app/graphql/types/milestone_wildcard_id_enum.rb
@@ -8,6 +8,6 @@ module Types
value 'NONE', 'No milestone is assigned.'
value 'ANY', 'Milestone is assigned.'
value 'STARTED', 'Milestone assigned is open and started (start date <= today).'
- value 'UPCOMING', 'Milestone assigned is due closest in the future (due date > today).'
+ value 'UPCOMING', 'Milestone assigned is due in the future (due date > today).'
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index ea50af1c554..cd4c45d2942 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -31,13 +31,20 @@ module Types
mount_mutation Mutations::Boards::Lists::Update
mount_mutation Mutations::Boards::Lists::Destroy
mount_mutation Mutations::Branches::Create, calls_gitaly: true
+ mount_mutation Mutations::Clusters::Agents::Create
+ mount_mutation Mutations::Clusters::Agents::Delete
+ mount_mutation Mutations::Clusters::AgentTokens::Create
+ mount_mutation Mutations::Clusters::AgentTokens::Delete
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji
mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji
+ mount_mutation Mutations::CustomerRelations::Contacts::Create
+ mount_mutation Mutations::CustomerRelations::Contacts::Update
mount_mutation Mutations::CustomerRelations::Organizations::Create
mount_mutation Mutations::CustomerRelations::Organizations::Update
mount_mutation Mutations::Discussions::ToggleResolve
mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update
+ mount_mutation Mutations::DependencyProxy::GroupSettings::Update
mount_mutation Mutations::Environments::CanaryIngress::Update
mount_mutation Mutations::Issues::Create
mount_mutation Mutations::Issues::SetAssignees
diff --git a/app/graphql/types/packages/nuget/metadatum_type.rb b/app/graphql/types/packages/nuget/metadatum_type.rb
index ed9d97724af..b58fd954a74 100644
--- a/app/graphql/types/packages/nuget/metadatum_type.rb
+++ b/app/graphql/types/packages/nuget/metadatum_type.rb
@@ -10,9 +10,9 @@ module Types
authorize :read_package
field :id, ::Types::GlobalIDType[::Packages::Nuget::Metadatum], null: false, description: 'ID of the metadatum.'
- field :license_url, GraphQL::Types::String, null: false, description: 'License URL of the Nuget package.'
- field :project_url, GraphQL::Types::String, null: false, description: 'Project URL of the Nuget package.'
- field :icon_url, GraphQL::Types::String, null: false, description: 'Icon URL of the Nuget package.'
+ field :license_url, GraphQL::Types::String, null: true, description: 'License URL of the Nuget package.'
+ field :project_url, GraphQL::Types::String, null: true, description: 'Project URL of the Nuget package.'
+ field :icon_url, GraphQL::Types::String, null: true, description: 'Icon URL of the Nuget package.'
end
end
end
diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb
index f3fa79cc08c..9851c6aec7e 100644
--- a/app/graphql/types/packages/package_type.rb
+++ b/app/graphql/types/packages/package_type.rb
@@ -6,6 +6,8 @@ module Types
graphql_name 'Package'
description 'Represents a package in the Package Registry. Note that this type is in beta and susceptible to changes'
+ connection_type_class(Types::CountableConnectionType)
+
authorize :read_package
field :id, ::Types::GlobalIDType[::Packages::Package], null: false,
@@ -26,6 +28,7 @@ module Types
description: 'Other versions of the package.',
deprecated: { reason: 'This field is now only returned in the PackageDetailsType', milestone: '13.11' }
field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.'
+ field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.'
def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
@@ -35,6 +38,10 @@ module Types
[]
end
+ def can_destroy
+ Ability.allowed?(current_user, :destroy_package, object)
+ end
+
# NOTE: This method must be kept in sync with the union
# type: `Types::Packages::MetadataType`.
#
diff --git a/app/graphql/types/permission_types/ci/runner.rb b/app/graphql/types/permission_types/ci/runner.rb
new file mode 100644
index 00000000000..2e92a4011e9
--- /dev/null
+++ b/app/graphql/types/permission_types/ci/runner.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module PermissionTypes
+ module Ci
+ class Runner < BasePermissionType
+ graphql_name 'RunnerPermissions'
+
+ abilities :read_runner, :update_runner, :delete_runner
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index aef46a05a2f..791875242df 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -208,6 +208,7 @@ module Types
Types::Ci::PipelineType,
null: true,
description: 'Build pipeline of the project.',
+ extras: [:lookahead],
resolver: Resolvers::ProjectPipelineResolver
field :ci_cd_settings,
@@ -361,6 +362,25 @@ module Types
complexity: 5,
resolver: ::Resolvers::TimelogResolver
+ field :agent_configurations,
+ ::Types::Kas::AgentConfigurationType.connection_type,
+ null: true,
+ description: 'Agent configurations defined by the project',
+ resolver: ::Resolvers::Kas::AgentConfigurationsResolver
+
+ field :cluster_agent,
+ ::Types::Clusters::AgentType,
+ null: true,
+ description: 'Find a single cluster agent by name.',
+ resolver: ::Resolvers::Clusters::AgentsResolver.single
+
+ field :cluster_agents,
+ ::Types::Clusters::AgentType.connection_type,
+ extras: [:lookahead],
+ null: true,
+ description: 'Cluster agents associated with the project.',
+ resolver: ::Resolvers::Clusters::AgentsResolver
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index e02191fbf3e..ed4ddbb982b 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -136,6 +136,10 @@ module Types
complexity: 5,
resolver: ::Resolvers::TimelogResolver
+ field :board_list, ::Types::BoardListType,
+ null: true,
+ resolver: Resolvers::BoardListResolver
+
def design_management
DesignManagementObject.new(nil)
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index cf15433f2e5..2103a37180f 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -333,6 +333,9 @@ module ApplicationSettingsHelper
:throttle_authenticated_files_api_enabled,
:throttle_authenticated_files_api_period_in_seconds,
:throttle_authenticated_files_api_requests_per_period,
+ :throttle_authenticated_deprecated_api_enabled,
+ :throttle_authenticated_deprecated_api_period_in_seconds,
+ :throttle_authenticated_deprecated_api_requests_per_period,
:throttle_unauthenticated_api_enabled,
:throttle_unauthenticated_api_period_in_seconds,
:throttle_unauthenticated_api_requests_per_period,
@@ -345,6 +348,9 @@ module ApplicationSettingsHelper
:throttle_unauthenticated_files_api_enabled,
:throttle_unauthenticated_files_api_period_in_seconds,
:throttle_unauthenticated_files_api_requests_per_period,
+ :throttle_unauthenticated_deprecated_api_enabled,
+ :throttle_unauthenticated_deprecated_api_period_in_seconds,
+ :throttle_unauthenticated_deprecated_api_requests_per_period,
:throttle_protected_paths_enabled,
:throttle_protected_paths_period_in_seconds,
:throttle_protected_paths_requests_per_period,
@@ -400,7 +406,8 @@ module ApplicationSettingsHelper
:user_deactivation_emails_enabled,
:sidekiq_job_limiter_mode,
:sidekiq_job_limiter_compression_threshold_bytes,
- :sidekiq_job_limiter_limit_bytes
+ :sidekiq_job_limiter_limit_bytes,
+ :suggest_pipeline_enabled
].tap do |settings|
settings << :deactivate_dormant_users unless Gitlab.com?
end
@@ -464,10 +471,6 @@ module ApplicationSettingsHelper
}
end
- def show_documentation_base_url_field?
- Feature.enabled?(:help_page_documentation_redirect)
- end
-
def valid_runner_registrars
Gitlab::CurrentSettings.valid_runner_registrars
end
@@ -477,8 +480,6 @@ module ApplicationSettingsHelper
end
def pending_user_count
- return 0 if Gitlab::CurrentSettings.new_user_signups_cap.blank?
-
User.blocked_pending_approval.count
end
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 4cfa1528d9b..dd852a68682 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -9,6 +9,10 @@ module AvatarsHelper
source_icon(group, options)
end
+ def topic_icon(topic, options = {})
+ source_icon(topic, options)
+ end
+
# Takes both user and email and returns the avatar_icon by
# user (preferred) or email.
def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true)
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index 882302f05ad..d02fe3f20b0 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -7,7 +7,7 @@ module Ci
"endpoint" => project_job_path(@project, @build, format: :json),
"project_path" => @project.full_path,
"artifact_help_url" => help_page_path('user/gitlab_com/index.html', anchor: 'gitlab-cicd'),
- "deployment_help_url" => help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting'),
+ "deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.html', anchor: 'troubleshooting'),
"runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'),
"page_path" => project_job_path(@project, @build),
"build_status" => @build.status,
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index c9231a4eff3..ec10610714b 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -77,7 +77,7 @@ module Ci
def toggle_shared_runners_settings_data(project)
{
is_enabled: "#{project.shared_runners_enabled?}",
- is_disabled_and_unoverridable: "#{project.group&.shared_runners_setting == 'disabled_and_unoverridable'}",
+ is_disabled_and_unoverridable: "#{project.group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE}",
update_path: toggle_shared_runners_project_runners_path(project)
}
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 53017beee85..ee5f4bb364a 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -17,6 +17,15 @@ module CommitsHelper
commit_person_link(commit, options.merge(source: :committer))
end
+ def commit_committer_avatar(committer, options = {})
+ user_avatar(options.merge({
+ user: committer,
+ user_name: committer.name,
+ user_email: committer.email,
+ css_class: 'd-none d-sm-inline-block float-none gl-mr-0! gl-vertical-align-text-bottom'
+ }))
+ end
+
def commit_to_html(commit, ref, project)
render 'projects/commits/commit.html',
commit: commit,
diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb
index 2b8804bc07e..e12c6c605d2 100644
--- a/app/helpers/feature_flags_helper.rb
+++ b/app/helpers/feature_flags_helper.rb
@@ -11,8 +11,15 @@ module FeatureFlagsHelper
project.feature_flags_client_token
end
- def feature_flag_issues_links_endpoint(_project, _feature_flag, _user)
- ''
+ def edit_feature_flag_data
+ {
+ endpoint: project_feature_flag_path(@project, @feature_flag),
+ project_id: @project.id,
+ feature_flags_path: project_feature_flags_path(@project),
+ environments_endpoint: search_project_environments_path(@project, format: :json),
+ strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
+ environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs')
+ }
end
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index a24776eb2e4..30aaa0a5acc 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -109,7 +109,7 @@ module GroupsHelper
end
def prevent_sharing_groups_outside_hierarchy_help_text(group)
- s_("GroupSettings|This setting is only available on the top-level group and it applies to all subgroups. Groups that have already been shared with a group outside %{group} will still be shared, and this access will have to be revoked manually.").html_safe % { group: link_to_group(group) }
+ s_("GroupSettings|Available only on the top-level group. Applies to all subgroups. Groups already shared with a group outside %{group} are still shared unless removed manually.").html_safe % { group: link_to_group(group) }
end
def parent_group_options(current_group)
@@ -178,7 +178,7 @@ module GroupsHelper
end
def default_help
- s_("GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually.")
+ s_("GroupSettings|Applied to all subgroups unless overridden by a group owner. Groups already added to the project lose access.")
end
def ancestor_locked_but_you_can_override(group)
diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb
index 2725d28c47c..c1dfd2b2cda 100644
--- a/app/helpers/hooks_helper.rb
+++ b/app/helpers/hooks_helper.rb
@@ -36,6 +36,15 @@ module HooksHelper
admin_hook_path(hook)
end
end
+
+ def hook_log_path(hook, hook_log)
+ case hook
+ when ProjectHook
+ hook_log.present.details_path
+ when SystemHook
+ admin_hook_hook_log_path(hook, hook_log)
+ end
+ end
end
HooksHelper.prepend_mod_with('HooksHelper')
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index 904508867d3..8819aa9e9cc 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -125,15 +125,6 @@ module IntegrationsHelper
!Gitlab.com?
end
- def integration_tabs(integration:)
- [
- { key: 'edit', text: _('Settings'), href: scoped_edit_integration_path(integration) },
- (
- { key: 'overrides', text: s_('Integrations|Projects using custom settings'), href: scoped_overrides_integration_path(integration) } if integration.instance_level?
- )
- ].compact
- end
-
def jira_issue_breadcrumb_link(issue_reference)
link_to '', { class: 'gl-display-flex gl-align-items-center gl-white-space-nowrap' } do
icon = image_tag image_path('illustrations/logos/jira.svg'), width: 15, height: 15, class: 'gl-mr-2'
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index f3cc46216e5..24c6ef8cd68 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -198,7 +198,7 @@ module IssuablesHelper
if count != -1
html << " " << content_tag(:span,
format_count(issuable_type, count, Gitlab::IssuablesCountForState::THRESHOLD),
- class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm'
+ class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex'
)
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 40e86b4623c..49f7d9aeef1 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -238,9 +238,10 @@ module IssuesHelper
)
end
- def group_issues_list_data(group, current_user, issues)
+ def group_issues_list_data(group, current_user, issues, projects)
common_issues_list_data(group, current_user).merge(
- has_any_issues: issues.to_a.any?.to_s
+ has_any_issues: issues.to_a.any?.to_s,
+ has_any_projects: any_projects?(projects).to_s
)
end
diff --git a/app/helpers/one_trust_helper.rb b/app/helpers/one_trust_helper.rb
new file mode 100644
index 00000000000..9f92a73a4d4
--- /dev/null
+++ b/app/helpers/one_trust_helper.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module OneTrustHelper
+ def one_trust_enabled?
+ Feature.enabled?(:ecomm_instrumentation, type: :ops) &&
+ Gitlab.config.extra.has_key?('one_trust_id') &&
+ Gitlab.config.extra.one_trust_id.present? &&
+ !current_user
+ end
+end
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index ebf30fb3538..c69d9eb1326 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -41,6 +41,7 @@ module PackagesHelper
def packages_list_data(type, resource)
{
resource_id: resource.id,
+ full_path: resource.full_path,
page_type: type,
empty_list_help_url: help_page_path('user/packages/package_registry/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg'),
@@ -70,6 +71,7 @@ module PackagesHelper
can_delete: can?(current_user, :destroy_package, project).to_s,
svg_path: image_path('illustrations/no-packages.svg'),
npm_path: package_registry_instance_url(:npm),
+ npm_project_path: package_registry_project_url(project.id, :npm),
npm_help_path: help_page_path('user/packages/npm_registry/index'),
maven_path: package_registry_project_url(project.id, :maven),
maven_help_path: help_page_path('user/packages/maven_repository/index'),
diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb
new file mode 100644
index 00000000000..20fa721cc3b
--- /dev/null
+++ b/app/helpers/projects/cluster_agents_helper.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Projects::ClusterAgentsHelper
+ def js_cluster_agent_details_data(agent_name, project)
+ {
+ agent_name: agent_name,
+ project_path: project.full_path
+ }
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index d7f1cd505e9..03e7fb5ffc4 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -20,16 +20,15 @@ module ProjectsHelper
end
def link_to_member_avatar(author, opts = {})
- default_opts = { size: 16, lazy_load: false }
+ default_opts = { size: 16 }
opts = default_opts.merge(opts)
classes = %W[avatar avatar-inline s#{opts[:size]}]
classes << opts[:avatar_class] if opts[:avatar_class]
avatar = avatar_icon_for_user(author, opts[:size])
- src = opts[:lazy_load] ? nil : avatar
- image_tag(src, width: opts[:size], class: classes, alt: '', "data-src" => avatar)
+ image_tag(avatar, width: opts[:size], class: classes, alt: '')
end
def author_content_tag(author, opts = {})
@@ -351,7 +350,7 @@ module ProjectsHelper
end
def show_terraform_banner?(project)
- project.repository_languages.with_programming_language('HCL').exists? && project.terraform_states.empty?
+ Feature.enabled?(:show_terraform_banner, type: :ops, default_enabled: true) && project.repository_languages.with_programming_language('HCL').exists? && project.terraform_states.empty?
end
def project_permissions_panel_data(project)
diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb
index 1d9320f0106..b73e49803ae 100644
--- a/app/helpers/routing/pseudonymization_helper.rb
+++ b/app/helpers/routing/pseudonymization_helper.rb
@@ -6,7 +6,8 @@ module Routing
return unless Feature.enabled?(:mask_page_urls, type: :ops)
mask_params(Rails.application.routes.recognize_path(request.original_fullpath))
- rescue ActionController::RoutingError, URI::InvalidURIError
+ rescue ActionController::RoutingError, URI::InvalidURIError => e
+ Gitlab::ErrorTracking.track_exception(e, url: request.original_fullpath)
nil
end
@@ -27,7 +28,7 @@ module Routing
when 'groups'
"/namespace:#{group.id}"
when 'projects'
- "/namespace:#{project.namespace.id}/project:#{project.id}"
+ "/namespace:#{project.namespace_id}/project:#{project.id}"
when 'root'
''
else
@@ -43,7 +44,7 @@ module Routing
masked_url = "#{request.protocol}#{request.host_with_port}"
if request_params.has_key?(:project_id)
- masked_url += "/namespace:#{project.namespace.id}/project:#{project.id}/-/#{namespace_type}"
+ masked_url += "/namespace:#{project.namespace_id}/project:#{project.id}/-/#{namespace_type}"
end
if request_params.has_key?(:id)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index b8e58e3afb1..cb28025c900 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -87,9 +87,9 @@ module SearchHelper
def search_entries_info_template(collection)
if collection.total_pages > 1
- s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}").html_safe
+ s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element}").html_safe
else
- s_("SearchResults|Showing %{count} %{scope} for%{term_element}").html_safe
+ s_("SearchResults|Showing %{count} %{scope} for %{term_element}").html_safe
end
end
diff --git a/app/helpers/startupjs_helper.rb b/app/helpers/startupjs_helper.rb
index b595590c7c9..2e8f0cb7dbe 100644
--- a/app/helpers/startupjs_helper.rb
+++ b/app/helpers/startupjs_helper.rb
@@ -5,6 +5,13 @@ module StartupjsHelper
@graphql_startup_calls
end
+ def page_startup_graphql_headers
+ {
+ 'X-CSRF-Token' => form_authenticity_token,
+ 'x-gitlab-feature-category' => ::Gitlab::ApplicationContext.current_context_attribute(:feature_category).presence || ''
+ }
+ end
+
def add_page_startup_graphql_call(query, variables = {})
@graphql_startup_calls ||= []
file_location = File.join(Rails.root, "app/graphql/queries/#{query}.query.graphql")
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index e64e1c935dd..a6bb2f3b246 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -1,6 +1,67 @@
# frozen_string_literal: true
module TabHelper
+ # Navigation tabs helper
+
+ # Create a <gl-tabs> container
+ #
+ # Returns a `ul` element with classes that correspond to
+ # the <gl-tabs/> component. Can be populated by
+ # gl_tab_link_to elements.
+ #
+ # See more at: https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-tabs-tab--default
+ def gl_tabs_nav(html_options = {}, &block)
+ gl_tabs_classes = %w[nav gl-tabs-nav]
+
+ html_options = html_options.merge(
+ class: [*html_options[:class], gl_tabs_classes].join(' '),
+ role: 'tablist'
+ )
+
+ content = capture(&block) if block_given?
+ content_tag(:ul, content, html_options)
+ end
+
+ # Create a <gl-tab> link
+ #
+ # When a tab is active it gets highlighted to indicate this is currently viewed tab.
+ # Internally `current_page?` is called to determine if this is the current tab.
+ #
+ # Usage is the same as "link_to", with the following additional options:
+ #
+ # html_options - The html_options hash (default: {})
+ # :item_active - Overrides the default state focing the "active" css classes (optional).
+ #
+ def gl_tab_link_to(name = nil, options = {}, html_options = {}, &block)
+ tab_class = 'nav-item'
+ link_classes = %w[nav-link gl-tab-nav-item]
+ active_link_classes = %w[active gl-tab-nav-item-active gl-tab-nav-item-active-indigo]
+
+ if block_given?
+ # Shift params to skip the omitted "name" param
+ html_options = options
+ options = name
+ end
+
+ html_options = html_options.merge(
+ class: [*html_options[:class], link_classes].join(' ')
+ )
+
+ if gl_tab_link_to_active?(options, html_options)
+ html_options[:class] = [*html_options[:class], active_link_classes].join(' ')
+ end
+
+ html_options = html_options.except(:item_active)
+
+ content_tag(:li, class: tab_class, role: 'presentation') do
+ if block_given?
+ link_to(options, html_options, &block)
+ else
+ link_to(name, options, html_options)
+ end
+ end
+ end
+
# Navigation link helper
#
# Returns an `li` element with an 'active' class if the supplied
@@ -12,7 +73,6 @@ module TabHelper
# :action - One or more action names to check (optional).
# :path - A shorthand path, such as 'dashboard#index', to check (optional).
# :html_options - Extra options to be passed to the list element (optional).
- # :unless - Callable object to skip rendering the 'active' class on `li` element (optional).
# block - An optional block that will become the contents of the returned
# `li` element.
#
@@ -57,11 +117,6 @@ module TabHelper
# nav_link(path: 'admin/appearances#show') { "Hello"}
# # => '<li class="active">Hello</li>'
#
- # # Shorthand path + unless
- # # Add `active` class when TreeController is requested, except the `index` action.
- # nav_link(controller: 'tree', unless: -> { action_name?('index') }) { "Hello" }
- # # => '<li class="active">Hello</li>'
- #
# # When `TreeController#index` is requested
# # => '<li>Hello</li>'
#
@@ -90,8 +145,6 @@ module TabHelper
end
def active_nav_link?(options)
- return false if options[:unless]&.call
-
controller = options.delete(:controller)
action = options.delete(:action)
@@ -148,4 +201,12 @@ module TabHelper
current_controller?(*controller) || current_action?(*action)
end
end
+
+ def gl_tab_link_to_active?(options, html_options)
+ if html_options.has_key?(:item_active)
+ return html_options[:item_active]
+ end
+
+ current_page?(options)
+ end
end
diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb
index f92e32ff9b6..a0d9c8403e8 100644
--- a/app/helpers/time_zone_helper.rb
+++ b/app/helpers/time_zone_helper.rb
@@ -33,6 +33,8 @@ module TimeZoneHelper
end
def local_time(timezone)
+ return if timezone.blank?
+
time_zone_instance = ActiveSupport::TimeZone.new(timezone) || Time.zone
time_zone_instance.now.strftime("%-l:%M %p")
end
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index 0993e210f42..eca40572735 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -78,19 +78,6 @@ module TimeboxesHelper
end
# rubocop: enable CodeReuse/ActiveRecord
- # Show 'active' class if provided GET param matches check
- # `or_blank` allows the function to return 'active' when given an empty param
- # Could be refactored to be simpler but that may make it harder to read
- def milestone_class_for_state(param, check, match_blank_param = false)
- if match_blank_param
- 'active' if param.blank? || param == check
- elsif param == check
- 'active'
- else
- check
- end
- end
-
def milestone_progress_tooltip_text(milestone)
has_issues = milestone.total_issues_count > 0
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index 2c3dc243d85..1c67ca983fa 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -10,6 +10,7 @@ module UserCalloutsHelper
REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
INVITE_MEMBERS_BANNER = 'invite_members_banner'
+ SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout'
def show_gke_cluster_integration_callout?(project)
active_nav_link?(controller: sidebar_operations_paths) &&
@@ -64,6 +65,11 @@ module UserCalloutsHelper
!multiple_members?(group)
end
+ def show_security_newsletter_user_callout?
+ current_user&.admin? &&
+ !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT)
+ end
+
private
def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index 8785c4cdcbb..4862282bc73 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -41,6 +41,15 @@ module WorkhorseHelper
head :ok
end
+ def send_dependency(token, url, filename)
+ headers.store(*Gitlab::Workhorse.send_dependency(token, url))
+ headers['Content-Disposition'] =
+ ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: filename)
+ headers['Content-Type'] = 'application/gzip'
+
+ head :ok
+ end
+
def set_workhorse_internal_api_content_type
headers['Content-Type'] = Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE
end
diff --git a/app/models/analytics/cycle_analytics/issue_stage_event.rb b/app/models/analytics/cycle_analytics/issue_stage_event.rb
index 1da8973ff21..3e6ed86d534 100644
--- a/app/models/analytics/cycle_analytics/issue_stage_event.rb
+++ b/app/models/analytics/cycle_analytics/issue_stage_event.rb
@@ -3,9 +3,14 @@
module Analytics
module CycleAnalytics
class IssueStageEvent < ApplicationRecord
+ include StageEventModel
extend SuppressCompositePrimaryKeyWarning
validates(*%i[stage_event_hash_id issue_id group_id project_id start_event_timestamp], presence: true)
+
+ def self.issuable_id_column
+ :issue_id
+ end
end
end
end
diff --git a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb
index d2f899ae933..d0ec3c4e8b9 100644
--- a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb
+++ b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb
@@ -3,9 +3,14 @@
module Analytics
module CycleAnalytics
class MergeRequestStageEvent < ApplicationRecord
+ include StageEventModel
extend SuppressCompositePrimaryKeyWarning
validates(*%i[stage_event_hash_id merge_request_id group_id project_id start_event_timestamp], presence: true)
+
+ def self.issuable_id_column
+ :merge_request_id
+ end
end
end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 5f16b990d01..5a8cbd8d71c 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -9,8 +9,6 @@ class ApplicationSetting < ApplicationRecord
include Sanitizable
ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22'
- ignore_column :seat_link_enabled, remove_with: '14.4', remove_after: '2021-09-22'
- ignore_column :cloud_license_enabled, remove_with: '14.4', remove_after: '2021-09-22'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
@@ -366,6 +364,10 @@ class ApplicationSetting < ApplicationRecord
validates :container_registry_expiration_policies_worker_capacity,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :dependency_proxy_ttl_group_policy_worker_capacity,
+ allow_nil: false,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
validates :invisible_captcha_enabled,
inclusion: { in: [true, false], message: _('must be a boolean value') }
@@ -481,6 +483,8 @@ class ApplicationSetting < ApplicationRecord
validates :throttle_unauthenticated_packages_api_period_in_seconds
validates :throttle_unauthenticated_files_api_requests_per_period
validates :throttle_unauthenticated_files_api_period_in_seconds
+ validates :throttle_unauthenticated_deprecated_api_requests_per_period
+ validates :throttle_unauthenticated_deprecated_api_period_in_seconds
validates :throttle_authenticated_api_requests_per_period
validates :throttle_authenticated_api_period_in_seconds
validates :throttle_authenticated_git_lfs_requests_per_period
@@ -491,6 +495,8 @@ class ApplicationSetting < ApplicationRecord
validates :throttle_authenticated_packages_api_period_in_seconds
validates :throttle_authenticated_files_api_requests_per_period
validates :throttle_authenticated_files_api_period_in_seconds
+ validates :throttle_authenticated_deprecated_api_requests_per_period
+ validates :throttle_authenticated_deprecated_api_period_in_seconds
validates :throttle_protected_paths_requests_per_period
validates :throttle_protected_paths_period_in_seconds
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 612fda158d3..7bdea36bb8a 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -159,6 +159,7 @@ module ApplicationSettingImplementation
spam_check_endpoint_enabled: false,
spam_check_endpoint_url: nil,
spam_check_api_key: nil,
+ suggest_pipeline_enabled: true,
terminal_max_session_time: 0,
throttle_authenticated_api_enabled: false,
throttle_authenticated_api_period_in_seconds: 3600,
@@ -175,6 +176,9 @@ module ApplicationSettingImplementation
throttle_authenticated_files_api_enabled: false,
throttle_authenticated_files_api_period_in_seconds: 15,
throttle_authenticated_files_api_requests_per_period: 500,
+ throttle_authenticated_deprecated_api_enabled: false,
+ throttle_authenticated_deprecated_api_period_in_seconds: 3600,
+ throttle_authenticated_deprecated_api_requests_per_period: 3600,
throttle_incident_management_notification_enabled: false,
throttle_incident_management_notification_per_period: 3600,
throttle_incident_management_notification_period_in_seconds: 3600,
@@ -193,6 +197,9 @@ module ApplicationSettingImplementation
throttle_unauthenticated_files_api_enabled: false,
throttle_unauthenticated_files_api_period_in_seconds: 15,
throttle_unauthenticated_files_api_requests_per_period: 125,
+ throttle_unauthenticated_deprecated_api_enabled: false,
+ throttle_unauthenticated_deprecated_api_period_in_seconds: 3600,
+ throttle_unauthenticated_deprecated_api_requests_per_period: 1800,
time_tracking_limit_to_hours: false,
two_factor_grace_period: 48,
unique_ips_limit_enabled: false,
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index f17fff742fe..a1c6793607f 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -71,7 +71,7 @@ class AuditEvent < ApplicationRecord
end
def lazy_author
- BatchLoader.for(author_id).batch(replace_methods: false) do |author_ids, loader|
+ BatchLoader.for(author_id).batch do |author_ids, loader|
User.select(:id, :name, :username).where(id: author_ids).find_each do |user|
loader.call(user.id, user)
end
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index d251b0adbd3..c8f6b9aaedb 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -66,5 +66,3 @@ class AwardEmoji < ApplicationRecord
awardable.try(:update_upvotes_count) if upvote?
end
end
-
-AwardEmoji.prepend_mod_with('AwardEmoji')
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
index dee55675304..818ae04ba29 100644
--- a/app/models/bulk_import.rb
+++ b/app/models/bulk_import.rb
@@ -4,7 +4,8 @@
# projects to a GitLab instance. It associates the import with the responsible
# user.
class BulkImport < ApplicationRecord
- MINIMUM_GITLAB_MAJOR_VERSION = 14
+ MIN_MAJOR_VERSION = 14
+ MIN_MINOR_VERSION_FOR_PROJECT = 4
belongs_to :user, optional: false
@@ -34,6 +35,14 @@ class BulkImport < ApplicationRecord
end
end
+ def source_version_info
+ Gitlab::VersionInfo.parse(source_version)
+ end
+
+ def self.min_gl_version_for_project_migration
+ Gitlab::VersionInfo.new(MIN_MAJOR_VERSION, MIN_MINOR_VERSION_FOR_PROJECT)
+ end
+
def self.all_human_statuses
state_machine.states.map(&:human_name)
end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index ab5d248ff8c..ecac4ab95f4 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -20,6 +20,8 @@
class BulkImports::Entity < ApplicationRecord
self.table_name = 'bulk_import_entities'
+ EXPORT_RELATIONS_URL = '/%{resource}/%{full_path}/export_relations'
+
belongs_to :bulk_import, optional: false
belongs_to :parent, class_name: 'BulkImports::Entity', optional: true
@@ -81,9 +83,9 @@ class BulkImports::Entity < ApplicationRecord
def pipelines
@pipelines ||= case source_type
when 'group_entity'
- BulkImports::Groups::Stage.pipelines
+ BulkImports::Groups::Stage.new(bulk_import).pipelines
when 'project_entity'
- BulkImports::Projects::Stage.pipelines
+ BulkImports::Projects::Stage.new(bulk_import).pipelines
end
end
@@ -102,6 +104,14 @@ class BulkImports::Entity < ApplicationRecord
end
end
+ def pluralized_name
+ source_type.gsub('_entity', '').pluralize
+ end
+
+ def export_relations_url_path
+ @export_relations_url_path ||= EXPORT_RELATIONS_URL % { resource: pluralized_name, full_path: encoded_source_full_path }
+ end
+
private
def validate_parent_is_a_group
diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb
index 371b58dea03..8d4d31ee92d 100644
--- a/app/models/bulk_imports/export.rb
+++ b/app/models/bulk_imports/export.rb
@@ -53,7 +53,7 @@ module BulkImports
end
def relation_definition
- config.portable_tree[:include].find { |include| include[relation.to_sym] }
+ config.relation_definition_for(relation)
end
def config
diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb
index ff165830cf1..abf064adaae 100644
--- a/app/models/bulk_imports/export_status.rb
+++ b/app/models/bulk_imports/export_status.rb
@@ -41,7 +41,7 @@ module BulkImports
end
def status_endpoint
- "/groups/#{entity.encoded_source_full_path}/export_relations/status"
+ File.join(entity.export_relations_url_path, 'status')
end
end
end
diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb
index ddea7c3f64c..4d370315ad5 100644
--- a/app/models/bulk_imports/file_transfer/base_config.rb
+++ b/app/models/bulk_imports/file_transfer/base_config.rb
@@ -22,15 +22,25 @@ module BulkImports
end
def export_path
- strong_memoize(:export_path) do
- relative_path = File.join(base_export_path, SecureRandom.hex)
-
- ::Gitlab::ImportExport.export_path(relative_path: relative_path)
- end
+ @export_path ||= Dir.mktmpdir('bulk_imports')
end
def portable_relations
- import_export_config.dig(:tree, portable_class_sym).keys.map(&:to_s) - skipped_relations
+ tree_relations + file_relations - skipped_relations
+ end
+
+ def tree_relation?(relation)
+ tree_relations.include?(relation)
+ end
+
+ def file_relation?(relation)
+ file_relations.include?(relation)
+ end
+
+ def tree_relation_definition_for(relation)
+ return unless tree_relation?(relation)
+
+ portable_tree[:include].find { |include| include[relation.to_sym] }
end
private
@@ -44,7 +54,7 @@ module BulkImports
end
def import_export_config
- ::Gitlab::ImportExport::Config.new(config: import_export_yaml).to_h
+ @config ||= ::Gitlab::ImportExport::Config.new(config: import_export_yaml).to_h
end
def portable_class
@@ -63,8 +73,12 @@ module BulkImports
raise NotImplementedError
end
- def base_export_path
- raise NotImplementedError
+ def tree_relations
+ import_export_config.dig(:tree, portable_class_sym).keys.map(&:to_s)
+ end
+
+ def file_relations
+ []
end
def skipped_relations
diff --git a/app/models/bulk_imports/file_transfer/group_config.rb b/app/models/bulk_imports/file_transfer/group_config.rb
index 2266cbb484f..6766c00246b 100644
--- a/app/models/bulk_imports/file_transfer/group_config.rb
+++ b/app/models/bulk_imports/file_transfer/group_config.rb
@@ -3,16 +3,14 @@
module BulkImports
module FileTransfer
class GroupConfig < BaseConfig
- def base_export_path
- portable.full_path
- end
+ SKIPPED_RELATIONS = %w(members).freeze
def import_export_yaml
::Gitlab::ImportExport.group_config_file
end
def skipped_relations
- @skipped_relations ||= %w(members)
+ SKIPPED_RELATIONS
end
end
end
diff --git a/app/models/bulk_imports/file_transfer/project_config.rb b/app/models/bulk_imports/file_transfer/project_config.rb
index 8a57f51c1c5..9a0434da08a 100644
--- a/app/models/bulk_imports/file_transfer/project_config.rb
+++ b/app/models/bulk_imports/file_transfer/project_config.rb
@@ -3,16 +3,23 @@
module BulkImports
module FileTransfer
class ProjectConfig < BaseConfig
- def base_export_path
- portable.disk_path
- end
+ UPLOADS_RELATION = 'uploads'
+
+ SKIPPED_RELATIONS = %w(
+ project_members
+ group_members
+ ).freeze
def import_export_yaml
::Gitlab::ImportExport.config_file
end
+ def file_relations
+ [UPLOADS_RELATION]
+ end
+
def skipped_relations
- @skipped_relations ||= %w(project_members group_members)
+ SKIPPED_RELATIONS
end
end
end
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
index c185470b1c2..9de3239ee0f 100644
--- a/app/models/bulk_imports/tracker.rb
+++ b/app/models/bulk_imports/tracker.rb
@@ -50,6 +50,8 @@ class BulkImports::Tracker < ApplicationRecord
event :start do
transition created: :started
+ # To avoid errors when re-starting a pipeline in case of network errors
+ transition started: :started
end
event :finish do
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 97fb8233d34..50bda64d537 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -31,7 +31,7 @@ module Ci
next unless bridge.triggers_downstream_pipeline?
bridge.run_after_commit do
- ::Ci::CreateCrossProjectPipelineWorker.perform_async(bridge.id)
+ ::Ci::CreateDownstreamPipelineWorker.perform_async(bridge.id)
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index e2e24247679..990ef71a457 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -42,6 +42,10 @@ module Ci
has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id, inverse_of: :build
has_many :report_results, class_name: 'Ci::BuildReportResult', inverse_of: :build
+ # Projects::DestroyService destroys Ci::Pipelines, which use_fast_destroy on :job_artifacts
+ # before we delete builds. By doing this, the relation should be empty and not fire any
+ # DELETE queries when the Ci::Build is destroyed. The next step is to remove `dependent: :destroy`.
+ # Details: https://gitlab.com/gitlab-org/gitlab/-/issues/24644#note_689472685
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent
has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id
has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id
@@ -55,6 +59,8 @@ module Ci
has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build
has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', inverse_of: :build
+ has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', dependent: :nullify, inverse_of: :build, foreign_key: :ci_build_id # rubocop:disable Cop/ActiveRecordDependent
+
accepts_nested_attributes_for :runner_session, update_only: true
accepts_nested_attributes_for :job_variables
@@ -64,8 +70,8 @@ module Ci
delegate :gitlab_deploy_token, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
- ignore_columns :id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22'
- ignore_columns :stage_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22'
+ ignore_columns :id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22'
+ ignore_columns :stage_id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22'
##
# Since Gitlab 11.5, deployments records started being created right after
@@ -192,7 +198,6 @@ module Ci
add_authentication_token_field :token, encrypted: :required
before_save :ensure_token
- before_destroy { unscoped_project }
after_save :stick_build_if_status_changed
@@ -308,8 +313,10 @@ module Ci
end
after_transition pending: :running do |build|
- Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
- build.deployment&.run
+ unless build.update_deployment_after_transaction_commit?
+ Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
+ build.deployment&.run
+ end
end
build.run_after_commit do
@@ -332,8 +339,10 @@ module Ci
end
after_transition any => [:success] do |build|
- Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
- build.deployment&.succeed
+ unless build.update_deployment_after_transaction_commit?
+ Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
+ build.deployment&.succeed
+ end
end
build.run_after_commit do
@@ -346,12 +355,14 @@ module Ci
next unless build.project
next unless build.deployment
- begin
- Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
- build.deployment.drop!
+ unless build.update_deployment_after_transaction_commit?
+ begin
+ Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
+ build.deployment.drop!
+ end
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id)
end
- rescue StandardError => e
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id)
end
true
@@ -370,14 +381,29 @@ module Ci
end
after_transition any => [:skipped, :canceled] do |build, transition|
- Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
- if transition.to_name == :skipped
- build.deployment&.skip
- else
- build.deployment&.cancel
+ unless build.update_deployment_after_transaction_commit?
+ Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
+ if transition.to_name == :skipped
+ build.deployment&.skip
+ else
+ build.deployment&.cancel
+ end
end
end
end
+
+ # Synchronize Deployment Status
+ # Please note that the data integirty is not assured because we can't use
+ # a database transaction due to DB decomposition.
+ after_transition do |build, transition|
+ next if transition.loopback?
+ next unless build.project
+ next unless build.update_deployment_after_transaction_commit?
+
+ build.run_after_commit do
+ build.deployment&.sync_status_with(build)
+ end
+ end
end
def self.build_matchers(project)
@@ -1094,6 +1120,12 @@ module Ci
runner&.instance_type?
end
+ def update_deployment_after_transaction_commit?
+ strong_memoize(:update_deployment_after_transaction_commit) do
+ Feature.enabled?(:update_deployment_after_transaction_commit, project, default_enabled: :yaml)
+ end
+ end
+
protected
def run_status_commit_hooks!
@@ -1108,7 +1140,7 @@ module Ci
return unless saved_change_to_status?
return unless running?
- ::Gitlab::Database::LoadBalancing::Sticking.stick(:build, id)
+ self.class.sticking.stick(:build, id)
end
def status_commit_hooks
@@ -1154,10 +1186,6 @@ module Ci
self.update(erased_by: user, erased_at: Time.current, artifacts_expire_at: nil)
end
- def unscoped_project
- @unscoped_project ||= Project.unscoped.find_by(id: project_id)
- end
-
def environment_url
options&.dig(:environment, :url) || persisted_environment&.external_url
end
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 90237a4be52..0d6d6f7a6a5 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -37,8 +37,8 @@ module Ci
job_timeout_source: 4
}
- ignore_column :build_id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22'
- ignore_columns :id_convert_to_bigint, remove_with: '14.3', remove_after: '2021-09-22'
+ ignore_column :build_id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22'
+ ignore_columns :id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22'
def update_timeout_state
timeout = timeout_with_highest_precedence
diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb
index 003659570b3..bf1470ca20f 100644
--- a/app/models/ci/build_need.rb
+++ b/app/models/ci/build_need.rb
@@ -5,8 +5,6 @@ module Ci
include BulkInsertSafe
include IgnorableColumns
- ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22'
-
belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs
validates :build, presence: true
diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb
index 45de47116cd..e12c0f82c99 100644
--- a/app/models/ci/build_runner_session.rb
+++ b/app/models/ci/build_runner_session.rb
@@ -6,8 +6,6 @@ module Ci
class BuildRunnerSession < Ci::ApplicationRecord
include IgnorableColumns
- ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22'
-
TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'
DEFAULT_SERVICE_NAME = 'build'
DEFAULT_PORT_NAME = 'default_port'
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 7a15d7ba940..6edb5ef4579 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -9,8 +9,6 @@ module Ci
include ::Gitlab::OptimisticLocking
include IgnorableColumns
- ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22'
-
belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
default_value_for :data_store, :redis_trace_chunks
diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb
index 901b84ceec6..1ffa0e31f99 100644
--- a/app/models/ci/build_trace_metadata.rb
+++ b/app/models/ci/build_trace_metadata.rb
@@ -37,8 +37,10 @@ module Ci
increment!(:archival_attempts, touch: :last_archival_attempt_at)
end
- def track_archival!(trace_artifact_id)
- update!(trace_artifact_id: trace_artifact_id, archived_at: Time.current)
+ def track_archival!(trace_artifact_id, checksum)
+ update!(trace_artifact_id: trace_artifact_id,
+ checksum: checksum,
+ archived_at: Time.current)
end
def archival_attempts_message
@@ -49,6 +51,11 @@ module Ci
end
end
+ def remote_checksum_valid?
+ checksum.present? &&
+ checksum == remote_checksum
+ end
+
private
def backoff
diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb
index 99118f8090b..c2ab8ca0929 100644
--- a/app/models/ci/job_token/project_scope_link.rb
+++ b/app/models/ci/job_token/project_scope_link.rb
@@ -5,7 +5,7 @@
module Ci
module JobToken
- class ProjectScopeLink < ApplicationRecord
+ class ProjectScopeLink < Ci::ApplicationRecord
self.table_name = 'ci_job_token_project_scope_links'
belongs_to :source_project, class_name: 'Project'
diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb
index 42cfdc21d66..3a5765aa00c 100644
--- a/app/models/ci/job_token/scope.rb
+++ b/app/models/ci/job_token/scope.rb
@@ -32,12 +32,15 @@ module Ci
def all_projects
Project.from_union([
Project.id_in(source_project),
- Project.where_exists(
- Ci::JobToken::ProjectScopeLink
- .from_project(source_project)
- .where('projects.id = ci_job_token_project_scope_links.target_project_id'))
+ Project.id_in(target_project_ids)
], remove_duplicates: false)
end
+
+ private
+
+ def target_project_ids
+ Ci::JobToken::ProjectScopeLink.from_project(source_project).pluck(:target_project_id)
+ end
end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 1a0cec3c935..0041ec5135c 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -82,7 +82,8 @@ module Ci
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest'
-
+ has_many :package_build_infos, class_name: 'Packages::BuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent
+ has_many :package_file_build_infos, class_name: 'Packages::PackageFileBuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :failed_builds, -> { latest.failed }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
@@ -861,11 +862,6 @@ module Ci
self.duration = Gitlab::Ci::Pipeline::Duration.from_pipeline(self)
end
- def execute_hooks
- project.execute_hooks(pipeline_data, :pipeline_hooks) if project.has_active_hooks?(:pipeline_hooks)
- project.execute_integrations(pipeline_data, :pipeline_hooks) if project.has_active_integrations?(:pipeline_hooks)
- end
-
# All the merge requests for which the current pipeline runs/ran against
def all_merge_requests
@all_merge_requests ||=
@@ -929,9 +925,22 @@ module Ci
end
def environments_in_self_and_descendants
- environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id')
+ if ::Feature.enabled?(:avoid_cross_joins_environments_in_self_and_descendants, default_enabled: :yaml)
+ # We limit to 100 unique environments for application safety.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
+ expanded_environment_names =
+ builds_in_self_and_descendants.joins(:metadata)
+ .where.not('ci_builds_metadata.expanded_environment_name' => nil)
+ .distinct('ci_builds_metadata.expanded_environment_name')
+ .limit(100)
+ .pluck(:expanded_environment_name)
+
+ Environment.where(project: project, name: expanded_environment_names)
+ else
+ environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id')
- Environment.where(id: environment_ids)
+ Environment.where(id: environment_ids)
+ end
end
# With multi-project and parent-child pipelines
@@ -1251,12 +1260,6 @@ module Ci
messages.build(severity: severity, content: content)
end
- def pipeline_data
- strong_memoize(:pipeline_data) do
- Gitlab::DataBuilder::Pipeline.build(self)
- end
- end
-
def merge_request_diff_sha
return unless merge_request?
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 30d335fd7d5..372df8cc264 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -58,7 +58,8 @@ module Ci
after_transition any => ::Ci::Processable.completed_statuses do |processable|
next unless processable.with_resource_group?
- next unless processable.resource_group.release_resource_from(processable)
+
+ processable.resource_group.release_resource_from(processable)
processable.run_after_commit do
Ci::ResourceGroups::AssignResourceFromResourceGroupWorker
diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb
index 8a7456041e6..6d25f747a9d 100644
--- a/app/models/ci/resource_group.rb
+++ b/app/models/ci/resource_group.rb
@@ -14,6 +14,12 @@ module Ci
before_create :ensure_resource
+ enum process_mode: {
+ unordered: 0,
+ oldest_first: 1,
+ newest_first: 2
+ }
+
##
# NOTE: This is concurrency-safe method that the subquery in the `UPDATE`
# works as explicit locking.
@@ -25,8 +31,34 @@ module Ci
resources.retained_by(processable).update_all(build_id: nil) > 0
end
+ def upcoming_processables
+ if unordered?
+ processables.waiting_for_resource
+ elsif oldest_first?
+ processables.waiting_for_resource_or_upcoming
+ .order(Arel.sql("commit_id ASC, #{sort_by_job_status}"))
+ elsif newest_first?
+ processables.waiting_for_resource_or_upcoming
+ .order(Arel.sql("commit_id DESC, #{sort_by_job_status}"))
+ else
+ Ci::Processable.none
+ end
+ end
+
private
+ # In order to avoid deadlock, we do NOT specify the job execution order in the same pipeline.
+ # The system processes wherever ready to transition to `pending` status from `waiting_for_resource`.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/202186 for more information.
+ def sort_by_job_status
+ <<~SQL
+ CASE status
+ WHEN 'waiting_for_resource' THEN 0
+ ELSE 1
+ END ASC
+ SQL
+ end
+
def ensure_resource
# Currently we only support one resource per group, which means
# maximum one build can be set to the resource group, thus builds
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 4aa232ad26b..2f718ad7582 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -51,7 +51,7 @@ module Ci
has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects
has_many :runner_namespaces, inverse_of: :runner, autosave: true
- has_many :groups, through: :runner_namespaces
+ has_many :groups, through: :runner_namespaces, disable_joins: true
has_one :last_build, -> { order('id DESC') }, class_name: 'Ci::Build'
@@ -246,7 +246,7 @@ module Ci
begin
transaction do
- self.projects << project
+ self.runner_projects << ::Ci::RunnerProject.new(project: project, runner: self)
self.save!
end
rescue ActiveRecord::RecordInvalid => e
@@ -280,7 +280,7 @@ module Ci
end
def belongs_to_more_than_one_project?
- self.projects.limit(2).count(:all) > 1
+ runner_projects.limit(2).count(:all) > 1
end
def assigned_to_group?
@@ -309,7 +309,9 @@ module Ci
end
def only_for?(project)
- projects == [project]
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
+ projects == [project]
+ end
end
def short_sha
@@ -344,7 +346,7 @@ module Ci
# intention here is not to execute `Ci::RegisterJobService#execute` on
# the primary database.
#
- ::Gitlab::Database::LoadBalancing::Sticking.stick(:runner, id)
+ ::Ci::Runner.sticking.stick(:runner, id)
SecureRandom.hex.tap do |new_update|
::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_update,
@@ -428,10 +430,8 @@ module Ci
end
def no_projects
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
- if projects.any?
- errors.add(:runner, 'cannot have projects assigned')
- end
+ if runner_projects.any?
+ errors.add(:runner, 'cannot have projects assigned')
end
end
@@ -444,14 +444,16 @@ module Ci
end
def any_project
- unless projects.any?
+ unless runner_projects.any?
errors.add(:runner, 'needs to be assigned to at least one project')
end
end
def exactly_one_group
- unless groups.one?
- errors.add(:runner, 'needs to be assigned to exactly one group')
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
+ unless groups.one?
+ errors.add(:runner, 'needs to be assigned to exactly one group')
+ end
end
end
diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb
index d1353b97ed9..52a31863fb2 100644
--- a/app/models/ci/runner_namespace.rb
+++ b/app/models/ci/runner_namespace.rb
@@ -7,7 +7,6 @@ module Ci
self.limit_name = 'ci_registered_group_runners'
self.limit_scope = :group
self.limit_relation = :recent_runners
- self.limit_feature_flag = :ci_runner_limits
self.limit_feature_flag_for_override = :ci_runner_limits_override
belongs_to :runner, inverse_of: :runner_namespaces
diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb
index e1c435e9b1f..148a29a0f8b 100644
--- a/app/models/ci/runner_project.rb
+++ b/app/models/ci/runner_project.rb
@@ -7,7 +7,6 @@ module Ci
self.limit_name = 'ci_registered_project_runners'
self.limit_scope = :project
self.limit_relation = :recent_runners
- self.limit_feature_flag = :ci_runner_limits
self.limit_feature_flag_for_override = :ci_runner_limits_override
belongs_to :runner, inverse_of: :runner_projects
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 39e26bf2785..131e18adf62 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -8,8 +8,6 @@ module Ci
include Presentable
include IgnorableColumns
- ignore_column :id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22'
-
enum status: Ci::HasStatus::STATUSES_ENUM
belongs_to :project
diff --git a/app/models/clusters/agents/group_authorization.rb b/app/models/clusters/agents/group_authorization.rb
index 74c0cec3b7e..28a711aaf17 100644
--- a/app/models/clusters/agents/group_authorization.rb
+++ b/app/models/clusters/agents/group_authorization.rb
@@ -10,7 +10,9 @@ module Clusters
validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
- delegate :project, to: :agent
+ def config_project
+ agent.project
+ end
end
end
end
diff --git a/app/models/clusters/agents/implicit_authorization.rb b/app/models/clusters/agents/implicit_authorization.rb
index 967cc686045..9f7f653ed65 100644
--- a/app/models/clusters/agents/implicit_authorization.rb
+++ b/app/models/clusters/agents/implicit_authorization.rb
@@ -6,12 +6,15 @@ module Clusters
attr_reader :agent
delegate :id, to: :agent, prefix: true
- delegate :project, to: :agent
def initialize(agent:)
@agent = agent
end
+ def config_project
+ agent.project
+ end
+
def config
nil
end
diff --git a/app/models/clusters/agents/project_authorization.rb b/app/models/clusters/agents/project_authorization.rb
index 1c71a0a432a..f6d19086751 100644
--- a/app/models/clusters/agents/project_authorization.rb
+++ b/app/models/clusters/agents/project_authorization.rb
@@ -9,6 +9,10 @@ module Clusters
belongs_to :project, class_name: '::Project', optional: false
validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
+
+ def config_project
+ agent.project
+ end
end
end
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 993ccb33655..7cef92ce81a 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -72,7 +72,7 @@ module Clusters
if cluster.group_type?
attributes[:groups] = [group]
elsif cluster.project_type?
- attributes[:projects] = [project]
+ attributes[:runner_projects] = [::Ci::RunnerProject.new(project: project)]
end
attributes
diff --git a/app/models/clusters/integrations/elastic_stack.rb b/app/models/clusters/integrations/elastic_stack.rb
index 565d268259a..97d73d252b9 100644
--- a/app/models/clusters/integrations/elastic_stack.rb
+++ b/app/models/clusters/integrations/elastic_stack.rb
@@ -14,6 +14,8 @@ module Clusters
validates :cluster, presence: true
validates :enabled, inclusion: { in: [true, false] }
+ scope :enabled, -> { where(enabled: true) }
+
def available?
enabled
end
diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb
index 3f2c47d48e6..d745a49afc1 100644
--- a/app/models/clusters/integrations/prometheus.rb
+++ b/app/models/clusters/integrations/prometheus.rb
@@ -21,6 +21,8 @@ module Clusters
default_value_for(:alert_manager_token) { SecureRandom.hex }
+ scope :enabled, -> { where(enabled: true) }
+
after_destroy do
run_after_commit do
deactivate_project_integrations
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 6c8b4ae1139..553681ee960 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -133,7 +133,7 @@ class Commit
end
def lazy(container, oid)
- BatchLoader.for({ container: container, oid: oid }).batch(replace_methods: false) do |items, loader|
+ BatchLoader.for({ container: container, oid: oid }).batch do |items, loader|
items_by_container = items.group_by { |i| i[:container] }
items_by_container.each do |container, commit_ids|
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 8cba3d04502..43427e2ebc7 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -62,6 +62,9 @@ class CommitStatus < Ci::ApplicationRecord
scope :updated_before, ->(lookback:, timeout:) {
where('(ci_builds.created_at BETWEEN ? AND ?) AND (ci_builds.updated_at BETWEEN ? AND ?)', lookback, timeout, lookback, timeout)
}
+ scope :scheduled_at_before, ->(date) {
+ where('ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?', date)
+ }
# The scope applies `pluck` to split the queries. Use with care.
scope :for_project_paths, -> (paths) do
diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb
index 7bb6004ca83..d9e6756ab86 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage.rb
@@ -27,7 +27,8 @@ module Analytics
alias_attribute :custom_stage?, :custom
scope :default_stages, -> { where(custom: false) }
scope :ordered, -> { order(:relative_position, :id) }
- scope :for_list, -> { includes(:start_event_label, :end_event_label).ordered }
+ scope :with_preloaded_labels, -> { includes(:start_event_label, :end_event_label) }
+ scope :for_list, -> { with_preloaded_labels.ordered }
scope :by_value_stream, -> (value_stream) { where(value_stream_id: value_stream.id) }
before_save :ensure_stage_event_hash_id
diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
new file mode 100644
index 00000000000..7462e1e828b
--- /dev/null
+++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Analytics
+ module CycleAnalytics
+ module StageEventModel
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def upsert_data(data)
+ upsert_values = data.map do |row|
+ row.values_at(
+ :stage_event_hash_id,
+ :issuable_id,
+ :group_id,
+ :project_id,
+ :author_id,
+ :milestone_id,
+ :start_event_timestamp,
+ :end_event_timestamp
+ )
+ end
+
+ value_list = Arel::Nodes::ValuesList.new(upsert_values).to_sql
+
+ query = <<~SQL
+ INSERT INTO #{quoted_table_name}
+ (
+ stage_event_hash_id,
+ #{connection.quote_column_name(issuable_id_column)},
+ group_id,
+ project_id,
+ milestone_id,
+ author_id,
+ start_event_timestamp,
+ end_event_timestamp
+ )
+ #{value_list}
+ ON CONFLICT(stage_event_hash_id, #{issuable_id_column})
+ DO UPDATE SET
+ group_id = excluded.group_id,
+ project_id = excluded.project_id,
+ start_event_timestamp = excluded.start_event_timestamp,
+ end_event_timestamp = excluded.end_event_timestamp,
+ milestone_id = excluded.milestone_id,
+ author_id = excluded.author_id
+ SQL
+
+ result = connection.execute(query)
+ result.cmd_tuples
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 84a74386ff7..b32502c3ee2 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -18,6 +18,7 @@ module Avatarable
prepend ShadowMethods
include ObjectStorage::BackgroundMove
include Gitlab::Utils::StrongMemoize
+ include ApplicationHelper
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validates :avatar, file_size: { maximum: MAXIMUM_FILE_SIZE }, if: :avatar_changed?
@@ -110,7 +111,7 @@ module Avatarable
def retrieve_upload_from_batch(identifier)
BatchLoader.for(identifier: identifier, model: self)
- .batch(key: self.class, cache: true, replace_methods: false) do |upload_params, loader, args|
+ .batch(key: self.class) do |upload_params, loader, args|
model_class = args[:key]
paths = upload_params.flat_map do |params|
params[:model].upload_paths(params[:identifier])
diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb
index 908f0b6a7e2..6c3093ca916 100644
--- a/app/models/concerns/bulk_insert_safe.rb
+++ b/app/models/concerns/bulk_insert_safe.rb
@@ -51,6 +51,12 @@ module BulkInsertSafe
PrimaryKeySetError = Class.new(StandardError)
class_methods do
+ def insert_all_proxy_class
+ @insert_all_proxy_class ||= Class.new(self) do
+ attr_readonly :created_at
+ end
+ end
+
def set_callback(name, *args)
unless _bulk_insert_callback_allowed?(name, args)
raise MethodNotAllowedError,
@@ -138,7 +144,7 @@ module BulkInsertSafe
when nil
false
else
- raise ArgumentError, "returns needs to be :ids or nil"
+ returns
end
# Handle insertions for tables with a composite primary key
@@ -153,9 +159,9 @@ module BulkInsertSafe
item_batch, validate, &handle_attributes)
ActiveRecord::InsertAll
- .new(self, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by)
+ .new(insert_all_proxy_class, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by)
.execute
- .pluck(primary_key)
+ .cast_values(insert_all_proxy_class.attribute_types).to_a
end
end
end
diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb
index 056abafd0ce..9812c62fcc4 100644
--- a/app/models/concerns/checksummable.rb
+++ b/app/models/concerns/checksummable.rb
@@ -8,8 +8,12 @@ module Checksummable
Zlib.crc32(data)
end
- def hexdigest(path)
+ def sha256_hexdigest(path)
::Digest::SHA256.file(path).hexdigest
end
+
+ def md5_hexdigest(path)
+ ::Digest::MD5.file(path).hexdigest
+ end
end
end
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index c1299e3d468..8d715279da8 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -95,6 +95,7 @@ module Ci
scope :failed_or_canceled, -> { with_status(:failed, :canceled) }
scope :complete, -> { with_status(completed_statuses) }
scope :incomplete, -> { without_statuses(completed_statuses) }
+ scope :waiting_for_resource_or_upcoming, -> { with_status(:created, :scheduled, :waiting_for_resource) }
scope :cancelable, -> do
where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled])
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index ec86746ae54..344f5aa4cd5 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -20,6 +20,7 @@ module Ci
delegate :interruptible, to: :metadata, prefix: false, allow_nil: true
delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true
delegate :environment_auto_stop_in, to: :metadata, prefix: false, allow_nil: true
+ delegate :runner_features, to: :metadata, prefix: false, allow_nil: false
before_create :ensure_metadata
end
diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb
index 7f46e44697e..1b4cc14f4a2 100644
--- a/app/models/concerns/enums/ci/commit_status.rb
+++ b/app/models/concerns/enums/ci/commit_status.rb
@@ -27,6 +27,7 @@ module Enums
no_matching_runner: 18, # not used anymore, but cannot be deleted because of old data
trace_size_exceeded: 19,
builds_disabled: 20,
+ environment_creation_failure: 21,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index 9218ba47d20..d614d6c4584 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -72,12 +72,10 @@ module HasRepository
end
def default_branch
- @default_branch ||= repository.root_ref || default_branch_from_preferences
+ @default_branch ||= repository.empty? ? default_branch_from_preferences : repository.root_ref
end
def default_branch_from_preferences
- return unless empty_repo?
-
(default_branch_from_group_preferences || Gitlab::CurrentSettings.default_branch_name).presence
end
diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb
index 1709b56080e..25a1d855119 100644
--- a/app/models/concerns/integrations/has_data_fields.rb
+++ b/app/models/concerns/integrations/has_data_fields.rb
@@ -45,7 +45,6 @@ module Integrations
included do
has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::IssueTrackerData'
has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::JiraTrackerData'
- has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::OpenProjectTrackerData'
has_one :zentao_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::ZentaoTrackerData'
def data_fields
diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb
index 933e8b5f687..209456f8b67 100644
--- a/app/models/concerns/issue_available_features.rb
+++ b/app/models/concerns/issue_available_features.rb
@@ -12,7 +12,8 @@ module IssueAvailableFeatures
{
assignee: %w(issue incident),
confidentiality: %w(issue incident),
- time_tracking: %w(issue incident)
+ time_tracking: %w(issue incident),
+ move_and_clone: %w(issue incident)
}.with_indifferent_access
end
end
diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb
index 196bec04be6..ff52769fce8 100644
--- a/app/models/concerns/packages/debian/distribution.rb
+++ b/app/models/concerns/packages/debian/distribution.rb
@@ -96,18 +96,8 @@ module Packages
architectures.pluck(:name).sort
end
- def needs_update?
- !file.exists? || time_duration_expired?
- end
-
private
- def time_duration_expired?
- return false unless valid_time_duration_seconds.present?
-
- updated_at + valid_time_duration_seconds.seconds + 6.hours < Time.current
- end
-
def unique_codename_and_suite
errors.add(:codename, _('has already been taken as Suite')) if codename_exists_as_suite?
errors.add(:suite, _('has already been taken as Codename')) if suite_exists_as_codename?
diff --git a/app/models/concerns/restricted_signup.rb b/app/models/concerns/restricted_signup.rb
index 587f8c35ff7..cf97be21165 100644
--- a/app/models/concerns/restricted_signup.rb
+++ b/app/models/concerns/restricted_signup.rb
@@ -7,15 +7,49 @@ module RestrictedSignup
def validate_admin_signup_restrictions(email)
return if allowed_domain?(email)
+ error_type = fetch_error_type(email)
+
+ return unless error_type.present?
+
+ [
+ signup_email_invalid_message,
+ error_message[created_by_key][error_type]
+ ].join(' ')
+ end
+
+ def fetch_error_type(email)
if allowlist_present?
- return _('domain is not authorized for sign-up.')
+ :allowlist
elsif denied_domain?(email)
- return _('is not from an allowed domain.')
+ :denylist
elsif restricted_email?(email)
- return _('is not allowed. Try again with a different email address, or contact your GitLab admin.')
+ :restricted
end
+ end
+
+ def error_message
+ {
+ admin: {
+ allowlist: html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check 'Allowed domains for sign-ups'.")).html_safe,
+ denylist: html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check the 'Domain denylist'.")).html_safe,
+ restricted: html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check 'Email restrictions for sign-ups'.")).html_safe,
+ group_setting: html_escape_once(_("Go to the group’s 'Settings &gt; General' page, and check 'Restrict membership by email domain'.")).html_safe
+ },
+ nonadmin: {
+ allowlist: error_nonadmin,
+ denylist: error_nonadmin,
+ restricted: error_nonadmin,
+ group_setting: error_nonadmin
+ }
+ }
+ end
+
+ def error_nonadmin
+ _("Check with your administrator.")
+ end
- nil
+ def created_by_key
+ created_by&.can_admin_all_resources? ? :admin : :nonadmin
end
def denied_domain?(email)
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index 847abdc1b6d..f382b3624ed 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -41,7 +41,7 @@ module Routable
has_one :route, as: :source, autosave: true, dependent: :destroy, inverse_of: :source # rubocop:disable Cop/ActiveRecordDependent
has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- validates :route, presence: true
+ validates :route, presence: true, unless: -> { is_a?(Namespaces::ProjectNamespace) }
scope :with_route, -> { includes(:route) }
@@ -185,6 +185,7 @@ module Routable
def prepare_route
return unless full_path_changed? || full_name_changed?
+ return if is_a?(Namespaces::ProjectNamespace)
route || build_route(source: self)
route.path = build_full_path
diff --git a/app/models/concerns/ttl_expirable.rb b/app/models/concerns/ttl_expirable.rb
new file mode 100644
index 00000000000..00abe0a06e6
--- /dev/null
+++ b/app/models/concerns/ttl_expirable.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module TtlExpirable
+ extend ActiveSupport::Concern
+
+ included do
+ validates :status, presence: true
+
+ enum status: { default: 0, expired: 1, processing: 2, error: 3 }
+
+ scope :updated_before, ->(number_of_days) { where("updated_at <= ?", Time.zone.now - number_of_days.days) }
+ scope :active, -> { where(status: :default) }
+
+ scope :lock_next_by, ->(sort) do
+ order(sort)
+ .limit(1)
+ .lock('FOR UPDATE SKIP LOCKED')
+ end
+ end
+end
diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb
index a656856487d..7f96b3901f1 100644
--- a/app/models/concerns/vulnerability_finding_helpers.rb
+++ b/app/models/concerns/vulnerability_finding_helpers.rb
@@ -2,6 +2,15 @@
module VulnerabilityFindingHelpers
extend ActiveSupport::Concern
+
+ # Manually resolvable report types cannot be considered fixed once removed from the
+ # target branch due to requiring active triage, such as rotation of an exposed token.
+ REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION = %w[secret_detection].freeze
+
+ def requires_manual_resolution?
+ REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION.include?(report_type)
+ end
+
def matches_signatures(other_signatures, other_uuid)
other_signature_types = other_signatures.index_by(&:algorithm_type)
diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb
index 9bacd9a0edf..aecb47f7a03 100644
--- a/app/models/container_expiration_policy.rb
+++ b/app/models/container_expiration_policy.rb
@@ -74,6 +74,7 @@ class ContainerExpirationPolicy < ApplicationRecord
'7d': _('%{days} days until tags are automatically removed') % { days: 7 },
'14d': _('%{days} days until tags are automatically removed') % { days: 14 },
'30d': _('%{days} days until tags are automatically removed') % { days: 30 },
+ '60d': _('%{days} days until tags are automatically removed') % { days: 60 },
'90d': _('%{days} days until tags are automatically removed') % { days: 90 }
}
end
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index aea48a5ec20..ecdac64b31b 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -5,7 +5,7 @@ class CustomEmoji < ApplicationRecord
belongs_to :namespace, inverse_of: :custom_emoji
- belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
+ belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'namespace_id'
belongs_to :creator, class_name: "User", inverse_of: :created_custom_emoji
# For now only external emoji are supported. See https://gitlab.com/gitlab-org/gitlab/-/issues/230467
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index aaa7e2ae175..c632f8e2efa 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -5,8 +5,9 @@ class CustomerRelations::Contact < ApplicationRecord
self.table_name = "customer_relations_contacts"
- belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'group_id'
+ belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'group_id'
belongs_to :organization, optional: true
+ has_and_belongs_to_many :issues, join_table: :issue_customer_relations_contacts # rubocop: disable Rails/HasAndBelongsToMany
strip_attributes! :phone, :first_name, :last_name
diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb
index a18d3ab8148..c206d1e05f5 100644
--- a/app/models/customer_relations/organization.rb
+++ b/app/models/customer_relations/organization.rb
@@ -5,7 +5,7 @@ class CustomerRelations::Organization < ApplicationRecord
self.table_name = "customer_relations_organizations"
- belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'group_id'
+ belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'group_id'
strip_attributes! :name
diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb
index 5de6b1cf28f..7ca15652586 100644
--- a/app/models/dependency_proxy/blob.rb
+++ b/app/models/dependency_proxy/blob.rb
@@ -2,15 +2,14 @@
class DependencyProxy::Blob < ApplicationRecord
include FileStoreMounter
+ include TtlExpirable
+ include EachBatch
belongs_to :group
validates :group, presence: true
validates :file, presence: true
validates :file_name, presence: true
- validates :status, presence: true
-
- enum status: { default: 0, expired: 1 }
mount_file_store_uploader DependencyProxy::FileUploader
diff --git a/app/models/dependency_proxy/image_ttl_group_policy.rb b/app/models/dependency_proxy/image_ttl_group_policy.rb
index 5a1b8cb8f1f..0dfb298a39e 100644
--- a/app/models/dependency_proxy/image_ttl_group_policy.rb
+++ b/app/models/dependency_proxy/image_ttl_group_policy.rb
@@ -8,4 +8,6 @@ class DependencyProxy::ImageTtlGroupPolicy < ApplicationRecord
validates :group, presence: true
validates :enabled, inclusion: { in: [true, false] }
validates :ttl, numericality: { greater_than: 0 }, allow_nil: true
+
+ scope :enabled, -> { where(enabled: true) }
end
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index 15e5137b50a..b83047efe54 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -2,6 +2,8 @@
class DependencyProxy::Manifest < ApplicationRecord
include FileStoreMounter
+ include TtlExpirable
+ include EachBatch
belongs_to :group
@@ -9,9 +11,6 @@ class DependencyProxy::Manifest < ApplicationRecord
validates :file, presence: true
validates :file_name, presence: true
validates :digest, presence: true
- validates :status, presence: true
-
- enum status: { default: 0, expired: 1 }
mount_file_store_uploader DependencyProxy::FileUploader
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 4a690ccc67e..f91700f764b 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -10,7 +10,8 @@ class Deployment < ApplicationRecord
include FastDestroyAll
include IgnorableColumns
- ignore_column :deployable_id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22'
+ StatusUpdateError = Class.new(StandardError)
+ StatusSyncError = Class.new(StandardError)
belongs_to :project, required: true
belongs_to :environment, required: true
@@ -48,7 +49,6 @@ class Deployment < ApplicationRecord
scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success }
scope :active, -> { where(status: %i[created running]) }
scope :older_than, -> (deployment) { where('deployments.id < ?', deployment.id) }
- scope :with_deployable, -> { joins('INNER JOIN ci_builds ON ci_builds.id = deployments.deployable_id').preload(:deployable) }
scope :with_api_entity_associations, -> { preload({ deployable: { runner: [], tags: [], user: [], job_artifacts_archive: [] } }) }
scope :finished_after, ->(date) { where('finished_at >= ?', date) }
@@ -150,6 +150,16 @@ class Deployment < ApplicationRecord
success.find_by!(iid: iid)
end
+ # It should be used with caution especially on chaining.
+ # Fetching any unbounded or large intermediate dataset could lead to loading too many IDs into memory.
+ # See: https://docs.gitlab.com/ee/development/database/multiple_databases.html#use-disable_joins-for-has_one-or-has_many-through-relations
+ # For safety we default limit to fetch not more than 1000 records.
+ def self.builds(limit = 1000)
+ deployable_ids = where.not(deployable_id: nil).limit(limit).pluck(:deployable_id)
+
+ Ci::Build.where(id: deployable_ids)
+ end
+
class << self
##
# FastDestroyAll concerns
@@ -305,20 +315,23 @@ class Deployment < ApplicationRecord
# Changes the status of a deployment and triggers the corresponding state
# machine events.
def update_status(status)
- case status
- when 'running'
- run
- when 'success'
- succeed
- when 'failed'
- drop
- when 'canceled'
- cancel
- when 'skipped'
- skip
- else
- raise ArgumentError, "The status #{status.inspect} is invalid"
- end
+ update_status!(status)
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(
+ StatusUpdateError.new(e.message), deployment_id: self.id)
+
+ false
+ end
+
+ def sync_status_with(build)
+ return false unless ::Deployment.statuses.include?(build.status)
+
+ update_status!(build.status)
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(
+ StatusSyncError.new(e.message), deployment_id: self.id, build_id: build.id)
+
+ false
end
def valid_sha
@@ -346,6 +359,23 @@ class Deployment < ApplicationRecord
private
+ def update_status!(status)
+ case status
+ when 'running'
+ run!
+ when 'success'
+ succeed!
+ when 'failed'
+ drop!
+ when 'canceled'
+ cancel!
+ when 'skipped'
+ skip!
+ else
+ raise ArgumentError, "The status #{status.inspect} is invalid"
+ end
+ end
+
def legacy_finished_at
self.created_at if success? && !read_attribute(:finished_at)
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 48522a23068..31ab426728b 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -28,8 +28,8 @@ class Environment < ApplicationRecord
has_one :last_deployment, -> { success.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment'
- has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: -> { ::Feature.enabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml) }
- has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: -> { ::Feature.enabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml) }
+ has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: true
+ has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: true
has_one :upcoming_deployment, -> { running.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
@@ -198,14 +198,14 @@ class Environment < ApplicationRecord
# Overriding association
def last_visible_deployable
- return super if association_cached?(:last_visible_deployable) || ::Feature.disabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml)
+ return super if association_cached?(:last_visible_deployable)
last_visible_deployment&.deployable
end
# Overriding association
def last_visible_pipeline
- return super if association_cached?(:last_visible_pipeline) || ::Feature.disabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml)
+ return super if association_cached?(:last_visible_pipeline)
last_visible_deployable&.pipeline
end
@@ -260,10 +260,9 @@ class Environment < ApplicationRecord
end
def cancel_deployment_jobs!
- jobs = active_deployments.with_deployable
- jobs.each do |deployment|
- Gitlab::OptimisticLocking.retry_lock(deployment.deployable, name: 'environment_cancel_deployment_jobs') do |deployable|
- deployable.cancel! if deployable&.cancelable?
+ active_deployments.builds.each do |build|
+ Gitlab::OptimisticLocking.retry_lock(build, name: 'environment_cancel_deployment_jobs') do |build|
+ build.cancel! if build&.cancelable?
end
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e, environment_id: id, deployment_id: deployment.id)
diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb
index 3be7af2e4bf..07c0983f239 100644
--- a/app/models/environment_status.rb
+++ b/app/models/environment_status.rb
@@ -100,13 +100,11 @@ class EnvironmentStatus
def self.build_environments_status(mr, user, pipeline)
return [] unless pipeline
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/340781') do
- pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment|
- next unless Ability.allowed?(user, :read_environment, environment)
+ pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment|
+ next unless Ability.allowed?(user, :read_environment, environment)
- EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
- end.compact
- end
+ EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
+ end.compact
end
private_class_method :build_environments_status
end
diff --git a/app/models/error_tracking/error.rb b/app/models/error_tracking/error.rb
index 39ecc487806..2d6a4694def 100644
--- a/app/models/error_tracking/error.rb
+++ b/app/models/error_tracking/error.rb
@@ -7,6 +7,14 @@ class ErrorTracking::Error < ApplicationRecord
has_many :events, class_name: 'ErrorTracking::ErrorEvent'
+ has_one :first_event,
+ -> { order(id: :asc) },
+ class_name: 'ErrorTracking::ErrorEvent'
+
+ has_one :last_event,
+ -> { order(id: :desc) },
+ class_name: 'ErrorTracking::ErrorEvent'
+
scope :for_status, -> (status) { where(status: status) }
validates :project, presence: true
@@ -90,7 +98,10 @@ class ErrorTracking::Error < ApplicationRecord
status: status,
tags: { level: nil, logger: nil },
external_url: external_url,
- external_base_url: external_base_url
+ external_base_url: external_base_url,
+ integrated: true,
+ first_release_version: first_event&.release,
+ last_release_version: last_event&.release
)
end
@@ -106,6 +117,6 @@ class ErrorTracking::Error < ApplicationRecord
# For compatibility with sentry integration
def external_base_url
- Gitlab::Routing.url_helpers.root_url
+ Gitlab::Routing.url_helpers.project_url(project)
end
end
diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb
index 4de13de7e2e..686518a39fb 100644
--- a/app/models/error_tracking/error_event.rb
+++ b/app/models/error_tracking/error_event.rb
@@ -22,6 +22,10 @@ class ErrorTracking::ErrorEvent < ApplicationRecord
)
end
+ def release
+ payload.dig('release')
+ end
+
private
def build_stacktrace
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index dd5ce9f7387..25f812645b1 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -46,6 +46,11 @@ module ErrorTracking
after_save :clear_reactive_cache!
+ # When a user enables the integrated error tracking
+ # we want to immediately provide them with a first
+ # working client key so they have a DSN for Sentry SDK.
+ after_save :create_client_key!
+
def sentry_enabled
enabled && !integrated_client?
end
@@ -54,6 +59,12 @@ module ErrorTracking
integrated
end
+ def gitlab_dsn
+ strong_memoize(:gitlab_dsn) do
+ client_key&.sentry_dsn
+ end
+ end
+
def api_url=(value)
super
clear_memoization(:api_url_slugs)
@@ -236,5 +247,19 @@ module ErrorTracking
errors.add(:project, 'is a required field')
end
end
+
+ def client_key
+ # Project can have multiple client keys.
+ # However for UI simplicity we render the first active one for user.
+ # In future we should make it possible to manage client keys from UI.
+ # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/329596
+ project.error_tracking_client_keys.active.first
+ end
+
+ def create_client_key!
+ if enabled? && integrated_client? && !client_key
+ project.error_tracking_client_keys.create!
+ end
+ end
end
end
diff --git a/app/models/group.rb b/app/models/group.rb
index a667a908707..c5e119451e3 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -192,9 +192,15 @@ class Group < Namespace
# Returns the ids of the passed group models where the `emails_disabled`
# column is set to true anywhere in the ancestor hierarchy.
def ids_with_disabled_email(groups)
- innner_query = Gitlab::ObjectHierarchy
- .new(Group.where('id = namespaces_with_emails_disabled.id'))
- .base_and_ancestors
+ inner_groups = Group.where('id = namespaces_with_emails_disabled.id')
+
+ inner_ancestors = if Feature.enabled?(:linear_group_ancestor_scopes, default_enabled: :yaml)
+ inner_groups.self_and_ancestors
+ else
+ Gitlab::ObjectHierarchy.new(inner_groups).base_and_ancestors
+ end
+
+ inner_query = inner_ancestors
.where(emails_disabled: true)
.select('1')
.limit(1)
@@ -202,7 +208,7 @@ class Group < Namespace
group_ids = Namespace
.from('(SELECT * FROM namespaces) as namespaces_with_emails_disabled')
.where(namespaces_with_emails_disabled: { id: groups })
- .where('EXISTS (?)', innner_query)
+ .where('EXISTS (?)', inner_query)
.pluck(:id)
Set.new(group_ids)
@@ -701,9 +707,9 @@ class Group < Namespace
raise ArgumentError unless SHARED_RUNNERS_SETTINGS.include?(state)
case state
- when 'disabled_and_unoverridable' then disable_shared_runners! # also disallows override
- when 'disabled_with_override' then disable_shared_runners_and_allow_override!
- when 'enabled' then enable_shared_runners! # set both to true
+ when SR_DISABLED_AND_UNOVERRIDABLE then disable_shared_runners! # also disallows override
+ when SR_DISABLED_WITH_OVERRIDE then disable_shared_runners_and_allow_override!
+ when SR_ENABLED then enable_shared_runners! # set both to true
end
end
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 9565dae08b5..0bf9e805aa8 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -22,7 +22,12 @@ class InstanceConfiguration
private
def ssh_algorithms_hashes
- SSH_ALGORITHMS.map { |algo| ssh_algorithm_hashes(algo) }.compact
+ SSH_ALGORITHMS.select { |algo| ssh_algorithm_enabled?(algo) }.map { |algo| ssh_algorithm_hashes(algo) }.compact
+ end
+
+ def ssh_algorithm_enabled?(algorithm)
+ algorithm_key_restriction = application_settings["#{algorithm.downcase}_key_restriction"]
+ algorithm_key_restriction.nil? || algorithm_key_restriction != ApplicationSetting::FORBIDDEN_KEY_VALUE
end
def host
@@ -98,6 +103,11 @@ class InstanceConfiguration
requests_per_period: application_settings[:throttle_authenticated_packages_api_requests_per_period],
period_in_seconds: application_settings[:throttle_authenticated_packages_api_period_in_seconds]
},
+ authenticated_git_lfs_api: {
+ enabled: application_settings[:throttle_authenticated_git_lfs_enabled],
+ requests_per_period: application_settings[:throttle_authenticated_git_lfs_requests_per_period],
+ period_in_seconds: application_settings[:throttle_authenticated_git_lfs_period_in_seconds]
+ },
issue_creation: application_setting_limit_per_minute(:issues_create_limit),
note_creation: application_setting_limit_per_minute(:notes_create_limit),
project_export: application_setting_limit_per_minute(:project_export_limit),
diff --git a/app/models/integrations/open_project.rb b/app/models/integrations/open_project.rb
deleted file mode 100644
index e4cfb24151a..00000000000
--- a/app/models/integrations/open_project.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-module Integrations
- class OpenProject < BaseIssueTracker
- validates :url, public_url: true, presence: true, if: :activated?
- validates :api_url, public_url: true, allow_blank: true, if: :activated?
- validates :token, presence: true, if: :activated?
- validates :project_identifier_code, presence: true, if: :activated?
-
- data_field :url, :api_url, :token, :closed_status_id, :project_identifier_code
-
- def data_fields
- open_project_tracker_data || self.build_open_project_tracker_data
- end
-
- def self.to_param
- 'open_project'
- end
- end
-end
diff --git a/app/models/integrations/open_project_tracker_data.rb b/app/models/integrations/open_project_tracker_data.rb
deleted file mode 100644
index b3f2618b94f..00000000000
--- a/app/models/integrations/open_project_tracker_data.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Integrations
- class OpenProjectTrackerData < ApplicationRecord
- include BaseDataFields
-
- # When the Open Project is fresh installed, the default closed status id is "13" based on current version: v8.
- DEFAULT_CLOSED_STATUS_ID = "13"
-
- attr_encrypted :url, encryption_options
- attr_encrypted :api_url, encryption_options
- attr_encrypted :token, encryption_options
-
- def closed_status_id
- super || DEFAULT_CLOSED_STATUS_ID
- end
- end
-end
diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb
index ad6a9164d00..e3e180ae959 100644
--- a/app/models/integrations/unify_circuit.rb
+++ b/app/models/integrations/unify_circuit.rb
@@ -15,13 +15,8 @@ module Integrations
end
def help
- 'This service sends notifications about projects events to a Unify Circuit conversation.<br />
- To set up this service:
- <ol>
- <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448" target="_blank" rel="noopener noreferrer">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>'
+ docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/unify_circuit'), target: '_blank', rel: 'noopener noreferrer'
+ s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def event_field(event)
@@ -37,7 +32,7 @@ module Integrations
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true },
+ { type: 'text', name: 'webhook', placeholder: "https://yourcircuit.com/rest/v2/webhooks/incoming/…", required: true },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
{ type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
diff --git a/app/models/issue.rb b/app/models/issue.rb
index e0b0c352c22..9c568414ec2 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -81,6 +81,7 @@ class Issue < ApplicationRecord
has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
has_many :prometheus_alerts, through: :prometheus_alert_events
+ has_and_belongs_to_many :customer_relations_contacts, join_table: :issue_customer_relations_contacts, class_name: 'CustomerRelations::Contact' # rubocop: disable Rails/HasAndBelongsToMany
accepts_nested_attributes_for :issuable_severity, update_only: true
accepts_nested_attributes_for :sentry_issue
@@ -107,8 +108,6 @@ class Issue < ApplicationRecord
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')) }
scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) }
- scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) }
- scope :order_relative_position_desc, -> { reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')) }
scope :order_closed_date_desc, -> { reorder(closed_at: :desc) }
scope :order_created_at_desc, -> { reorder(created_at: :desc) }
scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') }
@@ -127,6 +126,7 @@ class Issue < ApplicationRecord
project: [:route, { namespace: :route }])
}
scope :with_issue_type, ->(types) { where(issue_type: types) }
+ scope :without_issue_type, ->(types) { where.not(issue_type: types) }
scope :public_only, -> {
without_hidden.where(confidential: false)
@@ -166,6 +166,8 @@ class Issue < ApplicationRecord
scope :by_project_id_and_iid, ->(composites) do
where_composite(%i[project_id iid], composites)
end
+ scope :with_null_relative_position, -> { where(relative_position: nil) }
+ scope :with_non_null_relative_position, -> { where.not(relative_position: nil) }
after_commit :expire_etag_cache, unless: :importing?
after_save :ensure_metrics, unless: :importing?
@@ -266,8 +268,8 @@ class Issue < ApplicationRecord
'due_date' => -> { order_due_date_asc.with_order_id_desc },
'due_date_asc' => -> { order_due_date_asc.with_order_id_desc },
'due_date_desc' => -> { order_due_date_desc.with_order_id_desc },
- 'relative_position' => -> { order_relative_position_asc.with_order_id_desc },
- 'relative_position_asc' => -> { order_relative_position_asc.with_order_id_desc }
+ 'relative_position' => -> { order_by_relative_position },
+ 'relative_position_asc' => -> { order_by_relative_position }
}
)
end
@@ -277,7 +279,7 @@ class Issue < ApplicationRecord
when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date
when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc
when 'due_date_desc' then order_due_date_desc.with_order_id_desc
- when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc
+ when 'relative_position', 'relative_position_asc' then order_by_relative_position
when 'severity_asc' then order_severity_asc.with_order_id_desc
when 'severity_desc' then order_severity_desc.with_order_id_desc
else
@@ -285,13 +287,8 @@ class Issue < ApplicationRecord
end
end
- # `with_cte` argument allows sorting when using CTE queries and prevents
- # errors in postgres when using CTE search optimisation
- def self.order_by_position_and_priority(with_cte: false)
- order = Gitlab::Pagination::Keyset::Order.build([column_order_relative_position, column_order_highest_priority, column_order_id_desc])
-
- order_labels_priority(with_cte: with_cte)
- .reorder(order)
+ def self.order_by_relative_position
+ reorder(Gitlab::Pagination::Keyset::Order.build([column_order_relative_position, column_order_id_asc]))
end
def self.column_order_relative_position
@@ -306,25 +303,6 @@ class Issue < ApplicationRecord
)
end
- def self.column_order_highest_priority
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'highest_priority',
- column_expression: Arel.sql('highest_priorities.label_priority'),
- order_expression: Gitlab::Database.nulls_last_order('highest_priorities.label_priority', 'ASC'),
- reversed_order_expression: Gitlab::Database.nulls_last_order('highest_priorities.label_priority', 'DESC'),
- order_direction: :asc,
- nullable: :nulls_last,
- distinct: false
- )
- end
-
- def self.column_order_id_desc
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'id',
- order_expression: arel_table[:id].desc
- )
- end
-
def self.column_order_id_asc
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id',
@@ -541,6 +519,10 @@ class Issue < ApplicationRecord
issue_type_supports?(:time_tracking)
end
+ def supports_move_and_clone?
+ issue_type_supports?(:move_and_clone)
+ end
+
def email_participants_emails
issue_email_participants.pluck(:email)
end
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 53e7d52c558..9765ac6f2e9 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -49,7 +49,7 @@ class LfsObject < ApplicationRecord
end
def self.calculate_oid(path)
- self.hexdigest(path)
+ self.sha256_hexdigest(path)
end
end
diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb
index a39d88b2e49..ca5a2800a03 100644
--- a/app/models/loose_foreign_keys/deleted_record.rb
+++ b/app/models/loose_foreign_keys/deleted_record.rb
@@ -2,48 +2,4 @@
class LooseForeignKeys::DeletedRecord < ApplicationRecord
extend SuppressCompositePrimaryKeyWarning
- include PartitionedTable
-
- partitioned_by :created_at, strategy: :monthly, retain_for: 3.months, retain_non_empty_partitions: true
-
- scope :ordered_by_primary_keys, -> { order(:created_at, :deleted_table_name, :deleted_table_primary_key_value) }
-
- def self.load_batch(batch_size)
- ordered_by_primary_keys
- .limit(batch_size)
- .to_a
- end
-
- # Because the table has composite primary keys, the delete_all or delete methods are not going to work.
- # This method implements deletion that benefits from the primary key index, example:
- #
- # > DELETE
- # > FROM "loose_foreign_keys_deleted_records"
- # > WHERE (created_at,
- # > deleted_table_name,
- # > deleted_table_primary_key_value) IN
- # > (SELECT created_at::TIMESTAMP WITH TIME ZONE,
- # > deleted_table_name,
- # > deleted_table_primary_key_value
- # > FROM (VALUES (LIST_OF_VALUES)) AS primary_key_values (created_at, deleted_table_name, deleted_table_primary_key_value))
- def self.delete_records(records)
- values = records.pluck(:created_at, :deleted_table_name, :deleted_table_primary_key_value)
-
- primary_keys = connection.primary_keys(table_name).join(', ')
-
- primary_keys_with_type_cast = [
- Arel.sql('created_at::timestamp with time zone'),
- Arel.sql('deleted_table_name'),
- Arel.sql('deleted_table_primary_key_value')
- ]
-
- value_list = Arel::Nodes::ValuesList.new(values)
-
- # (SELECT primary keys FROM VALUES)
- inner_query = Arel::SelectManager.new
- inner_query.from("#{Arel::Nodes::Grouping.new([value_list]).as('primary_key_values').to_sql} (#{primary_keys})")
- inner_query.projections = primary_keys_with_type_cast
-
- where(Arel::Nodes::Grouping.new([Arel.sql(primary_keys)]).in(inner_query)).delete_all
- end
end
diff --git a/app/models/member.rb b/app/models/member.rb
index beb4c05f2a6..21fd4aebd7b 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -50,6 +50,11 @@ class Member < ApplicationRecord
},
if: :project_bot?
+ scope :with_invited_user_state, -> do
+ joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email')
+ .select('members.*', 'invited_user.state as invited_user_state')
+ end
+
scope :in_hierarchy, ->(source) do
groups = source.root_ancestor.self_and_descendants
group_members = Member.default_scoped.where(source: groups)
@@ -178,7 +183,13 @@ class Member < ApplicationRecord
after_destroy :post_destroy_hook, unless: :pending?, if: :hook_prerequisites_met?
after_save :log_invitation_token_cleanup
- after_commit :refresh_member_authorized_projects, unless: :importing?
+ after_commit on: [:create, :update], unless: :importing? do
+ refresh_member_authorized_projects(blocking: true)
+ end
+
+ after_commit on: [:destroy], unless: :importing? do
+ refresh_member_authorized_projects(blocking: false)
+ end
default_value_for :notification_level, NotificationSetting.levels[:global]
@@ -395,8 +406,8 @@ class Member < ApplicationRecord
# transaction has been committed, resulting in the job either throwing an
# error or not doing any meaningful work.
# rubocop: disable CodeReuse/ServiceClass
- def refresh_member_authorized_projects
- UserProjectAccessChangedService.new(user_id).execute
+ def refresh_member_authorized_projects(blocking:)
+ UserProjectAccessChangedService.new(user_id).execute(blocking: blocking)
end
# rubocop: enable CodeReuse/ServiceClass
@@ -442,6 +453,14 @@ class Member < ApplicationRecord
errors.add(:user, error) if error
end
+ def signup_email_invalid_message
+ if source_type == 'Project'
+ _("is not allowed for this project.")
+ else
+ _("is not allowed for this group.")
+ end
+ end
+
def update_highest_role?
return unless user_id.present?
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index a13133c90e9..9062a405218 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -43,15 +43,17 @@ class GroupMember < Member
# Because source_type is `Namespace`...
def real_source_type
- 'Group'
+ Group.sti_name
end
def notifiable_options
{ group: group }
end
+ private
+
override :refresh_member_authorized_projects
- def refresh_member_authorized_projects
+ def refresh_member_authorized_projects(blocking:)
# Here, `destroyed_by_association` will be present if the
# GroupMember is being destroyed due to the `dependent: :destroy`
# callback on Group. In this case, there is no need to refresh the
@@ -63,8 +65,6 @@ class GroupMember < Member
super
end
- private
-
def access_level_inclusion
return if access_level.in?(Gitlab::Access.all_values)
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 72cb831cc88..eec46b3493e 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -90,24 +90,28 @@ class ProjectMember < Member
{ project: project }
end
+ private
+
override :refresh_member_authorized_projects
- def refresh_member_authorized_projects
+ def refresh_member_authorized_projects(blocking:)
return super unless Feature.enabled?(:specialized_service_for_project_member_auth_refresh)
return unless user
# rubocop:disable CodeReuse/ServiceClass
- AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute
+ if blocking
+ AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute
+ else
+ AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id)
+ end
# Until we compare the inconsistency rates of the new, specialized service and
# the old approach, we still run AuthorizedProjectsWorker
# but with some delay and lower urgency as a safety net.
UserProjectAccessChangedService.new(user_id)
- .execute(blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY)
+ .execute(blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY)
# rubocop:enable CodeReuse/ServiceClass
end
- private
-
def send_invite
run_after_commit_or_now { notification_service.invite_project_member(self, @raw_invite_token) }
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index db49ec6f412..15862fb2bfa 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1111,15 +1111,23 @@ class MergeRequest < ApplicationRecord
can_be_merged? && !should_be_rebased?
end
+ # rubocop: disable CodeReuse/ServiceClass
def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
return false unless open?
return false if work_in_progress?
return false if broken?
- return false unless skip_ci_check || mergeable_ci_state?
return false unless skip_discussions_check || mergeable_discussions_state?
- true
+ if Feature.enabled?(:improved_mergeability_checks, self.project, default_enabled: :yaml)
+ additional_checks = MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: { skip_ci_check: skip_ci_check })
+ additional_checks.execute.all?(&:success?)
+ else
+ return false unless skip_ci_check || mergeable_ci_state?
+
+ true
+ end
end
+ # rubocop: enable CodeReuse/ServiceClass
def ff_merge_possible?
project.repository.ancestor?(target_branch_sha, diff_head_sha)
@@ -1658,6 +1666,10 @@ class MergeRequest < ApplicationRecord
service_class.new(project, current_user, id: id, report_type: report_type).execute(comparison_base_pipeline(identifier), actual_head_pipeline)
end
+ def recent_diff_head_shas(limit = 100)
+ merge_request_diffs.recent(limit).pluck(:head_commit_sha)
+ end
+
def all_commits
MergeRequestDiffCommit
.where(merge_request_diff: merge_request_diffs.recent)
@@ -1857,7 +1869,7 @@ class MergeRequest < ApplicationRecord
override :ensure_metrics
def ensure_metrics
- if Feature.enabled?(:use_upsert_query_for_mr_metrics)
+ if Feature.enabled?(:use_upsert_query_for_mr_metrics, default_enabled: :yaml)
MergeRequest::Metrics.record!(self)
else
# Backward compatibility: some merge request metrics records will not have target_project_id filled in.
@@ -1918,20 +1930,6 @@ class MergeRequest < ApplicationRecord
end
end
- def lazy_upvotes_count
- BatchLoader.for(id).batch(default_value: 0) do |ids, loader|
- counts = AwardEmoji
- .where(awardable_id: ids)
- .upvotes
- .group(:awardable_id)
- .count
-
- counts.each do |id, count|
- loader.call(id, count)
- end
- end
- end
-
private
def set_draft_status
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index d2b3ca753b1..bd94c0ad30e 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -66,7 +66,7 @@ class MergeRequestDiff < ApplicationRecord
joins(:merge_request).where(merge_requests: { target_project_id: project_id })
end
- scope :recent, -> { order(id: :desc).limit(100) }
+ scope :recent, -> (limit = 100) { order(id: :desc).limit(limit) }
scope :files_in_database, -> do
where(stored_externally: [false, nil]).where(arel_table[:files_count].gt(0))
diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb
index 3383dda20c9..d3d3f973398 100644
--- a/app/models/metrics/dashboard/annotation.rb
+++ b/app/models/metrics/dashboard/annotation.rb
@@ -32,19 +32,19 @@ module Metrics
def ending_at_after_starting_at
return if ending_at.blank? || starting_at.blank? || starting_at <= ending_at
- errors.add(:ending_at, s_("Metrics::Dashboard::Annotation|can't be before starting_at time"))
+ errors.add(:ending_at, s_("MetricsDashboardAnnotation|can't be before starting_at time"))
end
def single_ownership
return if cluster.nil? ^ environment.nil?
- errors.add(:base, s_("Metrics::Dashboard::Annotation|Annotation can't belong to both a cluster and an environment at the same time"))
+ errors.add(:base, s_("MetricsDashboardAnnotation|Annotation can't belong to both a cluster and an environment at the same time"))
end
def orphaned_annotation
return if cluster.present? || environment.present?
- errors.add(:base, s_("Metrics::Dashboard::Annotation|Annotation must belong to a cluster or an environment"))
+ errors.add(:base, s_("MetricsDashboardAnnotation|Annotation must belong to a cluster or an environment"))
end
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 0c160cedb4d..e6406293c66 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -28,7 +28,10 @@ class Namespace < ApplicationRecord
# Android repo (15) + some extra backup.
NUMBER_OF_ANCESTORS_ALLOWED = 20
- SHARED_RUNNERS_SETTINGS = %w[disabled_and_unoverridable disabled_with_override enabled].freeze
+ SR_DISABLED_AND_UNOVERRIDABLE = 'disabled_and_unoverridable'
+ SR_DISABLED_WITH_OVERRIDE = 'disabled_with_override'
+ SR_ENABLED = 'enabled'
+ SHARED_RUNNERS_SETTINGS = [SR_DISABLED_AND_UNOVERRIDABLE, SR_DISABLED_WITH_OVERRIDE, SR_ENABLED].freeze
URL_MAX_LENGTH = 255
cache_markdown_field :description, pipeline: :description
@@ -44,6 +47,8 @@ class Namespace < ApplicationRecord
# This should _not_ be `inverse_of: :namespace`, because that would also set
# `user.namespace` when this user creates a group with themselves as `owner`.
+ # TODO: can this be moved into the UserNamespace class?
+ # evaluate in issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070
belongs_to :owner, class_name: "User"
belongs_to :parent, class_name: "Namespace"
@@ -63,21 +68,31 @@ class Namespace < ApplicationRecord
length: { maximum: 255 }
validates :description, length: { maximum: 255 }
+
validates :path,
presence: true,
- length: { maximum: URL_MAX_LENGTH },
- namespace_path: true
+ length: { maximum: URL_MAX_LENGTH }
+
+ validates :path, namespace_path: true, if: ->(n) { !n.project_namespace? }
+ # Project path validator is used for project namespaces for now to assure
+ # compatibility with project paths
+ # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341764
+ validates :path, project_path: true, if: ->(n) { n.project_namespace? }
# Introduce minimal path length of 2 characters.
# Allow change of other attributes without forcing users to
# rename their user or group. At the same time prevent changing
# the path without complying with new 2 chars requirement.
# Issue https://gitlab.com/gitlab-org/gitlab/-/issues/225214
- validates :path, length: { minimum: 2 }, if: :path_changed?
+ #
+ # For ProjectNamespace we don't check minimal path length to keep
+ # compatibility with existing project restrictions.
+ # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341764
+ validates :path, length: { minimum: 2 }, if: :enforce_minimum_path_length?
validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
- validate :validate_parent_type, if: -> { Feature.enabled?(:validate_namespace_parent_type) }
+ validate :validate_parent_type, if: -> { Feature.enabled?(:validate_namespace_parent_type, default_enabled: :yaml) }
validate :nesting_level_allowed
validate :changing_shared_runners_enabled_is_allowed
validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed
@@ -93,7 +108,7 @@ class Namespace < ApplicationRecord
# Legacy Storage specific hooks
- after_update :move_dir, if: :saved_change_to_path_or_parent?
+ after_update :move_dir, if: :saved_change_to_path_or_parent?, unless: -> { is_a?(Namespaces::ProjectNamespace) }
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
after_commit :expire_child_caches, on: :update, if: -> {
@@ -101,7 +116,12 @@ class Namespace < ApplicationRecord
saved_change_to_name? || saved_change_to_path? || saved_change_to_parent_id?
}
- scope :for_user, -> { where(type: nil) }
+ # TODO: change to `type: Namespaces::UserNamespace.sti_name` when
+ # working on issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070
+ scope :user_namespaces, -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) }
+ # TODO: this can be simplified with `type != 'Project'` when working on issue
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/341070
+ scope :without_project_namespaces, -> { where("type IS DISTINCT FROM ?", Namespaces::ProjectNamespace.sti_name) }
scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) }
scope :include_route, -> { includes(:route) }
scope :by_parent, -> (parent) { where(parent_id: parent) }
@@ -138,14 +158,12 @@ class Namespace < ApplicationRecord
class << self
def sti_class_for(type_name)
case type_name
- when 'Group'
+ when Group.sti_name
Group
- when 'Project'
+ when Namespaces::ProjectNamespace.sti_name
Namespaces::ProjectNamespace
- when 'User'
- # TODO: We create a normal Namespace until
- # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68894 is ready
- Namespace
+ when Namespaces::UserNamespace.sti_name
+ Namespaces::UserNamespace
else
Namespace
end
@@ -247,27 +265,27 @@ class Namespace < ApplicationRecord
end
def kind
- return 'group' if group?
- return 'project' if project?
+ return 'group' if group_namespace?
+ return 'project' if project_namespace?
'user' # defaults to user
end
- def group?
+ def group_namespace?
type == Group.sti_name
end
- def project?
+ def project_namespace?
type == Namespaces::ProjectNamespace.sti_name
end
- def user?
+ def user_namespace?
# That last bit ensures we're considered a user namespace as a default
- type.nil? || type == Namespaces::UserNamespace.sti_name || !(group? || project?)
+ type.nil? || type == Namespaces::UserNamespace.sti_name || !(group_namespace? || project_namespace?)
end
def owner_required?
- user?
+ user_namespace?
end
def find_fork_of(project)
@@ -314,7 +332,7 @@ class Namespace < ApplicationRecord
# that belongs to this namespace
def all_projects
if Feature.enabled?(:recursive_approach_for_all_projects, default_enabled: :yaml)
- namespace = user? ? self : self_and_descendant_ids
+ namespace = user_namespace? ? self : self_and_descendant_ids
Project.where(namespace: namespace)
else
Project.inside_path(full_path)
@@ -416,7 +434,7 @@ class Namespace < ApplicationRecord
def changing_shared_runners_enabled_is_allowed
return unless new_record? || changes.has_key?(:shared_runners_enabled)
- if shared_runners_enabled && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable'
+ if shared_runners_enabled && has_parent? && parent.shared_runners_setting == SR_DISABLED_AND_UNOVERRIDABLE
errors.add(:shared_runners_enabled, _('cannot be enabled because parent group has shared Runners disabled'))
end
end
@@ -428,30 +446,30 @@ class Namespace < ApplicationRecord
errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be changed if shared runners are enabled'))
end
- if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable'
+ if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == SR_DISABLED_AND_UNOVERRIDABLE
errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be enabled because parent group does not allow it'))
end
end
def shared_runners_setting
if shared_runners_enabled
- 'enabled'
+ SR_ENABLED
else
if allow_descendants_override_disabled_shared_runners
- 'disabled_with_override'
+ SR_DISABLED_WITH_OVERRIDE
else
- 'disabled_and_unoverridable'
+ SR_DISABLED_AND_UNOVERRIDABLE
end
end
end
def shared_runners_setting_higher_than?(other_setting)
- if other_setting == 'enabled'
+ if other_setting == SR_ENABLED
false
- elsif other_setting == 'disabled_with_override'
- shared_runners_setting == 'enabled'
- elsif other_setting == 'disabled_and_unoverridable'
- shared_runners_setting == 'enabled' || shared_runners_setting == 'disabled_with_override'
+ elsif other_setting == SR_DISABLED_WITH_OVERRIDE
+ shared_runners_setting == SR_ENABLED
+ elsif other_setting == SR_DISABLED_AND_UNOVERRIDABLE
+ shared_runners_setting == SR_ENABLED || shared_runners_setting == SR_DISABLED_WITH_OVERRIDE
else
raise ArgumentError
end
@@ -536,21 +554,21 @@ class Namespace < ApplicationRecord
def validate_parent_type
unless has_parent?
- if project?
+ if project_namespace?
errors.add(:parent_id, _('must be set for a project namespace'))
end
return
end
- if parent.project?
+ if parent.project_namespace?
errors.add(:parent_id, _('project namespace cannot be the parent of another namespace'))
end
- if user?
+ if user_namespace?
errors.add(:parent_id, _('cannot not be used for user namespace'))
- elsif group?
- errors.add(:parent_id, _('user namespace cannot be the parent of another namespace')) if parent.user?
+ elsif group_namespace?
+ errors.add(:parent_id, _('user namespace cannot be the parent of another namespace')) if parent.user_namespace?
end
end
@@ -575,6 +593,10 @@ class Namespace < ApplicationRecord
project.track_project_repository
end
end
+
+ def enforce_minimum_path_length?
+ path_changed? && !project_namespace?
+ end
end
Namespace.prepend_mod_with('Namespace')
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index 73061b78637..99e32537595 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -57,7 +57,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord
end
def attributes_from_personal_snippets
- return {} unless namespace.user?
+ return {} unless namespace.user_namespace?
from_personal_snippets.take.slice(SNIPPETS_SIZE_STAT_NAME)
end
diff --git a/app/models/namespaces/user_namespace.rb b/app/models/namespaces/user_namespace.rb
index 517d68b118d..22b7a0a3b2b 100644
--- a/app/models/namespaces/user_namespace.rb
+++ b/app/models/namespaces/user_namespace.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# TODO: currently not created/mapped in the database, will be done in another issue
-# https://gitlab.com/gitlab-org/gitlab/-/issues/337102
+# https://gitlab.com/gitlab-org/gitlab/-/issues/341070
module Namespaces
class UserNamespace < Namespace
def self.sti_name
diff --git a/app/models/note.rb b/app/models/note.rb
index a8f5c305d9b..37473518892 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -149,7 +149,7 @@ class Note < ApplicationRecord
scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) }
before_validation :nullify_blank_type, :nullify_blank_line_code
- after_save :keep_around_commit, if: :for_project_noteable?, unless: :importing?
+ after_save :keep_around_commit, if: :for_project_noteable?, unless: -> { importing? || skip_keep_around_commits }
after_save :expire_etag_cache, unless: :importing?
after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache
@@ -355,8 +355,6 @@ class Note < ApplicationRecord
end
def noteable_author?(noteable)
- return false unless ::Feature.enabled?(:show_author_on_note, project)
-
noteable.author == self.author
end
diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb
index 46810749b18..7db396bcad5 100644
--- a/app/models/operations/feature_flag.rb
+++ b/app/models/operations/feature_flag.rb
@@ -19,13 +19,10 @@ module Operations
default_value_for :active, true
default_value_for :version, :new_version_flag
- # scopes exists only for the first version
- has_many :scopes, class_name: 'Operations::FeatureFlagScope'
# strategies exists only for the second version
has_many :strategies, class_name: 'Operations::FeatureFlags::Strategy'
has_many :feature_flag_issues
has_many :issues, through: :feature_flag_issues
- has_one :default_scope, -> { where(environment_scope: '*') }, class_name: 'Operations::FeatureFlagScope'
validates :project, presence: true
validates :name,
@@ -37,10 +34,7 @@ module Operations
}
validates :name, uniqueness: { scope: :project_id }
validates :description, allow_blank: true, length: 0..255
- validate :first_default_scope, on: :create, if: :has_scopes?
- validate :version_associations
- accepts_nested_attributes_for :scopes, allow_destroy: true
accepts_nested_attributes_for :strategies, allow_destroy: true
scope :ordered, -> { order(:name) }
@@ -56,7 +50,7 @@ module Operations
class << self
def preload_relations
- preload(:scopes, strategies: :scopes)
+ preload(strategies: :scopes)
end
def for_unleash_client(project, environment)
@@ -104,13 +98,6 @@ module Operations
Ability.issues_readable_by_user(issues, current_user)
end
- def execute_hooks(current_user)
- run_after_commit do
- feature_flag_data = Gitlab::DataBuilder::FeatureFlag.build(self, current_user)
- project.execute_hooks(feature_flag_data, :feature_flag_hooks)
- end
- end
-
def hook_attrs
{
id: id,
@@ -119,27 +106,5 @@ module Operations
active: active
}
end
-
- private
-
- def version_associations
- if new_version_flag? && scopes.any?
- errors.add(:version_associations, 'version 2 feature flags may not have scopes')
- end
- end
-
- def first_default_scope
- unless scopes.first.environment_scope == '*'
- errors.add(:default_scope, 'has to be the first element')
- end
- end
-
- def build_default_scope
- scopes.build(environment_scope: '*', active: self.active)
- end
-
- def has_scopes?
- scopes.any?
- end
end
end
diff --git a/app/models/operations/feature_flag_scope.rb b/app/models/operations/feature_flag_scope.rb
deleted file mode 100644
index 9068ca0f588..00000000000
--- a/app/models/operations/feature_flag_scope.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-# All of the legacy flags have been removed in 14.1, including all of the
-# `operations_feature_flag_scopes` rows. Therefore, this model and the database
-# table are unused and should be removed.
-
-module Operations
- class FeatureFlagScope < ApplicationRecord
- prepend HasEnvironmentScope
- include Gitlab::Utils::StrongMemoize
-
- self.table_name = 'operations_feature_flag_scopes'
-
- belongs_to :feature_flag
-
- validates :environment_scope, uniqueness: {
- scope: :feature_flag,
- message: "(%{value}) has already been taken"
- }
-
- validates :environment_scope,
- if: :default_scope?, on: :update,
- inclusion: { in: %w(*), message: 'cannot be changed from default scope' }
-
- validates :strategies, feature_flag_strategies: true
-
- before_destroy :prevent_destroy_default_scope, if: :default_scope?
-
- scope :ordered, -> { order(:id) }
- scope :enabled, -> { where(active: true) }
- scope :disabled, -> { where(active: false) }
-
- def self.with_name_and_description
- joins(:feature_flag)
- .select(FeatureFlag.arel_table[:name], FeatureFlag.arel_table[:description])
- end
-
- def self.for_unleash_client(project, environment)
- select_columns = [
- 'DISTINCT ON (operations_feature_flag_scopes.feature_flag_id) operations_feature_flag_scopes.id',
- '(operations_feature_flags.active AND operations_feature_flag_scopes.active) AS active',
- 'operations_feature_flag_scopes.strategies',
- 'operations_feature_flag_scopes.environment_scope',
- 'operations_feature_flag_scopes.created_at',
- 'operations_feature_flag_scopes.updated_at'
- ]
-
- select(select_columns)
- .with_name_and_description
- .where(feature_flag_id: project.operations_feature_flags.select(:id))
- .order(:feature_flag_id)
- .on_environment(environment)
- .reverse_order
- end
-
- private
-
- def default_scope?
- environment_scope_was == '*'
- end
-
- def prevent_destroy_default_scope
- raise ActiveRecord::ReadOnlyRecord, "default scope cannot be destroyed"
- end
- end
-end
diff --git a/app/models/packages/composer/cache_file.rb b/app/models/packages/composer/cache_file.rb
index ecd7596b989..5222101d171 100644
--- a/app/models/packages/composer/cache_file.rb
+++ b/app/models/packages/composer/cache_file.rb
@@ -9,15 +9,13 @@ module Packages
mount_file_store_uploader Packages::Composer::CacheUploader
- belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
+ belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'namespace_id'
belongs_to :namespace
validates :namespace, presence: true
scope :with_namespace, ->(namespace) { where(namespace: namespace) }
scope :with_sha, ->(sha) { where(file_sha256: sha) }
- scope :expired, -> { where("delete_at <= ?", Time.current) }
- scope :without_namespace, -> { where(namespace_id: nil) }
end
end
end
diff --git a/app/models/packages/helm/file_metadatum.rb b/app/models/packages/helm/file_metadatum.rb
index 1771003d1f9..dfa4ab6df82 100644
--- a/app/models/packages/helm/file_metadatum.rb
+++ b/app/models/packages/helm/file_metadatum.rb
@@ -12,7 +12,7 @@ module Packages
validates :channel,
presence: true,
- length: { maximum: 63 },
+ length: { maximum: 255 },
format: { with: Gitlab::Regex.helm_channel_regex }
validates :metadata,
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index c932d0bf800..0c5a155d48a 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -129,18 +129,15 @@ class PagesDomain < ApplicationRecord
store = OpenSSL::X509::Store.new
store.set_default_paths
- # This forces to load all intermediate certificates stored in `certificate`
- Tempfile.open('certificate_chain') do |f|
- f.write(certificate)
- f.flush
- store.add_file(f.path)
- end
-
- store.verify(x509)
+ store.verify(x509, untrusted_ca_certs_bundle)
rescue OpenSSL::X509::StoreError
false
end
+ def untrusted_ca_certs_bundle
+ ::Gitlab::X509::Certificate.load_ca_certs_bundle(certificate)
+ end
+
def expired?
return false unless x509
diff --git a/app/models/preloaders/merge_requests_preloader.rb b/app/models/preloaders/merge_requests_preloader.rb
deleted file mode 100644
index cefe8408cab..00000000000
--- a/app/models/preloaders/merge_requests_preloader.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Preloaders
- class MergeRequestsPreloader
- attr_reader :merge_requests
-
- def initialize(merge_requests)
- @merge_requests = merge_requests
- end
-
- def execute
- preloader = ActiveRecord::Associations::Preloader.new
- preloader.preload(merge_requests, { target_project: [:project_feature] })
- merge_requests.each do |merge_request|
- merge_request.lazy_upvotes_count
- end
- end
- end
-end
diff --git a/app/models/product_analytics_event.rb b/app/models/product_analytics_event.rb
index d2026d3b333..52baa3be6c4 100644
--- a/app/models/product_analytics_event.rb
+++ b/app/models/product_analytics_event.rb
@@ -20,8 +20,6 @@ class ProductAnalyticsEvent < ApplicationRecord
where('collector_tstamp BETWEEN ? AND ? ', today - duration + 1, today + 1)
}
- scope :by_category_and_action, ->(category, action) { where(se_category: category, se_action: action) }
-
def self.count_by_graph(graph, days)
group(graph).timerange(days).count
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 74ffeef797e..6eb19b4462c 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -98,6 +98,7 @@ class Project < ApplicationRecord
before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? }
before_save :ensure_runners_token
+ before_save :ensure_project_namespace_in_sync
after_save :update_project_statistics, if: :saved_change_to_namespace_id?
@@ -128,26 +129,9 @@ class Project < ApplicationRecord
after_initialize :use_hashed_storage
after_create :check_repository_absence!
- # Required during the `ActsAsTaggableOn::Tag -> Topic` migration
- # TODO: remove 'acts_as_ordered_taggable_on' and ':topics_acts_as_taggable' in the further process of the migration
- # https://gitlab.com/gitlab-org/gitlab/-/issues/335946
- acts_as_ordered_taggable_on :topics
- has_many :topics_acts_as_taggable, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") },
- class_name: 'ActsAsTaggableOn::Tag',
- through: :topic_taggings,
- source: :tag
-
has_many :project_topics, -> { order(:id) }, class_name: 'Projects::ProjectTopic'
has_many :topics, through: :project_topics, class_name: 'Projects::Topic'
- # Required during the `ActsAsTaggableOn::Tag -> Topic` migration
- # TODO: remove 'topics' in the further process of the migration
- # https://gitlab.com/gitlab-org/gitlab/-/issues/335946
- alias_method :topics_new, :topics
- def topics
- self.topics_acts_as_taggable + self.topics_new
- end
-
attr_accessor :old_path_with_namespace
attr_accessor :template_name
attr_writer :pipeline_status
@@ -159,11 +143,11 @@ class Project < ApplicationRecord
# Relations
belongs_to :pool_repository
belongs_to :creator, class_name: 'User'
- belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
+ belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'namespace_id'
belongs_to :namespace
# Sync deletion via DB Trigger to ensure we do not have
# a project without a project_namespace (or vice-versa)
- belongs_to :project_namespace, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project
+ belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project
alias_method :parent, :namespace
alias_attribute :parent_id, :namespace_id
@@ -233,6 +217,7 @@ class Project < ApplicationRecord
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :export_jobs, class_name: 'ProjectExportJob'
+ has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :project
has_one :project_repository, inverse_of: :project
has_one :tracing_setting, class_name: 'ProjectTracingSetting'
has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting'
@@ -652,15 +637,8 @@ class Project < ApplicationRecord
scope :with_topic, ->(topic_name) do
topic = Projects::Topic.find_by_name(topic_name)
- acts_as_taggable_on_topic = ActsAsTaggableOn::Tag.find_by_name(topic_name)
-
- return none unless topic || acts_as_taggable_on_topic
-
- relations = []
- relations << where(id: topic.project_topics.select(:project_id)) if topic
- relations << where(id: acts_as_taggable_on_topic.taggings.select(:taggable_id)) if acts_as_taggable_on_topic
- Project.from_union(relations)
+ topic ? where(id: topic.project_topics.select(:project_id)) : none
end
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -678,7 +656,7 @@ class Project < ApplicationRecord
mount_uploader :bfg_object_map, AttachmentUploader
def self.with_api_entity_associations
- preload(:project_feature, :route, :topics, :topics_acts_as_taggable, :group, :timelogs, namespace: [:route, :owner])
+ preload(:project_feature, :route, :topics, :group, :timelogs, namespace: [:route, :owner])
end
def self.with_web_entity_associations
@@ -851,7 +829,7 @@ class Project < ApplicationRecord
end
def group_ids
- joins(:namespace).where(namespaces: { type: 'Group' }).select(:namespace_id)
+ joins(:namespace).where(namespaces: { type: Group.sti_name }).select(:namespace_id)
end
# Returns ids of projects with issuables available for given user
@@ -1200,7 +1178,7 @@ class Project < ApplicationRecord
end
def import?
- external_import? || forked? || gitlab_project_import? || jira_import? || bare_repository_import?
+ external_import? || forked? || gitlab_project_import? || jira_import? || bare_repository_import? || gitlab_project_migration?
end
def external_import?
@@ -1223,6 +1201,10 @@ class Project < ApplicationRecord
import_type == 'gitlab_project'
end
+ def gitlab_project_migration?
+ import_type == 'gitlab_project_migration'
+ end
+
def gitea_import?
import_type == 'gitea'
end
@@ -1327,11 +1309,21 @@ class Project < ApplicationRecord
def changing_shared_runners_enabled_is_allowed
return unless new_record? || changes.has_key?(:shared_runners_enabled)
- if shared_runners_enabled && group && group.shared_runners_setting == 'disabled_and_unoverridable'
+ if shared_runners_setting_conflicting_with_group?
errors.add(:shared_runners_enabled, _('cannot be enabled because parent group does not allow it'))
end
end
+ def shared_runners_setting_conflicting_with_group?
+ shared_runners_enabled && group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE
+ end
+
+ def reconcile_shared_runners_setting!
+ if shared_runners_setting_conflicting_with_group?
+ self.shared_runners_enabled = false
+ end
+ end
+
def to_param
if persisted? && errors.include?(:path)
path_was
@@ -1814,7 +1806,7 @@ class Project < ApplicationRecord
def open_issues_count(current_user = nil)
return Projects::OpenIssuesCountService.new(self, current_user).count unless current_user.nil?
- BatchLoader.for(self).batch(replace_methods: false) do |projects, loader|
+ BatchLoader.for(self).batch do |projects, loader|
issues_count_per_project = ::Projects::BatchOpenIssuesCountService.new(projects).refresh_cache_and_retrieve_data
issues_count_per_project.each do |project, count|
@@ -2279,7 +2271,7 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def forks_count
- BatchLoader.for(self).batch(replace_methods: false) do |projects, loader|
+ BatchLoader.for(self).batch do |projects, loader|
fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data
fork_count_per_project.each do |project, count|
@@ -2418,7 +2410,7 @@ class Project < ApplicationRecord
end
def mark_primary_write_location
- ::Gitlab::Database::LoadBalancing::Sticking.mark_primary_write_location(:project, self.id)
+ self.class.sticking.mark_primary_write_location(:project, self.id)
end
def toggle_ci_cd_settings!(settings_attribute)
@@ -2677,10 +2669,6 @@ class Project < ApplicationRecord
ProjectStatistics.increment_statistic(self, statistic, delta)
end
- def merge_requests_author_approval
- !!read_attribute(:merge_requests_author_approval)
- end
-
def ci_forward_deployment_enabled?
return false unless ci_cd_settings
@@ -2734,15 +2722,9 @@ class Project < ApplicationRecord
@topic_list = @topic_list.split(',') if @topic_list.instance_of?(String)
@topic_list = @topic_list.map(&:strip).uniq.reject(&:empty?)
- if @topic_list != self.topic_list || self.topics_acts_as_taggable.any?
- self.topics_new.delete_all
+ if @topic_list != self.topic_list
+ self.topics.delete_all
self.topics = @topic_list.map { |topic| Projects::Topic.find_or_create_by(name: topic) }
-
- # Remove old topics (ActsAsTaggableOn::Tag)
- # Required during the `ActsAsTaggableOn::Tag -> Topic` migration
- # TODO: remove in the further process of the migration
- # https://gitlab.com/gitlab-org/gitlab/-/issues/335946
- self.topic_taggings.clear
end
@topic_list = nil
@@ -2912,6 +2894,15 @@ class Project < ApplicationRecord
def online_runners_with_tags
@online_runners_with_tags ||= active_runners.with_tags.online
end
+
+ def ensure_project_namespace_in_sync
+ if changes.keys & [:name, :path, :namespace_id, :visibility_level] && project_namespace.present?
+ project_namespace.name = name
+ project_namespace.path = path
+ project_namespace.parent = namespace
+ project_namespace.visibility_level = visibility_level
+ end
+ end
end
Project.prepend_mod_with('Project')
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index b2559636f32..24d892290a6 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -1,10 +1,6 @@
# frozen_string_literal: true
class ProjectSetting < ApplicationRecord
- include IgnorableColumns
-
- ignore_column :allow_editing_commit_messages, remove_with: '14.4', remove_after: '2021-09-10'
-
belongs_to :project, inverse_of: :project_setting
enum squash_option: {
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 387732cf151..99cec647a98 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -31,7 +31,6 @@ 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
@@ -70,7 +69,7 @@ class ProjectStatistics < ApplicationRecord
end
def update_lfs_objects_size
- self.lfs_objects_size = project.lfs_objects.sum(:size)
+ self.lfs_objects_size = LfsObject.joins(:lfs_objects_projects).where(lfs_objects_projects: { project_id: project.id }).sum(:size)
end
def update_uploads_size
diff --git a/app/models/projects/project_topic.rb b/app/models/projects/project_topic.rb
index d4b456ef482..7021a48646a 100644
--- a/app/models/projects/project_topic.rb
+++ b/app/models/projects/project_topic.rb
@@ -3,6 +3,6 @@
module Projects
class ProjectTopic < ApplicationRecord
belongs_to :project
- belongs_to :topic
+ belongs_to :topic, counter_cache: :total_projects_count
end
end
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index a17aa550edb..f3352ecc5ee 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -1,10 +1,30 @@
# frozen_string_literal: true
+require 'carrierwave/orm/activerecord'
+
module Projects
class Topic < ApplicationRecord
+ include Avatarable
+ include Gitlab::SQL::Pattern
+
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
+ validates :description, length: { maximum: 1024 }
has_many :project_topics, class_name: 'Projects::ProjectTopic'
has_many :projects, through: :project_topics
+
+ scope :order_by_total_projects_count, -> { order(total_projects_count: :desc).order(id: :asc) }
+ scope :reorder_by_similarity, -> (search) do
+ order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [
+ { column: arel_table['name'] }
+ ])
+ reorder(order_expression.desc, arel_table['total_projects_count'].desc, arel_table['id'])
+ end
+
+ class << self
+ def search(query)
+ fuzzy_search(query, [:name])
+ end
+ end
end
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 3d32144e0f8..b4e2d17c3e5 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -10,6 +10,8 @@ class ProtectedBranch < ApplicationRecord
scope :allowing_force_push,
-> { where(allow_force_push: true) }
+ scope :get_ids_by_name, -> (name) { where(name: name).pluck(:id) }
+
protected_ref_access_levels :merge, :push
def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil)
diff --git a/app/models/release.rb b/app/models/release.rb
index 0dd71c6ebfb..eac6346cc60 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -33,6 +33,7 @@ class Release < ApplicationRecord
includes(:author, :evidences, :milestones, :links, :sorted_links,
project: [:project_feature, :route, { namespace: :route }])
}
+ scope :with_milestones, -> { joins(:milestone_releases) }
scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) }
scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) }
scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) }
diff --git a/app/models/repository.rb b/app/models/repository.rb
index f20b306c806..119d874a6e1 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -732,7 +732,7 @@ class Repository
end
def tags_sorted_by(value)
- return raw_repository.tags(sort_by: value) if Feature.enabled?(:gitaly_tags_finder, project, default_enabled: :yaml)
+ return raw_repository.tags(sort_by: value) if Feature.enabled?(:tags_finder_gitaly, project, default_enabled: :yaml)
tags_ruby_sort(value)
end
@@ -1054,10 +1054,10 @@ class Repository
end
def squash(user, merge_request, message)
- raw.squash(user, merge_request.id, start_sha: merge_request.diff_start_sha,
- end_sha: merge_request.diff_head_sha,
- author: merge_request.author,
- message: message)
+ raw.squash(user, start_sha: merge_request.diff_start_sha,
+ end_sha: merge_request.diff_head_sha,
+ author: merge_request.author,
+ message: message)
end
def submodule_links
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 0a4acdfc7e3..c1a3df82457 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -18,6 +18,8 @@ class Upload < ApplicationRecord
before_save :calculate_checksum!, if: :foreground_checksummable?
after_commit :schedule_checksum, if: :needs_checksum?
+ after_commit :update_project_statistics, on: [:create, :destroy], if: :project?
+
# as the FileUploader is not mounted, the default CarrierWave ActiveRecord
# hooks are not executed and the file will not be deleted
after_destroy :delete_file!, if: -> { uploader_class <= FileUploader }
@@ -67,7 +69,7 @@ class Upload < ApplicationRecord
self.checksum = nil
return unless needs_checksum?
- self.checksum = self.class.hexdigest(absolute_path)
+ self.checksum = self.class.sha256_hexdigest(absolute_path)
end
# Initialize the associated Uploader class with current model
@@ -161,6 +163,14 @@ class Upload < ApplicationRecord
def mount_point
super&.to_sym
end
+
+ def project?
+ model_type == "Project"
+ end
+
+ def update_project_statistics
+ ProjectCacheWorker.perform_async(model_id, [], [:uploads_size])
+ end
end
Upload.prepend_mod_with('Upload')
diff --git a/app/models/user.rb b/app/models/user.rb
index a4c8d606911..25a2588a6a7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -112,7 +112,14 @@ class User < ApplicationRecord
#
# Namespace for personal projects
- has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, inverse_of: :owner, autosave: true # rubocop:disable Cop/ActiveRecordDependent
+ # TODO: change to `:namespace, -> { where(type: Namespaces::UserNamespace.sti_name}, class_name: 'Namespaces::UserNamespace'...`
+ # when working on issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070
+ has_one :namespace,
+ -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) },
+ dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent
+ foreign_key: :owner_id,
+ inverse_of: :owner,
+ autosave: true # rubocop:disable Cop/ActiveRecordDependent
# Profile
has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -229,9 +236,9 @@ class User < ApplicationRecord
validates :first_name, length: { maximum: 127 }
validates :last_name, length: { maximum: 127 }
validates :email, confirmation: true
- validates :notification_email, devise_email: true, allow_blank: true, if: ->(user) { user.notification_email != user.email }
+ validates :notification_email, devise_email: true, allow_blank: true
validates :public_email, uniqueness: true, devise_email: true, allow_blank: true
- validates :commit_email, devise_email: true, allow_blank: true, if: ->(user) { user.commit_email != user.email && user.commit_email != Gitlab::PrivateCommitEmail::TOKEN }
+ validates :commit_email, devise_email: true, allow_blank: true, unless: ->(user) { user.commit_email == Gitlab::PrivateCommitEmail::TOKEN }
validates :projects_limit,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
@@ -316,6 +323,7 @@ class User < ApplicationRecord
delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true
delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true
delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true
+ delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true
accepts_nested_attributes_for :user_preference, update_only: true
accepts_nested_attributes_for :user_detail, update_only: true
@@ -449,11 +457,12 @@ class User < ApplicationRecord
scope :dormant, -> { active.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) }
scope :with_no_activity, -> { active.where(last_activity_on: nil) }
scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) }
+ scope :get_ids_by_username, -> (username) { where(username: username).pluck(:id) }
def preferred_language
read_attribute('preferred_language') ||
I18n.default_locale.to_s.presence_in(Gitlab::I18n.available_locales) ||
- 'en'
+ default_preferred_language
end
def active_for_authentication?
@@ -728,7 +737,7 @@ class User < ApplicationRecord
end
def find_by_full_path(path, follow_redirects: false)
- namespace = Namespace.for_user.find_by_full_path(path, follow_redirects: follow_redirects)
+ namespace = Namespace.user_namespaces.find_by_full_path(path, follow_redirects: follow_redirects)
namespace&.owner
end
@@ -1434,7 +1443,10 @@ class User < ApplicationRecord
namespace.path = username if username_changed?
namespace.name = name if name_changed?
else
- namespace = build_namespace(path: username, name: name)
+ # TODO: we should no longer need the `type` parameter once we can make the
+ # the `has_one :namespace` association use the correct class.
+ # issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070
+ namespace = build_namespace(path: username, name: name, type: ::Namespaces::UserNamespace.sti_name)
namespace.build_namespace_settings
end
end
@@ -2003,6 +2015,11 @@ class User < ApplicationRecord
private
+ # To enable JiHu repository to modify the default language options
+ def default_preferred_language
+ 'en'
+ end
+
def notification_email_verified
return if notification_email.blank? || temp_oauth_email?
@@ -2094,10 +2111,14 @@ class User < ApplicationRecord
errors.add(:email, error) if error
end
+ def signup_email_invalid_message
+ _('is not allowed for sign-up.')
+ end
+
def check_username_format
return if username.blank? || Mime::EXTENSION_LOOKUP.keys.none? { |type| username.end_with?(".#{type}") }
- errors.add(:username, _('ending with a file extension is not allowed.'))
+ errors.add(:username, _('ending with a reserved file extension is not allowed.'))
end
def groups_with_developer_maintainer_project_access
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 04bc29755f8..b990aedd4f8 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -36,7 +36,8 @@ class UserCallout < ApplicationRecord
trial_status_reminder_d3: 35, # EE-only
security_configuration_devops_alert: 36, # EE-only
profile_personal_access_token_expiry: 37, # EE-only
- terraform_notification_dismissed: 38
+ terraform_notification_dismissed: 38,
+ security_newsletter_callout: 39
}
validates :feature_name,
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index c41cff67864..6b0ed89c683 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -3,7 +3,10 @@
class UserDetail < ApplicationRecord
extend ::Gitlab::Utils::Override
include IgnorableColumns
- ignore_columns %i[bio_html cached_markdown_version], remove_with: '13.6', remove_after: '2021-10-22'
+
+ ignore_columns %i[bio_html cached_markdown_version], remove_with: '14.5', remove_after: '2021-10-22'
+
+ REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze
belongs_to :user
@@ -14,6 +17,8 @@ class UserDetail < ApplicationRecord
before_save :prevent_nil_bio
+ enum registration_objective: REGISTRATION_OBJECTIVE_PAIRS, _suffix: true
+
private
def prevent_nil_bio
diff --git a/app/models/user_highest_role.rb b/app/models/user_highest_role.rb
index 4853fc3d248..dd5c85a5a87 100644
--- a/app/models/user_highest_role.rb
+++ b/app/models/user_highest_role.rb
@@ -3,7 +3,13 @@
class UserHighestRole < ApplicationRecord
belongs_to :user, optional: false
- validates :highest_access_level, allow_nil: true, inclusion: { in: Gitlab::Access.all_values }
+ validates :highest_access_level, allow_nil: true, inclusion: { in: ->(_) { self.allowed_values } }
scope :with_highest_access_level, -> (highest_access_level) { where(highest_access_level: highest_access_level) }
+
+ def self.allowed_values
+ Gitlab::Access.all_values
+ end
end
+
+UserHighestRole.prepend_mod
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 337ae7125f3..7687430cfd1 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -23,7 +23,6 @@ class UserPreference < ApplicationRecord
ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22'
default_value_for :tab_width, value: Gitlab::TabWidth::DEFAULT, allows_nil: false
- default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false
default_value_for :time_display_relative, value: true, allows_nil: false
default_value_for :time_format_in_24h, value: false, allows_nil: false
default_value_for :render_whitespace_in_code, value: false, allows_nil: false
diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb
index 5e255acd882..a4cc43d1f13 100644
--- a/app/models/users/credit_card_validation.rb
+++ b/app/models/users/credit_card_validation.rb
@@ -7,5 +7,18 @@ module Users
self.table_name = 'user_credit_card_validations'
belongs_to :user
+
+ validates :holder_name, length: { maximum: 26 }
+ validates :last_digits, allow_nil: true, numericality: {
+ greater_than_or_equal_to: 0, less_than_or_equal_to: 9999
+ }
+
+ def similar_records
+ self.class.where(
+ expiration_date: expiration_date,
+ last_digits: last_digits,
+ holder_name: holder_name
+ ).order(credit_card_validated_at: :desc).includes(:user)
+ end
end
end
diff --git a/app/policies/ci/resource_group_policy.rb b/app/policies/ci/resource_group_policy.rb
new file mode 100644
index 00000000000..ef384265b11
--- /dev/null
+++ b/app/policies/ci/resource_group_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class ResourceGroupPolicy < BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/app/policies/clusters/agent_policy.rb b/app/policies/clusters/agent_policy.rb
new file mode 100644
index 00000000000..25e78c84802
--- /dev/null
+++ b/app/policies/clusters/agent_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Clusters
+ class AgentPolicy < BasePolicy
+ alias_method :cluster_agent, :subject
+
+ delegate { cluster_agent.project }
+ end
+end
diff --git a/app/policies/clusters/agent_token_policy.rb b/app/policies/clusters/agent_token_policy.rb
new file mode 100644
index 00000000000..e876ecfac26
--- /dev/null
+++ b/app/policies/clusters/agent_token_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Clusters
+ class AgentTokenPolicy < BasePolicy
+ alias_method :token, :subject
+
+ delegate { token.agent }
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 7abffd2c352..64395f69c42 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -134,6 +134,8 @@ class GroupPolicy < BasePolicy
enable :create_package
enable :create_package_settings
enable :developer_access
+ enable :admin_organization
+ enable :admin_contact
end
rule { reporter }.policy do
@@ -147,7 +149,6 @@ class GroupPolicy < BasePolicy
enable :read_prometheus
enable :read_package
enable :read_package_settings
- enable :admin_organization
end
rule { maintainer }.policy do
@@ -162,7 +163,6 @@ class GroupPolicy < BasePolicy
enable :admin_cluster
enable :read_deploy_token
enable :create_jira_connect_subscription
- enable :update_runners_registration_token
enable :maintainer_access
end
@@ -179,6 +179,7 @@ class GroupPolicy < BasePolicy
enable :update_default_branch_protection
enable :create_deploy_token
enable :destroy_deploy_token
+ enable :update_runners_registration_token
enable :owner_access
end
diff --git a/app/policies/list_policy.rb b/app/policies/list_policy.rb
new file mode 100644
index 00000000000..97845746546
--- /dev/null
+++ b/app/policies/list_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ListPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass
+ delegate { @subject.board.resource_parent }
+end
diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb
index dcbeda9f5d3..0cf1bcb9737 100644
--- a/app/policies/namespace_policy.rb
+++ b/app/policies/namespace_policy.rb
@@ -1,26 +1,9 @@
# frozen_string_literal: true
-class NamespacePolicy < BasePolicy
- rule { anonymous }.prevent_all
-
- condition(:personal_project, scope: :subject) { @subject.kind == 'user' }
- condition(:can_create_personal_project, scope: :user) { @user.can_create_project? }
- condition(:owner) { @subject.owner == @user }
-
- rule { owner | admin }.policy do
- enable :owner_access
- enable :create_projects
- enable :admin_namespace
- enable :read_namespace
- enable :read_statistics
- enable :create_jira_connect_subscription
- enable :create_package_settings
- enable :read_package_settings
- end
-
- rule { personal_project & ~can_create_personal_project }.prevent :create_projects
-
- rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects
+class NamespacePolicy < ::Namespaces::UserNamespacePolicy
+ # NamespacePolicy has been traditionally for user namespaces.
+ # So these policies have been moved into Namespaces::UserNamespacePolicy.
+ # Once the user namespace conversion is complete, we can look at
+ # either removing this file or locating common namespace policy items
+ # here.
end
-
-NamespacePolicy.prepend_mod_with('NamespacePolicy')
diff --git a/app/policies/namespaces/project_namespace_policy.rb b/app/policies/namespaces/project_namespace_policy.rb
new file mode 100644
index 00000000000..bc08a7a45ed
--- /dev/null
+++ b/app/policies/namespaces/project_namespace_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class ProjectNamespacePolicy < BasePolicy
+ # For now users are not granted any permissions on project namespace
+ # as it's completely hidden to them. When we start using project
+ # namespaces in queries, we will have to extend this policy.
+ end
+end
diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb
new file mode 100644
index 00000000000..f8b285e5312
--- /dev/null
+++ b/app/policies/namespaces/user_namespace_policy.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class UserNamespacePolicy < BasePolicy
+ rule { anonymous }.prevent_all
+
+ condition(:personal_project, scope: :subject) { @subject.kind == 'user' }
+ condition(:can_create_personal_project, scope: :user) { @user.can_create_project? }
+ condition(:owner) { @subject.owner == @user }
+
+ rule { owner | admin }.policy do
+ enable :owner_access
+ enable :create_projects
+ enable :admin_namespace
+ enable :read_namespace
+ enable :read_statistics
+ enable :create_jira_connect_subscription
+ enable :create_package_settings
+ enable :read_package_settings
+ end
+
+ rule { personal_project & ~can_create_personal_project }.prevent :create_projects
+
+ rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects
+ end
+end
+
+Namespaces::UserNamespacePolicy.prepend_mod_with('Namespaces::UserNamespacePolicy')
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 54b11ea6041..59aa47beff9 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -357,6 +357,8 @@ class ProjectPolicy < BasePolicy
enable :update_commit_status
enable :create_build
enable :update_build
+ enable :read_resource_group
+ enable :update_resource_group
enable :create_merge_request_from
enable :create_wiki
enable :push_code
@@ -436,6 +438,7 @@ class ProjectPolicy < BasePolicy
enable :destroy_freeze_period
enable :admin_feature_flags_client
enable :update_runners_registration_token
+ enable :manage_project_google_cloud
end
rule { public_project & metrics_dashboard_allowed }.policy do
diff --git a/app/presenters/README.md b/app/presenters/README.md
index 62aec4fc8a2..dfd1818f97d 100644
--- a/app/presenters/README.md
+++ b/app/presenters/README.md
@@ -66,14 +66,15 @@ we gain the following benefits:
### Presenter definition
-Every presenter should inherit from `Gitlab::View::Presenter::Simple`, which
-provides a `.presents` the method which allows you to define an accessor for the
+If you need a presenter class that has only necessary interfaces for the view-related context,
+inherit from `Gitlab::View::Presenter::Simple`.
+It 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`.
```ruby
class LabelPresenter < Gitlab::View::Presenter::Simple
- presents :label
+ presents ::Label, as: :label
def text_color
label.color.to_s
@@ -85,13 +86,14 @@ class LabelPresenter < Gitlab::View::Presenter::Simple
end
```
-In some cases, it can be more practical to transparently delegate all missing
-method calls to the presented object, in these cases, you can make your
-presenter inherit from `Gitlab::View::Presenter::Delegated`:
+If you need a presenter class that delegates missing method calls to the presented object,
+inherit from `Gitlab::View::Presenter::Delegated`.
+This is more like an "extension" in the sense that the produced object is going to have
+all of interfaces of the presented object **AND** all of the interfaces in the presenter class:
```ruby
class LabelPresenter < Gitlab::View::Presenter::Delegated
- presents :label
+ presents ::Label, as: :label
def text_color
# color is delegated to label
@@ -152,3 +154,82 @@ You can also present the model in the view:
%div{ class: label.text_color }
= render partial: label, label: label
```
+
+### Validate accidental overrides
+
+We use presenters in many places, such as Controller, Haml, GraphQL/Rest API,
+it's very handy to extend the core/backend logic of Active Record models,
+however, there is a risk that it accidentally overrides important logic.
+
+For example, [this production incident](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/5498)
+was caused by [including `ActionView::Helpers::UrlHelper` in a presenter](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69537/diffs#4b581cff00ef3cc9780efd23682af383de302e7d_3_3).
+The `tag` accesor in `Ci::Build` was accidentally overridden by `ActionView::Helpers::TagHelper#tag`,
+and as a conseuqence, a wrong `tag` value was persited into database.
+
+Starting from GitLab 14.4, we validate the presenters (specifically all of the subclasses of `Gitlab::View::Presenter::Delegated`)
+that they do not accidentally override core/backend logic. In such case, a pipeline in merge requests fails with an error message,
+here is an example:
+
+```plaintext
+We've detected that a presetner is overriding a specific method(s) on a subject model.
+There is a risk that it accidentally modifies the backend/core logic that leads to production incident.
+Please follow https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/presenters/README.md#validate-accidental-overrides
+to resolve this error with caution.
+
+Here are the conflict details.
+
+- Ci::PipelinePresenter#tag is overriding Ci::Pipeline#tag. delegator_location: /devkitkat/services/rails/cache/ruby/2.7.0/gems/actionview-6.1.3.2/lib/action_view/helpers/tag_helper.rb:271 original_location: /devkitkat/services/rails/cache/ruby/2.7.0/gems/activemodel-6.1.3.2/lib/active_model/attribute_methods.rb:254
+```
+
+Here are the potential solutions:
+
+- If the conflict happens on an instance method in the presenter:
+ - If you intend to override the core/backend logic, define `delegator_override <method-name>` on top of the conflicted method.
+ This explicitly adds the method to an allowlist.
+ - If you do NOT intend to override the core/backend logic, rename the method name in the presenter.
+- If the conflict happens on an included module in the presenter, remove the module from the presenter and find a workaround.
+
+### How to use the `Gitlab::Utils::DelegatorOverride` validator
+
+If a presenter class inhertis from `Gitlab::View::Presenter::Delegated`,
+you should define what object class is presented:
+
+```ruby
+class WebHookLogPresenter < Gitlab::View::Presenter::Delegated
+ presents ::WebHookLog, as: :web_hook_log # This defines that the presenter presents `WebHookLog` Active Record model.
+```
+
+These presenters are validated not to accidentaly override the methods in the presented object.
+You can run the validation locally with:
+
+```shell
+bundle exec rake lint:static_verification
+```
+
+To add a method to an allowlist, use `delegator_override`. For example:
+
+```ruby
+class VulnerabilityPresenter < Gitlab::View::Presenter::Delegated
+ presents ::Vulnerability, as: :vulnerability
+
+ delegator_override :description # This adds the `description` method to an allowlist that the override is intentional.
+ def description
+ vulnerability.description || finding.description
+ end
+```
+
+To add methods of a module to an allowlist, use `delegator_override_with`. For example:
+
+```ruby
+module Ci
+ class PipelinePresenter < Gitlab::View::Presenter::Delegated
+ include Gitlab::Utils::StrongMemoize
+ include ActionView::Helpers::UrlHelper
+
+ delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+ delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag`
+```
+
+Keep in mind that if you use `delegator_override_with`,
+there is a high chance that you're doing **something wrong**.
+Read the [Validate Accidental Overrides](#validate-accidental-overrides) for more information.
diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb
index c6c6fe837a0..86fe9859271 100644
--- a/app/presenters/alert_management/alert_presenter.rb
+++ b/app/presenters/alert_management/alert_presenter.rb
@@ -2,10 +2,12 @@
module AlertManagement
class AlertPresenter < Gitlab::View::Presenter::Delegated
- include Gitlab::Utils::StrongMemoize
include IncidentManagement::Settings
include ActionView::Helpers::UrlHelper
+ presents ::AlertManagement::Alert
+ delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+
MARKDOWN_LINE_BREAK = " \n"
HORIZONTAL_LINE = "\n\n---\n\n"
@@ -30,6 +32,7 @@ module AlertManagement
started_at&.strftime('%d %B %Y, %-l:%M%p (%Z)')
end
+ delegator_override :details_url
def details_url
details_project_alert_management_url(project, alert.iid)
end
@@ -65,6 +68,7 @@ module AlertManagement
private
attr_reader :alert, :project
+
delegate :alert_markdown, :full_query, to: :parsed_payload
def issue_summary_markdown
diff --git a/app/presenters/award_emoji_presenter.rb b/app/presenters/award_emoji_presenter.rb
index 98713855d35..8a7b58e0aba 100644
--- a/app/presenters/award_emoji_presenter.rb
+++ b/app/presenters/award_emoji_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class AwardEmojiPresenter < Gitlab::View::Presenter::Delegated
- presents :award_emoji
+ presents ::AwardEmoji, as: :award_emoji
def description
as_emoji['description']
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index ecc16e2840c..c198859aa4c 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -7,7 +7,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
include TreeHelper
include ChecksCollaboration
- presents :blob
+ presents ::Blob, as: :blob
def highlight(to: nil, plain: nil)
load_all_blob_data
diff --git a/app/presenters/blobs/unfold_presenter.rb b/app/presenters/blobs/unfold_presenter.rb
index 487c6fe0757..b921b5bf670 100644
--- a/app/presenters/blobs/unfold_presenter.rb
+++ b/app/presenters/blobs/unfold_presenter.rb
@@ -6,6 +6,8 @@ module Blobs
include ActiveModel::AttributeAssignment
include Gitlab::Utils::StrongMemoize
+ presents ::Blob
+
attribute :full, :boolean, default: false
attribute :since, :integer, default: 1
attribute :to, :integer, default: 1
diff --git a/app/presenters/board_presenter.rb b/app/presenters/board_presenter.rb
index d7cecd44dd7..bb3e96b8faf 100644
--- a/app/presenters/board_presenter.rb
+++ b/app/presenters/board_presenter.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class BoardPresenter < Gitlab::View::Presenter::Delegated
- presents :board
+ presents ::Board, as: :board
end
diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb
index 724e10c26c3..a62d7cdbbd4 100644
--- a/app/presenters/ci/bridge_presenter.rb
+++ b/app/presenters/ci/bridge_presenter.rb
@@ -2,6 +2,9 @@
module Ci
class BridgePresenter < ProcessablePresenter
+ presents ::Ci::Bridge
+
+ delegator_override :detailed_status
def detailed_status
@detailed_status ||= subject.detailed_status(user)
end
diff --git a/app/presenters/ci/build_metadata_presenter.rb b/app/presenters/ci/build_metadata_presenter.rb
index 4871bb3a919..2f559adf77d 100644
--- a/app/presenters/ci/build_metadata_presenter.rb
+++ b/app/presenters/ci/build_metadata_presenter.rb
@@ -9,8 +9,9 @@ module Ci
job_timeout_source: 'job'
}.freeze
- presents :metadata
+ presents ::Ci::BuildMetadata, as: :metadata
+ delegator_override :timeout_source
def timeout_source
return unless metadata.timeout_source?
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index 06ed6791bb7..65e1c80085f 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -2,6 +2,8 @@
module Ci
class BuildPresenter < ProcessablePresenter
+ presents ::Ci::Build
+
def erased_by_user?
# Build can be erased through API, therefore it does not have
# `erased_by` user assigned in that case.
diff --git a/app/presenters/ci/group_variable_presenter.rb b/app/presenters/ci/group_variable_presenter.rb
index 99011150c84..dea9a42b622 100644
--- a/app/presenters/ci/group_variable_presenter.rb
+++ b/app/presenters/ci/group_variable_presenter.rb
@@ -2,7 +2,7 @@
module Ci
class GroupVariablePresenter < Gitlab::View::Presenter::Delegated
- presents :variable
+ presents ::Ci::GroupVariable, as: :variable
def placeholder
'GROUP_VARIABLE'
diff --git a/app/presenters/ci/legacy_stage_presenter.rb b/app/presenters/ci/legacy_stage_presenter.rb
index d5c21baba28..c803abfab6a 100644
--- a/app/presenters/ci/legacy_stage_presenter.rb
+++ b/app/presenters/ci/legacy_stage_presenter.rb
@@ -2,7 +2,7 @@
module Ci
class LegacyStagePresenter < Gitlab::View::Presenter::Delegated
- presents :legacy_stage
+ presents ::Ci::LegacyStage, as: :legacy_stage
def latest_ordered_statuses
preload_statuses(legacy_stage.statuses.latest_ordered)
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 82f00f74692..e0cb899c9d3 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -5,6 +5,9 @@ module Ci
include Gitlab::Utils::StrongMemoize
include ActionView::Helpers::UrlHelper
+ delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+ delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag`
+
# We use a class method here instead of a constant, allowing EE to redefine
# the returned `Hash` more easily.
def self.failure_reasons
@@ -20,8 +23,9 @@ module Ci
user_blocked: 'The user who created this pipeline is blocked.' }
end
- presents :pipeline
+ presents ::Ci::Pipeline, as: :pipeline
+ delegator_override :failed_builds
def failed_builds
return [] unless can?(current_user, :read_build, pipeline)
@@ -30,6 +34,7 @@ module Ci
end
end
+ delegator_override :failure_reason
def failure_reason
return unless pipeline.failure_reason?
diff --git a/app/presenters/ci/processable_presenter.rb b/app/presenters/ci/processable_presenter.rb
index 5a8a6649071..9f3a83a00f3 100644
--- a/app/presenters/ci/processable_presenter.rb
+++ b/app/presenters/ci/processable_presenter.rb
@@ -2,5 +2,6 @@
module Ci
class ProcessablePresenter < CommitStatusPresenter
+ presents ::Ci::Processable
end
end
diff --git a/app/presenters/ci/runner_presenter.rb b/app/presenters/ci/runner_presenter.rb
index 273328afc53..ad889d444f8 100644
--- a/app/presenters/ci/runner_presenter.rb
+++ b/app/presenters/ci/runner_presenter.rb
@@ -2,11 +2,14 @@
module Ci
class RunnerPresenter < Gitlab::View::Presenter::Delegated
- presents :runner
+ presents ::Ci::Runner, as: :runner
+ delegator_override :locked?
def locked?
read_attribute(:locked) && project_type?
end
+
+ delegator_override :locked
alias_method :locked, :locked?
end
end
diff --git a/app/presenters/ci/stage_presenter.rb b/app/presenters/ci/stage_presenter.rb
index 21bda86cded..bd5bf08abbc 100644
--- a/app/presenters/ci/stage_presenter.rb
+++ b/app/presenters/ci/stage_presenter.rb
@@ -2,7 +2,7 @@
module Ci
class StagePresenter < Gitlab::View::Presenter::Delegated
- presents :stage
+ presents ::Ci::Stage, as: :stage
PRELOADED_RELATIONS = [:pipeline, :metadata, :tags, :job_artifacts_archive, :downstream_pipeline].freeze
diff --git a/app/presenters/ci/trigger_presenter.rb b/app/presenters/ci/trigger_presenter.rb
index 605c8f328a4..5f0bd4b3a8b 100644
--- a/app/presenters/ci/trigger_presenter.rb
+++ b/app/presenters/ci/trigger_presenter.rb
@@ -2,12 +2,13 @@
module Ci
class TriggerPresenter < Gitlab::View::Presenter::Delegated
- presents :trigger
+ presents ::Ci::Trigger, as: :trigger
def has_token_exposed?
can?(current_user, :admin_trigger, trigger)
end
+ delegator_override :token
def token
if has_token_exposed?
trigger.token
diff --git a/app/presenters/ci/variable_presenter.rb b/app/presenters/ci/variable_presenter.rb
index f027f3aa560..ec04dd5e9ff 100644
--- a/app/presenters/ci/variable_presenter.rb
+++ b/app/presenters/ci/variable_presenter.rb
@@ -2,7 +2,7 @@
module Ci
class VariablePresenter < Gitlab::View::Presenter::Delegated
- presents :variable
+ presents ::Ci::Variable, as: :variable
def placeholder
'PROJECT_VARIABLE'
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index 03e26b92922..4b645510b51 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class ClusterablePresenter < Gitlab::View::Presenter::Delegated
- presents :clusterable
+ presents ::Project, ::Group, ::Clusters::Instance, as: :clusterable
def self.fabricate(clusterable, **attributes)
presenter_class = "#{clusterable.class.name}ClusterablePresenter".constantize
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index eb4bd8532af..ce060476cfd 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -3,21 +3,10 @@
module Clusters
class ClusterPresenter < Gitlab::View::Presenter::Delegated
include ::Gitlab::Utils::StrongMemoize
- include ActionView::Helpers::SanitizeHelper
- include ActionView::Helpers::UrlHelper
- include IconsHelper
- presents :cluster
+ delegator_override_with ::Gitlab::Utils::StrongMemoize # TODO: Remove `::Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
- # We do not want to show the group path for clusters belonging to the
- # clusterable, only for the ancestor clusters.
- def item_link(clusterable_presenter, *html_options)
- if cluster.group_type? && clusterable != clusterable_presenter.subject
- contracted_group_name(cluster.group) + ' / ' + link_to_cluster
- else
- link_to_cluster(*html_options)
- end
- end
+ presents ::Clusters::Cluster, as: :cluster
def provider_label
if aws?
@@ -39,16 +28,6 @@ module Clusters
can?(current_user, :read_cluster, cluster)
end
- def cluster_type_description
- if cluster.project_type?
- s_("ClusterIntegration|Project cluster")
- elsif cluster.group_type?
- s_("ClusterIntegration|Group cluster")
- elsif cluster.instance_type?
- s_("ClusterIntegration|Instance cluster")
- end
- end
-
def show_path(params: {})
if cluster.project_type?
project_cluster_path(project, cluster, params)
@@ -107,7 +86,7 @@ module Clusters
private
def image_path(path)
- ActionController::Base.helpers.image_path(path)
+ ApplicationController.helpers.image_path(path)
end
# currently log explorer is only available in the scope of the project
@@ -127,20 +106,6 @@ module Clusters
cluster.project
end
end
-
- def contracted_group_name(group)
- sanitize(group.full_name)
- .sub(%r{\/.*\/}, "/ #{contracted_icon} /")
- .html_safe
- end
-
- def contracted_icon
- sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle')
- end
-
- def link_to_cluster(html_options: {})
- link_to_if(can_read_cluster?, cluster.name, show_path, html_options)
- end
end
end
diff --git a/app/presenters/clusters/integration_presenter.rb b/app/presenters/clusters/integration_presenter.rb
index 57608be29b1..f7be59f00f3 100644
--- a/app/presenters/clusters/integration_presenter.rb
+++ b/app/presenters/clusters/integration_presenter.rb
@@ -2,7 +2,7 @@
module Clusters
class IntegrationPresenter < Gitlab::View::Presenter::Delegated
- presents :integration
+ presents ::Clusters::Integrations::Prometheus, ::Clusters::Integrations::ElasticStack, as: :integration
def application_type
integration.class.name.demodulize.underscore
diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb
index c14dcab6000..7df45ca03bb 100644
--- a/app/presenters/commit_presenter.rb
+++ b/app/presenters/commit_presenter.rb
@@ -3,7 +3,7 @@
class CommitPresenter < Gitlab::View::Presenter::Delegated
include GlobalID::Identification
- presents :commit
+ presents ::Commit, as: :commit
def status_for(ref)
return unless can?(current_user, :read_commit_status, commit.project)
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 3c39470b730..7919e501bf0 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -28,19 +28,36 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
ci_quota_exceeded: 'No more CI minutes available',
no_matching_runner: 'No matching runner available',
trace_size_exceeded: 'The job log size limit was reached',
- builds_disabled: 'The CI/CD is disabled for this project'
+ builds_disabled: 'The CI/CD is disabled for this project',
+ environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.'
+ }.freeze
+
+ TROUBLESHOOTING_DOC = {
+ environment_creation_failure: { path: 'ci/environments/index', anchor: 'a-deployment-job-failed-with-this-job-could-not-be-executed-because-it-would-create-an-environment-with-an-invalid-parameter-error' }
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
- presents :build
+ presents ::CommitStatus, as: :build
def self.callout_failure_messages
CALLOUT_FAILURE_MESSAGES
end
def callout_failure_message
- self.class.callout_failure_messages.fetch(failure_reason.to_sym)
+ message = self.class.callout_failure_messages.fetch(failure_reason.to_sym)
+
+ if doc = TROUBLESHOOTING_DOC[failure_reason.to_sym]
+ message += " #{help_page_link(doc[:path], doc[:anchor])}"
+ end
+
+ message
+ end
+
+ private
+
+ def help_page_link(path, anchor)
+ ActionController::Base.helpers.link_to('How do I fix it?', help_page_path(path, anchor: anchor))
end
end
diff --git a/app/presenters/dev_ops_report/metric_presenter.rb b/app/presenters/dev_ops_report/metric_presenter.rb
index 4d7ac1cd3ec..55326f8f678 100644
--- a/app/presenters/dev_ops_report/metric_presenter.rb
+++ b/app/presenters/dev_ops_report/metric_presenter.rb
@@ -2,6 +2,8 @@
module DevOpsReport
class MetricPresenter < Gitlab::View::Presenter::Simple
+ presents ::DevOpsReport::Metric
+
delegate :created_at, to: :subject
def cards
diff --git a/app/presenters/environment_presenter.rb b/app/presenters/environment_presenter.rb
index 6f67bbe2a5a..6c8da86187c 100644
--- a/app/presenters/environment_presenter.rb
+++ b/app/presenters/environment_presenter.rb
@@ -3,7 +3,7 @@
class EnvironmentPresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
- presents :environment
+ presents ::Environment, as: :environment
def path
project_environment_path(project, self)
diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb
index c37721f7213..4c787b88e20 100644
--- a/app/presenters/event_presenter.rb
+++ b/app/presenters/event_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class EventPresenter < Gitlab::View::Presenter::Delegated
- presents :event
+ presents ::Event, as: :event
def initialize(subject, **attributes)
super
@@ -10,6 +10,7 @@ class EventPresenter < Gitlab::View::Presenter::Delegated
end
# Caching `visible_to_user?` method in the presenter beause it might be called multiple times.
+ delegator_override :visible_to_user?
def visible_to_user?(user = nil)
@visible_to_user_cache.fetch(user&.id) { super(user) }
end
diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb
index 1f2445b04a1..e9340a42e51 100644
--- a/app/presenters/gitlab/blame_presenter.rb
+++ b/app/presenters/gitlab/blame_presenter.rb
@@ -12,7 +12,7 @@ module Gitlab
include TreeHelper
include IconsHelper
- presents :blame
+ presents nil, as: :blame
CommitData = Struct.new(
:author_avatar,
diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb
index 34e7084ab02..c51cd415029 100644
--- a/app/presenters/group_clusterable_presenter.rb
+++ b/app/presenters/group_clusterable_presenter.rb
@@ -2,7 +2,8 @@
class GroupClusterablePresenter < ClusterablePresenter
extend ::Gitlab::Utils::Override
- include ActionView::Helpers::UrlHelper
+
+ presents ::Group
override :cluster_status_cluster_path
def cluster_status_cluster_path(cluster, params = {})
@@ -31,7 +32,7 @@ class GroupClusterablePresenter < ClusterablePresenter
override :learn_more_link
def learn_more_link
- link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
end
def metrics_dashboard_path(cluster)
diff --git a/app/presenters/group_member_presenter.rb b/app/presenters/group_member_presenter.rb
index 5ab4b51f472..88facc3608d 100644
--- a/app/presenters/group_member_presenter.rb
+++ b/app/presenters/group_member_presenter.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class GroupMemberPresenter < MemberPresenter
+ presents ::GroupMember
+
private
def admin_member_permission
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
index 56d91f90b2e..f2550eb17e3 100644
--- a/app/presenters/instance_clusterable_presenter.rb
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -2,7 +2,8 @@
class InstanceClusterablePresenter < ClusterablePresenter
extend ::Gitlab::Utils::Override
- include ActionView::Helpers::UrlHelper
+
+ presents ::Clusters::Instance
def self.fabricate(clusterable, **attributes)
attributes_with_presenter_class = attributes.merge(presenter_class: InstanceClusterablePresenter)
@@ -69,7 +70,7 @@ class InstanceClusterablePresenter < ClusterablePresenter
override :learn_more_link
def learn_more_link
- link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
end
def metrics_dashboard_path(cluster)
diff --git a/app/presenters/invitation_presenter.rb b/app/presenters/invitation_presenter.rb
index d8c07f327dd..ada8227a477 100644
--- a/app/presenters/invitation_presenter.rb
+++ b/app/presenters/invitation_presenter.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class InvitationPresenter < Gitlab::View::Presenter::Delegated
- presents :invitation
+ presents nil, as: :invitation
end
diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb
index b7f4ac0555d..72fe14d224d 100644
--- a/app/presenters/issue_presenter.rb
+++ b/app/presenters/issue_presenter.rb
@@ -1,12 +1,13 @@
# frozen_string_literal: true
class IssuePresenter < Gitlab::View::Presenter::Delegated
- presents :issue
+ presents ::Issue, as: :issue
def issue_path
url_builder.build(issue, only_path: true)
end
+ delegator_override :subscribed?
def subscribed?
issue.subscribed?(current_user, issue.project)
end
diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb
index 9e51e6fa4ba..fafade2828f 100644
--- a/app/presenters/label_presenter.rb
+++ b/app/presenters/label_presenter.rb
@@ -1,9 +1,11 @@
# frozen_string_literal: true
class LabelPresenter < Gitlab::View::Presenter::Delegated
- presents :label
+ presents ::Label, as: :label
delegate :name, :full_name, to: :label_subject, prefix: :subject
+ delegator_override :subject # TODO: Fix `Gitlab::View::Presenter::Delegated#subject` not to override `Label#subject`.
+
def edit_path
case label
when GroupLabel then edit_group_label_path(label.group, label)
diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb
index b37a43bf251..67d044dd01c 100644
--- a/app/presenters/member_presenter.rb
+++ b/app/presenters/member_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class MemberPresenter < Gitlab::View::Presenter::Delegated
- presents :member
+ presents ::Member, as: :member
def access_level_roles
member.class.access_level_roles
diff --git a/app/presenters/members_presenter.rb b/app/presenters/members_presenter.rb
index 03ebea36d49..b572cf96235 100644
--- a/app/presenters/members_presenter.rb
+++ b/app/presenters/members_presenter.rb
@@ -3,7 +3,7 @@
class MembersPresenter < Gitlab::View::Presenter::Delegated
include Enumerable
- presents :members
+ presents nil, as: :members
def to_ary
to_a
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index fc8a290f5f7..d19d4964524 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -8,9 +8,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
include ChecksCollaboration
include Gitlab::Utils::StrongMemoize
+ delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+
APPROVALS_WIDGET_BASE_TYPE = 'base'
- presents :merge_request
+ presents ::MergeRequest, as: :merge_request
def ci_status
if pipeline
@@ -183,6 +185,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
.can_push_to_branch?(source_branch)
end
+ delegator_override :can_remove_source_branch?
def can_remove_source_branch?
source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
end
@@ -202,6 +205,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
end
+ delegator_override :subscribed?
def subscribed?
merge_request.subscribed?(current_user, merge_request.target_project)
end
diff --git a/app/presenters/milestone_presenter.rb b/app/presenters/milestone_presenter.rb
index 6bf8203702f..4084c8740f0 100644
--- a/app/presenters/milestone_presenter.rb
+++ b/app/presenters/milestone_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class MilestonePresenter < Gitlab::View::Presenter::Delegated
- presents :milestone
+ presents ::Milestone, as: :milestone
def milestone_path
url_builder.build(milestone, only_path: true)
diff --git a/app/presenters/pages_domain_presenter.rb b/app/presenters/pages_domain_presenter.rb
index 6ef89760bec..0523f702416 100644
--- a/app/presenters/pages_domain_presenter.rb
+++ b/app/presenters/pages_domain_presenter.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
class PagesDomainPresenter < Gitlab::View::Presenter::Delegated
- presents :pages_domain
+ presents ::PagesDomain, as: :pages_domain
+
+ delegator_override :subject # TODO: Fix `Gitlab::View::Presenter::Delegated#subject` not to override `PagesDomain#subject`.
def needs_verification?
Gitlab::CurrentSettings.pages_domain_verification_enabled? && unverified?
diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb
index 920304e743e..6c4d1143c0f 100644
--- a/app/presenters/project_clusterable_presenter.rb
+++ b/app/presenters/project_clusterable_presenter.rb
@@ -2,7 +2,8 @@
class ProjectClusterablePresenter < ClusterablePresenter
extend ::Gitlab::Utils::Override
- include ActionView::Helpers::UrlHelper
+
+ presents ::Project
override :cluster_status_cluster_path
def cluster_status_cluster_path(cluster, params = {})
@@ -26,7 +27,7 @@ class ProjectClusterablePresenter < ClusterablePresenter
override :learn_more_link
def learn_more_link
- link_to(s_('ClusterIntegration|Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
end
def metrics_dashboard_path(cluster)
diff --git a/app/presenters/project_hook_presenter.rb b/app/presenters/project_hook_presenter.rb
index a65c7221b5a..a696e9fd0ec 100644
--- a/app/presenters/project_hook_presenter.rb
+++ b/app/presenters/project_hook_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class ProjectHookPresenter < Gitlab::View::Presenter::Delegated
- presents :project_hook
+ presents ::ProjectHook, as: :project_hook
def logs_details_path(log)
project_hook_hook_log_path(project, self, log)
diff --git a/app/presenters/project_member_presenter.rb b/app/presenters/project_member_presenter.rb
index 17947266ed7..91d3ae96877 100644
--- a/app/presenters/project_member_presenter.rb
+++ b/app/presenters/project_member_presenter.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ProjectMemberPresenter < MemberPresenter
+ presents ::ProjectMember
+
private
def admin_member_permission
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 066f4786cff..bbd8c715f5c 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -12,7 +12,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
include Gitlab::Utils::StrongMemoize
include Gitlab::Experiment::Dsl
- presents :project
+ delegator_override_with GitlabRoutingHelper # TODO: Remove `GitlabRoutingHelper` inclusion as it's duplicate
+ delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+
+ presents ::Project, as: :project
AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon, :itemprop, :data)
MAX_TOPICS_TO_SHOW = 3
diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb
index f56760b55df..7b2ffb6d755 100644
--- a/app/presenters/projects/import_export/project_export_presenter.rb
+++ b/app/presenters/projects/import_export/project_export_presenter.rb
@@ -5,16 +5,24 @@ module Projects
class ProjectExportPresenter < Gitlab::View::Presenter::Delegated
include ActiveModel::Serializers::JSON
- presents :project
+ presents ::Project, as: :project
+ # TODO: Remove `ActiveModel::Serializers::JSON` inclusion as it's duplicate
+ delegator_override_with ActiveModel::Serializers::JSON
+ delegator_override_with ActiveModel::Naming
+ delegator_override :include_root_in_json, :include_root_in_json?
+
+ delegator_override :project_members
def project_members
super + converted_group_members
end
+ delegator_override :description
def description
self.respond_to?(:override_description) ? override_description : super
end
+ delegator_override :protected_branches
def protected_branches
project.exported_protected_branches
end
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 13290a8e632..e3323b75188 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -5,7 +5,7 @@ module Projects
class DeployKeysPresenter < Gitlab::View::Presenter::Simple
include Gitlab::Utils::StrongMemoize
- presents :project
+ presents ::Project, as: :project
delegate :size, to: :enabled_keys, prefix: true
delegate :size, to: :available_project_keys, prefix: true
delegate :size, to: :available_public_keys, prefix: true
diff --git a/app/presenters/prometheus_alert_presenter.rb b/app/presenters/prometheus_alert_presenter.rb
index 99e24bdcdb9..714329ede71 100644
--- a/app/presenters/prometheus_alert_presenter.rb
+++ b/app/presenters/prometheus_alert_presenter.rb
@@ -3,7 +3,7 @@
class PrometheusAlertPresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
- presents :prometheus_alert
+ presents ::PrometheusAlert, as: :prometheus_alert
def humanized_text
operator_text =
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index ac27e997b41..c919c7f4c60 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -3,8 +3,10 @@
class ReleasePresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
- presents :release
+ presents ::Release, as: :release
+ # TODO: Remove `delegate` as it's redundant due to SimpleDelegator.
+ delegator_override :tag, :project
delegate :project, :tag, to: :release
def commit_path
@@ -51,6 +53,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
edit_project_release_url(project, release)
end
+ delegator_override :assets_count
def assets_count
if can_download_code?
release.assets_count
@@ -59,6 +62,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
end
end
+ delegator_override :name
def name
can_download_code? ? release.name : "Release-#{release.id}"
end
diff --git a/app/presenters/releases/evidence_presenter.rb b/app/presenters/releases/evidence_presenter.rb
index a00cbacb7d8..bdc053a303b 100644
--- a/app/presenters/releases/evidence_presenter.rb
+++ b/app/presenters/releases/evidence_presenter.rb
@@ -4,7 +4,7 @@ module Releases
class EvidencePresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
- presents :evidence
+ presents ::Releases::Evidence, as: :evidence
def filepath
release = evidence.release
diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb
index ab43800b9f2..72f967b8beb 100644
--- a/app/presenters/search_service_presenter.rb
+++ b/app/presenters/search_service_presenter.rb
@@ -3,7 +3,7 @@
class SearchServicePresenter < Gitlab::View::Presenter::Delegated
include RendersCommits
- presents :search_service
+ presents ::SearchService, as: :search_service
SCOPE_PRELOAD_METHOD = {
projects: :with_web_entity_associations,
@@ -18,6 +18,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated
SORT_ENABLED_SCOPES = %w(issues merge_requests epics).freeze
+ delegator_override :search_objects
def search_objects
@search_objects ||= begin
objects = search_service.search_objects(SCOPE_PRELOAD_METHOD[scope.to_sym])
diff --git a/app/presenters/sentry_error_presenter.rb b/app/presenters/sentry_error_presenter.rb
index 669bcb68b7c..5862e54dfc7 100644
--- a/app/presenters/sentry_error_presenter.rb
+++ b/app/presenters/sentry_error_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class SentryErrorPresenter < Gitlab::View::Presenter::Delegated
- presents :error
+ presents nil, as: :error
FrequencyStruct = Struct.new(:time, :count, keyword_init: true)
diff --git a/app/presenters/service_hook_presenter.rb b/app/presenters/service_hook_presenter.rb
index 8f2ba1a905f..91911eb3dff 100644
--- a/app/presenters/service_hook_presenter.rb
+++ b/app/presenters/service_hook_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class ServiceHookPresenter < Gitlab::View::Presenter::Delegated
- presents :service_hook
+ presents ::ServiceHook, as: :service_hook
def logs_details_path(log)
project_service_hook_log_path(integration.project, integration, log)
diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
index ab8fc0f905b..4072696eb89 100644
--- a/app/presenters/snippet_blob_presenter.rb
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -3,6 +3,8 @@
class SnippetBlobPresenter < BlobPresenter
include GitlabRoutingHelper
+ presents ::SnippetBlob
+
def rich_data
return unless blob.rich_viewer
diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb
index 695aa266e2c..8badbe7f54a 100644
--- a/app/presenters/snippet_presenter.rb
+++ b/app/presenters/snippet_presenter.rb
@@ -1,16 +1,18 @@
# frozen_string_literal: true
class SnippetPresenter < Gitlab::View::Presenter::Delegated
- presents :snippet
+ presents ::Snippet, as: :snippet
def raw_url
url_builder.build(snippet, raw: true)
end
+ delegator_override :ssh_url_to_repo
def ssh_url_to_repo
snippet.ssh_url_to_repo if snippet.repository_exists?
end
+ delegator_override :http_url_to_repo
def http_url_to_repo
snippet.http_url_to_repo if snippet.repository_exists?
end
@@ -31,6 +33,7 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
snippet.submittable_as_spam_by?(current_user)
end
+ delegator_override :blob
def blob
return snippet.blob if snippet.empty_repo?
diff --git a/app/presenters/terraform/modules_presenter.rb b/app/presenters/terraform/modules_presenter.rb
index 608f69e2019..9e9c6a5cd2b 100644
--- a/app/presenters/terraform/modules_presenter.rb
+++ b/app/presenters/terraform/modules_presenter.rb
@@ -4,7 +4,7 @@ module Terraform
class ModulesPresenter < Gitlab::View::Presenter::Simple
attr_accessor :packages, :system
- presents :modules
+ presents nil, as: :modules
def initialize(packages, system)
@packages = packages
diff --git a/app/presenters/todo_presenter.rb b/app/presenters/todo_presenter.rb
index 291be7848e2..cb8d37be22d 100644
--- a/app/presenters/todo_presenter.rb
+++ b/app/presenters/todo_presenter.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class TodoPresenter < Gitlab::View::Presenter::Delegated
- presents :todo
+ presents ::Todo, as: :todo
end
diff --git a/app/presenters/tree_entry_presenter.rb b/app/presenters/tree_entry_presenter.rb
index 216b3b0d4c9..0b313d81360 100644
--- a/app/presenters/tree_entry_presenter.rb
+++ b/app/presenters/tree_entry_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class TreeEntryPresenter < Gitlab::View::Presenter::Delegated
- presents :tree
+ presents nil, as: :tree
def web_url
Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, File.join(tree.commit_id, tree.path))
diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb
index 7cd94082bac..5a99f10b6e7 100644
--- a/app/presenters/user_presenter.rb
+++ b/app/presenters/user_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class UserPresenter < Gitlab::View::Presenter::Delegated
- presents :user
+ presents ::User, as: :user
def group_memberships
should_be_private? ? GroupMember.none : user.group_members
diff --git a/app/presenters/web_hook_log_presenter.rb b/app/presenters/web_hook_log_presenter.rb
index fca03ddb5d7..a5166589073 100644
--- a/app/presenters/web_hook_log_presenter.rb
+++ b/app/presenters/web_hook_log_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class WebHookLogPresenter < Gitlab::View::Presenter::Delegated
- presents :web_hook_log
+ presents ::WebHookLog, as: :web_hook_log
def details_path
web_hook.present.logs_details_path(self)
diff --git a/app/serializers/feature_flag_entity.rb b/app/serializers/feature_flag_entity.rb
index 80cf869a389..196a4cd504f 100644
--- a/app/serializers/feature_flag_entity.rb
+++ b/app/serializers/feature_flag_entity.rb
@@ -24,8 +24,8 @@ class FeatureFlagEntity < Grape::Entity
project_feature_flag_path(feature_flag.project, feature_flag)
end
- expose :scopes, with: FeatureFlagScopeEntity do |feature_flag|
- feature_flag.scopes.sort_by(&:id)
+ expose :scopes do |_ff|
+ []
end
expose :strategies, with: FeatureFlags::StrategyEntity do |feature_flag|
diff --git a/app/serializers/feature_flag_scope_entity.rb b/app/serializers/feature_flag_scope_entity.rb
deleted file mode 100644
index 0450797a545..00000000000
--- a/app/serializers/feature_flag_scope_entity.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-class FeatureFlagScopeEntity < Grape::Entity
- include RequestAwareEntity
-
- expose :id
- expose :active
- expose :environment_scope
- expose :created_at
- expose :updated_at
- expose :strategies
-end
diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb
index 5100a41638e..d7221109ecb 100644
--- a/app/serializers/member_entity.rb
+++ b/app/serializers/member_entity.rb
@@ -44,6 +44,8 @@ class MemberEntity < Grape::Entity
MemberUserEntity.represent(member.user, source: options[:source])
end
+ expose :state
+
expose :invite, if: -> (member) { member.invite? } do
expose :email do |member|
member.invite_email
@@ -56,6 +58,10 @@ class MemberEntity < Grape::Entity
expose :can_resend do |member|
member.can_resend_invite?
end
+
+ expose :user_state do |member|
+ member.respond_to?(:invited_user_state) ? member.invited_user_state : ""
+ end
end
end
diff --git a/app/serializers/merge_request_metrics_helper.rb b/app/serializers/merge_request_metrics_helper.rb
new file mode 100644
index 00000000000..fb1769d0aa6
--- /dev/null
+++ b/app/serializers/merge_request_metrics_helper.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module MergeRequestMetricsHelper
+ # There are cases where where metrics object doesn't exist and it needs to be rebuilt.
+ # TODO: Once https://gitlab.com/gitlab-org/gitlab/-/issues/342508 has been resolved and
+ # all merge requests have metrics we can remove this helper method.
+ def build_metrics(merge_request)
+ # There's no need to query and serialize metrics data for merge requests that are not
+ # merged or closed.
+ return unless merge_request.merged? || merge_request.closed?
+ return merge_request.metrics if merge_request.merged? && merge_request.metrics&.merged_by_id
+ return merge_request.metrics if merge_request.closed? && merge_request.metrics&.latest_closed_by_id
+
+ build_metrics_from_events(merge_request)
+ end
+
+ private
+
+ def build_metrics_from_events(merge_request)
+ closed_event = merge_request.closed_event
+ merge_event = merge_request.merge_event
+
+ MergeRequest::Metrics.new(latest_closed_at: closed_event&.updated_at,
+ latest_closed_by: closed_event&.author,
+ merged_at: merge_event&.updated_at,
+ merged_by: merge_event&.author)
+ end
+end
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 7fba52cbe17..8b0f3c8eb74 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class MergeRequestPollCachedWidgetEntity < IssuableEntity
+ include MergeRequestMetricsHelper
+
expose :auto_merge_enabled
expose :state
expose :merged_commit_sha
@@ -158,29 +160,6 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
@presenters ||= {}
@presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) # rubocop: disable CodeReuse/Presenter
end
-
- # Once SchedulePopulateMergeRequestMetricsWithEventsData fully runs,
- # we can remove this method and just serialize MergeRequest#metrics
- # instead. See https://gitlab.com/gitlab-org/gitlab-foss/issues/41587
- def build_metrics(merge_request)
- # There's no need to query and serialize metrics data for merge requests that are not
- # merged or closed.
- return unless merge_request.merged? || merge_request.closed?
- return merge_request.metrics if merge_request.merged? && merge_request.metrics&.merged_by_id
- return merge_request.metrics if merge_request.closed? && merge_request.metrics&.latest_closed_by_id
-
- build_metrics_from_events(merge_request)
- end
-
- def build_metrics_from_events(merge_request)
- closed_event = merge_request.closed_event
- merge_event = merge_request.merge_event
-
- MergeRequest::Metrics.new(latest_closed_at: closed_event&.updated_at,
- latest_closed_by: closed_event&.author,
- merged_at: merge_event&.updated_at,
- merged_by: merge_event&.author)
- end
end
MergeRequestPollCachedWidgetEntity.prepend_mod_with('MergeRequestPollCachedWidgetEntity')
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 1c033dee5ff..1e4289ce774 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -83,7 +83,10 @@ class MergeRequestWidgetEntity < Grape::Entity
end
expose :is_dismissed_suggest_pipeline do |_merge_request|
- current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE)
+ next true unless current_user
+ next true unless Gitlab::CurrentSettings.suggest_pipeline_enabled?
+
+ current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE)
end
expose :human_access do |merge_request|
diff --git a/app/services/base_project_service.rb b/app/services/base_project_service.rb
index fb466e61673..1bf4a235a79 100644
--- a/app/services/base_project_service.rb
+++ b/app/services/base_project_service.rb
@@ -2,6 +2,8 @@
# Base class, scoped by project
class BaseProjectService < ::BaseContainerService
+ include ::Gitlab::Utils::StrongMemoize
+
attr_accessor :project
def initialize(project:, current_user: nil, params: {})
@@ -11,4 +13,12 @@ class BaseProjectService < ::BaseContainerService
end
delegate :repository, to: :project
+
+ private
+
+ def project_group
+ strong_memoize(:project_group) do
+ project.group
+ end
+ end
end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 9a3e3bc3bdb..6021d634f86 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -22,7 +22,7 @@ module Boards
def order(items)
return items.order_closed_date_desc if list&.closed?
- items.order_by_position_and_priority(with_cte: params[:search].present?)
+ items.order_by_relative_position
end
def finder
diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb
deleted file mode 100644
index 4e13e967dbd..00000000000
--- a/app/services/bulk_import_service.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-# frozen_string_literal: true
-
-# Entry point of the BulkImport feature.
-# This service receives a Gitlab Instance connection params
-# and a list of groups to be imported.
-#
-# Process topography:
-#
-# sync | async
-# |
-# User +--> P1 +----> Pn +---+
-# | ^ | Enqueue new job
-# | +-----+
-#
-# P1 (sync)
-#
-# - Create a BulkImport record
-# - Create a BulkImport::Entity for each group to be imported
-# - Enqueue a BulkImportWorker job (P2) to import the given groups (entities)
-#
-# Pn (async)
-#
-# - For each group to be imported (BulkImport::Entity.with_status(:created))
-# - Import the group data
-# - Create entities for each subgroup of the imported group
-# - Enqueue a BulkImportService job (Pn) to import the new entities (subgroups)
-#
-class BulkImportService
- attr_reader :current_user, :params, :credentials
-
- def initialize(current_user, params, credentials)
- @current_user = current_user
- @params = params
- @credentials = credentials
- end
-
- def execute
- bulk_import = create_bulk_import
-
- BulkImportWorker.perform_async(bulk_import.id)
-
- ServiceResponse.success(payload: bulk_import)
- rescue ActiveRecord::RecordInvalid => e
- ServiceResponse.error(
- message: e.message,
- http_status: :unprocessable_entity
- )
- end
-
- private
-
- def create_bulk_import
- BulkImport.transaction do
- bulk_import = BulkImport.create!(user: current_user, source_type: 'gitlab')
- bulk_import.create_configuration!(credentials.slice(:url, :access_token))
-
- params.each do |entity|
- BulkImports::Entity.create!(
- bulk_import: bulk_import,
- source_type: entity[:source_type],
- source_full_path: entity[:source_full_path],
- destination_name: entity[:destination_name],
- destination_namespace: entity[:destination_namespace]
- )
- end
-
- bulk_import
- end
- end
-end
diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb
new file mode 100644
index 00000000000..c1becbb5609
--- /dev/null
+++ b/app/services/bulk_imports/create_service.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+# Entry point of the BulkImport feature.
+# This service receives a Gitlab Instance connection params
+# and a list of groups to be imported.
+#
+# Process topography:
+#
+# sync | async
+# |
+# User +--> P1 +----> Pn +---+
+# | ^ | Enqueue new job
+# | +-----+
+#
+# P1 (sync)
+#
+# - Create a BulkImport record
+# - Create a BulkImport::Entity for each group to be imported
+# - Enqueue a BulkImportWorker job (P2) to import the given groups (entities)
+#
+# Pn (async)
+#
+# - For each group to be imported (BulkImport::Entity.with_status(:created))
+# - Import the group data
+# - Create entities for each subgroup of the imported group
+# - Enqueue a BulkImports::CreateService job (Pn) to import the new entities (subgroups)
+#
+module BulkImports
+ class CreateService
+ attr_reader :current_user, :params, :credentials
+
+ def initialize(current_user, params, credentials)
+ @current_user = current_user
+ @params = params
+ @credentials = credentials
+ end
+
+ def execute
+ bulk_import = create_bulk_import
+
+ BulkImportWorker.perform_async(bulk_import.id)
+
+ ServiceResponse.success(payload: bulk_import)
+ rescue ActiveRecord::RecordInvalid => e
+ ServiceResponse.error(
+ message: e.message,
+ http_status: :unprocessable_entity
+ )
+ end
+
+ private
+
+ def create_bulk_import
+ BulkImport.transaction do
+ bulk_import = BulkImport.create!(
+ user: current_user,
+ source_type: 'gitlab',
+ source_version: client.instance_version
+ )
+ bulk_import.create_configuration!(credentials.slice(:url, :access_token))
+
+ params.each do |entity|
+ BulkImports::Entity.create!(
+ bulk_import: bulk_import,
+ source_type: entity[:source_type],
+ source_full_path: entity[:source_full_path],
+ destination_name: entity[:destination_name],
+ destination_namespace: entity[:destination_namespace]
+ )
+ end
+
+ bulk_import
+ end
+ end
+
+ def client
+ @client ||= BulkImports::Clients::HTTP.new(
+ url: @credentials[:url],
+ token: @credentials[:access_token]
+ )
+ end
+ end
+end
diff --git a/app/services/bulk_imports/file_export_service.rb b/app/services/bulk_imports/file_export_service.rb
new file mode 100644
index 00000000000..a7e0f998666
--- /dev/null
+++ b/app/services/bulk_imports/file_export_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class FileExportService
+ include Gitlab::ImportExport::CommandLineUtil
+
+ def initialize(portable, export_path, relation)
+ @portable = portable
+ @export_path = export_path
+ @relation = relation
+ end
+
+ def execute
+ export_service.execute
+
+ archive_exported_data
+ end
+
+ def exported_filename
+ "#{relation}.tar"
+ end
+
+ private
+
+ attr_reader :export_path, :portable, :relation
+
+ def export_service
+ case relation
+ when FileTransfer::ProjectConfig::UPLOADS_RELATION
+ UploadsExportService.new(portable, export_path)
+ else
+ raise BulkImports::Error, 'Unsupported relation export type'
+ end
+ end
+
+ def archive_exported_data
+ archive_file = File.join(export_path, exported_filename)
+
+ tar_cf(archive: archive_file, dir: export_path)
+ end
+ end
+end
diff --git a/app/services/bulk_imports/get_importable_data_service.rb b/app/services/bulk_imports/get_importable_data_service.rb
new file mode 100644
index 00000000000..07e0b3976a1
--- /dev/null
+++ b/app/services/bulk_imports/get_importable_data_service.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class GetImportableDataService
+ def initialize(params, query_params, credentials)
+ @params = params
+ @query_params = query_params
+ @credentials = credentials
+ end
+
+ def execute
+ {
+ version_validation: version_validation,
+ response: importables
+ }
+ end
+
+ private
+
+ def importables
+ client.get('groups', @query_params)
+ end
+
+ def version_validation
+ {
+ features: {
+ project_migration: {
+ available: client.compatible_for_project_migration?,
+ min_version: BulkImport.min_gl_version_for_project_migration.to_s
+ },
+ source_instance_version: client.instance_version.to_s
+ }
+ }
+ end
+
+ def client
+ @client ||= BulkImports::Clients::HTTP.new(
+ url: @credentials[:url],
+ token: @credentials[:access_token],
+ per_page: @params[:per_page],
+ page: @params[:page]
+ )
+ end
+ end
+end
diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb
index 055f9cafd10..4718b3914b2 100644
--- a/app/services/bulk_imports/relation_export_service.rb
+++ b/app/services/bulk_imports/relation_export_service.rb
@@ -9,20 +9,23 @@ module BulkImports
@portable = portable
@relation = relation
@jid = jid
+ @config = FileTransfer.config_for(portable)
end
def execute
find_or_create_export! do |export|
remove_existing_export_file!(export)
- serialize_relation_to_file(export.relation_definition)
+ export_service.execute
compress_exported_relation
upload_compressed_file(export)
end
+ ensure
+ FileUtils.remove_entry(config.export_path)
end
private
- attr_reader :user, :portable, :relation, :jid
+ attr_reader :user, :portable, :relation, :jid, :config
def find_or_create_export!
validate_user_permissions!
@@ -55,52 +58,28 @@ module BulkImports
upload.save!
end
- def serialize_relation_to_file(relation_definition)
- serializer.serialize_relation(relation_definition)
- end
-
- def compress_exported_relation
- gzip(dir: export_path, filename: ndjson_filename)
+ def export_service
+ @export_service ||= if config.tree_relation?(relation)
+ TreeExportService.new(portable, config.export_path, relation)
+ elsif config.file_relation?(relation)
+ FileExportService.new(portable, config.export_path, relation)
+ else
+ raise BulkImports::Error, 'Unsupported export relation'
+ end
end
def upload_compressed_file(export)
- compressed_filename = File.join(export_path, "#{ndjson_filename}.gz")
+ compressed_file = File.join(config.export_path, "#{export_service.exported_filename}.gz")
+
upload = ExportUpload.find_or_initialize_by(export_id: export.id) # rubocop: disable CodeReuse/ActiveRecord
- File.open(compressed_filename) { |file| upload.export_file = file }
+ File.open(compressed_file) { |file| upload.export_file = file }
upload.save!
end
- def config
- @config ||= FileTransfer.config_for(portable)
- end
-
- def export_path
- @export_path ||= config.export_path
- end
-
- def portable_tree
- @portable_tree ||= config.portable_tree
- end
-
- # rubocop: disable CodeReuse/Serializer
- def serializer
- @serializer ||= ::Gitlab::ImportExport::Json::StreamingSerializer.new(
- portable,
- portable_tree,
- json_writer,
- exportable_path: ''
- )
- end
- # rubocop: enable CodeReuse/Serializer
-
- def json_writer
- @json_writer ||= ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path)
- end
-
- def ndjson_filename
- @ndjson_filename ||= "#{relation}.ndjson"
+ def compress_exported_relation
+ gzip(dir: config.export_path, filename: export_service.exported_filename)
end
end
end
diff --git a/app/services/bulk_imports/tree_export_service.rb b/app/services/bulk_imports/tree_export_service.rb
new file mode 100644
index 00000000000..b8e7ac4574b
--- /dev/null
+++ b/app/services/bulk_imports/tree_export_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class TreeExportService
+ def initialize(portable, export_path, relation)
+ @portable = portable
+ @export_path = export_path
+ @relation = relation
+ @config = FileTransfer.config_for(portable)
+ end
+
+ def execute
+ relation_definition = config.tree_relation_definition_for(relation)
+
+ raise BulkImports::Error, 'Unsupported relation export type' unless relation_definition
+
+ serializer.serialize_relation(relation_definition)
+ end
+
+ def exported_filename
+ "#{relation}.ndjson"
+ end
+
+ private
+
+ attr_reader :export_path, :portable, :relation, :config
+
+ # rubocop: disable CodeReuse/Serializer
+ def serializer
+ ::Gitlab::ImportExport::Json::StreamingSerializer.new(
+ portable,
+ config.portable_tree,
+ json_writer,
+ exportable_path: ''
+ )
+ end
+ # rubocop: enable CodeReuse/Serializer
+
+ def json_writer
+ ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path)
+ end
+ end
+end
diff --git a/app/services/bulk_imports/uploads_export_service.rb b/app/services/bulk_imports/uploads_export_service.rb
new file mode 100644
index 00000000000..32cc48c152c
--- /dev/null
+++ b/app/services/bulk_imports/uploads_export_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class UploadsExportService
+ include Gitlab::ImportExport::CommandLineUtil
+
+ BATCH_SIZE = 100
+
+ def initialize(portable, export_path)
+ @portable = portable
+ @export_path = export_path
+ end
+
+ def execute
+ portable.uploads.find_each(batch_size: BATCH_SIZE) do |upload| # rubocop: disable CodeReuse/ActiveRecord
+ uploader = upload.retrieve_uploader
+
+ next unless upload.exist?
+ next unless uploader.file
+
+ subdir_path = export_subdir_path(upload)
+ mkdir_p(subdir_path)
+ download_or_copy_upload(uploader, File.join(subdir_path, uploader.filename))
+ rescue Errno::ENAMETOOLONG => e
+ # Do not fail entire export process if downloaded file has filename that exceeds 255 characters.
+ # Ignore raised exception, skip such upload, log the error and keep going with the export instead.
+ Gitlab::ErrorTracking.log_exception(e, portable_id: portable.id, portable_class: portable.class.name, upload_id: upload.id)
+ end
+ end
+
+ private
+
+ attr_reader :portable, :export_path
+
+ def export_subdir_path(upload)
+ subdir = if upload.path == avatar_path
+ 'avatar'
+ else
+ upload.try(:secret).to_s
+ end
+
+ File.join(export_path, subdir)
+ end
+
+ def avatar_path
+ @avatar_path ||= portable.avatar&.upload&.path
+ end
+ end
+end
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index 995b58c6882..17cac38ace2 100644
--- a/app/services/ci/archive_trace_service.rb
+++ b/app/services/ci/archive_trace_service.rb
@@ -3,10 +3,15 @@
module Ci
class ArchiveTraceService
def execute(job, worker_name:)
+ unless job.trace.archival_attempts_available?
+ Sidekiq.logger.warn(class: worker_name, message: 'The job is out of archival attempts.', job_id: job.id)
+
+ job.trace.attempt_archive_cleanup!
+ return
+ end
+
unless job.trace.can_attempt_archival_now?
- Sidekiq.logger.warn(class: worker_name,
- message: job.trace.archival_attempts_message,
- job_id: job.id)
+ Sidekiq.logger.warn(class: worker_name, message: 'The job can not be archived right now.', job_id: job.id)
return
end
diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb
index dd5c8e0379f..476c7523d60 100644
--- a/app/services/ci/destroy_pipeline_service.rb
+++ b/app/services/ci/destroy_pipeline_service.rb
@@ -9,6 +9,9 @@ module Ci
pipeline.cancel_running if pipeline.cancelable?
+ # Ci::Pipeline#destroy triggers `use_fast_destroy :job_artifacts` and
+ # ci_builds has ON DELETE CASCADE to ci_pipelines. The pipeline, the builds,
+ # job and pipeline artifacts all get destroyed here.
pipeline.reset.destroy!
ServiceResponse.success(message: 'Pipeline not found')
diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb
index 53536b6fdf9..703bb22fb5d 100644
--- a/app/services/ci/pipelines/add_job_service.rb
+++ b/app/services/ci/pipelines/add_job_service.rb
@@ -16,15 +16,7 @@ module Ci
def execute!(job, &block)
assign_pipeline_attributes(job)
- if Feature.enabled?(:ci_pipeline_add_job_with_lock, pipeline.project, default_enabled: :yaml)
- in_lock("ci:pipelines:#{pipeline.id}:add-job", ttl: LOCK_TIMEOUT, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRIES) do
- Ci::Pipeline.transaction do
- yield(job)
-
- job.update_older_statuses_retried!
- end
- end
- else
+ in_lock("ci:pipelines:#{pipeline.id}:add-job", ttl: LOCK_TIMEOUT, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRIES) do
Ci::Pipeline.transaction do
yield(job)
diff --git a/app/services/ci/pipelines/hook_service.rb b/app/services/ci/pipelines/hook_service.rb
new file mode 100644
index 00000000000..629ed7e1ebd
--- /dev/null
+++ b/app/services/ci/pipelines/hook_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Ci
+ module Pipelines
+ class HookService
+ include Gitlab::Utils::StrongMemoize
+
+ HOOK_NAME = :pipeline_hooks
+
+ def initialize(pipeline)
+ @pipeline = pipeline
+ end
+
+ def execute
+ project.execute_hooks(hook_data, HOOK_NAME) if project.has_active_hooks?(HOOK_NAME)
+ project.execute_integrations(hook_data, HOOK_NAME) if project.has_active_integrations?(HOOK_NAME)
+ end
+
+ private
+
+ attr_reader :pipeline
+
+ def project
+ @project ||= pipeline.project
+ end
+
+ def hook_data
+ strong_memoize(:hook_data) do
+ Gitlab::DataBuilder::Pipeline.build(pipeline)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index fb26d5d3356..664915c5e2f 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -11,8 +11,6 @@ module Ci
def execute
increment_processing_counter
- update_retried
-
Ci::PipelineProcessing::AtomicProcessingService
.new(pipeline)
.execute
@@ -24,41 +22,6 @@ module Ci
private
- # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab
- # This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb
- # and ensures that functionality will not be broken before migration is run
- # this updates only when there are data that needs to be updated, there are two groups with no retried flag
- # rubocop: disable CodeReuse/ActiveRecord
- def update_retried
- return if Feature.enabled?(:ci_remove_update_retried_from_process_pipeline, pipeline.project, default_enabled: :yaml)
-
- # find the latest builds for each name
- latest_statuses = pipeline.latest_statuses
- .group(:name)
- .having('count(*) > 1')
- .pluck(Arel.sql('MAX(id)'), 'name')
-
- # mark builds that are retried
- if latest_statuses.any?
- updated_count = pipeline.latest_statuses
- .where(name: latest_statuses.map(&:second))
- .where.not(id: latest_statuses.map(&:first))
- .update_all(retried: true)
-
- # This counter is temporary. It will be used to check whether if we still use this method or not
- # after setting correct value of `GenericCommitStatus#retried`.
- # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50465#note_491657115
- if updated_count > 0
- Gitlab::AppJsonLogger.info(event: 'update_retried_is_used',
- project_id: pipeline.project.id,
- pipeline_id: pipeline.id)
-
- metrics.legacy_update_jobs_counter.increment
- end
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def increment_processing_counter
metrics.pipeline_processing_events_counter.increment
end
diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb
index 3276c427923..3c886cb023f 100644
--- a/app/services/ci/queue/build_queue_service.rb
+++ b/app/services/ci/queue/build_queue_service.rb
@@ -90,7 +90,7 @@ module Ci
def runner_projects_relation
if ::Feature.enabled?(:ci_pending_builds_project_runners_decoupling, runner, default_enabled: :yaml)
- runner.runner_projects.select(:project_id)
+ runner.runner_projects.select('"ci_runner_projects"."project_id"::bigint')
else
runner.projects.without_deleted.with_builds_enabled
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index c46ddd22558..67ef4f10709 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -22,7 +22,8 @@ module Ci
end
def execute(params = {})
- db_all_caught_up = ::Gitlab::Database::LoadBalancing::Sticking.all_caught_up?(:runner, runner.id)
+ db_all_caught_up =
+ ::Ci::Runner.sticking.all_caught_up?(:runner, runner.id)
@metrics.increment_queue_operation(:queue_attempt)
@@ -103,42 +104,40 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def each_build(params, &blk)
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339429') do
- queue = ::Ci::Queue::BuildQueueService.new(runner)
-
- builds = begin
- if runner.instance_type?
- queue.builds_for_shared_runner
- elsif runner.group_type?
- queue.builds_for_group_runner
- else
- queue.builds_for_project_runner
- end
- end
+ queue = ::Ci::Queue::BuildQueueService.new(runner)
- if runner.ref_protected?
- builds = queue.builds_for_protected_runner(builds)
+ builds = begin
+ if runner.instance_type?
+ queue.builds_for_shared_runner
+ elsif runner.group_type?
+ queue.builds_for_group_runner
+ else
+ queue.builds_for_project_runner
end
+ end
- # pick builds that does not have other tags than runner's one
- builds = queue.builds_matching_tag_ids(builds, runner.tags.ids)
+ if runner.ref_protected?
+ builds = queue.builds_for_protected_runner(builds)
+ end
- # pick builds that have at least one tag
- unless runner.run_untagged?
- builds = queue.builds_with_any_tags(builds)
- end
+ # pick builds that does not have other tags than runner's one
+ builds = queue.builds_matching_tag_ids(builds, runner.tags.ids)
- # pick builds that older than specified age
- if params.key?(:job_age)
- builds = queue.builds_queued_before(builds, params[:job_age].seconds.ago)
- end
+ # pick builds that have at least one tag
+ unless runner.run_untagged?
+ builds = queue.builds_with_any_tags(builds)
+ end
- build_ids = retrieve_queue(-> { queue.execute(builds) })
+ # pick builds that older than specified age
+ if params.key?(:job_age)
+ builds = queue.builds_queued_before(builds, params[:job_age].seconds.ago)
+ end
- @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type)
+ build_ids = retrieve_queue(-> { queue.execute(builds) })
- build_ids.each { |build_id| yield Ci::Build.find(build_id) }
- end
+ @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type)
+
+ build_ids.each { |build_id| yield Ci::Build.find(build_id) }
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb
index 1d329fe7b53..dfd97498fc8 100644
--- a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb
+++ b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb
@@ -9,7 +9,7 @@ module Ci
free_resources = resource_group.resources.free.count
- resource_group.processables.waiting_for_resource.take(free_resources).each do |processable|
+ resource_group.upcoming_processables.take(free_resources).each do |processable|
processable.enqueue_waiting_for_resource
end
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 08520c9514c..07cfbb9ce3c 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -17,7 +17,7 @@ module Ci
def execute(build)
build.ensure_scheduling_type!
- reprocess!(build).tap do |new_build|
+ clone!(build).tap do |new_build|
check_assignable_runners!(new_build)
next if new_build.failed?
@@ -31,7 +31,12 @@ module Ci
end
# rubocop: disable CodeReuse/ActiveRecord
- def reprocess!(build)
+ def clone!(build)
+ # Cloning a build requires a strict type check to ensure
+ # the attributes being used for the clone are taken straight
+ # from the model and not overridden by other abstractions.
+ raise TypeError unless build.instance_of?(Ci::Build)
+
check_access!(build)
new_build = clone_build(build)
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index 02ee40d2cf6..9ad46ca7585 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -9,20 +9,15 @@ module Ci
raise Gitlab::Access::AccessDeniedError
end
- needs = Set.new
-
pipeline.ensure_scheduling_type!
builds_relation(pipeline).find_each do |build|
next unless can_be_retried?(build)
- Ci::RetryBuildService.new(project, current_user)
- .reprocess!(build)
-
- needs += build.needs.map(&:name)
+ Ci::RetryBuildService.new(project, current_user).clone!(build)
end
- pipeline.builds.latest.skipped.find_each do |skipped|
+ pipeline.processables.latest.skipped.find_each do |skipped|
retry_optimistic_lock(skipped, name: 'ci_retry_pipeline') { |build| build.process(current_user) }
end
diff --git a/app/services/ci/stuck_builds/drop_service.rb b/app/services/ci/stuck_builds/drop_pending_service.rb
index 3fee9a94381..4653e701973 100644
--- a/app/services/ci/stuck_builds/drop_service.rb
+++ b/app/services/ci/stuck_builds/drop_pending_service.rb
@@ -2,27 +2,21 @@
module Ci
module StuckBuilds
- class DropService
+ class DropPendingService
include DropHelpers
- BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour
BUILD_PENDING_OUTDATED_TIMEOUT = 1.day
- BUILD_SCHEDULED_OUTDATED_TIMEOUT = 1.hour
BUILD_PENDING_STUCK_TIMEOUT = 1.hour
BUILD_LOOKBACK = 5.days
def execute
- Gitlab::AppLogger.info "#{self.class}: Cleaning stuck builds"
-
- drop(running_timed_out_builds, failure_reason: :stuck_or_timeout_failure)
+ Gitlab::AppLogger.info "#{self.class}: Cleaning pending timed-out builds"
drop(
pending_builds(BUILD_PENDING_OUTDATED_TIMEOUT.ago),
failure_reason: :stuck_or_timeout_failure
)
- drop(scheduled_timed_out_builds, failure_reason: :stale_schedule)
-
drop_stuck(
pending_builds(BUILD_PENDING_STUCK_TIMEOUT.ago),
failure_reason: :stuck_or_timeout_failure
@@ -43,20 +37,6 @@ module Ci
end
end
# rubocop: enable CodeReuse/ActiveRecord
-
- def scheduled_timed_out_builds
- Ci::Build.where(status: :scheduled).where( # rubocop: disable CodeReuse/ActiveRecord
- 'ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?',
- BUILD_SCHEDULED_OUTDATED_TIMEOUT.ago
- )
- end
-
- def running_timed_out_builds
- Ci::Build.running.where( # rubocop: disable CodeReuse/ActiveRecord
- 'ci_builds.updated_at < ?',
- BUILD_RUNNING_OUTDATED_TIMEOUT.ago
- )
- end
end
end
end
diff --git a/app/services/ci/stuck_builds/drop_running_service.rb b/app/services/ci/stuck_builds/drop_running_service.rb
new file mode 100644
index 00000000000..a79224cc231
--- /dev/null
+++ b/app/services/ci/stuck_builds/drop_running_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ module StuckBuilds
+ class DropRunningService
+ include DropHelpers
+
+ BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour
+
+ def execute
+ Gitlab::AppLogger.info "#{self.class}: Cleaning running, timed-out builds"
+
+ drop(running_timed_out_builds, failure_reason: :stuck_or_timeout_failure)
+ end
+
+ private
+
+ def running_timed_out_builds
+ if Feature.enabled?(:ci_new_query_for_running_stuck_jobs, default_enabled: :yaml)
+ Ci::Build
+ .running
+ .created_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
+ .updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
+ .order(created_at: :asc, project_id: :asc) # rubocop:disable CodeReuse/ActiveRecord
+ else
+ Ci::Build.running.updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ci/stuck_builds/drop_scheduled_service.rb b/app/services/ci/stuck_builds/drop_scheduled_service.rb
new file mode 100644
index 00000000000..d4f4252c2c0
--- /dev/null
+++ b/app/services/ci/stuck_builds/drop_scheduled_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Ci
+ module StuckBuilds
+ class DropScheduledService
+ include DropHelpers
+
+ BUILD_SCHEDULED_OUTDATED_TIMEOUT = 1.hour
+
+ def execute
+ Gitlab::AppLogger.info "#{self.class}: Cleaning scheduled, timed-out builds"
+
+ drop(scheduled_timed_out_builds, failure_reason: :stale_schedule)
+ end
+
+ private
+
+ def scheduled_timed_out_builds
+ Ci::Build.scheduled.scheduled_at_before(BUILD_SCHEDULED_OUTDATED_TIMEOUT.ago)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb
index abd50d2f110..3b403f92486 100644
--- a/app/services/ci/update_build_state_service.rb
+++ b/app/services/ci/update_build_state_service.rb
@@ -73,9 +73,11 @@ module Ci
::Gitlab::Ci::Trace::Checksum.new(build).then do |checksum|
unless checksum.valid?
metrics.increment_trace_operation(operation: :invalid)
+ metrics.increment_error_counter(type: :chunks_invalid_checksum)
if checksum.corrupted?
metrics.increment_trace_operation(operation: :corrupted)
+ metrics.increment_error_counter(type: :chunks_invalid_size)
end
next unless log_invalid_chunks?
diff --git a/app/services/ci/update_pending_build_service.rb b/app/services/ci/update_pending_build_service.rb
index dcba06e60bf..d546dbcfe3d 100644
--- a/app/services/ci/update_pending_build_service.rb
+++ b/app/services/ci/update_pending_build_service.rb
@@ -2,7 +2,7 @@
module Ci
class UpdatePendingBuildService
- VALID_PARAMS = %i[instance_runners_enabled].freeze
+ VALID_PARAMS = %i[instance_runners_enabled namespace_id namespace_traversal_ids].freeze
InvalidParamsError = Class.new(StandardError)
InvalidModelError = Class.new(StandardError)
diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb
new file mode 100644
index 00000000000..ae2617f510b
--- /dev/null
+++ b/app/services/clusters/agent_tokens/create_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Clusters
+ module AgentTokens
+ class CreateService < ::BaseContainerService
+ ALLOWED_PARAMS = %i[agent_id description name].freeze
+
+ def execute
+ return error_no_permissions unless current_user.can?(:create_cluster, container)
+
+ token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user))
+
+ if token.save
+ ServiceResponse.success(payload: { secret: token.token, token: token })
+ else
+ ServiceResponse.error(message: token.errors.full_messages)
+ end
+ end
+
+ private
+
+ def error_no_permissions
+ ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project'))
+ end
+
+ def filtered_params
+ params.slice(*ALLOWED_PARAMS)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/agents/create_service.rb b/app/services/clusters/agents/create_service.rb
new file mode 100644
index 00000000000..568f168d63b
--- /dev/null
+++ b/app/services/clusters/agents/create_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class CreateService < BaseService
+ def execute(name:)
+ return error_no_permissions unless cluster_agent_permissions?
+
+ agent = ::Clusters::Agent.new(name: name, project: project, created_by_user: current_user)
+
+ if agent.save
+ success.merge(cluster_agent: agent)
+ else
+ error(agent.errors.full_messages)
+ end
+ end
+
+ private
+
+ def cluster_agent_permissions?
+ current_user.can?(:admin_pipeline, project) && current_user.can?(:create_cluster, project)
+ end
+
+ def error_no_permissions
+ error(s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project'))
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/agents/delete_service.rb b/app/services/clusters/agents/delete_service.rb
new file mode 100644
index 00000000000..2132dffa606
--- /dev/null
+++ b/app/services/clusters/agents/delete_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class DeleteService < ::BaseContainerService
+ def execute(cluster_agent)
+ return error_no_permissions unless current_user.can?(:admin_cluster, cluster_agent)
+
+ if cluster_agent.destroy
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: cluster_agent.errors.full_messages)
+ end
+ end
+
+ private
+
+ def error_no_permissions
+ ServiceResponse.error(message: s_('ClusterAgent|You have insufficient permissions to delete this cluster agent'))
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/agents/refresh_authorization_service.rb b/app/services/clusters/agents/refresh_authorization_service.rb
index a9e3340dbf5..7f401eef720 100644
--- a/app/services/clusters/agents/refresh_authorization_service.rb
+++ b/app/services/clusters/agents/refresh_authorization_service.rb
@@ -99,7 +99,7 @@ module Clusters
end
def group_root_ancestor?
- root_ancestor.group?
+ root_ancestor.group_namespace?
end
end
end
diff --git a/app/services/concerns/rate_limited_service.rb b/app/services/concerns/rate_limited_service.rb
new file mode 100644
index 00000000000..87cba7814fe
--- /dev/null
+++ b/app/services/concerns/rate_limited_service.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module RateLimitedService
+ extend ActiveSupport::Concern
+
+ RateLimitedNotSetupError = Class.new(StandardError)
+
+ class RateLimitedError < StandardError
+ def initialize(key:, rate_limiter:)
+ @key = key
+ @rate_limiter = rate_limiter
+ end
+
+ def headers
+ # TODO: This will be fleshed out in https://gitlab.com/gitlab-org/gitlab/-/issues/342370
+ {}
+ end
+
+ def log_request(request, current_user)
+ rate_limiter.class.log_request(request, "#{key}_request_limit".to_sym, current_user)
+ end
+
+ private
+
+ attr_reader :key, :rate_limiter
+ end
+
+ class RateLimiterScopedAndKeyed
+ attr_reader :key, :opts, :rate_limiter_klass
+
+ def initialize(key:, opts:, rate_limiter_klass:)
+ @key = key
+ @opts = opts
+ @rate_limiter_klass = rate_limiter_klass
+ end
+
+ def rate_limit!(service)
+ evaluated_scope = evaluated_scope_for(service)
+ return if feature_flag_disabled?(evaluated_scope[:project])
+
+ rate_limiter = new_rate_limiter(evaluated_scope)
+ if rate_limiter.throttled?
+ raise RateLimitedError.new(key: key, rate_limiter: rate_limiter), _('This endpoint has been requested too many times. Try again later.')
+ end
+ end
+
+ private
+
+ def users_allowlist
+ @users_allowlist ||= opts[:users_allowlist] ? opts[:users_allowlist].call : []
+ end
+
+ def evaluated_scope_for(service)
+ opts[:scope].each_with_object({}) do |var, all|
+ all[var] = service.public_send(var) # rubocop: disable GitlabSecurity/PublicSend
+ end
+ end
+
+ def feature_flag_disabled?(project)
+ Feature.disabled?("rate_limited_service_#{key}", project, default_enabled: :yaml)
+ end
+
+ def new_rate_limiter(evaluated_scope)
+ rate_limiter_klass.new(key, **opts.merge(scope: evaluated_scope.values, users_allowlist: users_allowlist))
+ end
+ end
+
+ prepended do
+ attr_accessor :rate_limiter_bypassed
+ cattr_accessor :rate_limiter_scoped_and_keyed
+
+ def self.rate_limit(key:, opts:, rate_limiter_klass: ::Gitlab::ApplicationRateLimiter)
+ self.rate_limiter_scoped_and_keyed = RateLimiterScopedAndKeyed.new(key: key,
+ opts: opts,
+ rate_limiter_klass: rate_limiter_klass)
+ end
+ end
+
+ def execute_without_rate_limiting(*args, **kwargs)
+ self.rate_limiter_bypassed = true
+ execute(*args, **kwargs)
+ ensure
+ self.rate_limiter_bypassed = false
+ end
+
+ def execute(*args, **kwargs)
+ raise RateLimitedNotSetupError if rate_limiter_scoped_and_keyed.nil?
+
+ rate_limiter_scoped_and_keyed.rate_limit!(self) unless rate_limiter_bypassed
+
+ super
+ end
+end
diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb
index cd988cdc5fe..0da5e552c48 100644
--- a/app/services/container_expiration_policies/cleanup_service.rb
+++ b/app/services/container_expiration_policies/cleanup_service.rb
@@ -4,7 +4,7 @@ module ContainerExpirationPolicies
class CleanupService
attr_reader :repository
- SERVICE_RESULT_FIELDS = %i[original_size before_truncate_size after_truncate_size before_delete_size deleted_size].freeze
+ SERVICE_RESULT_FIELDS = %i[original_size before_truncate_size after_truncate_size before_delete_size deleted_size cached_tags_count].freeze
def initialize(repository)
@repository = repository
@@ -24,8 +24,8 @@ module ContainerExpirationPolicies
begin
service_result = Projects::ContainerRepository::CleanupTagsService
- .new(project, nil, policy_params.merge('container_expiration_policy' => true))
- .execute(repository)
+ .new(repository, nil, policy_params.merge('container_expiration_policy' => true))
+ .execute
rescue StandardError
repository.cleanup_unfinished!
diff --git a/app/services/customer_relations/contacts/base_service.rb b/app/services/customer_relations/contacts/base_service.rb
new file mode 100644
index 00000000000..89f6f2c3f1f
--- /dev/null
+++ b/app/services/customer_relations/contacts/base_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CustomerRelations
+ module Contacts
+ class BaseService < ::BaseGroupService
+ private
+
+ def allowed?
+ current_user&.can?(:admin_contact, group)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: Array(message))
+ end
+ end
+ end
+end
diff --git a/app/services/customer_relations/contacts/create_service.rb b/app/services/customer_relations/contacts/create_service.rb
new file mode 100644
index 00000000000..7ff8b731e0d
--- /dev/null
+++ b/app/services/customer_relations/contacts/create_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module CustomerRelations
+ module Contacts
+ class CreateService < BaseService
+ def execute
+ return error_no_permissions unless allowed?
+ return error_organization_invalid unless organization_valid?
+
+ contact = Contact.create(params.merge(group_id: group.id))
+
+ return error_creating(contact) unless contact.persisted?
+
+ ServiceResponse.success(payload: contact)
+ end
+
+ private
+
+ def organization_valid?
+ return true unless params[:organization_id]
+
+ organization = Organization.find(params[:organization_id])
+ organization.group_id == group.id
+ rescue ActiveRecord::RecordNotFound
+ false
+ end
+
+ def error_organization_invalid
+ error('The specified organization was not found or does not belong to this group')
+ end
+
+ def error_no_permissions
+ error('You have insufficient permissions to create a contact for this group')
+ end
+
+ def error_creating(contact)
+ error(contact&.errors&.full_messages || 'Failed to create contact')
+ end
+ end
+ end
+end
diff --git a/app/services/customer_relations/contacts/update_service.rb b/app/services/customer_relations/contacts/update_service.rb
new file mode 100644
index 00000000000..473a80be262
--- /dev/null
+++ b/app/services/customer_relations/contacts/update_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module CustomerRelations
+ module Contacts
+ class UpdateService < BaseService
+ def execute(contact)
+ return error_no_permissions unless allowed?
+ return error_updating(contact) unless contact.update(params)
+
+ ServiceResponse.success(payload: contact)
+ end
+
+ private
+
+ def error_no_permissions
+ error('You have insufficient permissions to update a contact for this group')
+ end
+
+ def error_updating(contact)
+ error(contact&.errors&.full_messages || 'Failed to update contact')
+ end
+ end
+ end
+end
diff --git a/app/services/customer_relations/organizations/base_service.rb b/app/services/customer_relations/organizations/base_service.rb
index 63261534b37..8f8480d697c 100644
--- a/app/services/customer_relations/organizations/base_service.rb
+++ b/app/services/customer_relations/organizations/base_service.rb
@@ -10,7 +10,7 @@ module CustomerRelations
end
def error(message)
- ServiceResponse.error(message: message)
+ ServiceResponse.error(message: Array(message))
end
end
end
diff --git a/app/services/customer_relations/organizations/create_service.rb b/app/services/customer_relations/organizations/create_service.rb
index 9c223796eaf..aad1b7e2ca4 100644
--- a/app/services/customer_relations/organizations/create_service.rb
+++ b/app/services/customer_relations/organizations/create_service.rb
@@ -7,9 +7,7 @@ module CustomerRelations
def execute
return error_no_permissions unless allowed?
- params[:group_id] = group.id
-
- organization = Organization.create(params)
+ organization = Organization.create(params.merge(group_id: group.id))
return error_creating(organization) unless organization.persisted?
diff --git a/app/services/dependency_proxy/auth_token_service.rb b/app/services/dependency_proxy/auth_token_service.rb
index 16279ed12b0..c6c9eb534bb 100644
--- a/app/services/dependency_proxy/auth_token_service.rb
+++ b/app/services/dependency_proxy/auth_token_service.rb
@@ -12,10 +12,16 @@ module DependencyProxy
JSONWebToken::HMACToken.decode(token, ::Auth::DependencyProxyAuthenticationService.secret).first
end
- class << self
- def decoded_token_payload(token)
- self.new(token).execute
+ def self.user_or_deploy_token_from_jwt(raw_jwt)
+ token_payload = self.new(raw_jwt).execute
+
+ if token_payload['user_id']
+ User.find(token_payload['user_id'])
+ elsif token_payload['deploy_token']
+ DeployToken.active.find_by_token(token_payload['deploy_token'])
end
+ rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature
+ nil
end
end
end
diff --git a/app/services/dependency_proxy/find_or_create_blob_service.rb b/app/services/dependency_proxy/find_or_create_blob_service.rb
index f3dbf31dcdb..0a6db6e3d34 100644
--- a/app/services/dependency_proxy/find_or_create_blob_service.rb
+++ b/app/services/dependency_proxy/find_or_create_blob_service.rb
@@ -12,7 +12,7 @@ module DependencyProxy
def execute
from_cache = true
file_name = @blob_sha.sub('sha256:', '') + '.gz'
- blob = @group.dependency_proxy_blobs.find_or_build(file_name)
+ blob = @group.dependency_proxy_blobs.active.find_or_build(file_name)
unless blob.persisted?
from_cache = false
@@ -30,6 +30,8 @@ module DependencyProxy
blob.save!
end
+ # Technical debt: change to read_at https://gitlab.com/gitlab-org/gitlab/-/issues/341536
+ blob.touch if from_cache
success(blob: blob, from_cache: from_cache)
end
diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_or_create_manifest_service.rb
index 0eb990ab7f8..1976d4d47f4 100644
--- a/app/services/dependency_proxy/find_or_create_manifest_service.rb
+++ b/app/services/dependency_proxy/find_or_create_manifest_service.rb
@@ -13,11 +13,16 @@ module DependencyProxy
def execute
@manifest = @group.dependency_proxy_manifests
+ .active
.find_or_initialize_by_file_name_or_digest(file_name: @file_name, digest: @tag)
head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute
- return success(manifest: @manifest, from_cache: true) if cached_manifest_matches?(head_result)
+ if cached_manifest_matches?(head_result)
+ @manifest.touch
+
+ return success(manifest: @manifest, from_cache: true)
+ end
pull_new_manifest
respond(from_cache: false)
@@ -46,6 +51,9 @@ module DependencyProxy
def respond(from_cache: true)
if @manifest.persisted?
+ # Technical debt: change to read_at https://gitlab.com/gitlab-org/gitlab/-/issues/341536
+ @manifest.touch if from_cache
+
success(manifest: @manifest, from_cache: from_cache)
else
error('Failed to download the manifest from the external registry', 503)
diff --git a/app/services/dependency_proxy/group_settings/update_service.rb b/app/services/dependency_proxy/group_settings/update_service.rb
new file mode 100644
index 00000000000..ba43452def3
--- /dev/null
+++ b/app/services/dependency_proxy/group_settings/update_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ module GroupSettings
+ class UpdateService < BaseContainerService
+ ALLOWED_ATTRIBUTES = %i[enabled].freeze
+
+ def execute
+ return ServiceResponse.error(message: 'Access Denied', http_status: 403) unless allowed?
+ return ServiceResponse.error(message: 'Dependency proxy setting not found', http_status: 404) unless dependency_proxy_setting
+
+ if dependency_proxy_setting.update(dependency_proxy_setting_params)
+ ServiceResponse.success(payload: { dependency_proxy_setting: dependency_proxy_setting })
+ else
+ ServiceResponse.error(
+ message: dependency_proxy_setting.errors.full_messages.to_sentence || 'Bad request',
+ http_status: 400
+ )
+ end
+ end
+
+ private
+
+ def dependency_proxy_setting
+ container.dependency_proxy_setting
+ end
+
+ def allowed?
+ Ability.allowed?(current_user, :admin_dependency_proxy, container)
+ end
+
+ def dependency_proxy_setting_params
+ params.slice(*ALLOWED_ATTRIBUTES)
+ end
+ end
+ end
+end
diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb
index 100d1267848..504b55b99ac 100644
--- a/app/services/deployments/older_deployments_drop_service.rb
+++ b/app/services/deployments/older_deployments_drop_service.rb
@@ -11,23 +11,23 @@ module Deployments
def execute
return unless @deployment&.running?
- older_deployments.find_each do |older_deployment|
- Gitlab::OptimisticLocking.retry_lock(older_deployment.deployable, name: 'older_deployments_drop') do |deployable|
- deployable.drop(:forward_deployment_failure)
+ older_deployments_builds.each do |build|
+ Gitlab::OptimisticLocking.retry_lock(build, name: 'older_deployments_drop') do |build|
+ build.drop(:forward_deployment_failure)
end
rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, deployment_id: older_deployment.id)
+ Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, build_id: build.id)
end
end
private
- def older_deployments
+ def older_deployments_builds
@deployment
.environment
.active_deployments
.older_than(@deployment)
- .with_deployable
+ .builds
end
end
end
diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb
index 86c7791e759..1979816b88d 100644
--- a/app/services/error_tracking/list_issues_service.rb
+++ b/app/services/error_tracking/list_issues_service.rb
@@ -76,16 +76,21 @@ module ErrorTracking
filter_opts = {
status: opts[:issue_status],
sort: opts[:sort],
- limit: opts[:limit]
+ limit: opts[:limit],
+ cursor: opts[:cursor]
}
errors = ErrorTracking::ErrorsFinder.new(current_user, project, filter_opts).execute
+ pagination = {}
+ pagination[:next] = { cursor: errors.cursor_for_next_page } if errors.has_next_page?
+ pagination[:previous] = { cursor: errors.cursor_for_previous_page } if errors.has_previous_page?
+
# We use the same response format as project_error_tracking_setting
# method below for compatibility with existing code.
{
issues: errors.map(&:to_sentry_error),
- pagination: {}
+ pagination: pagination
}
else
project_error_tracking_setting.list_sentry_issues(**opts)
diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb
index 9ae9ab4de63..ca0b6b89199 100644
--- a/app/services/feature_flags/base_service.rb
+++ b/app/services/feature_flags/base_service.rb
@@ -7,6 +7,8 @@ module FeatureFlags
AUDITABLE_ATTRIBUTES = %w(name description active).freeze
def success(**args)
+ audit_event = args.fetch(:audit_event) { audit_event(args[:feature_flag]) }
+ save_audit_event(audit_event)
sync_to_jira(args[:feature_flag])
super
end
@@ -66,5 +68,11 @@ module FeatureFlags
feature_flag_by_name.scopes.find_by_environment_scope(params[:environment_scope])
end
end
+
+ private
+
+ def audit_message(feature_flag)
+ raise NotImplementedError, "This method should be overriden by subclasses"
+ end
end
end
diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb
index 65f8f8e33f6..ebbe71f39c7 100644
--- a/app/services/feature_flags/create_service.rb
+++ b/app/services/feature_flags/create_service.rb
@@ -10,8 +10,6 @@ module FeatureFlags
feature_flag = project.operations_feature_flags.new(params)
if feature_flag.save
- save_audit_event(audit_event(feature_flag))
-
success(feature_flag: feature_flag)
else
error(feature_flag.errors.full_messages, 400)
diff --git a/app/services/feature_flags/destroy_service.rb b/app/services/feature_flags/destroy_service.rb
index 986fe004db6..817a80940c0 100644
--- a/app/services/feature_flags/destroy_service.rb
+++ b/app/services/feature_flags/destroy_service.rb
@@ -13,8 +13,6 @@ module FeatureFlags
ApplicationRecord.transaction do
if feature_flag.destroy
- save_audit_event(audit_event(feature_flag))
-
success(feature_flag: feature_flag)
else
error(feature_flag.errors.full_messages)
diff --git a/app/services/feature_flags/hook_service.rb b/app/services/feature_flags/hook_service.rb
new file mode 100644
index 00000000000..6f77a70bd09
--- /dev/null
+++ b/app/services/feature_flags/hook_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class HookService
+ HOOK_NAME = :feature_flag_hooks
+
+ def initialize(feature_flag, current_user)
+ @feature_flag = feature_flag
+ @current_user = current_user
+ end
+
+ def execute
+ project.execute_hooks(hook_data, HOOK_NAME)
+ end
+
+ private
+
+ attr_reader :feature_flag, :current_user
+
+ def project
+ @project ||= feature_flag.project
+ end
+
+ def hook_data
+ Gitlab::DataBuilder::FeatureFlag.build(feature_flag, current_user)
+ end
+ end
+end
diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb
index ccfd1b57d44..bcfd2c15189 100644
--- a/app/services/feature_flags/update_service.rb
+++ b/app/services/feature_flags/update_service.rb
@@ -7,6 +7,11 @@ module FeatureFlags
'parameters' => 'parameters'
}.freeze
+ def success(**args)
+ execute_hooks_after_commit(args[:feature_flag])
+ super
+ end
+
def execute(feature_flag)
return error('Access Denied', 403) unless can_update?(feature_flag)
return error('Not Found', 404) unless valid_user_list_ids?(feature_flag, user_list_ids(params))
@@ -20,16 +25,11 @@ module FeatureFlags
end
end
+ # We generate the audit event before the feature flag is saved as #changed_strategies_messages depends on the strategies' states before save
audit_event = audit_event(feature_flag)
- if feature_flag.active_changed?
- feature_flag.execute_hooks(current_user)
- end
-
if feature_flag.save
- save_audit_event(audit_event)
-
- success(feature_flag: feature_flag)
+ success(feature_flag: feature_flag, audit_event: audit_event)
else
error(feature_flag.errors.full_messages, :bad_request)
end
@@ -38,6 +38,16 @@ module FeatureFlags
private
+ def execute_hooks_after_commit(feature_flag)
+ return unless feature_flag.active_previously_changed?
+
+ # The `current_user` method (defined in `BaseService`) is not available within the `run_after_commit` block
+ user = current_user
+ feature_flag.run_after_commit do
+ HookService.new(feature_flag, user).execute
+ end
+ end
+
def audit_message(feature_flag)
changes = changed_attributes_messages(feature_flag)
changes += changed_strategies_messages(feature_flag)
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index b7eae06b963..774f81b0a5e 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -29,6 +29,7 @@ module Groups
update_group_attributes
ensure_ownership
update_integrations
+ update_pending_builds!
end
post_update_hooks(@updated_project_ids)
@@ -139,6 +140,10 @@ module Groups
# these records again.
@updated_project_ids = projects_to_update.pluck(:id)
+ Namespaces::ProjectNamespace
+ .where(id: projects_to_update.select(:project_namespace_id))
+ .update_all(visibility_level: @new_parent_group.visibility_level)
+
projects_to_update
.update_all(visibility_level: @new_parent_group.visibility_level)
end
@@ -217,6 +222,15 @@ module Groups
PropagateIntegrationWorker.perform_async(integration.id)
end
end
+
+ def update_pending_builds!
+ update_params = {
+ namespace_traversal_ids: group.traversal_ids,
+ namespace_id: group.id
+ }
+
+ ::Ci::UpdatePendingBuildService.new(group, update_params).execute
+ end
end
end
diff --git a/app/services/import/validate_remote_git_endpoint_service.rb b/app/services/import/validate_remote_git_endpoint_service.rb
new file mode 100644
index 00000000000..afccb5373a9
--- /dev/null
+++ b/app/services/import/validate_remote_git_endpoint_service.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Import
+ class ValidateRemoteGitEndpointService
+ # Validates if the remote endpoint is a valid GIT repository
+ # Only smart protocol is supported
+ # Validation rules are taken from https://git-scm.com/docs/http-protocol#_smart_clients
+
+ GIT_SERVICE_NAME = "git-upload-pack"
+ GIT_EXPECTED_FIRST_PACKET_LINE = "# service=#{GIT_SERVICE_NAME}"
+ GIT_BODY_MESSAGE_REGEXP = /^[0-9a-f]{4}#{GIT_EXPECTED_FIRST_PACKET_LINE}/.freeze
+ # https://github.com/git/git/blob/master/Documentation/technical/protocol-common.txt#L56-L59
+ GIT_PROTOCOL_PKT_LEN = 4
+ GIT_MINIMUM_RESPONSE_LENGTH = GIT_PROTOCOL_PKT_LEN + GIT_EXPECTED_FIRST_PACKET_LINE.length
+ EXPECTED_CONTENT_TYPE = "application/x-#{GIT_SERVICE_NAME}-advertisement"
+
+ def initialize(params)
+ @params = params
+ end
+
+ def execute
+ uri = Gitlab::Utils.parse_url(@params[:url])
+
+ return ServiceResponse.error(message: "#{@params[:url]} is not a valid URL") unless uri
+
+ uri.fragment = nil
+ url = Gitlab::Utils.append_path(uri.to_s, "/info/refs?service=#{GIT_SERVICE_NAME}")
+
+ response_body = ''
+ result = nil
+ Gitlab::HTTP.try_get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |fragment|
+ response_body += fragment
+ next if response_body.length < GIT_MINIMUM_RESPONSE_LENGTH
+
+ result = if status_code_is_valid(fragment) && content_type_is_valid(fragment) && response_body_is_valid(response_body)
+ :success
+ else
+ :error
+ end
+
+ # We are interested only in the first chunks of the response
+ # So we're using stream_body: true and breaking when receive enough body
+ break
+ end
+
+ if result == :success
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: "#{uri} is not a valid HTTP Git repository")
+ end
+ end
+
+ private
+
+ def auth
+ unless @params[:user].to_s.blank?
+ {
+ username: @params[:user],
+ password: @params[:password]
+ }
+ end
+ end
+
+ def status_code_is_valid(fragment)
+ fragment.http_response.code == '200'
+ end
+
+ def content_type_is_valid(fragment)
+ fragment.http_response['content-type'] == EXPECTED_CONTENT_TYPE
+ end
+
+ def response_body_is_valid(response_body)
+ response_body.match?(GIT_BODY_MESSAGE_REGEXP)
+ end
+ end
+end
diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb
index 4a6b7540ded..4a2078a4e60 100644
--- a/app/services/issuable/import_csv/base_service.rb
+++ b/app/services/issuable/import_csv/base_service.rb
@@ -71,7 +71,14 @@ module Issuable
# NOTE: CSV imports are performed by workers, so we do not have a request context in order
# to create a SpamParams object to pass to the issuable create service.
spam_params = nil
- create_issuable_class.new(project: @project, current_user: @user, params: attributes, spam_params: spam_params).execute
+ create_service = create_issuable_class.new(project: @project, current_user: @user, params: attributes, spam_params: spam_params)
+
+ # For now, if create_issuable_class prepends RateLimitedService let's bypass rate limiting
+ if create_issuable_class < RateLimitedService
+ create_service.execute_without_rate_limiting
+ else
+ create_service.execute
+ end
end
def email_results_to_user
diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb
index cb42334fe32..c675f957cd7 100644
--- a/app/services/issues/clone_service.rb
+++ b/app/services/issues/clone_service.rb
@@ -8,13 +8,7 @@ module Issues
@target_project = target_project
@with_notes = with_notes
- unless issue.can_clone?(current_user, target_project)
- raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!')
- end
-
- if target_project.pending_delete?
- raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.')
- end
+ verify_can_clone_issue!(issue, target_project)
super(issue, target_project)
@@ -30,6 +24,20 @@ module Issues
attr_reader :target_project
attr_reader :with_notes
+ def verify_can_clone_issue!(issue, target_project)
+ unless issue.supports_move_and_clone?
+ raise CloneError, s_('CloneIssue|Cannot clone issues of \'%{issue_type}\' type.') % { issue_type: issue.issue_type }
+ end
+
+ unless issue.can_clone?(current_user, target_project)
+ raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!')
+ end
+
+ if target_project.pending_delete?
+ raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.')
+ end
+ end
+
def update_new_entity
# we don't call `super` because we want to be able to decide whether or not to copy all comments over.
update_new_entity_description
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index ea64239dd99..ac846c769a3 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -3,8 +3,8 @@
module Issues
class CloseService < Issues::BaseService
# Closes the supplied issue if the current user is able to do so.
- def execute(issue, commit: nil, notifications: true, system_note: true)
- return issue unless can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue)
+ def execute(issue, commit: nil, notifications: true, system_note: true, skip_authorization: false)
+ return issue unless can_close?(issue, skip_authorization: skip_authorization)
close_issue(issue,
closed_via: commit,
@@ -24,7 +24,7 @@ module Issues
return issue
end
- if project.issues_enabled? && issue.close(current_user)
+ if perform_close(issue)
event_service.close_issue(issue, current_user)
create_note(issue, closed_via) if system_note
@@ -51,6 +51,15 @@ module Issues
private
+ # Overridden on EE
+ def perform_close(issue)
+ issue.close(current_user)
+ end
+
+ def can_close?(issue, skip_authorization: false)
+ skip_authorization || can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue)
+ end
+
def perform_incident_management_actions(issue)
resolve_alert(issue)
end
@@ -82,11 +91,11 @@ module Issues
end
end
- def store_first_mentioned_in_commit_at(issue, merge_request)
+ def store_first_mentioned_in_commit_at(issue, merge_request, max_commit_lookup: 100)
metrics = issue.metrics
return if metrics.nil? || metrics.first_mentioned_in_commit_at
- first_commit_timestamp = merge_request.commits(limit: 1).first.try(:authored_date)
+ first_commit_timestamp = merge_request.commits(limit: max_commit_lookup).last.try(:authored_date)
return unless first_commit_timestamp
metrics.update!(first_mentioned_in_commit_at: first_commit_timestamp)
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index b15b3e49c9a..fcedd1c1c8d 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -3,6 +3,10 @@
module Issues
class CreateService < Issues::BaseService
include ResolveDiscussions
+ prepend RateLimitedService
+
+ rate_limit key: :issues_create,
+ opts: { scope: [:project, :current_user], users_allowlist: -> { [User.support_bot.username] } }
# NOTE: For Issues::CreateService, we require the spam_params and do not default it to nil, because
# spam_checking is likely to be necessary. However, if there is not a request available in scope
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index ff78221c941..4418b4eb2bf 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -7,13 +7,7 @@ module Issues
def execute(issue, target_project)
@target_project = target_project
- unless issue.can_move?(current_user, @target_project)
- raise MoveError, s_('MoveIssue|Cannot move issue due to insufficient permissions!')
- end
-
- if @project == @target_project
- raise MoveError, s_('MoveIssue|Cannot move issue to project it originates from!')
- end
+ verify_can_move_issue!(issue, target_project)
super
@@ -32,6 +26,20 @@ module Issues
attr_reader :target_project
+ def verify_can_move_issue!(issue, target_project)
+ unless issue.supports_move_and_clone?
+ raise MoveError, s_('MoveIssue|Cannot move issues of \'%{issue_type}\' type.') % { issue_type: issue.issue_type }
+ end
+
+ unless issue.can_move?(current_user, @target_project)
+ raise MoveError, s_('MoveIssue|Cannot move issue due to insufficient permissions!')
+ end
+
+ if @project == @target_project
+ raise MoveError, s_('MoveIssue|Cannot move issue to project it originates from!')
+ end
+ end
+
def update_service_desk_sent_notifications
return unless original_entity.from_service_desk?
diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb
index 7d199f99a24..23bb409f3cd 100644
--- a/app/services/issues/relative_position_rebalancing_service.rb
+++ b/app/services/issues/relative_position_rebalancing_service.rb
@@ -82,7 +82,7 @@ module Issues
collection.each do |project|
caching.cache_current_project_id(project.id)
index += 1
- scope = Issue.in_projects(project).reorder(custom_reorder).select(:id, :relative_position)
+ scope = Issue.in_projects(project).order_by_relative_position.with_non_null_relative_position.select(:id, :relative_position)
with_retry(PREFETCH_ISSUES_BATCH_SIZE, 100) do |batch_size|
Gitlab::Pagination::Keyset::Iterator.new(scope: scope).each_batch(of: batch_size) do |batch|
@@ -166,10 +166,6 @@ module Issues
@start_position ||= (RelativePositioning::START_POSITION - (gaps / 2) * gap_size).to_i
end
- def custom_reorder
- ::Gitlab::Pagination::Keyset::Order.build([Issue.column_order_relative_position, Issue.column_order_id_asc])
- end
-
def with_retry(initial_batch_size, exit_batch_size)
retries = 0
batch_size = initial_batch_size
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index 977b924ed72..4abd1dfbf4e 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -2,10 +2,10 @@
module Issues
class ReopenService < Issues::BaseService
- def execute(issue)
- return issue unless can?(current_user, :reopen_issue, issue)
+ def execute(issue, skip_authorization: false)
+ return issue unless can_reopen?(issue, skip_authorization: skip_authorization)
- if issue.reopen
+ if perform_reopen(issue)
event_service.reopen_issue(issue, current_user)
create_note(issue, 'reopened')
notification_service.async.reopen_issue(issue, current_user)
@@ -22,6 +22,15 @@ module Issues
private
+ # Overriden on EE
+ def perform_reopen(issue)
+ issue.reopen
+ end
+
+ def can_reopen?(issue, skip_authorization: false)
+ skip_authorization || can?(current_user, :reopen_issue, issue)
+ end
+
def perform_incident_management_actions(issue)
end
diff --git a/app/services/merge_requests/mergeability/check_base_service.rb b/app/services/merge_requests/mergeability/check_base_service.rb
new file mode 100644
index 00000000000..d5ddcb4b828
--- /dev/null
+++ b/app/services/merge_requests/mergeability/check_base_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+module MergeRequests
+ module Mergeability
+ class CheckBaseService
+ attr_reader :merge_request, :params
+
+ def initialize(merge_request:, params:)
+ @merge_request = merge_request
+ @params = params
+ end
+
+ def skip?
+ raise NotImplementedError
+ end
+
+ # When this method is true, we need to implement a cache_key
+ def cacheable?
+ raise NotImplementedError
+ end
+
+ def cache_key
+ raise NotImplementedError
+ end
+
+ private
+
+ def success(*args)
+ Gitlab::MergeRequests::Mergeability::CheckResult.success(*args)
+ end
+
+ def failure(*args)
+ Gitlab::MergeRequests::Mergeability::CheckResult.failed(*args)
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/mergeability/check_ci_status_service.rb b/app/services/merge_requests/mergeability/check_ci_status_service.rb
new file mode 100644
index 00000000000..c0ef5ba1c30
--- /dev/null
+++ b/app/services/merge_requests/mergeability/check_ci_status_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+module MergeRequests
+ module Mergeability
+ class CheckCiStatusService < CheckBaseService
+ def execute
+ if merge_request.mergeable_ci_state?
+ success
+ else
+ failure
+ end
+ end
+
+ def skip?
+ params[:skip_ci_check].present?
+ end
+
+ def cacheable?
+ false
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb
new file mode 100644
index 00000000000..c1d65fb65cc
--- /dev/null
+++ b/app/services/merge_requests/mergeability/run_checks_service.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+module MergeRequests
+ module Mergeability
+ class RunChecksService
+ include Gitlab::Utils::StrongMemoize
+
+ # We want to have the cheapest checks first in the list,
+ # that way we can fail fast before running the more expensive ones
+ CHECKS = [
+ CheckCiStatusService
+ ].freeze
+
+ def initialize(merge_request:, params:)
+ @merge_request = merge_request
+ @params = params
+ end
+
+ def execute
+ CHECKS.each_with_object([]) do |check_class, results|
+ check = check_class.new(merge_request: merge_request, params: params)
+
+ next if check.skip?
+
+ check_result = run_check(check)
+ results << check_result
+
+ break results if check_result.failed?
+ end
+ end
+
+ private
+
+ attr_reader :merge_request, :params
+
+ def run_check(check)
+ return check.execute unless Feature.enabled?(:mergeability_caching, merge_request.project, default_enabled: :yaml)
+ return check.execute unless check.cacheable?
+
+ cached_result = results.read(merge_check: check)
+ return cached_result if cached_result.respond_to?(:status)
+
+ check.execute.tap do |result|
+ results.write(merge_check: check, result_hash: result.to_hash)
+ end
+ end
+
+ def results
+ strong_memoize(:results) do
+ Gitlab::MergeRequests::Mergeability::ResultsStore.new(merge_request: merge_request)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index af041de5596..c5395138902 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -248,7 +248,7 @@ module MergeRequests
def merge_from_quick_action(merge_request)
last_diff_sha = params.delete(:merge)
- MergeRequests::MergeOrchestrationService
+ ::MergeRequests::MergeOrchestrationService
.new(project, current_user, { sha: last_diff_sha })
.execute(merge_request)
end
diff --git a/app/services/metrics/dashboard/annotations/create_service.rb b/app/services/metrics/dashboard/annotations/create_service.rb
index 54f4e96378c..b86fa82a5e8 100644
--- a/app/services/metrics/dashboard/annotations/create_service.rb
+++ b/app/services/metrics/dashboard/annotations/create_service.rb
@@ -30,7 +30,7 @@ module Metrics
options[:environment] = environment
success(options)
else
- error(s_('Metrics::Dashboard::Annotation|You are not authorized to create annotation for selected environment'))
+ error(s_('MetricsDashboardAnnotation|You are not authorized to create annotation for selected environment'))
end
end
@@ -39,7 +39,7 @@ module Metrics
options[:cluster] = cluster
success(options)
else
- error(s_('Metrics::Dashboard::Annotation|You are not authorized to create annotation for selected cluster'))
+ error(s_('MetricsDashboardAnnotation|You are not authorized to create annotation for selected cluster'))
end
end
@@ -51,7 +51,7 @@ module Metrics
success(options)
rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
- error(s_('Metrics::Dashboard::Annotation|Dashboard with requested path can not be found'))
+ error(s_('MetricsDashboardAnnotation|Dashboard with requested path can not be found'))
end
def create(options)
diff --git a/app/services/metrics/dashboard/annotations/delete_service.rb b/app/services/metrics/dashboard/annotations/delete_service.rb
index 3efe6924a9b..3cb22f8d3da 100644
--- a/app/services/metrics/dashboard/annotations/delete_service.rb
+++ b/app/services/metrics/dashboard/annotations/delete_service.rb
@@ -27,7 +27,7 @@ module Metrics
if Ability.allowed?(user, :delete_metrics_dashboard_annotation, annotation)
success
else
- error(s_('Metrics::Dashboard::Annotation|You are not authorized to delete this annotation'))
+ error(s_('MetricsDashboardAnnotation|You are not authorized to delete this annotation'))
end
end
@@ -35,7 +35,7 @@ module Metrics
if annotation.destroy
success
else
- error(s_('Metrics::Dashboard::Annotation|Annotation has not been deleted'))
+ error(s_('MetricsDashboardAnnotation|Annotation has not been deleted'))
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
index 9642df87861..0d028f120d3 100644
--- a/app/services/metrics/users_starred_dashboards/create_service.rb
+++ b/app/services/metrics/users_starred_dashboards/create_service.rb
@@ -35,7 +35,7 @@ module Metrics
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'))
+ error(s_('MetricsUsersStarredDashboards|You are not authorized to add star to this dashboard'))
end
end
@@ -44,7 +44,7 @@ module Metrics
options[:dashboard_path] = dashboard_path
success(options)
else
- error(s_('Metrics::UsersStarredDashboards|Dashboard with requested path can not be found'))
+ error(s_('MetricsUsersStarredDashboards|Dashboard with requested path can not be found'))
end
end
diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb
index 8215a3385a4..0f5429f667e 100644
--- a/app/services/packages/composer/create_package_service.rb
+++ b/app/services/packages/composer/create_package_service.rb
@@ -17,10 +17,6 @@ module Packages
})
end
- unless Feature.enabled?(:remove_composer_v1_cache_code, project)
- ::Packages::Composer::CacheUpdateWorker.perform_async(created_package.project_id, created_package.name, nil)
- end
-
created_package
end
diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb
index 953b386b754..a3d54bc6b58 100644
--- a/app/services/projects/after_rename_service.rb
+++ b/app/services/projects/after_rename_service.rb
@@ -12,6 +12,8 @@ module Projects
#
# Projects::AfterRenameService.new(project).execute
class AfterRenameService
+ include BaseServiceUtility
+
# @return [String] The Project being renamed.
attr_reader :project
@@ -78,7 +80,7 @@ module Projects
def execute_system_hooks
project.old_path_with_namespace = full_path_before
- SystemHooksService.new.execute_hooks_for(project, :rename)
+ system_hook_service.execute_hooks_for(project, :rename)
end
def update_repository_configuration
@@ -110,7 +112,7 @@ module Projects
end
def log_completion
- Gitlab::AppLogger.info(
+ log_info(
"Project #{project.id} has been renamed from " \
"#{full_path_before} to #{full_path_after}"
)
@@ -140,7 +142,7 @@ module Projects
def rename_failed!
error = "Repository #{full_path_before} could not be renamed to #{full_path_after}"
- Gitlab::AppLogger.error(error)
+ log_error(error)
raise RenameFailedError, error
end
diff --git a/app/services/projects/container_repository/cache_tags_created_at_service.rb b/app/services/projects/container_repository/cache_tags_created_at_service.rb
new file mode 100644
index 00000000000..3a5346d7a23
--- /dev/null
+++ b/app/services/projects/container_repository/cache_tags_created_at_service.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ class CacheTagsCreatedAtService
+ def initialize(container_repository)
+ @container_repository = container_repository
+ @cached_tag_names = Set.new
+ end
+
+ def populate(tags)
+ return if tags.empty?
+
+ # This will load all tags in one Redis roundtrip
+ # the maximum number of tags is configurable and is set to 200 by default.
+ # https://gitlab.com/gitlab-org/gitlab/blob/master/doc/user/packages/container_registry/index.md#set-cleanup-limits-to-conserve-resources
+ keys = tags.map(&method(:cache_key))
+ cached_tags_count = 0
+
+ ::Gitlab::Redis::Cache.with do |redis|
+ tags.zip(redis.mget(keys)).each do |tag, created_at|
+ next unless created_at
+
+ tag.created_at = DateTime.rfc3339(created_at)
+ @cached_tag_names << tag.name
+ cached_tags_count += 1
+ end
+ end
+
+ cached_tags_count
+ end
+
+ def insert(tags, max_ttl_in_seconds)
+ return unless max_ttl_in_seconds
+ return if tags.empty?
+
+ # tags with nil created_at are not cacheable
+ # tags already cached don't need to be cached again
+ cacheable_tags = tags.select do |tag|
+ tag.created_at.present? && !tag.name.in?(@cached_tag_names)
+ end
+
+ return if cacheable_tags.empty?
+
+ now = Time.zone.now
+
+ ::Gitlab::Redis::Cache.with do |redis|
+ # we use a pipeline instead of a MSET because each tag has
+ # a specific ttl
+ redis.pipelined do
+ cacheable_tags.each do |tag|
+ created_at = tag.created_at
+ # ttl is the max_ttl_in_seconds reduced by the number
+ # of seconds that the tag has already existed
+ ttl = max_ttl_in_seconds - (now - created_at).seconds
+ ttl = ttl.to_i
+ redis.set(cache_key(tag), created_at.rfc3339, ex: ttl) if ttl > 0
+ end
+ end
+ end
+ end
+
+ private
+
+ def cache_key(tag)
+ "container_repository:{#{@container_repository.id}}:tag:#{tag.name}:created_at"
+ end
+ end
+ end
+end
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index 793d2fec033..3a60de0f1ee 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -2,116 +2,152 @@
module Projects
module ContainerRepository
- class CleanupTagsService < BaseService
- def execute(container_repository)
+ class CleanupTagsService
+ include BaseServiceUtility
+ include ::Gitlab::Utils::StrongMemoize
+
+ def initialize(container_repository, user = nil, params = {})
+ @container_repository = container_repository
+ @current_user = user
+ @params = params.dup
+
+ @project = container_repository.project
+ @tags = container_repository.tags
+ tags_size = @tags.size
+ @counts = {
+ original_size: tags_size,
+ cached_tags_count: 0
+ }
+ end
+
+ def execute
return error('access denied') unless can_destroy?
return error('invalid regex') unless valid_regex?
- tags = container_repository.tags
- original_size = tags.size
+ filter_out_latest
+ filter_by_name
- tags = without_latest(tags)
- tags = filter_by_name(tags)
+ truncate
+ populate_from_cache
- before_truncate_size = tags.size
- tags = truncate(tags)
- after_truncate_size = tags.size
+ filter_keep_n
+ filter_by_older_than
- tags = filter_keep_n(tags)
- tags = filter_by_older_than(tags)
-
- delete_tags(container_repository, tags).tap do |result|
- result[:original_size] = original_size
- result[:before_truncate_size] = before_truncate_size
- result[:after_truncate_size] = after_truncate_size
- result[:before_delete_size] = tags.size
+ delete_tags.merge(@counts).tap do |result|
+ result[:before_delete_size] = @tags.size
result[:deleted_size] = result[:deleted]&.size
- result[:status] = :error if before_truncate_size != after_truncate_size
+ result[:status] = :error if @counts[:before_truncate_size] != @counts[:after_truncate_size]
end
end
private
- def delete_tags(container_repository, tags)
- return success(deleted: []) unless tags.any?
-
- tag_names = tags.map(&:name)
+ def delete_tags
+ return success(deleted: []) unless @tags.any?
service = Projects::ContainerRepository::DeleteTagsService.new(
- container_repository.project,
- current_user,
- tags: tag_names,
- container_expiration_policy: params['container_expiration_policy']
+ @project,
+ @current_user,
+ tags: @tags.map(&:name),
+ container_expiration_policy: container_expiration_policy
)
- service.execute(container_repository)
+ service.execute(@container_repository)
end
- def without_latest(tags)
- tags.reject(&:latest?)
+ def filter_out_latest
+ @tags.reject!(&:latest?)
end
- def order_by_date(tags)
+ def order_by_date
now = DateTime.current
- tags.sort_by { |tag| tag.created_at || now }.reverse
+ @tags.sort_by! { |tag| tag.created_at || now }
+ .reverse!
end
- def filter_by_name(tags)
- regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_delete'] || params['name_regex']}\\z")
- regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_keep']}\\z")
+ def filter_by_name
+ regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_delete || name_regex}\\z")
+ regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_keep}\\z")
- tags.select do |tag|
+ @tags.select! do |tag|
# regex_retain will override any overlapping matches by regex_delete
regex_delete.match?(tag.name) && !regex_retain.match?(tag.name)
end
end
- def filter_keep_n(tags)
- return tags unless params['keep_n']
+ def filter_keep_n
+ return unless keep_n
- tags = order_by_date(tags)
- tags.drop(keep_n)
+ order_by_date
+ cache_tags(@tags.first(keep_n_as_integer))
+ @tags = @tags.drop(keep_n_as_integer)
end
- def filter_by_older_than(tags)
- return tags unless params['older_than']
+ def filter_by_older_than
+ return unless older_than
- older_than = ChronicDuration.parse(params['older_than']).seconds.ago
+ older_than_timestamp = older_than_in_seconds.ago
- tags.select do |tag|
- tag.created_at && tag.created_at < older_than
+ @tags, tags_to_keep = @tags.partition do |tag|
+ tag.created_at && tag.created_at < older_than_timestamp
end
+
+ cache_tags(tags_to_keep)
end
def can_destroy?
- return true if params['container_expiration_policy']
+ return true if container_expiration_policy
- can?(current_user, :destroy_container_image, project)
+ can?(@current_user, :destroy_container_image, @project)
end
def valid_regex?
%w(name_regex_delete name_regex name_regex_keep).each do |param_name|
- regex = params[param_name]
+ regex = @params[param_name]
::Gitlab::UntrustedRegexp.new(regex) unless regex.blank?
end
true
rescue RegexpError => e
- ::Gitlab::ErrorTracking.log_exception(e, project_id: project.id)
+ ::Gitlab::ErrorTracking.log_exception(e, project_id: @project.id)
false
end
- def truncate(tags)
- return tags unless throttling_enabled?
- return tags if max_list_size == 0
+ def truncate
+ @counts[:before_truncate_size] = @tags.size
+ @counts[:after_truncate_size] = @tags.size
+
+ return unless throttling_enabled?
+ return if max_list_size == 0
# truncate the list to make sure that after the #filter_keep_n
# execution, the resulting list will be max_list_size
- truncated_size = max_list_size + keep_n
+ truncated_size = max_list_size + keep_n_as_integer
- return tags if tags.size <= truncated_size
+ return if @tags.size <= truncated_size
+
+ @tags = @tags.sample(truncated_size)
+ @counts[:after_truncate_size] = @tags.size
+ end
+
+ def populate_from_cache
+ @counts[:cached_tags_count] = cache.populate(@tags) if caching_enabled?
+ end
+
+ def cache_tags(tags)
+ cache.insert(tags, older_than_in_seconds) if caching_enabled?
+ end
+
+ def cache
+ strong_memoize(:cache) do
+ ::Projects::ContainerRepository::CacheTagsCreatedAtService.new(@container_repository)
+ end
+ end
- tags.sample(truncated_size)
+ def caching_enabled?
+ container_expiration_policy &&
+ older_than.present? &&
+ Feature.enabled?(:container_registry_expiration_policies_caching, @project)
end
def throttling_enabled?
@@ -123,7 +159,37 @@ module Projects
end
def keep_n
- params['keep_n'].to_i
+ @params['keep_n']
+ end
+
+ def keep_n_as_integer
+ keep_n.to_i
+ end
+
+ def older_than_in_seconds
+ strong_memoize(:older_than_in_seconds) do
+ ChronicDuration.parse(older_than).seconds
+ end
+ end
+
+ def older_than
+ @params['older_than']
+ end
+
+ def name_regex_delete
+ @params['name_regex_delete']
+ end
+
+ def name_regex
+ @params['name_regex']
+ end
+
+ def name_regex_keep
+ @params['name_regex_keep']
+ end
+
+ def container_expiration_policy
+ @params['container_expiration_policy']
end
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index e717491b19d..1536f0a22b8 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -8,6 +8,7 @@ module Projects
@current_user = user
@params = params.dup
@skip_wiki = @params.delete(:skip_wiki)
+ @initialize_with_sast = Gitlab::Utils.to_boolean(@params.delete(:initialize_with_sast))
@initialize_with_readme = Gitlab::Utils.to_boolean(@params.delete(:initialize_with_readme))
@import_data = @params.delete(:import_data)
@relations_block = @params.delete(:relations_block)
@@ -118,6 +119,7 @@ module Projects
Projects::PostCreationWorker.perform_async(@project.id)
create_readme if @initialize_with_readme
+ create_sast_commit if @initialize_with_sast
end
# Add an authorization for the current user authorizations inline
@@ -160,6 +162,10 @@ module Projects
Files::CreateService.new(@project, current_user, commit_attrs).execute
end
+ def create_sast_commit
+ ::Security::CiConfiguration::SastCreateService.new(@project, current_user, {}, commit_on_default: true).execute
+ end
+
def readme_content
@readme_template.presence || experiment(:new_project_readme_content, namespace: @project.namespace).run_with(@project)
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index afa8de04fca..27f813f4661 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -5,6 +5,7 @@ module Projects
include Gitlab::ShellAdapter
DestroyError = Class.new(StandardError)
+ BATCH_SIZE = 100
def async_execute
project.update_attribute(:pending_delete, true)
@@ -119,6 +120,12 @@ module Projects
destroy_web_hooks!
destroy_project_bots!
+ if ::Feature.enabled?(:ci_optimize_project_records_destruction, project, default_enabled: :yaml) &&
+ Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml)
+
+ destroy_ci_records!
+ end
+
# Rails attempts to load all related records into memory before
# destroying: https://github.com/rails/rails/issues/22510
# This ensures we delete records in batches.
@@ -133,6 +140,23 @@ module Projects
log_info("Attempting to destroy #{project.full_path} (#{project.id})")
end
+ def destroy_ci_records!
+ project.all_pipelines.find_each(batch_size: BATCH_SIZE) do |pipeline| # rubocop: disable CodeReuse/ActiveRecord
+ # Destroy artifacts, then builds, then pipelines
+ # All builds have already been dropped by Ci::AbortPipelinesService,
+ # so no Ci::Build-instantiating cancellations happen here.
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71342#note_691523196
+
+ ::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline)
+ end
+
+ deleted_count = project.commit_statuses.delete_all
+
+ if deleted_count > 0
+ Gitlab::AppLogger.info "Projects::DestroyService - Project #{project.id} - #{deleted_count} leftover commit statuses"
+ end
+ end
+
# The project can have multiple webhooks with hundreds of thousands of web_hook_logs.
# By default, they are removed with "DELETE CASCADE" option defined via foreign_key.
# But such queries can exceed the statement_timeout limit and fail to delete the project.
diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb
index 475ab17f1a1..a836b96cac3 100644
--- a/app/services/projects/group_links/update_service.rb
+++ b/app/services/projects/group_links/update_service.rb
@@ -20,19 +20,15 @@ module Projects
attr_reader :group_link
def refresh_authorizations
- if Feature.enabled?(:specialized_worker_for_project_share_update_auth_recalculation)
- AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
-
- # Until we compare the inconsistency rates of the new specialized worker and
- # the old approach, we still run AuthorizedProjectsWorker
- # but with some delay and lower urgency as a safety net.
- group_link.group.refresh_members_authorized_projects(
- blocking: false,
- priority: UserProjectAccessChangedService::LOW_PRIORITY
- )
- else
- group_link.group.refresh_members_authorized_projects
- end
+ AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
+
+ # Until we compare the inconsistency rates of the new specialized worker and
+ # the old approach, we still run AuthorizedProjectsWorker
+ # but with some delay and lower urgency as a safety net.
+ group_link.group.refresh_members_authorized_projects(
+ blocking: false,
+ priority: UserProjectAccessChangedService::LOW_PRIORITY
+ )
end
def requires_authorization_refresh?(params)
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index b5288aad6f0..4979af6dfe1 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -16,6 +16,8 @@ module Projects
end
def execute
+ track_start_import
+
add_repository_to_project
download_lfs_objects
@@ -25,16 +27,17 @@ module Projects
after_execute_hook
success
- rescue Gitlab::UrlBlocker::BlockedUrlError => e
- Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type)
+ rescue Gitlab::UrlBlocker::BlockedUrlError, StandardError => e
+ Gitlab::Import::ImportFailureService.track(
+ project_id: project.id,
+ error_source: self.class.name,
+ exception: e,
+ metrics: true
+ )
- error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: e.message })
- rescue StandardError => e
message = Projects::ImportErrorFilter.filter_message(e.message)
-
- Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type)
-
- error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message })
+ 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
@@ -54,6 +57,10 @@ module Projects
# Defined in EE::Projects::ImportService
end
+ def track_start_import
+ has_importer? && importer_class.try(:track_start_import, project)
+ end
+
def add_repository_to_project
if project.external_import? && !unknown_url?
begin
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
index f35370c427f..2612001eb95 100644
--- a/app/services/projects/overwrite_project_service.rb
+++ b/app/services/projects/overwrite_project_service.rb
@@ -3,7 +3,7 @@
module Projects
class OverwriteProjectService < BaseService
def execute(source_project)
- return unless source_project && source_project.namespace == @project.namespace
+ return unless source_project && source_project.namespace_id == @project.namespace_id
start_time = ::Gitlab::Metrics::System.monotonic_time
@@ -40,7 +40,7 @@ module Projects
duration = ::Gitlab::Metrics::System.monotonic_time - start_time
Gitlab::AppJsonLogger.info(class: self.class.name,
- namespace_id: source_project.namespace.id,
+ namespace_id: source_project.namespace_id,
project_id: source_project.id,
duration_s: duration.to_f,
error: exception.class.name)
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index 228115d72b8..1616a8a4062 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -36,14 +36,17 @@ module Projects
private
def project_members_through_invited_groups
- groups_with_ancestors_ids = Gitlab::ObjectHierarchy
- .new(visible_groups)
- .base_and_ancestors
- .pluck_primary_key
+ groups_with_ancestors = if ::Feature.enabled?(:linear_participants_service_ancestor_scopes, current_user, default_enabled: :yaml)
+ visible_groups.self_and_ancestors
+ else
+ Gitlab::ObjectHierarchy
+ .new(visible_groups)
+ .base_and_ancestors
+ end
GroupMember
.active_without_invites_and_requests
- .with_source_id(groups_with_ancestors_ids)
+ .with_source_id(groups_with_ancestors.pluck_primary_key)
end
def visible_groups
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 27376173f07..a69e6488ebc 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -81,7 +81,7 @@ module Projects
# Apply changes to the project
update_namespace_and_visibility(@new_namespace)
- update_shared_runners_settings
+ project.reconcile_shared_runners_setting!
project.save!
# Notifications
@@ -104,6 +104,8 @@ module Projects
update_repository_configuration(@new_path)
execute_system_hooks
+
+ update_pending_builds!
end
post_update_hooks(project)
@@ -154,19 +156,15 @@ module Projects
user_ids = @old_namespace.user_ids_for_project_authorizations |
@new_namespace.user_ids_for_project_authorizations
- if Feature.enabled?(:specialized_worker_for_project_transfer_auth_recalculation)
- AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
-
- # Until we compare the inconsistency rates of the new specialized worker and
- # the old approach, we still run AuthorizedProjectsWorker
- # but with some delay and lower urgency as a safety net.
- UserProjectAccessChangedService.new(user_ids).execute(
- blocking: false,
- priority: UserProjectAccessChangedService::LOW_PRIORITY
- )
- else
- UserProjectAccessChangedService.new(user_ids).execute
- end
+ AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
+
+ # Until we compare the inconsistency rates of the new specialized worker and
+ # the old approach, we still run AuthorizedProjectsWorker
+ # but with some delay and lower urgency as a safety net.
+ UserProjectAccessChangedService.new(user_ids).execute(
+ blocking: false,
+ priority: UserProjectAccessChangedService::LOW_PRIORITY
+ )
end
def rollback_side_effects
@@ -189,7 +187,7 @@ module Projects
end
def execute_system_hooks
- SystemHooksService.new.execute_hooks_for(project, :transfer)
+ system_hook_service.execute_hooks_for(project, :transfer)
end
def move_project_folders(project)
@@ -241,18 +239,19 @@ module Projects
"#{new_path}#{::Gitlab::GlRepository::DESIGN.path_suffix}"
end
- def update_shared_runners_settings
- # If a project is being transferred to another group it means it can already
- # have shared runners enabled but we need to check whether the new group allows that.
- if project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable'
- project.shared_runners_enabled = false
- end
- end
-
def update_integrations
project.integrations.with_default_settings.delete_all
Integration.create_from_active_default_integrations(project, :project_id)
end
+
+ def update_pending_builds!
+ update_params = {
+ namespace_id: new_namespace.id,
+ namespace_traversal_ids: new_namespace.traversal_ids
+ }
+
+ ::Ci::UpdatePendingBuildService.new(project, update_params).execute
+ end
end
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index dc75fe1014a..0000e713cb4 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -136,13 +136,11 @@ module Projects
def validate_outdated_sha!
return if latest?
- if Feature.enabled?(:pages_smart_check_outdated_sha, project, default_enabled: :yaml)
- # use pipeline_id in case the build is retried
- last_deployed_pipeline_id = project.pages_metadatum&.pages_deployment&.ci_build&.pipeline_id
+ # use pipeline_id in case the build is retried
+ last_deployed_pipeline_id = project.pages_metadatum&.pages_deployment&.ci_build&.pipeline_id
- return unless last_deployed_pipeline_id
- return if last_deployed_pipeline_id <= build.pipeline_id
- end
+ return unless last_deployed_pipeline_id
+ return if last_deployed_pipeline_id <= build.pipeline_id
raise InvalidStateError, 'build SHA is outdated for this ref'
end
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index b87564fcaef..a32e80af4b1 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -105,7 +105,7 @@ module Projects
end
update_pages_config if changing_pages_related_config?
- update_pending_builds if shared_runners_toggled?
+ update_pending_builds if runners_settings_toggled?
end
def after_rename_service(project)
@@ -181,13 +181,36 @@ module Projects
end
def update_pending_builds
- update_params = { instance_runners_enabled: project.shared_runners_enabled }
+ update_params = {
+ instance_runners_enabled: project.shared_runners_enabled?,
+ namespace_traversal_ids: group_runner_traversal_ids
+ }
- ::Ci::UpdatePendingBuildService.new(project, update_params).execute
+ ::Ci::UpdatePendingBuildService
+ .new(project, update_params)
+ .execute
end
- def shared_runners_toggled?
- project.previous_changes.include?('shared_runners_enabled')
+ def shared_runners_settings_toggled?
+ project.previous_changes.include?(:shared_runners_enabled)
+ end
+
+ def group_runners_settings_toggled?
+ return false unless project.ci_cd_settings.present?
+
+ project.ci_cd_settings.previous_changes.include?(:group_runners_enabled)
+ end
+
+ def runners_settings_toggled?
+ shared_runners_settings_toggled? || group_runners_settings_toggled?
+ end
+
+ def group_runner_traversal_ids
+ if project.group_runners_enabled?
+ project.namespace.traversal_ids
+ else
+ []
+ end
end
end
end
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index 33faf2d6698..cee59360b4b 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -24,7 +24,7 @@ module Search
# rubocop: disable CodeReuse/ActiveRecord
def projects
- @projects ||= ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute.preload(:topics, :taggings)
+ @projects ||= ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute.preload(:topics, :project_topics)
end
def allowed_scopes
diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb
index adb45244adb..ea77cd98ba3 100644
--- a/app/services/security/ci_configuration/base_create_service.rb
+++ b/app/services/security/ci_configuration/base_create_service.rb
@@ -25,7 +25,7 @@ module Security
rescue Gitlab::Git::PreReceiveError => e
ServiceResponse.error(message: e.message)
rescue StandardError
- project.repository.rm_branch(current_user, branch_name) if project.repository.branch_exists?(branch_name)
+ remove_branch_on_exception
raise
end
@@ -50,6 +50,10 @@ module Security
Gitlab::Routing.url_helpers.project_new_merge_request_url(project, merge_request: merge_request_params)
end
+ def remove_branch_on_exception
+ project.repository.rm_branch(current_user, branch_name) if project.repository.branch_exists?(branch_name)
+ end
+
def track_event(attributes_for_commit)
action = attributes_for_commit[:actions].first
diff --git a/app/services/security/ci_configuration/sast_create_service.rb b/app/services/security/ci_configuration/sast_create_service.rb
index f495cac18f8..47e01847b17 100644
--- a/app/services/security/ci_configuration/sast_create_service.rb
+++ b/app/services/security/ci_configuration/sast_create_service.rb
@@ -5,15 +5,28 @@ module Security
class SastCreateService < ::Security::CiConfiguration::BaseCreateService
attr_reader :params
- def initialize(project, current_user, params)
+ def initialize(project, current_user, params, commit_on_default: false)
super(project, current_user)
@params = params
+
+ @commit_on_default = commit_on_default
+ @branch_name = project.default_branch if @commit_on_default
end
private
+ def remove_branch_on_exception
+ super unless @commit_on_default
+ end
+
def action
- Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_gitlab_ci_content).generate
+ existing_content = begin
+ existing_gitlab_ci_content # this can fail on the very first commit
+ rescue StandardError
+ nil
+ end
+
+ Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_content).generate
end
def next_branch
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index 3417ce4f583..63e01603d47 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -78,7 +78,7 @@ module ServicePing
def store_metrics(response)
metrics = response['conv_index'] || response['dev_ops_score'] # leaving dev_ops_score here, as the response data comes from the gitlab-version-com
- return unless metrics.present?
+ return unless metrics.except('usage_data_id').present?
DevOpsReport::Metric.create!(
metrics.slice(*METRICS)
diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb
index e9a13cee764..f13477b8b34 100644
--- a/app/services/terraform/remote_state_handler.rb
+++ b/app/services/terraform/remote_state_handler.rb
@@ -2,8 +2,6 @@
module Terraform
class RemoteStateHandler < BaseService
- include Gitlab::OptimisticLocking
-
StateLockedError = Class.new(StandardError)
UnauthorizedError = Class.new(StandardError)
@@ -60,7 +58,7 @@ module Terraform
private
def retrieve_with_lock(find_only: false)
- create_or_find!(find_only: find_only).tap { |state| retry_optimistic_lock(state, name: 'terraform_remote_state_handler_retrieve') { |state| yield state } }
+ create_or_find!(find_only: find_only).tap { |state| state.with_lock { yield state } }
end
def create_or_find!(find_only:)
diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb
index 5f48f410bf7..5bba986f4ad 100644
--- a/app/services/user_project_access_changed_service.rb
+++ b/app/services/user_project_access_changed_service.rb
@@ -30,7 +30,7 @@ class UserProjectAccessChangedService
end
end
- ::Gitlab::Database::LoadBalancing::Sticking.bulk_stick(:user, @user_ids)
+ ::User.sticking.bulk_stick(:user, @user_ids)
result
end
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index 23c67231a29..c3df9b153a1 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -5,15 +5,18 @@ module Users
include NewUserNotifier
attr_reader :user, :identity_params
+ ATTRS_REQUIRING_PASSWORD_CHECK = %w[email].freeze
+
def initialize(current_user, params = {})
@current_user = current_user
+ @validation_password = params.delete(:validation_password)
@user = params.delete(:user)
@status_params = params.delete(:status)
@identity_params = params.slice(*identity_attributes)
@params = params.dup
end
- def execute(validate: true, &block)
+ def execute(validate: true, check_password: false, &block)
yield(@user) if block_given?
user_exists = @user.persisted?
@@ -21,6 +24,11 @@ module Users
discard_read_only_attributes
assign_attributes
+
+ if check_password && require_password_check? && !@user.valid_password?(@validation_password)
+ return error(s_("Profiles|Invalid password"))
+ end
+
assign_identity
build_canonical_email
@@ -32,8 +40,8 @@ module Users
end
end
- def execute!(*args, &block)
- result = execute(*args, &block)
+ def execute!(*args, **kargs, &block)
+ result = execute(*args, **kargs, &block)
raise ActiveRecord::RecordInvalid, @user unless result[:status] == :success
@@ -42,6 +50,14 @@ module Users
private
+ def require_password_check?
+ return false unless @user.persisted?
+ return false if @user.password_automatically_set?
+
+ changes = @user.changed
+ ATTRS_REQUIRING_PASSWORD_CHECK.any? { |param| changes.include?(param) }
+ end
+
def build_canonical_email
return unless @user.email_changed?
diff --git a/app/services/users/upsert_credit_card_validation_service.rb b/app/services/users/upsert_credit_card_validation_service.rb
index 70a96b3ec6b..86b5b923418 100644
--- a/app/services/users/upsert_credit_card_validation_service.rb
+++ b/app/services/users/upsert_credit_card_validation_service.rb
@@ -7,6 +7,14 @@ module Users
end
def execute
+ @params = {
+ user_id: params.fetch(:user_id),
+ credit_card_validated_at: params.fetch(:credit_card_validated_at),
+ expiration_date: get_expiration_date(params),
+ last_digits: Integer(params.fetch(:credit_card_mask_number), 10),
+ holder_name: params.fetch(:credit_card_holder_name)
+ }
+
::Users::CreditCardValidation.upsert(@params)
ServiceResponse.success(message: 'CreditCardValidation was set')
@@ -16,5 +24,14 @@ module Users
Gitlab::ErrorTracking.track_exception(e, params: @params, class: self.class.to_s)
ServiceResponse.error(message: "Could not set CreditCardValidation: #{e.message}")
end
+
+ private
+
+ def get_expiration_date(params)
+ year = params.fetch(:credit_card_expiration_year)
+ month = params.fetch(:credit_card_expiration_month)
+
+ Date.new(year, month, -1) # last day of the month
+ end
end
end
diff --git a/app/uploaders/dependency_proxy/file_uploader.rb b/app/uploaders/dependency_proxy/file_uploader.rb
index 5154f180454..f0222d4cf06 100644
--- a/app/uploaders/dependency_proxy/file_uploader.rb
+++ b/app/uploaders/dependency_proxy/file_uploader.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class DependencyProxy::FileUploader < GitlabUploader
+ extend Workhorse::UploadPath
include ObjectStorage::Concern
before :cache, :set_content_type
diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml
index fab3ce584f0..96fb848b568 100644
--- a/app/views/admin/application_settings/_abuse.html.haml
+++ b/app/views/admin/application_settings/_abuse.html.haml
@@ -5,7 +5,5 @@
.form-group
= f.label :abuse_notification_email, _('Abuse reports notification email'), class: 'label-bold'
= f.text_field :abuse_notification_email, class: 'form-control gl-form-input'
- .form-text.text-muted
- = _('Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index eb30efabb98..19c38d7be62 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -23,11 +23,11 @@
.form-group
= f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light'
= f.number_field :max_import_size, class: 'form-control gl-form-input qa-receive-max-import-size-field', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' }
- %span.form-text.text-muted= _('0 for unlimited, only effective with remote storage enabled.')
+ %span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.')
.form-group
= f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light'
= f.number_field :session_expire_delay, class: 'form-control gl-form-input', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' }
- %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes.')
+ %span.form-text.text-muted#session_expire_delay_help_block= _('Restart GitLab to apply changes.')
= render_if_exists 'admin/application_settings/git_two_factor_session_expiry', form: f
= render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f
@@ -45,13 +45,13 @@
.form-check
= f.check_box :user_default_external, class: 'form-check-input'
= f.label :user_default_external, class: 'form-check-label' do
- = _('Newly registered users will by default be external')
+ = _('Newly-registered users are external by default')
.gl-mt-3
= _('Internal users')
= f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-form-input gl-mt-2'
.help-block
- = _('Specify an e-mail address regex pattern to identify default internal users.')
- = link_to _('More information'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'),
+ = _('Specify an email address regex pattern to identify default internal users.')
+ = link_to _('Learn more'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'),
target: '_blank'
- unless Gitlab.com?
.form-group
@@ -59,11 +59,13 @@
.form-check
= f.check_box :deactivate_dormant_users, class: 'form-check-input'
= f.label :deactivate_dormant_users, class: 'form-check-label' do
- = _('Deactivate dormant users after 90 days of inactivity. Users can return to active status by signing in to their account. While inactive, a user is not counted as an active user in the instance.')
- = link_to _('More information'), help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users'), target: '_blank'
+ = _('Deactivate dormant users after 90 days of inactivity')
+ .help-block
+ = _('Users can reactivate their account by signing in.')
+ = link_to _('Learn more'), help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users'), target: '_blank'
.form-group
= f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light'
- = f.text_field :personal_access_token_prefix, placeholder: _('Max 20 characters'), class: 'form-control gl-form-input'
+ = f.text_field :personal_access_token_prefix, placeholder: _('Maximum 20 characters'), class: 'form-control gl-form-input'
.form-group
= f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold'
.form-check
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index fea116bd419..8026ec4702b 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -69,5 +69,12 @@
%p.form-text.text-muted
= _("The default CI/CD configuration file and path for new projects.").html_safe
= link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank'
+ .form-group
+ .form-check
+ = f.check_box :suggest_pipeline_enabled, class: 'form-check-input'
+ = f.label :suggest_pipeline_enabled, class: 'form-check-label' do
+ = s_('AdminSettings|Enable pipeline suggestion banner')
+ .form-text.text-muted
+ = s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_files_limits.html.haml b/app/views/admin/application_settings/_files_limits.html.haml
deleted file mode 100644
index 9cd12fa1caa..00000000000
--- a/app/views/admin/application_settings/_files_limits.html.haml
+++ /dev/null
@@ -1,34 +0,0 @@
-= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-files-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
-
- %fieldset
- %legend.h5.gl-border-none
- = _('Unauthenticated API request rate limit')
- .form-group
- = f.gitlab_ui_checkbox_component :throttle_unauthenticated_files_api_enabled,
- _('Enable unauthenticated API request rate limit'),
- help_text: _('Helps reduce request volume (e.g. from crawlers or abusive bots)'),
- checkbox_options: { data: { qa_selector: 'throttle_unauthenticated_files_api_checkbox' } }
- .form-group
- = f.label :throttle_unauthenticated_files_api_requests_per_period, 'Max unauthenticated API requests per period per IP', class: 'label-bold'
- = f.number_field :throttle_unauthenticated_files_api_requests_per_period, class: 'form-control gl-form-input'
- .form-group
- = f.label :throttle_unauthenticated_files_api_period_in_seconds, 'Unauthenticated API rate limit period in seconds', class: 'label-bold'
- = f.number_field :throttle_unauthenticated_files_api_period_in_seconds, class: 'form-control gl-form-input'
-
- %fieldset
- %legend.h5.gl-border-none
- = _('Authenticated API request rate limit')
- .form-group
- = f.gitlab_ui_checkbox_component :throttle_authenticated_files_api_enabled,
- _('Enable authenticated API request rate limit'),
- help_text: _('Helps reduce request volume (e.g. from crawlers or abusive bots)'),
- checkbox_options: { data: { qa_selector: 'throttle_authenticated_files_api_checkbox' } }
- .form-group
- = f.label :throttle_authenticated_files_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold'
- = f.number_field :throttle_authenticated_files_api_requests_per_period, class: 'form-control gl-form-input'
- .form-group
- = f.label :throttle_authenticated_files_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold'
- = f.number_field :throttle_authenticated_files_api_period_in_seconds, class: 'form-control gl-form-input'
-
- = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index ecf3203df9a..cd7eaa1896a 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -18,11 +18,10 @@
= f.text_field :help_page_support_url, class: 'form-control gl-form-input', placeholder: 'https://company.example.com/getting-help', :'aria-describedby' => 'support_help_block'
%span.form-text.text-muted#support_help_block= _('Alternate support URL for Help page and Help dropdown.')
- - if show_documentation_base_url_field?
- .form-group
- = f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'label-bold'
- = f.text_field :help_page_documentation_base_url, class: 'form-control gl-form-input', placeholder: 'https://docs.gitlab.com'
- - docs_link_url = help_page_path('user/admin_area/settings/help_page', anchor: 'destination-requirements')
- - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
- %span.form-text.text-muted#support_help_block= html_escape(_('Requests for pages at %{code_start}%{help_text_url}%{code_end} redirect to the URL. The destination must meet certain requirements. %{docs_link_start}Learn more.%{docs_link_end}')) % { code_start: '<code>'.html_safe, help_text_url: help_url, code_end: '</code>'.html_safe, docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
+ .form-group
+ = f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'gl-font-weight-bold'
+ = f.text_field :help_page_documentation_base_url, class: 'form-control gl-form-input', placeholder: 'https://docs.gitlab.com'
+ - docs_link_url = help_page_path('user/admin_area/settings/help_page', anchor: 'destination-requirements')
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
+ %span.form-text.text-muted#support_help_block= html_escape(_('Requests for pages at %{code_start}%{help_text_url}%{code_end} redirect to the URL. The destination must meet certain requirements. %{docs_link_start}Learn more.%{docs_link_end}')) % { code_start: '<code>'.html_safe, help_text_url: help_url, code_end: '</code>'.html_safe, docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml
index 40b4d5cac6d..ad9e84ffdab 100644
--- a/app/views/admin/application_settings/_mailgun.html.haml
+++ b/app/views/admin/application_settings/_mailgun.html.haml
@@ -6,7 +6,7 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank') }
+ = _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank', rel: 'noopener noreferrer') }
.settings-content
= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-mailgun-settings'), html: { class: 'fieldset-form', id: 'mailgun-settings' } do |f|
= form_errors(@application_setting) if expanded
diff --git a/app/views/admin/application_settings/_network_rate_limits.html.haml b/app/views/admin/application_settings/_network_rate_limits.html.haml
new file mode 100644
index 00000000000..f1857a9749a
--- /dev/null
+++ b/app/views/admin/application_settings/_network_rate_limits.html.haml
@@ -0,0 +1,33 @@
+= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: anchor), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ = _("Rate limits can help reduce request volume (like from crawlers or abusive bots).")
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :"throttle_unauthenticated_#{setting_fragment}_enabled",
+ _('Enable unauthenticated API request rate limit'),
+ checkbox_options: { data: { qa_selector: "throttle_unauthenticated_#{setting_fragment}_checkbox" } },
+ label_options: { class: 'label-bold' }
+ .form-group
+ = f.label :"throttle_unauthenticated_#{setting_fragment}_requests_per_period", _('Maximum unauthenticated API requests per rate limit period per IP'), class: 'label-bold'
+ = f.number_field :"throttle_unauthenticated_#{setting_fragment}_requests_per_period", class: 'form-control gl-form-input'
+ .form-group
+ = f.label :"throttle_unauthenticated_#{setting_fragment}_period_in_seconds", _('Unauthenticated API rate limit period in seconds'), class: 'label-bold'
+ = f.number_field :"throttle_unauthenticated_#{setting_fragment}_period_in_seconds", class: 'form-control gl-form-input'
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :"throttle_authenticated_#{setting_fragment}_enabled",
+ _('Enable authenticated API request rate limit'),
+ checkbox_options: { data: { qa_selector: "throttle_authenticated_#{setting_fragment}_checkbox" } },
+ label_options: { class: 'label-bold' }
+ .form-group
+ = f.label :"throttle_authenticated_#{setting_fragment}_requests_per_period", _('Maximum authenticated API requests per rate limit period per user'), class: 'label-bold'
+ = f.number_field :"throttle_authenticated_#{setting_fragment}_requests_per_period", class: 'form-control gl-form-input'
+ .form-group
+ = f.label :"throttle_authenticated_#{setting_fragment}_period_in_seconds", _('Authenticated API rate limit period in seconds'), class: 'label-bold'
+ = f.number_field :"throttle_authenticated_#{setting_fragment}_period_in_seconds", class: 'form-control gl-form-input'
+
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_package_registry_limits.html.haml b/app/views/admin/application_settings/_package_registry_limits.html.haml
deleted file mode 100644
index 8769171c9e0..00000000000
--- a/app/views/admin/application_settings/_package_registry_limits.html.haml
+++ /dev/null
@@ -1,32 +0,0 @@
-= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-packages-limits-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
-
- %fieldset
- = _("The package registry rate limits can help reduce request volume (like from crawlers or abusive bots).")
-
- %fieldset
- .form-group
- .form-check
- = f.check_box :throttle_unauthenticated_packages_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_unauthenticated_packages_api_checkbox' }
- = f.label :throttle_unauthenticated_packages_api_enabled, class: 'form-check-label label-bold' do
- = _('Enable unauthenticated API request rate limit')
- .form-group
- = f.label :throttle_unauthenticated_packages_api_requests_per_period, _('Maximum unauthenticated API requests per rate limit period per IP'), class: 'label-bold'
- = f.number_field :throttle_unauthenticated_packages_api_requests_per_period, class: 'form-control gl-form-input'
- .form-group
- = f.label :throttle_unauthenticated_packages_api_period_in_seconds, _('Unauthenticated API rate limit period in seconds'), class: 'label-bold'
- = f.number_field :throttle_unauthenticated_packages_api_period_in_seconds, class: 'form-control gl-form-input'
- %hr
- .form-group
- .form-check
- = f.check_box :throttle_authenticated_packages_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_packages_api_checkbox' }
- = f.label :throttle_authenticated_packages_api_enabled, class: 'form-check-label label-bold' do
- = _('Enable authenticated API request rate limit')
- .form-group
- = f.label :throttle_authenticated_packages_api_requests_per_period, _('Maximum authenticated API requests per rate limit period per user'), class: 'label-bold'
- = f.number_field :throttle_authenticated_packages_api_requests_per_period, class: 'form-control gl-form-input'
- .form-group
- = f.label :throttle_authenticated_packages_api_period_in_seconds, _('Authenticated API rate limit period in seconds'), class: 'label-bold'
- = f.number_field :throttle_authenticated_packages_api_period_in_seconds, class: 'form-control gl-form-input'
-
- = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
index 50fc11ec7f3..82e56cf8b81 100644
--- a/app/views/admin/application_settings/_performance.html.haml
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -6,29 +6,24 @@
.form-check
= f.check_box :authorized_keys_enabled, class: 'form-check-input'
= f.label :authorized_keys_enabled, class: 'form-check-label' do
- = _('Write to "authorized_keys" file')
+ = _('Use authorized_keys file to authenticate SSH keys')
.form-text.text-muted
- By default, we write to the "authorized_keys" file to support Git
- over SSH without additional configuration. GitLab can be optimized
- to authenticate SSH keys via the database file. Only uncheck this
- if you have configured your OpenSSH server to use the
- AuthorizedKeysCommand. Click on the help icon for more details.
- = link_to sprite_icon('question-o'), help_page_path('administration/operations/fast_ssh_key_lookup')
-
+ = _('Authenticate user SSH keys without requiring additional configuration. Performance of GitLab can be improved by using the GitLab database instead.')
+ = link_to _('How do I configure authentication using the GitLab database?'), help_page_path('administration/operations/fast_ssh_key_lookup'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.label :raw_blob_request_limit, _('Raw blob request rate limit per minute'), class: 'label-bold'
= f.number_field :raw_blob_request_limit, class: 'form-control gl-form-input'
.form-text.text-muted
- = _('Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0.')
+ = _('Maximum number of requests per minute for each raw path (default is 300). Set to 0 to disable throttling.')
.form-group
= f.label :push_event_hooks_limit, class: 'label-bold'
= f.number_field :push_event_hooks_limit, class: 'form-control gl-form-input'
.form-text.text-muted
- = _("Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value.")
+ = _('Maximum number of changes (branches or tags) in a single push for which webhooks and services trigger (default is 3).')
.form-group
= f.label :push_event_activities_limit, class: 'label-bold'
= f.number_field :push_event_activities_limit, class: 'form-control gl-form-input'
.form-text.text-muted
- = _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.')
+ = _('Threshold number of changes (branches or tags) in a single push above which a bulk push event is created (default is 3).')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index 8c98778147e..756c0e770a6 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -7,7 +7,7 @@
= expanded ? _('Collapse') : _('Expand')
%p
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('development/snowplow/index') }
- = html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank').html_safe, link_start: link_start, link_end: '</a>'.html_safe }
+ = html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank', rel: 'noopener noreferrer').html_safe, link_start: link_start, link_end: '</a>'.html_safe }
.settings-content
= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form', id: 'snowplow-settings' } do |f|
= form_errors(@application_setting) if expanded
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
index 011bce3ca99..53ca4d4aa79 100644
--- a/app/views/admin/application_settings/_spam.html.haml
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -2,6 +2,11 @@
= form_errors(@application_setting)
%fieldset
+ %h5
+ = _('reCAPTCHA')
+ %p
+ = _('reCAPTCHA helps prevent credential stuffing.')
+ = link_to _('Only reCAPTCHA v2 is supported:'), 'https://developers.google.com/recaptcha/docs/versions', target: '_blank', rel: 'noopener noreferrer'
.form-group
.form-check
= f.check_box :recaptcha_enabled, class: 'form-check-input'
@@ -9,25 +14,31 @@
= _("Enable reCAPTCHA")
%span.form-text.text-muted#recaptcha_help_block
= _('Helps prevent bots from creating accounts.')
+ = link_to _('How do I configure it?'), help_page_path('integration/recaptcha.md'), target: '_blank', rel: 'noopener noreferrer'
.form-group
.form-check
= f.check_box :login_recaptcha_protection_enabled, class: 'form-check-input'
= f.label :login_recaptcha_protection_enabled, class: 'form-check-label' do
- = _("Enable reCAPTCHA for login")
+ = _('Enable reCAPTCHA for login.')
%span.form-text.text-muted#recaptcha_help_block
= _('Helps prevent bots from brute-force attacks.')
.form-group
- = f.label :recaptcha_site_key, _('reCAPTCHA Site Key'), class: 'label-bold'
+ = f.label :recaptcha_site_key, _('reCAPTCHA site key'), class: 'label-bold'
= f.text_field :recaptcha_site_key, class: 'form-control gl-form-input'
.form-text.text-muted
= _("Generate site and private keys at")
%a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha
.form-group
- = f.label :recaptcha_private_key, _('reCAPTCHA Private Key'), class: 'label-bold'
- .form-group
+ = f.label :recaptcha_private_key, _('reCAPTCHA private key'), class: 'label-bold'
= f.text_field :recaptcha_private_key, class: 'form-control gl-form-input'
+ %h5
+ = _('Invisible Captcha')
+ %p
+ = _('Invisible Captcha helps prevent the creation of spam accounts. It adds a honeypot field and time-sensitive form submission to the account signup form.')
+ = link_to _('Read their documentation.'), 'https://github.com/markets/invisible_captcha', target: '_blank', rel: 'noopener noreferrer'
+
.form-group
.form-check
= f.check_box :invisible_captcha_enabled, class: 'form-check-input'
@@ -36,12 +47,18 @@
%span.form-text.text-muted
= _('Helps prevent bots from creating accounts.')
+ %h5
+ = _('Akismet')
+ %p
+ = _('Akismet helps prevent the creation of spam issues in public projects.')
+ = link_to _('How do I configure Akismet?'), help_page_path('integration/akismet.md'), target: '_blank', rel: 'noopener noreferrer'
+
.form-group
.form-check
= f.check_box :akismet_enabled, class: 'form-check-input'
= f.label :akismet_enabled, class: 'form-check-label' do
Enable Akismet
- %span.form-text.text-muted#akismet_help_block= _("Helps prevent bots from creating issues")
+ %span.form-text.text-muted#akismet_help_block= _("Helps prevent bots from creating issues.")
.form-group
= f.label :akismet_api_key, _('Akismet API Key'), class: 'label-bold'
@@ -50,25 +67,31 @@
Generate API key at
%a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com
+ %h5
+ = _('IP address restrictions')
+
.form-group
.form-check
= f.check_box :unique_ips_limit_enabled, class: 'form-check-input'
= f.label :unique_ips_limit_enabled, class: 'form-check-label' do
- = _("Limit sign in from multiple ips")
+ = _("Limit sign in from multiple IP addresses")
%span.form-text.text-muted#unique_ip_help_block
- = _("Helps prevent malicious users hide their activity")
+ = _("Helps prevent malicious users hide their activity.")
.form-group
- = f.label :unique_ips_limit_per_user, _('IPs per user'), class: 'label-bold'
+ = f.label :unique_ips_limit_per_user, _('IP addresses per user'), class: 'label-bold'
= f.number_field :unique_ips_limit_per_user, class: 'form-control gl-form-input'
.form-text.text-muted
- = _("Maximum number of unique IPs per user")
+ = _("Maximum number of unique IP addresses per user.")
.form-group
- = f.label :unique_ips_limit_time_window, _('IP expiration time'), class: 'label-bold'
+ = f.label :unique_ips_limit_time_window, _('IP address expiration time'), class: 'label-bold'
= f.number_field :unique_ips_limit_time_window, class: 'form-control gl-form-input'
.form-text.text-muted
- = _("How many seconds an IP will be counted towards the limit")
+ = _("How many seconds an IP counts toward the IP address limit.")
+
+ %h5
+ = _('Spam Check')
.form-group
.form-check
@@ -79,8 +102,8 @@
= f.label :spam_check_endpoint_url, _('URL of the external Spam Check endpoint'), class: 'label-bold'
= f.text_field :spam_check_endpoint_url, class: 'form-control gl-form-input'
.form-group
- = f.label :spam_check_api_key, _('Spam Check API Key'), class: 'gl-font-weight-bold'
+ = f.label :spam_check_api_key, _('Spam Check API key'), class: 'gl-font-weight-bold'
= f.text_field :spam_check_api_key, class: 'form-control gl-form-input'
- .form-text.text-muted= _('The API key used by GitLab for accessing the Spam Check service endpoint')
+ .form-text.text-muted= _('The API key used by GitLab for accessing the Spam Check service endpoint.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml
index d6e31a24cf6..c53f63e124b 100644
--- a/app/views/admin/application_settings/_terminal.html.haml
+++ b/app/views/admin/application_settings/_terminal.html.haml
@@ -6,5 +6,5 @@
= f.label :terminal_max_session_time, _('Max session time'), class: 'label-bold'
= f.number_field :terminal_max_session_time, class: 'form-control gl-form-input'
.form-text.text-muted
- = _('Maximum time for web terminal websocket connection (in seconds). 0 for unlimited.')
+ = _('Maximum time, in seconds, for a web terminal websocket connection. 0 for unlimited.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index ddd0abb4c34..5bdad50c161 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -10,21 +10,21 @@
= f.label :version_check_enabled, class: 'form-check-label' do
= _("Enable version check")
.form-text.text-muted
- = _("GitLab will inform you if a new version is available.")
- = _("%{link_start}Learn more%{link_end} about what information is shared with GitLab Inc.").html_safe % { link_start: "<a href='#{help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check")}'>".html_safe, link_end: '</a>'.html_safe }
+ = _("GitLab informs you if a new version is available.")
+ = _("%{link_start}What information does GitLab Inc. collect?%{link_end}").html_safe % { link_start: "<a href='#{help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check")}'>".html_safe, link_end: '</a>'.html_safe }
.form-group
- can_be_configured = @application_setting.usage_ping_can_be_configured?
.form-check
= f.check_box :usage_ping_enabled, disabled: !can_be_configured, class: 'form-check-input'
= f.label :usage_ping_enabled, class: 'form-check-label' do
- = _('Enable service ping')
+ = _('Enable Service Ping')
.form-text.text-muted
- if can_be_configured
- %p.mb-2= _('To help improve GitLab and its user experience, GitLab will periodically collect usage information.')
+ %p.mb-2= _('To help improve GitLab and its user experience, GitLab periodically collects usage information.')
- - service_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'service-ping')
+ - service_ping_path = help_page_path('development/service_ping/index.md')
- service_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_ping_path }
- %p.mb-2= s_('%{service_ping_link_start}Learn more%{service_ping_link_end} about what information is shared with GitLab Inc.').html_safe % { service_ping_link_start: service_ping_link_start, service_ping_link_end: '</a>'.html_safe }
+ %p.mb-2= s_('%{service_ping_link_start}What information is shared with GitLab Inc.?%{service_ping_link_end}').html_safe % { service_ping_link_start: service_ping_link_start, service_ping_link_end: '</a>'.html_safe }
%button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
.gl-spinner.js-spinner.gl-display-none.gl-mr-2
@@ -46,15 +46,23 @@
- if usage_ping_enabled
%p.gl-mb-3.text-muted{ id: 'service_ping_features_helper_text' }= _('You can enable Registration Features because Service Ping is enabled. To continue using Registration Features in the future, you will also need to register with GitLab via a new cloud licensing service.')
- else
- %p.gl-mb-3.text-muted{ id: 'service_ping_features_helper_text' }= _('To enable Registration Features, make sure "Enable service ping" is checked.')
+ %p.gl-mb-3.text-muted{ id: 'service_ping_features_helper_text' }= _('To enable Registration Features, first enable Service Ping.')
%p.gl-mb-3.text-muted= _('Registration Features include:')
.form-text
- email_from_gitlab_path = help_page_path('tools/email.md')
+ - repo_size_limit_path = help_page_path('user/admin_area/settings/account_and_limit_settings.md', anchor: 'repository-size-limit')
+ - restrict_ip_path = help_page_path('user/group/index.md', anchor: 'restrict-group-access-by-ip-address')
- link_end = '</a>'.html_safe
- email_from_gitlab_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: email_from_gitlab_path }
+ - repo_size_limit_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repo_size_limit_path }
+ - restrict_ip_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: restrict_ip_path }
%ul
%li
= _('Email from GitLab - email users right from the Admin Area. %{link_start}Learn more%{link_end}.').html_safe % { link_start: email_from_gitlab_link, link_end: link_end }
+ %li
+ = _('Limit project size at a global, group, and project level. %{link_start}Learn more%{link_end}.').html_safe % { link_start: repo_size_limit_link, link_end: link_end }
+ %li
+ = _('Restrict group access by IP address. %{link_start}Learn more%{link_end}.').html_safe % { link_start: restrict_ip_link, link_end: link_end }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/appearances/preview_sign_in.html.haml b/app/views/admin/application_settings/appearances/preview_sign_in.html.haml
index 77c37abbeef..2e4ab714048 100644
--- a/app/views/admin/application_settings/appearances/preview_sign_in.html.haml
+++ b/app/views/admin/application_settings/appearances/preview_sign_in.html.haml
@@ -1,12 +1,13 @@
= render 'devise/shared/tab_single', tab_title: _('Sign in preview')
.login-box
%form.gl-show-field-errors
+ - title = _('This form is disabled in preview')
.form-group
= label_tag :login
- = text_field_tag :login, nil, class: "form-control gl-form-input top", title: _('Please provide your username or email address.')
+ = text_field_tag :login, nil, disabled: true, class: "form-control gl-form-input top", title: title
.form-group
= label_tag :password
- = password_field_tag :password, nil, class: "form-control gl-form-input bottom", title: _('This field is required.')
+ = password_field_tag :password, nil, disabled: true, class: "form-control gl-form-input bottom", title: title
.form-group
- = button_tag _("Sign in"), class: "btn gl-button btn-confirm", type: "button"
+ = button_tag _("Sign in"), disabled: true, class: "btn gl-button btn-confirm", type: "button", title: title
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 9102769cc6e..a72c96bb577 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -79,7 +79,8 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Set max session time for web terminal.')
+ = _('Set the maximum session time for a web terminal.')
+ = link_to _('How do I use a web terminal?'), help_page_path('ci/environments/index.md', anchor: 'web-terminals'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'terminal'
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 f1e37c76130..6087551d7c7 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -49,7 +49,7 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Enable or disable version check and service ping.')
+ = _('Enable or disable version check and Service Ping.')
.settings-content
= render 'usage'
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 8dff2bc36cb..58e3f3f1136 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -35,9 +35,10 @@
= _('Set rate limits for package registry API requests that supersede the general user and IP rate limits.')
= link_to _('Learn more.'), help_page_path('user/admin_area/settings/package_registry_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- = render 'package_registry_limits'
+ = render partial: 'network_rate_limits', locals: { anchor: 'js-packages-limits-settings', setting_fragment: 'packages_api' }
+
- if Feature.enabled?(:files_api_throttling, default_enabled: :yaml)
- %section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'files-limits-settings' } }
+ %section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
= _('Files API Rate Limits')
@@ -46,7 +47,19 @@
%p
= _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.')
.settings-content
- = render 'files_limits'
+ = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' }
+
+%section.settings.as-deprecated-limits.no-animate#js-deprecated-limits-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Deprecated API rate limits')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Configure specific limits for deprecated API requests that supersede the general user and IP rate limits.')
+ = link_to _('Which API requests are affected?'), help_page_path('user/admin_area/settings/deprecated_api_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = render partial: 'network_rate_limits', locals: { anchor: 'js-deprecated-limits-settings', setting_fragment: 'deprecated_api' }
%section.settings.as-git-lfs-limits.no-animate#js-git-lfs-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'git_lfs_limits_content' } }
.settings-header
diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml
index 914a09ff5db..d2e118f0624 100644
--- a/app/views/admin/application_settings/reporting.html.haml
+++ b/app/views/admin/application_settings/reporting.html.haml
@@ -9,9 +9,7 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- - recaptcha_v2_link_url = 'https://developers.google.com/recaptcha/docs/versions'
- - recaptcha_v2_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: recaptcha_v2_link_url }
- = _('Enable reCAPTCHA, Invisible Captcha, Akismet and set IP limits. For reCAPTCHA, we currently only support %{recaptcha_v2_link_start}v2%{recaptcha_v2_link_end}').html_safe % { recaptcha_v2_link_start: recaptcha_v2_link_start, recaptcha_v2_link_end: '</a>'.html_safe }
+ = _('Configure CAPTCHAs, IP address limits, and other anti-spam measures.')
.settings-content
= render 'spam'
@@ -22,6 +20,7 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Set notification email for abuse reports.')
+ = _('Receive notification of abuse reports by email.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'abuse'
diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
new file mode 100644
index 00000000000..ece0f7ca4d9
--- /dev/null
+++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
@@ -0,0 +1,14 @@
+- return unless show_security_newsletter_user_callout?
+
+= render 'shared/global_alert',
+ title: s_('AdminArea|Get security updates from GitLab and stay up to date'),
+ variant: :tip,
+ alert_class: 'js-security-newsletter-callout',
+ is_contained: true,
+ alert_data: { feature_id: UserCalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: user_callouts_path, defer_links: 'true' },
+ close_button_data: { testid: 'close-security-newsletter-callout' } do
+ .gl-alert-body
+ = s_('AdminArea|Sign up for the GitLab Security Newsletter to get notified for security updates.')
+ .gl-alert-actions
+ = link_to 'https://about.gitlab.com/company/preference-center/', target: '_blank', rel: 'noreferrer noopener', class: 'deferred-link gl-alert-action btn-confirm btn-md gl-button' do
+ = s_('AdminArea|Sign up for the GitLab newsletter')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 97b3a757a3f..681e7ccb613 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -4,6 +4,7 @@
- billable_users_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: billable_users_url }
= render_if_exists 'shared/qrtly_reconciliation_alert'
+= render 'admin/dashboard/security_newsletter_callout'
- if @notices
- @notices.each do |notice|
diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml
index a7f947f96ea..6a46b0b3510 100644
--- a/app/views/admin/hook_logs/_index.html.haml
+++ b/app/views/admin/hook_logs/_index.html.haml
@@ -1,37 +1,11 @@
+- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks')
+- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
+- link_end = '</a>'.html_safe
+
.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0
- = _('Recent Deliveries')
- %p= _('When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.')
+ = _('Recent events')
+ %p= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end }
.col-lg-9
- - if hook_logs.present?
- %table.table
- %thead
- %tr
- %th= _('Status')
- %th= _('Trigger')
- %th= _('URL')
- %th= _('Elapsed time')
- %th= _('Request time')
- %th
- - hook_logs.each do |hook_log|
- %tr
- %td
- = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
- %td.d-none.d-sm-block
- %span.badge.badge-gray.deploy-project-label
- = hook_log.trigger.singularize.titleize
- %td
- = truncate(hook_log.url, length: 50)
- %td.light
- #{number_with_precision(hook_log.execution_duration, precision: 2)} sec
- %td.light
- = time_ago_with_tooltip(hook_log.created_at)
- %td
- = link_to _('View details'), admin_hook_hook_log_path(hook, hook_log)
-
- = paginate hook_logs, theme: 'gitlab'
-
- - else
- .settings-message.text-center
- = _("You don't have any webhooks deliveries")
+ = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs }
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index 5ebfd296e2b..f947e174990 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -1,33 +1,24 @@
- page_title _('Projects')
- params[:visibility_level] ||= []
-- active_tab_classes = 'active gl-tab-nav-item-active gl-tab-nav-item-active-indigo'
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- %ul.nav.gl-tabs-nav.gl-overflow-x-auto.gl-display-flex.gl-flex-grow-1.gl-flex-shrink-1.gl-border-b-0.gl-flex-nowrap.gl-webkit-scrollbar-display-none
- = nav_link(html_options: { class: "nav-item" } ) do
- = link_to _('All'), admin_projects_path, class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level].empty?}"
- = nav_link(html_options: { class: "nav-item" } ) do
- = link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s}"
- = nav_link(html_options: { class: "nav-item" } ) do
- = link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s}"
- = nav_link(html_options: { class: "nav-item" } ) do
- = link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s}"
+ = gl_tabs_nav({ class: 'gl-border-b-0 gl-overflow-x-auto gl-flex-grow-1 gl-flex-nowrap gl-webkit-scrollbar-display-none' }) do
+ = gl_tab_link_to _('All'), admin_projects_path(visibility_level: nil), { item_active: params[:visibility_level].empty? }
+ = gl_tab_link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ = gl_tab_link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ = gl_tab_link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
.nav-controls
.search-holder
= render 'shared/projects/search_form', autofocus: true, admin_view: true
- .dropdown
- - toggle_text = _('Namespace')
- - if params[:namespace_id].present?
- = hidden_field_tag :namespace_id, params[:namespace_id]
- - namespace = Namespace.find(params[:namespace_id])
- - toggle_text = "#{namespace.kind}: #{namespace.full_path}"
- = dropdown_toggle(toggle_text, { toggle: 'dropdown', is_filter: 'true' }, { toggle_class: 'js-namespace-select large' })
- .dropdown-menu.dropdown-select.dropdown-menu-right
- = dropdown_title(_('Namespaces'))
- = dropdown_filter(_("Search for Namespace"))
- = dropdown_content
- = dropdown_loading
+ - current_namespace = _('Namespace')
+ - if params[:namespace_id].present?
+ - namespace = Namespace.find(params[:namespace_id])
+ - current_namespace = "#{namespace.kind}: #{namespace.full_path}"
+ %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { show_any: 'true', field_name: 'namespace_id', placeholder: current_namespace, update_location: 'true' }, type: 'button' }
+ %span.gl-new-dropdown-button-text
+ = current_namespace
+
= render 'shared/projects/dropdown'
= link_to new_project_path, class: 'gl-button btn btn-confirm' do
= _('New Project')
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 1a87b21351c..3069aab2710 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -143,13 +143,10 @@
.col-sm-3.col-form-label
= f.label :new_namespace_id, _("Namespace")
.col-sm-9
- .dropdown
- = dropdown_toggle(_('Search for Namespace'), { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' })
- .dropdown-menu.dropdown-select
- = dropdown_title(_('Namespaces'))
- = dropdown_filter(_('Search for Namespace'))
- = dropdown_content
- = dropdown_loading
+ - placeholder = _('Search for Namespace')
+ %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { field_name: 'new_namespace_id', placeholder: placeholder }, type: 'button' }
+ %span.gl-new-dropdown-button-text
+ = placeholder
.form-group.row
.offset-sm-3.col-sm-9
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 59523ed3a0c..808b2bb4f8e 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -9,7 +9,7 @@
.row
.col-md-6
%h4= _('Restrict projects for this runner')
- - if @runner.projects.any?
+ - if @runner.runner_projects.any?
%table.table{ data: { testid: 'assigned-projects' } }
%thead
%tr
diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml
deleted file mode 100644
index a3e1ccc5d4a..00000000000
--- a/app/views/admin/serverless/domains/_form.html.haml
+++ /dev/null
@@ -1,99 +0,0 @@
-- form_name = 'js-serverless-domain-settings'
-- form_url = @domain.persisted? ? admin_serverless_domain_path(@domain.id, anchor: form_name) : admin_serverless_domains_path(anchor: form_name)
-- show_certificate_card = @domain.persisted? && @domain.errors.blank?
-= form_for @domain, url: form_url, html: { class: 'fieldset-form' } do |f|
- = form_errors(@domain)
-
- %fieldset
- - if @domain.persisted?
- - dns_record = "*.#{@domain.domain} CNAME #{Settings.pages.host}."
- - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}"
- .form-group.row
- .col-sm-6.position-relative
- = f.label :domain, _('Domain'), class: 'label-bold'
- = f.text_field :domain, class: 'form-control has-floating-status-badge', readonly: true
- .status-badge.floating-status-badge
- - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success']
- .badge{ class: status }
- = text
- = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "gl-button btn has-tooltip", title: _("Retry verification")
-
- .col-sm-6
- = f.label :serverless_domain_dns, _('DNS'), class: 'label-bold'
- .input-group
- = text_field_tag :serverless_domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true
- .input-group-append
- = clipboard_button(target: '#serverless_domain_dns', class: 'btn-default input-group-text d-none d-sm-block')
-
- .col-sm-12.form-text.text-muted
- = _("To access this domain create a new DNS record")
-
- .form-group
- = f.label :serverless_domain_verification, _('Verification status'), class: 'label-bold'
- .input-group
- = text_field_tag :serverless_domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true
- .input-group-append
- = clipboard_button(target: '#serverless_domain_verification', class: 'btn-default d-none d-sm-block')
- %p.form-text.text-muted
- - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership'))
- = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration.").html_safe % { link_to_help: link_to_help }
-
- - else
- .form-group
- = f.label :domain, _('Domain'), class: 'label-bold'
- = f.text_field :domain, class: 'form-control'
-
- - if show_certificate_card
- .card.js-domain-cert-show
- .card-header
- = _('Certificate')
- .d-flex.justify-content-between.align-items-center.p-3
- %span
- = @domain.subject || _('missing')
- %button.gl-button.btn.btn-danger.btn-sm.js-domain-cert-replace-btn{ type: 'button' }
- = _('Replace')
-
- .js-domain-cert-inputs{ class: ('hidden' if show_certificate_card) }
- .form-group
- = f.label :user_provided_certificate, _('Certificate (PEM)'), class: 'label-bold'
- = f.text_area :user_provided_certificate, rows: 5, class: 'form-control', value: ''
- %span.form-text.text-muted
- = _("Upload a certificate for your domain with all intermediates")
- .form-group
- = f.label :user_provided_key, _('Key (PEM)'), class: 'label-bold'
- = f.text_area :user_provided_key, rows: 5, class: 'form-control', value: ''
- %span.form-text.text-muted
- = _("Upload a private key for your certificate")
-
- = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-confirm js-serverless-domain-submit", disabled: @domain.persisted?
- - if @domain.persisted?
- %button.gl-button.btn.btn-danger{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } }
- = _('Delete domain')
-
--# haml-lint:disable NoPlainNodes
-- if @domain.persisted?
- - domain_attached = @domain.serverless_domain_clusters.count > 0
- .modal{ id: "modal-delete-domain", tabindex: -1 }
- .modal-dialog
- .modal-content
- .modal-header
- %h3.page-title= _('Delete serverless domain?')
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": "true" } &times;
-
- .modal-body
- - if domain_attached
- = _("You must disassociate %{domain} from all clusters it is attached to before deletion.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe }
- - else
- = _("You are about to delete %{domain} from your instance. This domain will no longer be available to any Knative application.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe }
-
- .modal-footer
- %a{ href: '#', data: { dismiss: 'modal' }, class: 'gl-button btn btn-default' }
- = _('Cancel')
-
- = link_to _('Delete domain'),
- admin_serverless_domain_path(@domain.id),
- title: _('Delete'),
- method: :delete,
- class: "gl-button btn btn-danger",
- disabled: domain_attached
diff --git a/app/views/admin/serverless/domains/index.html.haml b/app/views/admin/serverless/domains/index.html.haml
deleted file mode 100644
index c2b6baed4de..00000000000
--- a/app/views/admin/serverless/domains/index.html.haml
+++ /dev/null
@@ -1,25 +0,0 @@
-- breadcrumb_title _("Operations")
-- page_title _("Operations")
-- @content_class = "limit-container-width" unless fluid_layout
-
--# normally expanded_by_default? is used here, but since this is the only panel
--# in this settings page, let's leave it always open by default
-- expanded = true
-
-%section.settings.as-serverless-domain.no-animate#js-serverless-domain-settings{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- = _('Serverless domain')
- %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' }
- = expanded ? _('Collapse') : _('Expand')
- %p
- = _('Set an instance-wide domain that will be available to all clusters when installing Knative.')
- .settings-content
- - if Gitlab.config.pages.enabled
- = render 'form'
- - else
- .card
- .card-header
- = s_('GitLabPages|Domains')
- .nothing-here-block
- = s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.")
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index 47ef4f26889..c9b002a4dd2 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -1,7 +1,7 @@
= 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' }
+ = password_field_tag 'user[password]', nil, class: 'form-control', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
.submit-container.move-submit-down
= submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml
new file mode 100644
index 00000000000..21a1d74a8c6
--- /dev/null
+++ b/app/views/admin/topics/_form.html.haml
@@ -0,0 +1,40 @@
+= gitlab_ui_form_for @topic, url: url, html: { multipart: true, class: 'js-project-topic-form gl-show-field-errors common-note-form js-quick-submit js-requires-input' }, authenticity_token: true do |f|
+ = form_errors(@topic)
+
+ .form-group
+ = f.label :name do
+ = _("Topic name")
+ = f.text_field :name, placeholder: _('My topic'), class: 'form-control input-lg', data: { qa_selector: 'topic_name_field' },
+ required: true,
+ title: _('Please fill in a name for your topic.'),
+ autofocus: true
+
+ .form-group
+ = f.label :description, _("Description")
+ = render layout: 'shared/md_preview', locals: { url: preview_markdown_admin_topics_path, referenced_users: false } do
+ = render 'shared/zen', f: f, attr: :description,
+ classes: 'note-textarea',
+ placeholder: _('Write a description…'),
+ supports_quick_actions: false,
+ supports_autocomplete: false,
+ qa_selector: 'topic_form_description'
+ = render 'shared/notes/hints', supports_file_upload: false
+
+ .form-group.gl-mt-3.gl-mb-3
+ = f.label :avatar, _('Topic avatar'), class: 'gl-display-block'
+ - if @topic.avatar?
+ .avatar-container.rect-avatar.s90
+ = topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s90')
+ = render 'shared/choose_avatar_button', f: f
+ - if @topic.avatar?
+ = link_to _('Remove avatar'), admin_topic_avatar_path(@topic), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary gl-mt-2'
+
+ - if @topic.new_record?
+ .form-actions
+ = f.submit _('Create topic'), class: "gl-button btn btn-confirm"
+ = link_to _('Cancel'), admin_topics_path, class: "gl-button btn btn-default btn-cancel"
+
+ - else
+ .form-actions
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
+ = link_to _('Cancel'), admin_topics_path, class: "gl-button btn btn-cancel"
diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml
new file mode 100644
index 00000000000..abf3cffa422
--- /dev/null
+++ b/app/views/admin/topics/_topic.html.haml
@@ -0,0 +1,17 @@
+- topic = local_assigns.fetch(:topic)
+
+%li.topic-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'topic_row_content' } }
+ .avatar-container.rect-avatar.s40.gl-flex-shrink-0
+ = topic_icon(topic, class: "avatar s40")
+
+ .gl-min-w-0.gl-flex-grow-1
+ .title
+ = topic.name
+
+ .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex
+ %span.gl-ml-5.has-tooltip{ title: n_('%d project', '%d projects', topic.total_projects_count) % topic.total_projects_count }
+ = sprite_icon('bookmark', css_class: 'gl-vertical-align-text-bottom')
+ = number_with_delimiter(topic.total_projects_count)
+
+ .controls.gl-flex-shrink-0.gl-ml-5
+ = link_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}", class: 'btn gl-button btn-default'
diff --git a/app/views/admin/topics/edit.html.haml b/app/views/admin/topics/edit.html.haml
new file mode 100644
index 00000000000..4416bb0fe18
--- /dev/null
+++ b/app/views/admin/topics/edit.html.haml
@@ -0,0 +1,4 @@
+- page_title _("Edit"), @topic.name, _("Topics")
+%h3.page-title= _('Edit topic: %{topic_name}') % { topic_name: @topic.name }
+%hr
+= render 'form', url: admin_topic_path(@topic)
diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml
new file mode 100644
index 00000000000..6485b8aa411
--- /dev/null
+++ b/app/views/admin/topics/index.html.haml
@@ -0,0 +1,19 @@
+- page_title _("Topics")
+
+= form_tag admin_topics_path, method: :get do |f|
+ .gl-py-3.gl-display-flex.gl-flex-direction-column-reverse.gl-md-flex-direction-row.gl-border-b-solid.gl-border-gray-100.gl-border-b-1
+ .gl-flex-grow-1.gl-mt-3.gl-md-mt-0
+ .inline.gl-w-full.gl-md-w-auto
+ - search = params.fetch(:search, nil)
+ .search-field-holder
+ = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' }
+ = sprite_icon('search', css_class: 'search-icon')
+ .nav-controls
+ = link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do
+ = _('New topic')
+%ul.content-list
+ = render partial: 'topic', collection: @topics
+
+= paginate_collection @topics
+- if @topics.empty?
+ = render 'shared/empty_states/topics'
diff --git a/app/views/admin/topics/new.html.haml b/app/views/admin/topics/new.html.haml
new file mode 100644
index 00000000000..8b4a8ac269e
--- /dev/null
+++ b/app/views/admin/topics/new.html.haml
@@ -0,0 +1,4 @@
+- page_title _("New topic")
+%h3.page-title= _('New topic')
+%hr
+= render 'form', url: admin_topics_path(@topic)
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index aeb274fe2cb..6a5f07dd2db 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -19,22 +19,20 @@
.col-sm-10
- editing_current_user = (current_user == @user)
- = f.radio_button :access_level, :regular, disabled: editing_current_user
- = f.label :access_level_regular, class: 'font-weight-bold' do
- = s_('AdminUsers|Regular')
- %p.light
- = s_('AdminUsers|Regular users have access to their groups and projects')
+ = f.gitlab_ui_radio_component :access_level, :regular,
+ s_('AdminUsers|Regular'),
+ radio_options: { disabled: editing_current_user },
+ help_text: s_('AdminUsers|Regular users have access to their groups and projects.')
= render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
- = f.radio_button :access_level, :admin, disabled: editing_current_user
- = f.label :access_level_admin, class: 'font-weight-bold' do
- = s_('AdminUsers|Admin')
- %p.light
- = s_('AdminUsers|Administrators have access to all groups, projects and users and can manage all features in this installation')
- - if editing_current_user
- %p.light
- = s_('AdminUsers|You cannot remove your own admin rights.')
+ - help_text = s_('AdminUsers|Administrators have access to all groups, projects and users and can manage all features in this installation.')
+ - help_text += ' ' + s_('AdminUsers|You cannot remove your own admin rights.') if editing_current_user
+ = f.gitlab_ui_radio_component :access_level, :admin,
+ s_('AdminUsers|Admin'),
+ radio_options: { disabled: editing_current_user },
+ help_text: help_text
+
.form-group.row
.col-sm-2.col-form-label.gl-pt-0
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 9d62c19e2fc..3869a2b6dcd 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -1,5 +1,5 @@
.user_new
- = form_for [:admin, @user], html: { class: 'fieldset-form' } do |f|
+ = gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f|
= form_errors(@user)
%fieldset
@@ -39,12 +39,12 @@
.col-sm-2.col-form-label
= f.label :password
.col-sm-10
- = f.password_field :password, disabled: f.object.force_random_password, class: 'form-control gl-form-input'
+ = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input'
.form-group.row
.col-sm-2.col-form-label
= f.label :password_confirmation
.col-sm-10
- = f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control gl-form-input'
+ = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input'
= render partial: 'access_levels', locals: { f: f }
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index f4b1a2853f1..bafb2085589 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -33,16 +33,11 @@
- if can_force_email_confirmation?(@user)
%button.btn.gl-button.btn-info.js-confirm-modal-button{ data: confirm_user_data(@user) }
= _('Confirm user')
-%ul.nav-links.nav.nav-tabs
- = nav_link(path: 'users#show') do
- = link_to _("Account"), admin_user_path(@user)
- = nav_link(path: 'users#projects') do
- = link_to _("Groups and projects"), projects_admin_user_path(@user)
- = nav_link(path: 'users#keys') do
- = link_to _("SSH keys"), keys_admin_user_path(@user)
- = nav_link(controller: :identities) do
- = link_to _("Identities"), admin_user_identities_path(@user)
+= gl_tabs_nav do
+ = gl_tab_link_to _("Account"), admin_user_path(@user)
+ = gl_tab_link_to _("Groups and projects"), projects_admin_user_path(@user)
+ = gl_tab_link_to _("SSH keys"), keys_admin_user_path(@user)
+ = gl_tab_link_to _("Identities"), admin_user_identities_path(@user)
- if impersonation_enabled?
- = nav_link(controller: :impersonation_tokens) do
- = link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user)
+ = gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user)
.gl-mb-3
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index ad8d9d1f04f..2a9b4694e7b 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -61,7 +61,6 @@
= _('Disabled')
= render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace
- = render_if_exists 'admin/users/credit_card_info', user: @user
%li
%span.light= _('External User:')
@@ -139,6 +138,8 @@
= render_if_exists 'namespaces/shared_runner_status', namespace: @user.namespace
+ = render_if_exists 'admin/users/credit_card_info', user: @user, link_to_match_page: true
+
= render 'shared/custom_attributes', custom_attributes: @user.custom_attributes
-# Rendered on desktop only so order of cards can be different on desktop vs mobile
diff --git a/app/views/authentication/_authenticate.html.haml b/app/views/authentication/_authenticate.html.haml
index 5a2ae3f44c2..7dcec50573f 100644
--- a/app/views/authentication/_authenticate.html.haml
+++ b/app/views/authentication/_authenticate.html.haml
@@ -1,14 +1,17 @@
#js-authenticate-token-2fa
%a.gl-button.btn.btn-block.btn-confirm#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
+-# haml-lint:disable InlineJavaScript
%script#js-authenticate-token-2fa-in-progress{ type: "text/template" }
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
+-# haml-lint:disable InlineJavaScript
%script#js-authenticate-token-2fa-error{ type: "text/template" }
%div
%p <%= error_message %> (<%= error_name %>)
%a.btn.btn-default.gl-button.btn-block#js-token-2fa-try-again= _("Try again?")
+-# haml-lint:disable InlineJavaScript
%script#js-authenticate-token-2fa-authenticated{ type: "text/template" }
%div
%p= _("We heard back from your device. You have been authenticated.")
diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml
index 678fd3c8e8c..5eed969ed35 100644
--- a/app/views/authentication/_register.html.haml
+++ b/app/views/authentication/_register.html.haml
@@ -1,8 +1,10 @@
#js-register-token-2fa
+-# haml-lint:disable InlineJavaScript
%script#js-register-2fa-message{ type: "text/template" }
%p <%= message %>
+-# haml-lint:disable InlineJavaScript
%script#js-register-token-2fa-setup{ type: "text/template" }
- if current_user.two_factor_otp_enabled?
.row.gl-mb-3
@@ -17,12 +19,14 @@
.col-md-8
%p= _("You need to register a two-factor authentication app before you can set up a device.")
+-# haml-lint:disable InlineJavaScript
%script#js-register-token-2fa-error{ type: "text/template" }
%div
%p
%span <%= error_message %> (<%= error_name %>)
%a.btn.btn-default.gl-button#js-token-2fa-try-again= _("Try again?")
+-# haml-lint:disable InlineJavaScript
%script#js-register-token-2fa-registered{ type: "text/template" }
.row.gl-mb-3
.col-md-12
diff --git a/app/views/clusters/clusters/_advanced_settings_tab.html.haml b/app/views/clusters/clusters/_advanced_settings_tab.html.haml
index b491a64e43d..8c9d98604dd 100644
--- a/app/views/clusters/clusters/_advanced_settings_tab.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings_tab.html.haml
@@ -1,6 +1,5 @@
- active = params[:tab] == 'settings'
- if can_admin_cluster?(current_user, @cluster)
- %li.nav-item{ role: 'presentation' }
- %a#cluster-settings-tab.nav-link{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'settings'}) }
- %span= _('Advanced Settings')
+ = gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'settings' }), { item_active: active } do
+ = _('Advanced Settings')
diff --git a/app/views/clusters/clusters/_details_tab.html.haml b/app/views/clusters/clusters/_details_tab.html.haml
index 564c5103d34..734910686e7 100644
--- a/app/views/clusters/clusters/_details_tab.html.haml
+++ b/app/views/clusters/clusters/_details_tab.html.haml
@@ -1,5 +1,4 @@
- active = params[:tab] == 'details' || !params[:tab].present?
-%li.nav-item{ role: 'presentation' }
- %a#cluster-details-tab.nav-link.qa-details{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'details'}) }
- %span= _('Details')
+= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'details' }), { item_active: active } do
+ = _('Details')
diff --git a/app/views/clusters/clusters/_health_tab.html.haml b/app/views/clusters/clusters/_health_tab.html.haml
index fda392693f6..4292066cc6f 100644
--- a/app/views/clusters/clusters/_health_tab.html.haml
+++ b/app/views/clusters/clusters/_health_tab.html.haml
@@ -1,5 +1,4 @@
- active = params[:tab] == 'health'
-%li.nav-item{ role: 'presentation' }
- %a#cluster-health-tab.nav-link.qa-health{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'health'}) }
- %span= _('Health')
+= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'health' }), { item_active: active, data: { testid: 'cluster-health-tab' } } do
+ = _('Health')
diff --git a/app/views/clusters/clusters/_integrations_tab.html.haml b/app/views/clusters/clusters/_integrations_tab.html.haml
index 77b8b6ca3e6..e229c1fbe1e 100644
--- a/app/views/clusters/clusters/_integrations_tab.html.haml
+++ b/app/views/clusters/clusters/_integrations_tab.html.haml
@@ -1,6 +1,4 @@
-- tab_name = 'integrations'
-- active = params[:tab] == tab_name
+- active = params[:tab] == 'integrations'
-%li.nav-item{ role: 'presentation' }
- %a#cluster-apps-tab.nav-link{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: tab_name}) }
- %span= _('Integrations')
+= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'integrations' }), { item_active: active } do
+ = _('Integrations')
diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml
index 9b728e7a89b..6412972e195 100644
--- a/app/views/clusters/clusters/_namespace.html.haml
+++ b/app/views/clusters/clusters/_namespace.html.haml
@@ -1,7 +1,6 @@
- managed_namespace_help_text = s_('ClusterIntegration|Set a prefix for your namespaces. If not set, defaults to your project path. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
- non_managed_namespace_help_text = s_('ClusterIntegration|The namespace associated with your project. This will be used for deploy boards, logs, and Web terminals.')
-- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md',
- anchor: 'gitlab-managed-clusters'), target: '_blank'
+- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank'
.js-namespace-prefixed
= platform_field.text_field :namespace,
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index 93db7db06b3..f6d50410e9a 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -12,6 +12,6 @@
'role-arn' => @aws_role.role_arn,
'instance-types' => @instance_types,
'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'),
- 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'create-a-new-certificate-based-eks-cluster'),
- 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'create-a-new-certificate-based-eks-cluster'),
+ 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'how-to-create-a-new-cluster-on-eks-through-cluster-certificates-deprecated'),
+ 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'how-to-create-a-new-cluster-on-eks-through-cluster-certificates-deprecated'),
'external-link-icon' => sprite_icon('external-link') } }
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 0a482f1eb01..2a09d8d8cc0 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -15,7 +15,7 @@
provider_type: @cluster.provider_type,
help_path: help_page_path('user/project/clusters/index.md'),
environments_help_path: help_page_path('ci/environments/index.md', anchor: 'create-a-static-environment'),
- clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'),
+ clusters_help_path: help_page_path('user/project/clusters/deploy_to_cluster.md'),
deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'),
cluster_id: @cluster.id } }
@@ -24,11 +24,10 @@
.js-serverless-survey-banner{ data: { user_name: current_user.name, user_email: current_user.email } }
- .d-flex.my-3
- %p.badge.badge-light.p-2.mr-2
+ %h4.gl-my-5
+ = @cluster.name
+ %span.badge.badge-info.badge-pill.gl-badge.md.gl-vertical-align-middle
= cluster_type_label(@cluster.cluster_type)
- %h4.m-0
- = @cluster.name
= render 'banner'
@@ -42,12 +41,12 @@
- if cluster_created?(@cluster)
.js-toggle-container
- %ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' }
- = render 'details_tab'
+ = gl_tabs_nav do
+ = render 'clusters/clusters/details_tab'
= render_if_exists 'clusters/clusters/environments_tab'
= render 'clusters/clusters/health_tab'
- = render 'integrations_tab'
- = render 'advanced_settings_tab'
+ = render 'clusters/clusters/integrations_tab'
+ = render 'clusters/clusters/advanced_settings_tab'
.tab-content.py-3
.tab-pane.active{ role: 'tabpanel' }
diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml
index 0daadd20f54..c65b947d1ba 100644
--- a/app/views/dashboard/_activity_head.html.haml
+++ b/app/views/dashboard/_activity_head.html.haml
@@ -2,13 +2,7 @@
%h1.page-title= _('Activity')
.top-area
- %ul.nav-links.nav.nav-tabs
- %li{ class: active_when(params[:filter].nil?) }>
- = link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do
- = _('Your projects')
- %li{ class: active_when(params[:filter] == 'starred') }>
- = link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do
- = _('Starred projects')
- %li{ class: active_when(params[:filter] == 'followed') }>
- = link_to activity_dashboard_path(filter: 'followed'), data: {placement: 'right'} do
- = _('Followed users')
+ = gl_tabs_nav({ class: 'gl-border-b-0', data: { testid: 'dashboard-activity-tabs' } }) do
+ = gl_tab_link_to _("Your projects"), activity_dashboard_path, { item_active: params[:filter].nil? }
+ = gl_tab_link_to _("Starred projects"), activity_dashboard_path(filter: 'starred')
+ = gl_tab_link_to _("Followed users"), activity_dashboard_path(filter: 'followed')
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index b92f35c108c..7b1d25b9b43 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -6,13 +6,9 @@
= link_to _("New group"), new_group_path, class: "gl-button btn btn-confirm", data: { testid: "new-group-button" }
.top-area
- %ul.nav-links.mobile-separator.nav.nav-tabs
- = nav_link(page: dashboard_groups_path) do
- = link_to dashboard_groups_path, title: _("Your groups") do
- Your groups
- = nav_link(page: explore_groups_path) do
- = link_to explore_groups_path, title: _("Explore public groups") do
- Explore public groups
+ = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do
+ = gl_tab_link_to _("Your groups"), dashboard_groups_path
+ = gl_tab_link_to _("Explore public groups"), explore_groups_path
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index 037b2f247c1..9fb0fb734f9 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -1,16 +1,15 @@
- user_email = "(#{params[:email]})" if params[:email].present?
+- request_link_start = '<a href="%{new_user_confirmation_path}">'.html_safe % { new_user_confirmation_path: new_user_confirmation_path }
+- request_link_end = '</a>'.html_safe
.well-confirmation.gl-text-center.gl-mb-6
%h1.gl-mt-0
= _("Almost there...")
- %p.lead.gl-mb-6
+ %p{ class: 'gl-mb-6 gl-font-lg!' }
= _('Please check your email %{email} to confirm your account') % { email: user_email }
%hr
- if Gitlab::CurrentSettings.after_sign_up_text.present?
.well-confirmation.gl-text-center
= markdown_field(Gitlab::CurrentSettings, :after_sign_up_text)
-%p.text-center
- = _("No confirmation email received? Please check your spam folder or")
-.gl-mb-6.prepend-top-20.gl-text-center
- %a.gl-link{ href: new_user_confirmation_path }
- = _("Request new confirmation email")
+%p.gl-text-center
+ = _("No confirmation email received? Check your spam folder or %{request_link_start}request new confirmation email%{request_link_end}.").html_safe % { request_link_start: request_link_start, request_link_end: request_link_end }
diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
index ab46aaaca1a..32e88047a9c 100644
--- a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
+++ b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
@@ -1,4 +1,4 @@
-<%= _(" %{name}, confirm your email address now! ") % { name: @resource.user.name } %>
+<%= _("%{name}, confirm your email address now!") % { name: @resource.user.name } %>
<%= _("Use the link below to confirm your email address (%{email})") % { email: @resource.email } %>
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index 10c04423589..56bd30fac73 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -7,10 +7,10 @@
= f.hidden_field :reset_password_token
.form-group
= f.label _('New password'), for: "user_password"
- = f.password_field :password, class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'}
+ = f.password_field :password, autocomplete: 'new-password', class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'}
.form-group
= f.label _('Confirm new password'), for: "user_password_confirmation"
- = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true
+ = f.password_field :password_confirmation, autocomplete: 'new-password', class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true
.clearfix
= f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 4ec3fcde337..87108c8ea78 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -2,6 +2,7 @@
- add_page_specific_style 'page_bundles/signup'
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
+ = render "layouts/one_trust"
= render "layouts/google_tag_manager_body"
.signup-page
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 82c0df354d4..b40373ecc37 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -4,12 +4,12 @@
= f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' }
.form-group
= f.label :password, class: 'label-bold'
- = f.password_field :password, class: 'form-control gl-form-input bottom', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
+ = f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
- if devise_mapping.rememberable?
- .remember-me
+ %div
%label{ for: 'user_remember_me' }
- = f.check_box :remember_me, class: 'remember-me-checkbox'
- %span Remember me
+ = f.check_box :remember_me
+ %span= _('Remember me')
.float-right
- if unconfirmed_email?
= link_to _('Resend confirmation email'), new_user_confirmation_path
diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml
index 769268748f4..fb4c011dd49 100644
--- a/app/views/devise/sessions/_new_crowd.html.haml
+++ b/app/views/devise/sessions/_new_crowd.html.haml
@@ -4,7 +4,7 @@
= text_field_tag :username, nil, { class: "form-control top", title: _("This field is required."), autofocus: "autofocus", required: true }
.form-group
= label_tag :password
- = password_field_tag :password, nil, { class: "form-control bottom", title: _("This field is required."), required: true }
+ = password_field_tag :password, nil, { autocomplete: 'current-password', class: "form-control bottom", title: _("This field is required."), required: true }
- if devise_mapping.rememberable?
.remember-me
%label{ for: "remember_me" }
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index f599a652b71..fea58779c17 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -8,7 +8,7 @@
= text_field_tag :username, nil, { class: "form-control gl-form-input top", title: _("This field is required."), autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true }
.form-group
= label_tag :password
- = password_field_tag :password, nil, { class: "form-control gl-form-input bottom", title: _("This field is required."), data: { qa_selector: 'password_field' }, required: true }
+ = password_field_tag :password, nil, { autocomplete: 'current-password', class: "form-control gl-form-input bottom", title: _("This field is required."), data: { qa_selector: 'password_field' }, required: true }
- if !hide_remember_me && devise_mapping.rememberable?
.remember-me
%label{ for: "remember_me" }
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index 74f3e3e7e34..da6232b2a2b 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -1,6 +1,7 @@
- page_title _("Sign in")
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
+ = render "layouts/one_trust"
= render "layouts/google_tag_manager_body"
#signin-container
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 1752a43b032..bd7fe41ae8d 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -1,20 +1,20 @@
- hide_remember_me = local_assigns.fetch(:hide_remember_me, false)
.omniauth-container.gl-mt-5
- %label.label-bold.d-block
+ %label.gl-font-weight-bold
= _('Sign in with')
- providers = enabled_button_based_providers
- .d-flex.justify-content-between.flex-wrap
+ .gl-display-flex.gl-justify-content-between.gl-flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default omniauth-btn oauth-login #{qa_class_for_provider(provider)}", form: { class: 'gl-w-full' } do
+ = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-w-full js-oauth-login #{qa_class_for_provider(provider)}", form: { class: 'gl-w-full gl-mb-3' } do
- if has_icon
= provider_image_tag(provider)
%span.gl-button-text
= label_for_provider(provider)
- unless hide_remember_me
- %fieldset.remember-me
+ %fieldset
%label
- = check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
+ = check_box_tag :remember_me, nil, false
%span
= _('Remember me')
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index f9649875538..15143684b8b 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -53,6 +53,7 @@
= f.password_field :password,
class: 'form-control gl-form-input bottom',
data: { qa_selector: 'new_user_password_field' },
+ autocomplete: 'new-password',
required: true,
pattern: ".{#{@minimum_password_length},}",
title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index 43e0802ee2a..c24e8770f05 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -1,9 +1,9 @@
-%label.label-bold.d-block
+%label.gl-font-weight-bold
= _("Create an account using:")
-.d-flex.justify-content-between.flex-wrap
+.gl-display-flex.gl-justify-content-between.gl-flex-wrap
- providers.each do |provider|
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn gl-button btn-default gl-display-flex gl-align-items-center gl-text-left gl-mb-2 gl-p-2 omniauth-btn oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
- %span.ml-2
+ %span.gl-button-text
= label_for_provider(provider)
diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml
index a653d44d694..30a54ab86a6 100644
--- a/app/views/devise/shared/_signup_omniauth_providers.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers.haml
@@ -1,3 +1,3 @@
-.omniauth-divider.d-flex.align-items-center.text-center
+.omniauth-divider.gl-display-flex.gl-align-items-center
= _("or")
= render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers
diff --git a/app/views/devise/shared/_signup_omniauth_providers_top.haml b/app/views/devise/shared/_signup_omniauth_providers_top.haml
index 9a2629443ed..8eb22c0b023 100644
--- a/app/views/devise/shared/_signup_omniauth_providers_top.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers_top.haml
@@ -1,3 +1,3 @@
= render 'devise/shared/signup_omniauth_provider_list', providers: popular_enabled_button_based_providers
-.omniauth-divider.d-flex.align-items-center.text-center
+.omniauth-divider.gl-display-flex.gl-align-items-center
= _("or")
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index ea191449fe3..ab6861b5f24 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -36,4 +36,4 @@
.offset-sm-2.col-sm-10
.form-check
= f.text_field :two_factor_grace_period, class: 'form-control'
- .form-text.text-muted= _("Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication")
+ .form-text.text-muted= _("Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.")
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 0352f366f5d..e530d9c60b6 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -5,17 +5,19 @@
.group-home-panel
.row.mb-3
.home-panel-title-row.col-md-12.col-lg-6.d-flex
- .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none
+ .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.float-none{ class: 'gl-mr-3!' }
= group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo')
.d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline
- %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' }
+ %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-ml-3{ itemprop: 'name' }
= @group.name
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, options: {class: 'icon'})
- .home-panel-metadata.text-secondary
- %span
- = _("Group ID: %{group_id}") % { group_id: @group.id }
+ .home-panel-metadata.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal
+ - if can?(current_user, :read_group, @group)
+ - button_class = "btn gl-button btn-sm btn-tertiary btn-default-tertiary home-panel-metadata"
+ - button_text = s_('GroupPage|Group ID: %{group_id}') % { group_id: @group.id }
+ = clipboard_button(title: s_('GroupPage|Copy group ID'), text: @group.id, hide_button_icon: true, button_text: button_text, class: button_class, qa_selector: 'group_id_content', itemprop: 'identifier')
- if current_user
%span.gl-ml-3
= render 'shared/members/access_request_links', source: @group
diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml
index 2b9277c67e9..06a86c2465f 100644
--- a/app/views/groups/_import_group_from_another_instance_panel.html.haml
+++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml
@@ -1,16 +1,15 @@
= form_with url: configure_import_bulk_imports_path, class: 'group-form gl-show-field-errors' do |f|
.gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
- %h4.gl-display-flex
- = s_('GroupsNew|Import groups from another instance of GitLab')
- %span.badge.badge-info.badge-pill.gl-badge.md.gl-ml-3
- = _('Beta')
+ .gl-display-flex.gl-align-items-center
+ %h4.gl-display-flex
+ = s_('GroupsNew|Import groups from another instance of GitLab')
+ = link_to _('History'), history_import_bulk_imports_path, class: 'gl-link gl-ml-auto'
.gl-alert.gl-alert-warning{ role: 'alert' }
= sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
- docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') }
- - feedback_link_start = '<a href="https://gitlab.com/gitlab-org/gitlab/-/issues/284495" target="_blank" rel="noopener noreferrer">'.html_safe
- - link_end = '</a>'.html_safe
- = s_('GroupsNew|Not all related objects are migrated, as %{docs_link_start}described here%{docs_link_end}. Please %{feedback_link_start}leave feedback%{feedback_link_end} on this feature.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end, feedback_link_start: feedback_link_start, feedback_link_end: link_end }
+ - docs_link_end = '</a>'.html_safe
+ = s_('GroupsNew|Not all related objects are migrated. %{docs_link_start}More info%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
%p.gl-mt-3
= s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.')
.form-group.gl-display-flex.gl-flex-direction-column
diff --git a/app/views/groups/dependency_proxies/_url.html.haml b/app/views/groups/dependency_proxies/_url.html.haml
deleted file mode 100644
index 9a76da63a72..00000000000
--- a/app/views/groups/dependency_proxies/_url.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-- proxy_url = @group.dependency_proxy_image_prefix
-
-%h5.prepend-top-20= _('Dependency proxy image prefix')
-
-.row
- .col-lg-8.col-md-12.input-group
- = text_field_tag :url, "#{proxy_url}", class: 'js-dependency-proxy-url form-control', readonly: true
- = clipboard_button(text: "#{proxy_url}", title: _("Copy %{proxy_url}") % { proxy_url: proxy_url })
-
-.row
- .col-12.help-block.gl-mt-3{ data: { qa_selector: 'dependency_proxy_count' } }
- = _('Contains %{count} blobs of images (%{size})') % { count: @blobs_count, size: number_to_human_size(@blobs_total_size) }
diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml
index 177018af830..8936c4dcbb4 100644
--- a/app/views/groups/dependency_proxies/show.html.haml
+++ b/app/views/groups/dependency_proxies/show.html.haml
@@ -1,30 +1,5 @@
- page_title _("Dependency Proxy")
+- dependency_proxy_available = Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public?
-.settings-header
- %h4= _('Dependency proxy')
-
- %p
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/packages/dependency_proxy/index') }
- = _('Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
-
-- if Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public?
- - if can?(current_user, :admin_dependency_proxy, @group)
- = form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f|
- .form-group
- %h5.prepend-top-20= _('Enable proxy')
- .js-dependency-proxy-toggle-area
- = render "shared/buttons/project_feature_toggle", is_checked: @dependency_proxy.enabled?, label: s_("DependencyProxy|Toggle Dependency Proxy"), data: { qa_selector: 'dependency_proxy_setting_toggle' } do
- = f.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
-
- - if @dependency_proxy.enabled
- = render 'groups/dependency_proxies/url'
-
- - else
- - if @dependency_proxy.enabled
- = render 'groups/dependency_proxies/url'
-- else
- .gl-alert.gl-alert-info
- .gl-alert-container
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- = _('Dependency proxy feature is limited to public groups for now.')
+#js-dependency-proxy{ data: { group_path: @group.full_path,
+ dependency_proxy_available: dependency_proxy_available.to_s } }
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 6e355d31204..420771257c9 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title _("General Settings")
-- page_title _("General Settings")
+- breadcrumb_title _("General settings")
+- page_title _("General settings")
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
@@ -13,6 +13,7 @@
= _('Collapse')
%p
= _('Update your group name, description, avatar, and visibility.')
+ = link_to s_('Learn more about groups.'), help_page_path('user/group/index')
.settings-content
= render 'groups/settings/general'
@@ -23,7 +24,7 @@
%button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _('Advanced permissions, Large File Storage and Two-Factor authentication settings.')
+ = _('Configure advanced permissions, Large File Storage, and two-factor authentication settings.')
.settings-content
= render 'groups/settings/permissions'
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 1f746484b7d..0c6776a6038 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -6,7 +6,7 @@
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
- if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml)
- .js-issues-list{ data: group_issues_list_data(@group, current_user, @issues) }
+ .js-issues-list{ data: group_issues_list_data(@group, current_user, @issues, @projects) }
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- else
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index fa6bd021e45..2901c8fa46b 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -16,7 +16,8 @@
is_group_page: "true",
"group_path": @group.full_path,
"gid_prefix": container_repository_gid_prefix,
- character_error: @character_error.to_s,
+ connection_error: (!!@connection_error).to_s,
+ invalid_path_error: (!!@invalid_path_error).to_s,
user_callouts_path: user_callouts_path,
user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT,
show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s } }
diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml
index 66ffef98553..a76701ea5d2 100644
--- a/app/views/groups/runners/_runner.html.haml
+++ b/app/views/groups/runners/_runner.html.haml
@@ -39,7 +39,7 @@
- if runner.group_type?
= _('n/a')
- else
- = runner.projects.count(:all)
+ = runner.runner_projects.count(:all)
.table-section.section-5
.table-mobile-header{ role: 'rowheader' }= _('Jobs')
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 7a2d5c91af6..ed76a9fe253 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -14,8 +14,9 @@
.row.gl-mt-3
.form-group.col-md-9
- = f.label :description, _('Group description (optional)'), class: 'label-bold'
+ = f.label :description, _('Group description'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
+ .form-text.text-muted= _('Optional.')
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
diff --git a/app/views/groups/settings/_lfs.html.haml b/app/views/groups/settings/_lfs.html.haml
index 1255a2901ea..9f04b579a97 100644
--- a/app/views/groups/settings/_lfs.html.haml
+++ b/app/views/groups/settings/_lfs.html.haml
@@ -3,10 +3,10 @@
%h5= _('Large File Storage')
-%p= s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
+%p= s_('%{docs_link_start}What is Large File Storage?%{docs_link_end}').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :lfs_enabled,
_('Allow projects within this group to use Git LFS'),
- help_text: _('This setting can be overridden in each project.'),
+ help_text: _('Can be overridden in each project.'),
checkbox_options: { checked: @group.lfs_enabled?, data: { qa_selector: 'lfs_checkbox' } }
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 8f428909e60..eb38aa43881 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -7,7 +7,7 @@
- if @group.root?
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :prevent_sharing_groups_outside_hierarchy,
- s_('GroupSettings|Prevent members from sending invitations to groups outside of %{group} and its subgroups.').html_safe % { group: link_to_group(@group) },
+ s_('GroupSettings|Prevent members from sending invitations to groups outside of %{group} and its subgroups').html_safe % { group: link_to_group(@group) },
help_text: prevent_sharing_groups_outside_hierarchy_help_text(@group),
checkbox_options: { disabled: !can_change_prevent_sharing_groups_outside_hierarchy?(@group) }
@@ -21,13 +21,13 @@
= f.gitlab_ui_checkbox_component :emails_disabled,
s_('GroupSettings|Disable email notifications'),
checkbox_options: { checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group) },
- help_text: s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
+ help_text: s_('GroupSettings|Overrides user notification preferences for all members of the group, subgroups, and projects.')
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :mentions_disabled,
s_('GroupSettings|Disable group mentions'),
checkbox_options: { checked: @group.mentions_disabled? },
- help_text: s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
+ help_text: s_('GroupSettings|Prevents group members from being notified if the group is mentioned.')
= render 'groups/settings/project_access_token_creation', f: f, group: @group
= render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group
diff --git a/app/views/groups/settings/_two_factor_auth.html.haml b/app/views/groups/settings/_two_factor_auth.html.haml
index 8204cafcb44..f86bcb24e63 100644
--- a/app/views/groups/settings/_two_factor_auth.html.haml
+++ b/app/views/groups/settings/_two_factor_auth.html.haml
@@ -4,16 +4,16 @@
%h5= _('Two-factor authentication')
-%p= s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
+%p= s_('%{docs_link_start}What is two-factor authentication?%{docs_link_end}').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
.form-group
= f.gitlab_ui_checkbox_component :require_two_factor_authentication,
- _('Require all users in this group to setup two-factor authentication'),
+ _('Require all users in this group to set up two-factor authentication'),
checkbox_options: { data: { qa_selector: 'require_2fa_checkbox' } }
.form-group
- = f.label :two_factor_grace_period, _('Time before enforced'), class: 'label-bold'
- = f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto'
- .form-text.text-muted= _('Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication')
+ = f.label :two_factor_grace_period, _('Time before enforced')
+ = f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto gl-form-input gl-mb-3'
+ .form-text.text-muted= _('Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.')
- unless group.has_parent?
.form-group
= f.gitlab_ui_checkbox_component :allow_mfa_for_subgroups,
diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml
index 1a12ad4902b..7be6dc73c49 100644
--- a/app/views/groups/settings/packages_and_registries/show.html.haml
+++ b/app/views/groups/settings/packages_and_registries/show.html.haml
@@ -1,5 +1,8 @@
- breadcrumb_title _('Packages & Registries')
- page_title _('Packages & Registries')
- @content_class = 'limit-container-width' unless fluid_layout
+- dependency_proxy_available = Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public?
-%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s, group_path: @group.full_path } }
+%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s,
+ group_path: @group.full_path,
+ dependency_proxy_available: dependency_proxy_available.to_s } }
diff --git a/app/views/help/instance_configuration/_gitlab_pages.html.haml b/app/views/help/instance_configuration/_gitlab_pages.html.haml
index 51835c202d6..1d8958c93e8 100644
--- a/app/views/help/instance_configuration/_gitlab_pages.html.haml
+++ b/app/views/help/instance_configuration/_gitlab_pages.html.haml
@@ -7,7 +7,7 @@
= _('GitLab Pages')
%p
- - link_to_gitlab_pages = link_to(_('GitLab Pages'), gitlab_pages[:url], target: '_blank')
+ - link_to_gitlab_pages = link_to(_('GitLab Pages'), gitlab_pages[:url], target: '_blank', rel: 'noopener noreferrer')
= _('Below are the settings for %{link_to_gitlab_pages}.').html_safe % { link_to_gitlab_pages: link_to_gitlab_pages }
.table-responsive
%table
diff --git a/app/views/help/instance_configuration/_rate_limits.html.haml b/app/views/help/instance_configuration/_rate_limits.html.haml
index d72bd845c5b..ed71b5a609c 100644
--- a/app/views/help/instance_configuration/_rate_limits.html.haml
+++ b/app/views/help/instance_configuration/_rate_limits.html.haml
@@ -24,6 +24,7 @@
= render 'help/instance_configuration/rate_limit_row', title: _('Protected Paths: requests'), rate_limit: rate_limits[:protected_paths]
= render 'help/instance_configuration/rate_limit_row', title: _('Package Registry: unauthenticated API requests'), rate_limit: rate_limits[:unauthenticated_packages_api], public_visible: true
= render 'help/instance_configuration/rate_limit_row', title: _('Package Registry: authenticated API requests'), rate_limit: rate_limits[:authenticated_packages_api]
+ = render 'help/instance_configuration/rate_limit_row', title: _('Authenticated Git LFS requests'), rate_limit: rate_limits[:authenticated_git_lfs_api]
= render 'help/instance_configuration/rate_limit_row', title: _('Issue creation requests'), rate_limit: rate_limits[:issue_creation]
= render 'help/instance_configuration/rate_limit_row', title: _('Note creation requests'), rate_limit: rate_limits[:note_creation]
= render 'help/instance_configuration/rate_limit_row', title: _('Project export requests'), rate_limit: rate_limits[:project_export]
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 02a8f3142c6..8f18d68fd55 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -6,7 +6,7 @@
- provider_title = Gitlab::ImportSources.title(provider)
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title,
can_select_namespace: current_user.can_select_namespace?.to_s,
diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml
index ce6bdd7a2fb..721447186a6 100644
--- a/app/views/import/bitbucket_server/new.html.haml
+++ b/app/views/import/bitbucket_server/new.html.haml
@@ -1,6 +1,6 @@
- page_title _('Bitbucket Server Import')
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h3.page-title.d-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
diff --git a/app/views/import/bulk_imports/history.html.haml b/app/views/import/bulk_imports/history.html.haml
new file mode 100644
index 00000000000..80eb0c7a764
--- /dev/null
+++ b/app/views/import/bulk_imports/history.html.haml
@@ -0,0 +1,6 @@
+- add_to_breadcrumbs _('New group'), new_group_path
+- add_to_breadcrumbs _('Import group'), new_group_path(anchor: 'import-group-pane')
+- add_page_specific_style 'page_bundles/import'
+- page_title _('Import history')
+
+#import-history-mount-element
diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml
index 51156797270..d716d08529c 100644
--- a/app/views/import/fogbugz/new.html.haml
+++ b/app/views/import/fogbugz/new.html.haml
@@ -1,6 +1,6 @@
- page_title _("FogBugz Import")
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h3.page-title.d-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml
index 4281d77e833..93572e14a65 100644
--- a/app/views/import/fogbugz/new_user_map.html.haml
+++ b/app/views/import/fogbugz/new_user_map.html.haml
@@ -1,6 +1,6 @@
- page_title _('User map'), _('FogBugz import')
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h3.page-title.d-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml
index 288ae5f1cec..de717ce87eb 100644
--- a/app/views/import/gitea/new.html.haml
+++ b/app/views/import/gitea/new.html.haml
@@ -1,6 +1,6 @@
- page_title _("Gitea Import")
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h3.page-title
= custom_icon('gitea_logo')
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index 3407f9202bf..3f7f929f766 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -1,7 +1,7 @@
- title = _('Authenticate with GitHub')
- page_title title
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h3.page-title
= title
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index 028268482cd..533d0d13be3 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -1,6 +1,6 @@
- page_title _("GitLab Import")
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h3.page-title.d-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
diff --git a/app/views/import/manifest/new.html.haml b/app/views/import/manifest/new.html.haml
index bfaff3bb300..a949e14e273 100644
--- a/app/views/import/manifest/new.html.haml
+++ b/app/views/import/manifest/new.html.haml
@@ -1,6 +1,6 @@
- page_title _("Manifest file import")
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h3.page-title
diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml
index 9596fdb615a..0249dc446e4 100644
--- a/app/views/import/phabricator/new.html.haml
+++ b/app/views/import/phabricator/new.html.haml
@@ -1,6 +1,6 @@
- page_title _('Phabricator Server Import')
- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project')
+- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h3.page-title.d-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
diff --git a/app/views/layouts/_one_trust.html.haml b/app/views/layouts/_one_trust.html.haml
new file mode 100644
index 00000000000..4fab017d273
--- /dev/null
+++ b/app/views/layouts/_one_trust.html.haml
@@ -0,0 +1,16 @@
+- if one_trust_enabled?
+ - one_trust_id = sanitize(extra_config.one_trust_id, scrubber: Rails::Html::TextOnlyScrubber.new)
+
+ <!-- OneTrust -->
+ = javascript_include_tag "https://cdn.cookielaw.org/consent/#{one_trust_id}/OtAutoBlock.js"
+ = javascript_tag nonce: content_security_policy_nonce do
+ :plain
+ const oneTrustScript = document.createElement('script');
+ oneTrustScript.src = 'https://cdn.cookielaw.org/scripttemplates/otSDKStub.js';
+ oneTrustScript.dataset.domainScript = '#{one_trust_id}';
+ oneTrustScript.nonce = '#{content_security_policy_nonce}'
+ oneTrustScript.charset = 'UTF-8';
+ oneTrustScript.defer = true;
+ document.head.appendChild(oneTrustScript);
+
+ function OptanonWrapper() { }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index ec2904245d3..dff1b5e3d04 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -17,6 +17,7 @@
= render_two_factor_auth_recovery_settings_check
= render_if_exists "layouts/header/ee_subscribable_banner"
= render_if_exists "shared/namespace_storage_limit_alert"
+ = render_if_exists "shared/namespace_user_cap_reached_alert"
= render_if_exists "shared/new_user_signups_cap_reached_alert"
= yield :page_level_alert
= yield :customize_homepage_banner
diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml
index 35cd191c600..0bf9c16b0d2 100644
--- a/app/views/layouts/_startup_js.html.haml
+++ b/app/views/layouts/_startup_js.html.haml
@@ -8,20 +8,30 @@
if (gl.startup_calls && window.fetch) {
Object.keys(gl.startup_calls).forEach(apiCall => {
- // fetch won’t send cookies in older browsers, unless you set the credentials init option.
- // We set to `same-origin` which is default value in modern browsers.
- // See https://github.com/whatwg/fetch/pull/585 for more information.
- gl.startup_calls[apiCall] = {
- fetchCall: fetch(apiCall, { credentials: 'same-origin' })
+ gl.startup_calls[apiCall] = {
+ fetchCall: fetch(apiCall, {
+ // Emulate XHR for Rails AJAX request checks
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest'
+ },
+ // fetch won’t send cookies in older browsers, unless you set the credentials init option.
+ // We set to `same-origin` which is default value in modern browsers.
+ // See https://github.com/whatwg/fetch/pull/585 for more information.
+ credentials: 'same-origin'
+ })
};
});
}
if (gl.startup_graphql_calls && window.fetch) {
+ const headers = #{page_startup_graphql_headers.to_json};
const url = `#{api_graphql_url}`
const opts = {
method: "POST",
- headers: { "Content-Type": "application/json", 'X-CSRF-Token': "#{form_authenticity_token}" },
+ headers: {
+ "Content-Type": "application/json",
+ ...headers,
+ }
};
gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 3e7155b2c0e..8d28823bfa4 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -34,7 +34,8 @@
#js-header-search.header-search{ data: { 'search-context' => search_context.to_json,
'search-path' => search_path,
'issues-path' => issues_dashboard_path,
- 'mr-path' => merge_requests_dashboard_path } }
+ 'mr-path' => merge_requests_dashboard_path,
+ 'autocomplete-path' => search_autocomplete_path } }
%input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' }
- else
= render 'layouts/search'
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
index c111714f552..02a37dac158 100644
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -19,8 +19,9 @@
= render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after
- unless @skip_current_level_breadcrumb
%li
- %h2.breadcrumbs-sub-title
+ %h2.breadcrumbs-sub-title{ data: { qa_selector: 'breadcrumb_sub_title_content' } }
= link_to @breadcrumb_title, breadcrumb_title_link
+ -# haml-lint:disable InlineJavaScript
%script{ type: 'application/ld+json' }
:plain
#{schema_breadcrumb_json}
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index d0b73a3364a..842fb23d24a 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -1,13 +1,13 @@
%aside.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation') }
.nav-sidebar-inner-scroll
.context-header
- = link_to admin_root_path, title: _('Admin Overview') do
+ = link_to admin_root_path, title: _('Admin Overview'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
%span{ class: ['avatar-container', 'settings-avatar', 'rect-avatar', 's32'] }
= sprite_icon('admin', size: 18)
%span.sidebar-context-title
= _('Admin Area')
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } }
- = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts), html_options: {class: 'home'}) do
+ = nav_link(controller: %w(dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts), html_options: {class: 'home'}) do
= link_to admin_root_path, class: 'has-sub-items' do
.nav-icon-container
= sprite_icon('overview')
@@ -35,6 +35,10 @@
= link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'groups_overview_link' } do
%span
= _('Groups')
+ = nav_link(controller: [:admin, 'admin/topics']) do
+ = link_to admin_topics_path, title: _('Topics'), data: { qa_selector: 'topics_overview_link' } do
+ %span
+ = _('Topics')
= nav_link path: 'jobs#index' do
= link_to admin_jobs_path, title: _('Jobs') do
%span
@@ -257,11 +261,6 @@
= link_to ci_cd_admin_application_settings_path, title: _('CI/CD') do
%span
= _('CI/CD')
- - if Feature.enabled?(:serverless_domain)
- = nav_link(path: 'application_settings#operations') do
- = link_to admin_serverless_domains_path, title: _('Operations') do
- %span
- = _('Operations')
= nav_link(path: 'application_settings#reporting') do
= link_to reporting_admin_application_settings_path, title: _('Reporting') do
%span
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index 4db1e532ba5..16c0c00ad3f 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -1,7 +1,7 @@
%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(current_user), 'aria-label': _('User settings') }
.nav-sidebar-inner-scroll
.context-header
- = link_to profile_path, title: _('Profile Settings') do
+ = link_to profile_path, title: _('Profile Settings'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
%span{ class: ['avatar-container', 'settings-avatar', 's32'] }
= image_tag avatar_icon_for_user(current_user, 32), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', 's32'], alt: current_user.name, data: { testid: 'sidebar-user-avatar' }
%span.sidebar-context-title= _('User Settings')
diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml
index bc678c2c429..1057e96f442 100644
--- a/app/views/profiles/_email_settings.html.haml
+++ b/app/views/profiles/_email_settings.html.haml
@@ -3,8 +3,11 @@
- email_change_disabled = local_assigns.fetch(:email_change_disabled, nil)
- read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user)
- help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text
+- password_automatically_set = @user.password_automatically_set?
= form.text_field :email, required: true, class: 'input-lg gl-form-input', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled
+- unless password_automatically_set
+ = hidden_field_tag 'user[validation_password]', :validation_password, class: 'js-password-prompt-field', help: s_("Profiles|Enter your password to confirm the email change")
= form.select :public_email, options_for_select(@user.public_verified_emails, selected: @user.public_email),
{ help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") },
control_class: 'select2 input-lg', disabled: email_change_disabled
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 2cc919fc70e..5d3e0720176 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -19,16 +19,16 @@
- unless @user.password_automatically_set?
.form-group
- = f.label :current_password, _('Current password'), class: 'label-bold'
- = f.password_field :current_password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
+ = f.label :password, _('Current password'), class: 'label-bold'
+ = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
%p.form-text.text-muted
= _('You must provide your current password in order to change it.')
.form-group
- = f.label :password, _('New password'), class: 'label-bold'
- = f.password_field :password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' }
+ = f.label :new_password, _('New password'), class: 'label-bold'
+ = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' }
.form-group
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
- = f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' }
+ = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' }
.gl-mt-3.gl-mb-3
= f.submit _('Save password'), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'save_password_button' }
- unless @user.password_automatically_set?
diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml
index 7780ffe0cb4..9154c94abb6 100644
--- a/app/views/profiles/passwords/new.html.haml
+++ b/app/views/profiles/passwords/new.html.haml
@@ -14,18 +14,18 @@
- unless @user.password_automatically_set?
.form-group.row
.col-sm-2.col-form-label
- = f.label :current_password, _('Current password')
+ = f.label :password, _('Current password')
.col-sm-10
- = f.password_field :current_password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
+ = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
.form-group.row
.col-sm-2.col-form-label
- = f.label :password, _('New password')
+ = f.label :new_password, _('New password')
.col-sm-10
- = f.password_field :password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' }
+ = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' }
.form-group.row
.col-sm-2.col-form-label
= f.label :password_confirmation, _('Password confirmation')
.col-sm-10
- = f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' }
+ = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' }
.form-actions
= f.submit _('Set new password'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'set_new_password_button' }
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 0bb4859dd1e..3e41f107e04 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -5,7 +5,7 @@
- availability = availability_values
- custom_emoji = show_status_emoji?(@user.status)
-= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
+= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
= form_errors(@user)
.row.js-search-settings-section
@@ -80,7 +80,7 @@
%p= s_("Profiles|Set your local time zone")
.col-lg-8
%h5= _("Time zone")
- = dropdown_tag(_("Select a time zone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg', title: _("Select a time zone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ = dropdown_tag(_("Select a time zone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg gl-w-full!', title: _("Select a time zone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
%input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone }
.col-lg-12
%hr
@@ -124,9 +124,11 @@
.help-block
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
%hr
- = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3'
+ = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3 js-password-prompt-btn'
= link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel'
+#password-prompt-modal
+
.modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } }
.modal-dialog
.modal-content
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index bd3cb7e60f0..00df8608957 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -50,7 +50,7 @@
- if current_password_required?
.form-group
= label_tag :current_password, _('Current password'), class: 'label-bold'
- = password_field_tag :current_password, nil, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
+ = password_field_tag :current_password, nil, autocomplete: 'current-password', required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
%p.form-text.text-muted
= _('Your current password is required to register a two-factor authenticator app.')
.gl-mt-3
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 597a22bf34a..cdcc98552f9 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -20,5 +20,6 @@
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true
#js-tree-list{ data: vue_file_list_data(project, ref) }
- - if can_edit_tree?
+ - if !Feature.enabled?(:new_dir_modal, default_enabled: :yaml) && can_edit_tree?
= render 'projects/blob/new_dir'
+
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index f2cee618849..1f2c16324fb 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -45,11 +45,10 @@
- if can?(current_user, :download_code, @project)
= cache_if(cache_enabled, [@project, :download_code], expires_in: 1.minute) do
%nav.project-stats
- .nav-links.quick-links
- - if @project.empty_repo?
- = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
- - else
- = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
+ - if @project.empty_repo?
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
+ - else
+ = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
.home-panel-home-desc.mt-1
- if @project.description.present?
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 815a3cf6966..81d9726fcdc 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -83,7 +83,7 @@
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
- = form_for @project, html: { class: 'new_project' } do |f|
+ = form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f|
%hr
= render "shared/import_form", f: f
= render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index fb7a7ef8985..256c3ebad0a 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -16,7 +16,12 @@
- if current_user.can_select_namespace?
- namespace_id = namespace_id_from(params)
- if Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml)
- .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path, namespace_id: namespace_id, root_url: root_url, track_label: track_label } }
+ .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path,
+ namespace_id: namespace_id,
+ root_url: root_url,
+ track_label: track_label,
+ user_namespace_full_path: current_user.namespace.full_path,
+ user_namespace_id: current_user.namespace.id } }
- else
.input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
.input-group-text
@@ -53,15 +58,36 @@
= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false
- if !hide_init_with_readme
- .form-group.row.initialize-with-readme-setting
- %div{ :class => "col-sm-12" }
- .form-check
- = check_box_tag 'project[initialize_with_readme]', '1', true, class: 'form-check-input', data: { qa_selector: "initialize_with_readme_checkbox", track_label: "#{track_label}", track_action: "activate_form_input", track_property: "init_with_readme", track_value: "" }
- = label_tag 'project[initialize_with_readme]', class: 'form-check-label' do
- .option-title
- %strong= s_('ProjectsNew|Initialize repository with a README')
- .option-description
- = s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.')
+ = f.label :project_configuration, class: 'label-bold' do
+ = s_('ProjectsNew|Project Configuration')
+
+ .form-group
+ .form-check.gl-mb-3
+ = check_box_tag 'project[initialize_with_readme]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_readme_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' }
+ = label_tag 'project[initialize_with_readme]', s_('ProjectsNew|Initialize repository with a README'), class: 'form-check-label'
+ .form-text.text-muted
+ = s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.')
+
+ - experiment(:new_project_sast_enabled, user: current_user) do |e|
+ - e.try do
+ .form-group
+ .form-check.gl-mb-3
+ = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
+ = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
+ = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
+ .form-text.text-muted
+ = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
+ = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
+ - e.try(:free_indicator) do
+ .form-group
+ .form-check.gl-mb-3
+ = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
+ = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
+ = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
+ %span.badge.badge-info.badge-pill.gl-badge.sm= _('Free')
+ .form-text.text-muted
+ = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
+ = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }
= link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 66e9badbafb..168b240c657 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -18,3 +18,4 @@
= render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put
= render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration?
+= render 'shared/web_ide_path'
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index b1d465d0e75..6733db69c34 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,19 +1,12 @@
- page_title _('Branches')
- add_to_breadcrumbs(_('Repository'), project_tree_path(@project))
-.top-area.adjust
- %ul.nav-links.issues-state-filters.nav.nav-tabs
- %li{ class: active_when(@mode == 'overview') }>
- = link_to s_('Branches|Overview'), project_branches_path(@project), title: s_('Branches|Show overview of the branches')
-
- %li{ class: active_when(@mode == 'active') }>
- = link_to s_('Branches|Active'), project_branches_filtered_path(@project, state: 'active'), title: s_('Branches|Show active branches')
-
- %li{ class: active_when(@mode == 'stale') }>
- = link_to s_('Branches|Stale'), project_branches_filtered_path(@project, state: 'stale'), title: s_('Branches|Show stale branches')
-
- %li{ class: active_when(!%w[overview active stale].include?(@mode)) }>
- = link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), title: s_('Branches|Show all branches')
+.top-area.gl-border-0
+ = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-b-0' }) do
+ = gl_tab_link_to s_('Branches|Overview'), project_branches_path(@project), { item_active: @mode == 'overview', title: s_('Branches|Show overview of the branches') }
+ = gl_tab_link_to s_('Branches|Active'), project_branches_filtered_path(@project, state: 'active'), { title: s_('Branches|Show active branches') }
+ = gl_tab_link_to s_('Branches|Stale'), project_branches_filtered_path(@project, state: 'stale'), { title: s_('Branches|Show stale branches') }
+ = gl_tab_link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), { item_active: !%w[overview active stale].include?(@mode), title: s_('Branches|Show all branches') }
.nav-controls
#js-branches-sort-dropdown{ data: { project_branches_filtered_path: project_branches_path(@project, state: 'all'), sort_options: branches_sort_options_hash.to_json, mode: @mode } }
@@ -38,7 +31,10 @@
%h5
= s_('Branches|Protected branches can be managed in %{project_settings_link}.').html_safe % { project_settings_link: project_settings_link }
-- if @mode == 'overview' && (@active_branches.any? || @stale_branches.any?)
+- if @gitaly_unavailable
+ = render 'shared/errors/gitaly_unavailable', reason: s_('Branches|Unable to load branches')
+
+- elsif @mode == 'overview' && (@active_branches.any? || @stale_branches.any?)
= render "projects/branches/panel", branches: @active_branches, state: 'active', panel_title: s_('Branches|Active branches'), show_more_text: s_('Branches|Show more active branches'), project: @project, overview_max_branches: @overview_max_branches
= render "projects/branches/panel", branches: @stale_branches, state: 'stale', panel_title: s_('Branches|Stale branches'), show_more_text: s_('Branches|Show more stale branches'), project: @project, overview_max_branches: @overview_max_branches
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 27858932e5e..8ee7910de4b 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -31,4 +31,5 @@
.form-actions
= button_tag 'Create branch', class: 'gl-button btn btn-confirm'
= link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel'
+-# haml-lint:disable InlineJavaScript
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml
index 674765e9f89..ce6f7553ab4 100644
--- a/app/views/projects/ci/pipeline_editor/show.html.haml
+++ b/app/views/projects/ci/pipeline_editor/show.html.haml
@@ -1,3 +1,5 @@
+- add_page_specific_style 'page_bundles/pipelines'
+
- page_title s_('Pipelines|Pipeline Editor')
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco')
diff --git a/app/views/projects/cluster_agents/show.html.haml b/app/views/projects/cluster_agents/show.html.haml
new file mode 100644
index 00000000000..a2d3426d99c
--- /dev/null
+++ b/app/views/projects/cluster_agents/show.html.haml
@@ -0,0 +1,4 @@
+- add_to_breadcrumbs _('Kubernetes'), project_clusters_path(@project)
+- page_title @agent_name
+
+#js-cluster-agent-details{ data: js_cluster_agent_details_data(@agent_name, @project) }
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index bc0d14743b9..62ed50f5a0c 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -39,8 +39,14 @@
.committer
- commit_author_link = commit_author_link(commit, avatar: false, size: 24)
- - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom')
- - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago }
+ - commit_authored_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom')
+ - if commit.different_committer? && commit.committer
+ - commit_committer_link = commit_committer_link(commit)
+ - commit_committer_timeago = time_ago_with_tooltip(commit.committed_date, placement: 'bottom')
+ - commit_committer_avatar = commit_committer_avatar(commit.committer, size: 18, has_tooltip: false)
+ - commit_text = _('%{commit_author_link} authored %{commit_authored_timeago} and %{commit_committer_avatar} %{commit_committer_link} committed %{commit_committer_timeago}') % { commit_author_link: commit_author_link, commit_authored_timeago: commit_authored_timeago, commit_committer_avatar: commit_committer_avatar, commit_committer_link: commit_committer_link, commit_committer_timeago: commit_committer_timeago }
+ - else
+ - commit_text = _('%{commit_author_link} authored %{commit_authored_timeago}') % { commit_author_link: commit_author_link, commit_authored_timeago: commit_authored_timeago }
#{ commit_text.html_safe }
= render_if_exists 'projects/commits/project_namespace', show_project_name: show_project_name, project: project
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index 8270477ed3f..57dfcb8cf4a 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -27,7 +27,7 @@
= link_to deployment_path(deployment), class: 'build-link' do
#{deployment.deployable.name} (##{deployment.deployable.id})
- else
- .badge.badge-info.suggestion-help-hover{ title: s_('Deployment|This deployment was created using the API') }
+ .badge.badge-info.gl-cursor-help{ title: s_('Deployment|This deployment was created using the API') }
= s_('Deployment|API')
.table-section.section-10{ role: 'gridcell' }
diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml
index c1c9f58265d..ac8c0575077 100644
--- a/app/views/projects/feature_flags/edit.html.haml
+++ b/app/views/projects/feature_flags/edit.html.haml
@@ -4,10 +4,4 @@
- breadcrumb_title @feature_flag.name
- page_title s_('FeatureFlags|Edit Feature Flag')
-#js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag),
- project_id: @project.id,
- feature_flags_path: project_feature_flags_path(@project),
- environments_endpoint: search_project_environments_path(@project, format: :json),
- strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
- environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs'),
- feature_flag_issues_endpoint: feature_flag_issues_links_endpoint(@project, @feature_flag, current_user) } }
+#js-edit-feature-flag{ data: edit_feature_flag_data }
diff --git a/app/views/projects/google_cloud/index.html.haml b/app/views/projects/google_cloud/index.html.haml
new file mode 100644
index 00000000000..4fc66e17810
--- /dev/null
+++ b/app/views/projects/google_cloud/index.html.haml
@@ -0,0 +1,83 @@
+- breadcrumb_title _('Google Cloud')
+- page_title _('Google Cloud')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-google-cloud
+
+ %h1.gl-font-size-h1 Google Cloud
+
+ %section#js-section-google-cloud-service-accounts
+
+ %h2.gl-font-size-h2 Service Accounts
+
+ %p= _('Service Accounts keys are required to authorize GitLab to deploy your Google Cloud project.')
+
+ %table.table.b-table.gl-table
+
+ %thead
+ %tr
+ %th Environment
+ %th GCP Project ID
+ %th Service Account Key
+
+ %tbody
+
+ %tr
+ %td *
+ %td serving-salutes-453
+ %td .....
+
+ %tr
+ %td production
+ %td crimson-corey-234
+ %td .....
+
+ %tr
+ %td review/*
+ %td roving-river-379
+ %td .....
+
+ %a.gl-button.btn.btn-primary= _('Add new service account')
+
+ %br
+
+ %section#js-section-google-cloud-deployments
+
+ .row.row-fluid
+
+ .col-lg-4
+ %h2.gl-font-size-h2 Deployments
+ %p= _('Google Cloud offers several deployment targets. Select the one most suitable for your project.')
+ %p
+ = _('Deployments to Google Kubernetes Engine can be ')
+ %a{ href: '#' }= _('managed')
+ = _('in Infrastructure :: Kubernetes clusters')
+
+ .col-lg-8
+
+ %br
+
+ .gl-card.gl-mb-6
+ .gl-card-body
+ .gl-display-flex.gl-align-items-baseline
+ %strong.gl-font-lg App Engine
+ .gl-ml-auto.gl-text-gray-500 Disabled
+ %p= _('App Engine description and apps that are suitable for this deployment target')
+ %button.gl-button.btn.btn-default= _('Configure via Merge Request')
+
+ .gl-card.gl-mb-6
+ .gl-card-body
+ .gl-display-flex.gl-align-items-baseline
+ %strong.gl-font-lg Cloud Functions
+ .gl-ml-auto.gl-text-gray-500 Disabled
+ %p= _('Cloud Functions description and apps that are suitable for this deployment target')
+ %button.gl-button.btn.btn-default= _('Configure via Merge Request')
+
+ .gl-card.gl-mb-6
+ .gl-card-body
+ .gl-display-flex.gl-align-items-baseline
+ %strong.gl-font-lg Cloud Run
+ .gl-ml-auto.gl-text-gray-500 Disabled
+ %p= _('Cloud Run description and apps that are suitable for this deployment target')
+ %button.gl-button.btn.btn-default= _('Configure via Merge Request')
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
index ee4dbf5c05c..6a46b0b3510 100644
--- a/app/views/projects/hook_logs/_index.html.haml
+++ b/app/views/projects/hook_logs/_index.html.haml
@@ -1,37 +1,11 @@
-.row.gl-mt-7.gl-mb-3
+- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks')
+- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
+- link_end = '</a>'.html_safe
+
+.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0
- Recent Deliveries
- %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.
+ = _('Recent events')
+ %p= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end }
.col-lg-9
- - if hook_logs.present?
- %table.table
- %thead
- %tr
- %th Status
- %th Trigger
- %th URL
- %th Elapsed time
- %th Request time
- %th
- - hook_logs.each do |hook_log|
- %tr
- %td
- = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
- %td.d-none.d-sm-block
- %span.badge.badge-gray.deploy-project-label
- = hook_log.trigger.singularize.titleize
- %td
- = truncate(hook_log.url, length: 50)
- %td.light
- #{number_with_precision(hook_log.execution_duration, precision: 2)} sec
- %td.light
- = time_ago_with_tooltip(hook_log.created_at)
- %td
- = link_to 'View details', hook_log.present.details_path
-
- = paginate hook_logs, theme: 'gitlab'
-
- - else
- .settings-message.text-center
- You don't have any webhooks deliveries
+ = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs }
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index ebe179c3454..86dfa1929d6 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -5,8 +5,8 @@
.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0
- Request details
+ = _("Request details")
.col-lg-9
- = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3"
+ = link_to _('Resend Request'), @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index 0d69f6f69aa..8d16c3d978f 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -5,11 +5,11 @@
- can_edit = can?(current_user, :admin_project, @project)
- notification_email = @current_user.present? ? @current_user.notification_email_or_default : nil
-.nav-controls.issues-nav-controls
+.nav-controls.issues-nav-controls.gl-font-size-0
- if show_feed_buttons
= render 'shared/issuable/feed_buttons'
- .js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, container_class: 'gl-mr-3', can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project) } }
+ .js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, container_class: 'gl-mr-3', can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } }
- if @can_bulk_update
= button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
@@ -18,4 +18,3 @@
issue: { milestone_id: finder.milestones.first.try(:id) }),
class: "gl-button btn btn-confirm",
id: "new_issue_link"
-
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 0604e89be6e..c47257eec4a 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -1,9 +1,9 @@
- if @related_branches.any?
- %h2.related-branches-title
+ %h2.gl-font-lg
= pluralize(@related_branches.size, 'Related Branch')
%ul.unstyled-list.related-merge-requests
- @related_branches.each do |branch|
- %li
+ %li.gl-display-flex.gl-align-items-center
- if branch[:pipeline_status].present?
%span.related-branch-ci-status
= render 'ci/status/icon', status: branch[:pipeline_status]
diff --git a/app/views/projects/issues/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml
index d131d20f079..bab37609c20 100644
--- a/app/views/projects/issues/_related_issues.html.haml
+++ b/app/views/projects/issues/_related_issues.html.haml
@@ -3,4 +3,3 @@
can_add_related_issues: "#{can?(current_user, :admin_issue_link, @issue)}",
help_path: help_page_path('user/project/issues/related_issues'),
show_categorized_issues: "false" } }
- - render('projects/issues/related_issues_block')
diff --git a/app/views/projects/issues/_related_issues_block.html.haml b/app/views/projects/issues/_related_issues_block.html.haml
deleted file mode 100644
index 8d986b64b1d..00000000000
--- a/app/views/projects/issues/_related_issues_block.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-.related-issues-block
- .card.card-slim
- .card-header.panel-empty-heading.border-bottom-0
- %h3.card-title.mt-0.mb-0.h5
- = _('Linked issues')
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 5a983fb5565..0e8de3c2bb8 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -32,20 +32,20 @@
.dropdown-menu.dropdown-menu-right
%ul
- if can_update_merge_request
- %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ %li= link_to _('Edit'), edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- if @merge_request.opened?
%li
= link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_merge_request_path(@merge_request), method: :put, class: "js-draft-toggle-button"
%li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] }
- = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
+ = link_to _('Close'), merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
- if can_reopen_merge_request
%li{ class: merge_request_button_visibility(@merge_request, false) }
- = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request'
+ = link_to _('Reopen'), merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request'
- unless @merge_request.merged? || current_user == @merge_request.author
- %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
+ %li= link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
- = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" }
+ = link_to _('Edit'), edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" }
- if can_update_merge_request && !are_close_and_open_buttons_hidden
= render 'projects/merge_requests/close_reopen_draft_report_toggle'
diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml
index b34cf23634c..00d12423eb9 100644
--- a/app/views/projects/merge_requests/_nav_btns.html.haml
+++ b/app/views/projects/merge_requests/_nav_btns.html.haml
@@ -5,7 +5,7 @@
.js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } }
- if @can_bulk_update
- = button_tag "Edit merge requests", class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
+ = button_tag _("Edit merge requests"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
- if merge_project
- = link_to new_merge_request_path, class: "gl-button btn btn-confirm", title: "New merge request" do
- New merge request
+ = link_to new_merge_request_path, class: "gl-button btn btn-confirm", title: _("New merge request") do
+ = _('New merge request')
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 47a0d05fc65..459742c3b81 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -19,6 +19,6 @@
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}';
window.gl.mrWidgetData.false_positive_doc_url = '#{help_page_path('user/application_security/vulnerabilities/index')}';
- window.gl.mrWidgetData.can_view_false_positive = '#{(Feature.enabled?(:vulnerability_flags, default_enabled: :yaml) && @merge_request.project.licensed_feature_available?(:sast_fp_reduction)).to_s}';
+ window.gl.mrWidgetData.can_view_false_positive = '#{@merge_request.project.licensed_feature_available?(:sast_fp_reduction).to_s}';
#js-vue-mr-widget.mr-widget
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index ee296258d04..5ba42ca7610 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -6,7 +6,7 @@
.merge-request-details.issuable-details
= render "projects/merge_requests/mr_box"
-= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, source_branch: @merge_request.source_branch
+= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch
#conflicts{ data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request),
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 7e260a03c5d..2154ef6b596 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -49,6 +49,7 @@
= render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do
.row
%section.col-md-12
+ -# haml-lint:disable InlineJavaScript
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion.js-vue-notes-event
- if @merge_request.description.present?
@@ -98,3 +99,4 @@
= render 'projects/invite_members_modal', project: @project
- if Gitlab::CurrentSettings.gitpod_enabled && !current_user&.gitpod_enabled
= render 'shared/gitpod/enable_gitpod_modal'
+= render 'shared/web_ide_path'
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index b89aa9d402e..d253ab8e32b 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 _('How do I mirror repositories?'), help_page_path('user/project/repository/repository_mirroring'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('How do I mirror repositories?'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- if mirror_settings_enabled
@@ -32,7 +32,7 @@
= label_tag :only_protected_branches, _('Mirror only protected branches'), class: 'form-check-label'
.form-text.text-muted
= _('If enabled, only protected branches will be mirrored.')
- = link_to _('Learn more.'), help_page_path('user/project/repository/repository_mirroring', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer'
.panel-footer
= f.submit _('Mirror repository'), class: 'gl-button btn btn-confirm js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml
index b3e0f71bf19..339c5d82919 100644
--- a/app/views/projects/mirrors/_mirror_repos_push.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml
@@ -12,4 +12,4 @@
= label_tag :keep_divergent_refs, _('Keep divergent refs'), class: 'form-check-label'
.form-text.text-muted
- link_opening_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
- = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/repository_mirroring', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe }
+ = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/mirror/push.md', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe }
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 23606e24563..93afddce779 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -70,10 +70,10 @@
= link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'gl-button btn btn-default btn-icon' do
= sprite_icon('repeat', css_class: 'gl-icon')
- if can?(current_user, :read_build, job)
- %tr.build-trace-row.responsive-table-border-end
+ %tr.build-log-row.responsive-table-border-end
%td
- %td.responsive-table-cell.build-trace-container{ colspan: 4 }
- %pre.build-trace.build-trace-rounded
+ %td.responsive-table-cell.build-log-container{ colspan: 4 }
+ %pre.build-log.build-log-rounded
%code.bash.js-build-output
= build_summary(build)
diff --git a/app/views/projects/product_analytics/test.html.haml b/app/views/projects/product_analytics/test.html.haml
index 60d897ee138..3204cd5fbbe 100644
--- a/app/views/projects/product_analytics/test.html.haml
+++ b/app/views/projects/product_analytics/test.html.haml
@@ -12,5 +12,6 @@
%code
= @event.as_json_wo_empty
+-# haml-lint:disable InlineJavaScript
:javascript
#{render 'tracker'}
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index bdb5f021b70..cfdbf3410b1 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -20,7 +20,8 @@
"is_admin": current_user&.admin.to_s,
"show_cleanup_policy_on_alert": show_cleanup_policy_on_alert(@project).to_s,
"cleanup_policies_settings_path": project_settings_packages_and_registries_path(@project),
- character_error: @character_error.to_s,
+ connection_error: (!!@connection_error).to_s,
+ invalid_path_error: (!!@invalid_path_error).to_s,
user_callouts_path: user_callouts_path,
user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT,
show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s, } }
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
index 6b2a1468eec..23b1ec4dea3 100644
--- a/app/views/projects/settings/operations/_error_tracking.html.haml
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -18,4 +18,5 @@
api_host: setting.api_host,
enabled: setting.enabled.to_json,
integrated: setting.integrated.to_json,
+ gitlab_dsn: setting.gitlab_dsn,
token: setting.token.present? ? '*' * 12 : nil } }
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index 8ef53c40b11..3e6acdb130a 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")
-#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet) } }
+#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } }
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet)
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 79205a51d71..d3cc409df1d 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -18,6 +18,9 @@
= render_if_exists 'projects/commits/mirror_status'
+ - if @tags_loading_error
+ = render 'shared/errors/gitaly_unavailable', reason: s_('TagsPage|Unable to load tags')
+
.tags
- if @tags.any?
%ul.flex-list.content-list
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index fe00772d1d6..4281152225a 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -54,4 +54,5 @@
.form-actions.gl-display-flex
= button_tag s_('TagsPage|Create tag'), class: 'gl-button btn btn-confirm gl-mr-3', data: { qa_selector: "create_tag_button" }
= link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'gl-button btn btn-default btn-cancel'
+-# haml-lint:disable InlineJavaScript
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 2d0c4cc20a0..1553eda1cfb 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -11,3 +11,4 @@
= render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
+= render 'shared/web_ide_path'
diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml
index dfd46af0499..6c7cccfb9b1 100644
--- a/app/views/projects/usage_quotas/index.html.haml
+++ b/app/views/projects/usage_quotas/index.html.haml
@@ -6,7 +6,7 @@
.row
.col-sm-12
= s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + '.'
- %a{ href: help_page_path('user/usage_quotas.md') }
+ %a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' }
= s_('UsageQuota|Learn more about usage quotas') + '.'
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml
index 5645fbfb238..41058034d6f 100644
--- a/app/views/search/results/_issuable.html.haml
+++ b/app/views/search/results/_issuable.html.haml
@@ -13,7 +13,8 @@
= highlight_and_truncate_issuable(issuable, @search_term, @search_highlight)
.col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right
- if issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0
- %li.issuable-upvotes.gl-list-style-none.has-tooltip{ title: _('Upvotes') }
- = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle")
- = issuable.upvotes_count
+ %li.issuable-upvotes.gl-list-style-none
+ %span.has-tooltip{ title: _('Upvotes') }
+ = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle")
+ = issuable.upvotes_count
%span.gl-text-gray-500= sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index f03314563cb..3ab2b969b75 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -9,17 +9,12 @@
= f.text_field :import_url, value: import_url.sanitized_url,
autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true
= render 'shared/global_alert',
- variant: :warning,
- alert_class: 'gl-mt-3 js-import-url-warning hide',
+ variant: :danger,
+ alert_class: 'gl-mt-3 js-import-url-error hide',
dismissible: false,
close_button_class: 'js-close-2fa-enabled-success-alert' do
.gl-alert-body
- = s_('Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct.')
-
- .gl-alert.gl-alert-not-dismissible.gl-alert-warning.gl-mt-3.hide#project_import_url_warning
- .gl-alert-container
- = sprite_icon('warning-solid', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content{ role: 'alert' }
+ = s_('Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials.')
.row
.form-group.col-md-6
= f.label :import_url_user, class: 'label-bold' do
diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml
index eb50960202a..117ed212fd9 100644
--- a/app/views/shared/_milestones_filter.html.haml
+++ b/app/views/shared/_milestones_filter.html.haml
@@ -1,13 +1,15 @@
-%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')
- %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')
- %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')
- %span.badge.badge-pill= counts[:all]
+- count_badge_classes = 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex'
+
+= gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'milestones-filter' } } ) do
+ = gl_tab_link_to milestones_filter_path(state: 'opened'), { item_active: params[:state].blank? || params[:state] == 'opened' } do
+ = _('Open')
+ %span{ class: count_badge_classes }
+ = counts[:opened]
+ = gl_tab_link_to milestones_filter_path(state: 'closed', sort: 'due_date_desc'), { item_active: params[:state] == 'closed' } do
+ = _('Closed')
+ %span{ class: count_badge_classes }
+ = counts[:closed]
+ = gl_tab_link_to milestones_filter_path(state: 'all', sort: 'due_date_desc'), { item_active: params[:state] == 'all' } do
+ = _('All')
+ %span{ class: count_badge_classes }
+ = counts[:all]
diff --git a/app/views/shared/_web_ide_path.html.haml b/app/views/shared/_web_ide_path.html.haml
new file mode 100644
index 00000000000..73d00bcd408
--- /dev/null
+++ b/app/views/shared/_web_ide_path.html.haml
@@ -0,0 +1,4 @@
+= javascript_tag do
+ :plain
+ window.gl = window.gl || {};
+ window.gl.webIDEPath = '#{web_ide_url}'
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index 98752345074..165564c5666 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -2,7 +2,7 @@
- @no_breadcrumb_container = true
- @no_container = true
- @content_wrapper_class = "#{@content_wrapper_class} gl-relative"
-- @content_class = "issue-boards-content js-focus-mode-board"
+- @content_class = "js-focus-mode-board"
- is_epic_board = board.to_type == "EpicBoard"
- if is_epic_board
- breadcrumb_title _("Epic Boards")
diff --git a/app/views/shared/builds/_build_output.html.haml b/app/views/shared/builds/_build_output.html.haml
index 380fac4d0e4..a3b7d4926f8 100644
--- a/app/views/shared/builds/_build_output.html.haml
+++ b/app/views/shared/builds/_build_output.html.haml
@@ -1,4 +1,4 @@
-%pre.build-trace#build-trace
+%pre.build-log
%code.bash.js-build-output
.build-loader-animation.js-build-refresh
.dot
diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml
index 4973309edf5..498e9cc33ce 100644
--- a/app/views/shared/builds/_tabs.html.haml
+++ b/app/views/shared/builds/_tabs.html.haml
@@ -1,24 +1,19 @@
-%ul.nav-links.mobile-separator.nav.nav-tabs
- %li{ class: active_when(scope.nil?) }>
- = link_to build_path_proc.call(nil) do
- All
- %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm.js-totalbuilds-count
- = limited_counter_with_delimiter(all_builds)
+- count_badge_classes = 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex'
- %li{ class: active_when(scope == 'pending') }>
- = link_to build_path_proc.call('pending') do
- Pending
- %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
- = limited_counter_with_delimiter(all_builds.pending)
-
- %li{ class: active_when(scope == 'running') }>
- = link_to build_path_proc.call('running') do
- Running
- %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
- = limited_counter_with_delimiter(all_builds.running)
-
- %li{ class: active_when(scope == 'finished') }>
- = link_to build_path_proc.call('finished') do
- Finished
- %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
- = limited_counter_with_delimiter(all_builds.finished)
+= gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'jobs-tabs' } } ) do
+ = gl_tab_link_to build_path_proc.call(nil), { item_active: scope.nil? } do
+ = _('All')
+ %span{ class: count_badge_classes }
+ = limited_counter_with_delimiter(all_builds)
+ = gl_tab_link_to build_path_proc.call('pending'), { item_active: scope == 'pending' } do
+ = _('Pending')
+ %span{ class: count_badge_classes }
+ = limited_counter_with_delimiter(all_builds.pending)
+ = gl_tab_link_to build_path_proc.call('running'), { item_active: scope == 'running' } do
+ = _('Running')
+ %span{ class: count_badge_classes }
+ = limited_counter_with_delimiter(all_builds.running)
+ = gl_tab_link_to build_path_proc.call('finished'), { item_active: scope == 'finished' } do
+ = _('Finished')
+ %span{ class: count_badge_classes }
+ = limited_counter_with_delimiter(all_builds.finished)
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 652da4b396a..e049afbc40b 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -4,7 +4,6 @@
= s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}').html_safe % { link_start: group_deploy_tokens_help_link_start, link_end: '</a>'.html_safe }
= form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: Feature.enabled?(:ajax_new_deploy_token, group_or_project) do |f|
- = form_errors(token)
.form-group
= f.label :name, class: 'label-bold'
diff --git a/app/views/shared/empty_states/_topics.html.haml b/app/views/shared/empty_states/_topics.html.haml
new file mode 100644
index 00000000000..fd82a853037
--- /dev/null
+++ b/app/views/shared/empty_states/_topics.html.haml
@@ -0,0 +1,7 @@
+.row.empty-state
+ .col-12
+ .svg-content
+ = image_tag 'illustrations/labels.svg', data: { qa_selector: 'svg_content' }
+ .text-content.gl-text-center.gl-pt-0!
+ %h4= _('There are no topics to show.')
+ %p= _('Add topics to projects to help users find them.')
diff --git a/app/views/shared/errors/_gitaly_unavailable.html.haml b/app/views/shared/errors/_gitaly_unavailable.html.haml
new file mode 100644
index 00000000000..96a68cbcdc6
--- /dev/null
+++ b/app/views/shared/errors/_gitaly_unavailable.html.haml
@@ -0,0 +1,8 @@
+.gl-alert.gl-alert-danger.gl-mb-5.gl-mt-5
+ .gl-alert-container
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-content
+ .gl-alert-title
+ = reason
+ .gl-alert-body
+ = s_('The git server, Gitaly, is not available at this time. Please contact your administrator.')
diff --git a/app/views/shared/hook_logs/_recent_deliveries_table.html.haml b/app/views/shared/hook_logs/_recent_deliveries_table.html.haml
new file mode 100644
index 00000000000..31ef8560781
--- /dev/null
+++ b/app/views/shared/hook_logs/_recent_deliveries_table.html.haml
@@ -0,0 +1,34 @@
+%table.gl-table.gl-w-full
+ %thead
+ %tr
+ %th= _('Status')
+ %th.d-none.d-sm-table-cell= _('Trigger')
+ %th= _('Elapsed time')
+ %th= _('Request time')
+ %th
+
+ - if hook_logs.present?
+ - hook_logs.each do |hook_log|
+ %tr
+ %td
+ = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
+ %td.d-none.d-sm-table-cell
+ %span.badge.badge-pill.gl-badge.badge-muted.sm
+ = hook_log.trigger.singularize.titleize
+ %td
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} sec
+ %td
+ = time_ago_with_tooltip(hook_log.created_at)
+ %td
+ = link_to _('View details'), hook_log_path(hook, hook_log)
+
+
+- if hook_logs.present?
+ = paginate hook_logs, theme: 'gitlab'
+- else
+ .gl-text-center.gl-mt-7
+ %h4= _('No webhook events')
+ %p
+ %span.gl-display-block= _('Webhook events will be displayed here.')
+ %span= _('Use the %{strongStart}Test%{strongEnd} option above to create an event.').html_safe % { strongStart: '<strong>'.html_safe, strongEnd: '</strong>'.html_safe }
+
diff --git a/app/views/shared/hook_logs/_status_label.html.haml b/app/views/shared/hook_logs/_status_label.html.haml
index dfa5ecee448..b930074303c 100644
--- a/app/views/shared/hook_logs/_status_label.html.haml
+++ b/app/views/shared/hook_logs/_status_label.html.haml
@@ -1,3 +1,3 @@
- label_status = hook_log.success? ? 'badge-success' : 'badge-danger'
-%span{ class: "badge #{label_status}" }
+%span{ class: "badge badge-pill gl-badge sm #{label_status}" }
= hook_log.internal_error? ? _('Error') : hook_log.response_status
diff --git a/app/views/shared/integrations/_tabs.html.haml b/app/views/shared/integrations/_tabs.html.haml
index 553401e47bd..d6ca0bd7d1e 100644
--- a/app/views/shared/integrations/_tabs.html.haml
+++ b/app/views/shared/integrations/_tabs.html.haml
@@ -1,18 +1,11 @@
-- active_tab = local_assigns.fetch(:active_tab, 'edit')
-- active_classes = 'gl-tab-nav-item-active gl-tab-nav-item-active-indigo active'
-- tabs = integration_tabs(integration: integration)
-
-- if tabs.length <= 1
- = yield
-- else
+- if integration.instance_level?
.tabs.gl-tabs
%div
- %ul.nav.gl-tabs-nav{ role: 'tablist' }
- - tabs.each do |tab|
- %li.nav-item{ role: 'presentation' }
- %a.nav-link.gl-tab-nav-item{ role: 'tab', class: (active_classes if tab[:key] == active_tab), href: tab[:href] }
- = tab[:text]
+ = gl_tabs_nav({ class: 'gl-mb-5' }) do
+ = gl_tab_link_to _('Settings'), scoped_edit_integration_path(integration)
+ = gl_tab_link_to s_('Integrations|Projects using custom settings'), scoped_overrides_integration_path(integration)
- .tab-content.gl-tab-content
- .tab-pane.gl-pt-3.active{ role: 'tabpanel' }
- = yield
+ = yield
+
+- else
+ = yield
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index cff50eef88b..4a33f625347 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -2,22 +2,16 @@
- page_context_word = type.to_s.humanize(capitalize: false)
- display_count = local_assigns.fetch(:display_count, true)
-%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 open.") % { page_context_word: page_context_word }, data: { state: 'opened' } do
- #{issuables_state_counter_text(type, :opened, display_count)}
-
+= gl_tabs_nav({ class: 'issues-state-filters gl-border-b-0 gl-flex-grow-1' }) do
+ = gl_tab_link_to page_filter_path(state: 'opened'), { item_active: params[:state] == 'opened', id: 'state-opened', title: _("Filter by %{page_context_word} that are currently open.") % { 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
- #{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
- #{issuables_state_counter_text(type, :closed, display_count)}
+ = gl_tab_link_to page_filter_path(state: 'merged'), item_active: params[: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)}
+ = gl_tab_link_to page_filter_path(state: 'closed'), item_active: params[: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
- #{issuables_state_counter_text(type, :closed, display_count)}
+ = gl_tab_link_to page_filter_path(state: 'closed'), item_active: params[: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 e6c4b3f4814..81a7581d392 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -19,7 +19,7 @@
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- if @can_bulk_update
- .check-all-holder.d-none.d-sm-block.hidden
+ .check-all-holder.gl-display-none.gl-sm-display-block.hidden.gl-float-left.gl-mr-5.gl-line-height-36
- checkbox_id = 'check-all-issues'
%label.gl-sr-only{ for: checkbox_id }= _('Select all')
= check_box_tag checkbox_id, nil, false, class: "check-all-issues left"
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 1e8724c3448..62539bfeffd 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -7,6 +7,7 @@
- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit)
- add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras"
- reviewers = local_assigns.fetch(:reviewers, nil)
+- in_group_context_with_iterations = @project.group.present? && issuable_sidebar[:supports_iterations]
%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite', 'aria-label': issuable_type }
.issuable-sidebar
@@ -28,11 +29,11 @@
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_milestone]
- .block.milestone{ :class => ("gl-border-b-0!" if issuable_sidebar[:supports_iterations]), data: { qa_selector: 'milestone_block' } }
+ .block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block' } }
.js-milestone-select{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
- - if @project.group.present? && issuable_sidebar[:supports_iterations]
- .block{ class: 'gl-pt-0!', data: { qa_selector: 'iteration_container' } }
+ - if in_group_context_with_iterations
+ .block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }<
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_time_tracking]
@@ -55,11 +56,13 @@
.js-sidebar-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) }
- if issuable_sidebar.has_key?(:confidential)
+ -# haml-lint:disable InlineJavaScript
%script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
#js-confidential-entry-point
= render_if_exists 'shared/issuable/sidebar_cve_id_request', issuable_sidebar: issuable_sidebar
+ -# haml-lint:disable InlineJavaScript
%script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe
#js-lock-entry-point
@@ -72,7 +75,7 @@
#js-reference-entry-point
- if issuable_type == 'merge_request'
.sub-block.js-sidebar-source-branch
- .sidebar-collapsed-icon.dont-change-state
+ .sidebar-collapsed-icon.js-dont-change-state
= clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport')
.gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed
%span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap
diff --git a/app/views/shared/issuable/nav_links/_all.html.haml b/app/views/shared/issuable/nav_links/_all.html.haml
index c92a50bcb70..7afa194d5db 100644
--- a/app/views/shared/issuable/nav_links/_all.html.haml
+++ b/app/views/shared/issuable/nav_links/_all.html.haml
@@ -1,6 +1,5 @@
- page_context_word = local_assigns.fetch(:page_context_word)
- counter = local_assigns.fetch(:counter)
-%li{ class: active_when(params[:state] == 'all') }>
- = link_to page_filter_path(state: 'all'), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do
- #{counter}
+= gl_tab_link_to page_filter_path(state: 'all'), item_active: params[:state] == 'all', id: 'state-all', title: _("Show all %{issuable_type}.") % { issuable_type: page_context_word }, data: { state: 'all' } do
+ #{counter}
diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml
index 26d30341999..d2c851a4e49 100644
--- a/app/views/shared/issue_type/_emoji_block.html.haml
+++ b/app/views/shared/issue_type/_emoji_block.html.haml
@@ -4,7 +4,7 @@
.row.gl-m-0.gl-justify-content-space-between
.js-noteable-awards
= render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path
- .new-branch-col.gl-my-2
+ .new-branch-col.gl-my-2.gl-font-size-0
= render_if_exists "projects/issues/timeline_toggle", issuable: issuable
#js-vue-sort-issue-discussions
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(issuable), notes_filters: UserPreference.notes_filters.to_json } }
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 56b2b0d5801..c66ba5ba2e1 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -161,11 +161,11 @@
- milestone_ref = milestone.try(:to_reference, full: true)
- if milestone_ref.present?
.block.reference
- .sidebar-collapsed-icon.dont-change-state
+ .sidebar-collapsed-icon.js-dont-change-state
= clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport')
.cross-project-reference.hide-collapsed
- %span
+ %span.gl-display-inline-block.gl-text-truncate
= s_('MilestoneSidebar|Reference:')
%span{ title: milestone_ref }
= milestone_ref
- = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport')
+ = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport', class: 'btn-clipboard btn-transparent gl-float-right gl-bg-gray-10')
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index d0a2d97df0f..3e880a36e29 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -1,31 +1,4 @@
- noteable_name = @note.noteable.human_class_name
-.float-left.btn-group.gl-sm-mr-3.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown
+.js-comment-type-dropdown.float-left.gl-sm-mr-3{ data: { noteable_name: noteable_name } }
%input.btn.gl-button.btn-confirm.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } }
-
- - if @note.can_be_discussion_note?
- = button_tag type: 'button', class: 'gl-button btn dropdown-toggle btn-confirm js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do
- = sprite_icon('chevron-down')
-
- %ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } }
- %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => _('Comment'), 'close-text' => _("Comment & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Comment & reopen %{noteable_name}") % { noteable_name: noteable_name } } }
- %button.btn.gl-button.btn-default-tertiary
- = sprite_icon('check', css_class: 'icon')
- .description
- %strong= _("Comment")
- %p
- = _("Add a general comment to this %{noteable_name}.") % { noteable_name: noteable_name }
-
- %li.divider.droplab-item-ignore
-
- %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start thread'), 'close-text' => _("Start thread & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start thread & reopen %{noteable_name}") % { noteable_name: noteable_name } } }
- %button.btn.gl-button.btn-default-tertiary
- = sprite_icon('check', css_class: 'icon')
- .description
- %strong= _("Start thread")
- %p
- = succeed '.' do
- - if @note.noteable.supports_resolvable_notes?
- = _('Discuss a specific suggestion or question that needs to be resolved')
- - else
- = _('Discuss a specific suggestion or question')
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index a03e8446f5d..6231f817704 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -1,4 +1,5 @@
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
+- supports_file_upload = local_assigns.fetch(:supports_file_upload, true)
.comment-toolbar.clearfix
.toolbar-text
= link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank'
@@ -10,33 +11,34 @@
is
supported
- %span.uploading-container
- %span.uploading-progress-container.hide
- = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
- %span.attaching-file-message
- -# Populated by app/assets/javascripts/dropzone_input.js
- %span.uploading-progress 0%
- = loading_icon(css_class: 'align-text-bottom gl-mr-2')
-
- %span.uploading-error-container.hide
- %span.uploading-error-icon
+ - if supports_file_upload
+ %span.uploading-container
+ %span.uploading-progress-container.hide
= sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
- %span.uploading-error-message
- -# Populated by app/assets/javascripts/dropzone_input.js
- %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link
- %span.gl-button-text
- = _("Try again")
- = _("or")
- %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline
- %span.gl-button-text
- = _("attach a new file")
- = _(".")
+ %span.attaching-file-message
+ -# Populated by app/assets/javascripts/dropzone_input.js
+ %span.uploading-progress 0%
+ = loading_icon(css_class: 'align-text-bottom gl-mr-2')
- %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom
- = sprite_icon('media')
- %span.gl-button-text
- = _("Attach a file")
+ %span.uploading-error-container.hide
+ %span.uploading-error-icon
+ = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
+ %span.uploading-error-message
+ -# Populated by app/assets/javascripts/dropzone_input.js
+ %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link
+ %span.gl-button-text
+ = _("Try again")
+ = _("or")
+ %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline
+ %span.gl-button-text
+ = _("attach a new file")
+ = _(".")
- %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide
- %span.gl-button-text
- = _("Cancel")
+ %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom
+ = sprite_icon('media')
+ %span.gl-button-text
+ = _("Attach a file")
+
+ %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide
+ %span.gl-button-text
+ = _("Cancel")
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index f7f5c02370d..e34f412baa4 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -25,4 +25,5 @@
= sprite_icon('lock', css_class: 'icon')
%span
= html_escape(_("This %{issuable} is locked. Only %{strong_open}project members%{strong_close} can comment.")) % { issuable: issuable.class.to_s.titleize.downcase, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
+-# haml-lint:disable InlineJavaScript
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
diff --git a/app/views/shared/topics/_search_form.html.haml b/app/views/shared/topics/_search_form.html.haml
new file mode 100644
index 00000000000..97343983b3c
--- /dev/null
+++ b/app/views/shared/topics/_search_form.html.haml
@@ -0,0 +1,7 @@
+= form_tag page_filter_path, method: :get, class: "topic-filter-form js-topic-filter-form", id: 'topic-filter-form' do |f|
+ = search_field_tag :search, params[:search],
+ placeholder: s_('Filter by name'),
+ class: 'topic-filter-form-field form-control input-short',
+ spellcheck: false,
+ id: 'topic-filter-form-field',
+ autofocus: local_assigns[:autofocus]
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index abe23d0be78..fd124c2967d 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -5,12 +5,12 @@
%div
- hook.class.triggers.each_value do |trigger|
- if hook.public_send(trigger)
- %span.gl-badge.gl-bg-gray-10.gl-mt-2.rounded.deploy-project-label= trigger.to_s.titleize
- %span.gl-badge.gl-bg-gray-10.gl-mt-2.rounded
+ %span.gl-badge.badge-muted.badge-pill.sm.gl-mt-2.deploy-project-label= trigger.to_s.titleize
+ %span.gl-badge.badge-muted.badge-pill.sm.gl-mt-2
= _('SSL Verification:')
= hook.enable_ssl_verification ? _('enabled') : _('disabled')
.col-md-4.col-lg-5.text-right-md.gl-mt-2
%span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm btn-default gl-mr-3'
%span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn gl-button btn-default btn-sm gl-mr-3'
- = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn gl-button btn-default btn-sm'
+ = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn gl-button btn-secondary btn-danger-secondary btn-sm'
diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml
index 15710f0df49..e0860bc473d 100644
--- a/app/views/shared/wikis/edit.html.haml
+++ b/app/views/shared/wikis/edit.html.haml
@@ -4,7 +4,7 @@
- if @error
#js-wiki-error{ data: { error: @error, wiki_page_path: wiki_page_path(@wiki, @page) } }
-.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
+.js-wiki-edit-page.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
= wiki_sidebar_toggle_button
%h3.page-title.gl-flex-grow-1
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index ca52a1f8f46..f1093a3b730 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -12,7 +12,7 @@
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco', prefetch: true)
-#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet) } }
+#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } }
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 20cbe08225e..522f0f771cd 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -84,10 +84,12 @@
= sprite_icon('location', css_class: 'fgray')
%span{ itemprop: 'addressLocality' }
= @user.location
- = render 'middle_dot_divider', stacking: true do
- = sprite_icon('clock', css_class: 'fgray')
- %span
- = local_time(@user.timezone)
+ - user_local_time = local_time(@user.timezone)
+ - unless user_local_time.nil?
+ = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do
+ = sprite_icon('clock', css_class: 'fgray')
+ %span
+ = user_local_time
- unless work_information(@user).blank?
= render 'middle_dot_divider', stacking: true do
= sprite_icon('work', css_class: 'fgray')
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 955674b52a4..c7ce2eb8d00 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -30,6 +30,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: authorized_project_update:authorized_project_update_project_recalculate_per_user
+ :worker_name: AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_from_replica
:worker_name: AuthorizedProjectUpdate::UserRefreshFromReplicaWorker
:feature_category: :authentication_and_authorization
@@ -46,7 +55,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent: true
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency
:worker_name: AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker
@@ -185,7 +194,7 @@
:tags: []
- :name: cronjob:ci_delete_unit_tests
:worker_name: Ci::DeleteUnitTestsWorker
- :feature_category: :continuous_integration
+ :feature_category: :code_testing
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -194,7 +203,7 @@
:tags: []
- :name: cronjob:ci_pipeline_artifacts_expire_artifacts
:worker_name: Ci::PipelineArtifacts::ExpireArtifactsWorker
- :feature_category: :continuous_integration
+ :feature_category: :build_artifacts
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -219,6 +228,24 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:ci_stuck_builds_drop_running
+ :worker_name: Ci::StuckBuilds::DropRunningWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: cronjob:ci_stuck_builds_drop_scheduled
+ :worker_name: Ci::StuckBuilds::DropScheduledWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:container_expiration_policy
:worker_name: ContainerExpirationPolicyWorker
:feature_category: :container_registry
@@ -255,6 +282,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:dependency_proxy_image_ttl_group_policy
+ :worker_name: DependencyProxy::ImageTtlGroupPolicyWorker
+ :feature_category: :dependency_proxy
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: cronjob:environments_auto_delete_cron
:worker_name: Environments::AutoDeleteCronWorker
:feature_category: :continuous_delivery
@@ -275,7 +311,7 @@
:tags: []
- :name: cronjob:expire_build_artifacts
:worker_name: ExpireBuildArtifactsWorker
- :feature_category: :continuous_integration
+ :feature_category: :build_artifacts
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -347,7 +383,7 @@
:tags: []
- :name: cronjob:namespaces_in_product_marketing_emails
:worker_name: Namespaces::InProductMarketingEmailsWorker
- :feature_category: :subgroups
+ :feature_category: :experimentation_activation
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -557,7 +593,7 @@
:feature_category: :continuous_integration
:has_external_dependencies:
:urgency: :low
- :resource_boundary: :cpu
+ :resource_boundary: :unknown
:weight: 1
:idempotent:
:tags: []
@@ -642,6 +678,24 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: dependency_proxy_blob:dependency_proxy_cleanup_blob
+ :worker_name: DependencyProxy::CleanupBlobWorker
+ :feature_category: :dependency_proxy
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: dependency_proxy_manifest:dependency_proxy_cleanup_manifest
+ :worker_name: DependencyProxy::CleanupManifestWorker
+ :feature_category: :dependency_proxy
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: deployment:deployments_drop_older_deployments
:worker_name: Deployments::DropOlderDeploymentsWorker
:feature_category: :continuous_delivery
@@ -1418,7 +1472,7 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 3
- :idempotent:
+ :idempotent: true
:tags: []
- :name: pipeline_cache:expire_pipeline_cache
:worker_name: ExpirePipelineCacheWorker
@@ -1474,6 +1528,15 @@
:weight: 3
:idempotent:
:tags: []
+- :name: pipeline_default:ci_create_downstream_pipeline
+ :worker_name: Ci::CreateDownstreamPipelineWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 3
+ :idempotent:
+ :tags: []
- :name: pipeline_default:ci_drop_pipeline
:worker_name: Ci::DropPipelineWorker
:feature_category: :continuous_integration
@@ -1890,7 +1953,7 @@
:tags: []
- :name: default
:worker_name:
- :feature_category:
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency:
:resource_boundary:
@@ -2044,7 +2107,7 @@
:tags: []
- :name: expire_build_instance_artifacts
:worker_name: ExpireBuildInstanceArtifactsWorker
- :feature_category: :continuous_integration
+ :feature_category: :build_artifacts
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -2215,7 +2278,7 @@
:tags: []
- :name: mailers
:worker_name: ActionMailer::MailDeliveryJob
- :feature_category: :issue_tracking
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: low
:resource_boundary:
@@ -2314,7 +2377,7 @@
:tags: []
- :name: namespaces_onboarding_issue_created
:worker_name: Namespaces::OnboardingIssueCreatedWorker
- :feature_category: :issue_tracking
+ :feature_category: :onboarding
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -2323,7 +2386,7 @@
:tags: []
- :name: namespaces_onboarding_pipeline_created
:worker_name: Namespaces::OnboardingPipelineCreatedWorker
- :feature_category: :subgroups
+ :feature_category: :onboarding
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -2332,7 +2395,7 @@
:tags: []
- :name: namespaces_onboarding_progress
:worker_name: Namespaces::OnboardingProgressWorker
- :feature_category: :product_analytics
+ :feature_category: :onboarding
:has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
@@ -2341,7 +2404,7 @@
:tags: []
- :name: namespaces_onboarding_user_added
:worker_name: Namespaces::OnboardingUserAddedWorker
- :feature_category: :users
+ :feature_category: :onboarding
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -2411,15 +2474,6 @@
:weight: 1
:idempotent:
:tags: []
-- :name: pages_remove
- :worker_name: PagesRemoveWorker
- :feature_category: :pages
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent:
- :tags: []
- :name: pages_transfer
:worker_name: PagesTransferWorker
:feature_category: :pages
diff --git a/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb
new file mode 100644
index 00000000000..352c82e5021
--- /dev/null
+++ b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module AuthorizedProjectUpdate
+ class ProjectRecalculatePerUserWorker < ProjectRecalculateWorker
+ data_consistency :always
+
+ feature_category :authentication_and_authorization
+ urgency :high
+ queue_namespace :authorized_project_update
+
+ deduplicate :until_executing, including_scheduled: true
+ idempotent!
+
+ def perform(project_id, user_id)
+ project = Project.find_by_id(project_id)
+ user = User.find_by_id(user_id)
+
+ return unless project && user
+
+ in_lock(lock_key(project), ttl: 10.seconds) do
+ AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/authorized_project_update/project_recalculate_worker.rb b/app/workers/authorized_project_update/project_recalculate_worker.rb
index 4d350d95e7e..3d073f18622 100644
--- a/app/workers/authorized_project_update/project_recalculate_worker.rb
+++ b/app/workers/authorized_project_update/project_recalculate_worker.rb
@@ -26,7 +26,9 @@ module AuthorizedProjectUpdate
private
def lock_key(project)
- "#{self.class.name.underscore}/projects/#{project.id}"
+ # The self.class.name.underscore value is hardcoded here as the prefix, so that the same
+ # lock_key for this superclass will be used by the ProjectRecalculatePerUserWorker subclass.
+ "authorized_project_update/project_recalculate_worker/projects/#{project.id}"
end
end
end
diff --git a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
index 48e3d0837c7..daebb23baae 100644
--- a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
@@ -30,8 +30,6 @@ module AuthorizedProjectUpdate
# does not allow us to deduplicate these jobs.
# https://gitlab.com/gitlab-org/gitlab/-/issues/325291
def use_replica_if_available(&block)
- return yield unless ::Gitlab::Database::LoadBalancing.enable?
-
::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&block)
end
diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
index ab4d9c13422..f5327449242 100644
--- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
@@ -19,11 +19,10 @@ module AuthorizedProjectUpdate
feature_category :authentication_and_authorization
urgency :low
queue_namespace :authorized_project_update
- # This job will not be deduplicated since it is marked with
- # `data_consistency :delayed` and not `idempotent!`
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/325291
+
deduplicate :until_executing, including_scheduled: true
data_consistency :delayed
+ idempotent!
def perform(start_user_id, end_user_id)
User.where(id: start_user_id..end_user_id).find_each do |user| # rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb
index fa255d064cc..d560ebcc6e6 100644
--- a/app/workers/bulk_import_worker.rb
+++ b/app/workers/bulk_import_worker.rb
@@ -26,7 +26,7 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
created_entities.first(next_batch_size).each do |entity|
entity.create_pipeline_trackers!
- BulkImports::ExportRequestWorker.perform_async(entity.id) if entity.group_entity?
+ BulkImports::ExportRequestWorker.perform_async(entity.id)
BulkImports::EntityWorker.perform_async(entity.id)
entity.start!
diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb
index d5f7215b08a..8bc0acc9b22 100644
--- a/app/workers/bulk_imports/export_request_worker.rb
+++ b/app/workers/bulk_imports/export_request_worker.rb
@@ -10,8 +10,6 @@ module BulkImports
worker_has_external_dependencies!
feature_category :importers
- GROUP_EXPORTED_URL_PATH = "/groups/%s/export_relations"
-
def perform(entity_id)
entity = BulkImports::Entity.find(entity_id)
@@ -21,8 +19,7 @@ module BulkImports
private
def request_export(entity)
- http_client(entity.bulk_import.configuration)
- .post(GROUP_EXPORTED_URL_PATH % entity.encoded_source_full_path)
+ http_client(entity.bulk_import.configuration).post(entity.export_relations_url_path)
end
def http_client(configuration)
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index 760a309a381..35633b55489 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -16,7 +16,7 @@ module BulkImports
def perform(pipeline_tracker_id, stage, entity_id)
pipeline_tracker = ::BulkImports::Tracker
- .with_status(:created)
+ .with_status(:created, :started)
.find_by_id(pipeline_tracker_id)
if pipeline_tracker.present?
@@ -59,18 +59,35 @@ module BulkImports
pipeline_tracker.pipeline_class.new(context).run
pipeline_tracker.finish!
+ rescue BulkImports::NetworkError => e
+ if e.retriable?(pipeline_tracker)
+ logger.error(
+ worker: self.class.name,
+ entity_id: pipeline_tracker.entity.id,
+ pipeline_name: pipeline_tracker.pipeline_name,
+ message: "Retrying error: #{e.message}"
+ )
+
+ reenqueue(pipeline_tracker, delay: e.retry_delay)
+ else
+ fail_tracker(pipeline_tracker, e)
+ end
rescue StandardError => e
+ fail_tracker(pipeline_tracker, e)
+ end
+
+ def fail_tracker(pipeline_tracker, exception)
pipeline_tracker.update!(status_event: 'fail_op', jid: jid)
logger.error(
worker: self.class.name,
entity_id: pipeline_tracker.entity.id,
pipeline_name: pipeline_tracker.pipeline_name,
- message: e.message
+ message: exception.message
)
Gitlab::ErrorTracking.track_exception(
- e,
+ exception,
entity_id: pipeline_tracker.entity.id,
pipeline_name: pipeline_tracker.pipeline_name
)
@@ -88,8 +105,13 @@ module BulkImports
(Time.zone.now - pipeline_tracker.entity.created_at) > Pipeline::NDJSON_EXPORT_TIMEOUT
end
- def reenqueue(pipeline_tracker)
- self.class.perform_in(NDJSON_PIPELINE_PERFORM_DELAY, pipeline_tracker.id, pipeline_tracker.stage, pipeline_tracker.entity.id)
+ def reenqueue(pipeline_tracker, delay: NDJSON_PIPELINE_PERFORM_DELAY)
+ self.class.perform_in(
+ delay,
+ pipeline_tracker.id,
+ pipeline_tracker.stage,
+ pipeline_tracker.entity.id
+ )
end
end
end
diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb
index 3bca3015988..f047ba8fde5 100644
--- a/app/workers/ci/build_finished_worker.rb
+++ b/app/workers/ci/build_finished_worker.rb
@@ -15,13 +15,13 @@ module Ci
ARCHIVE_TRACES_IN = 2.minutes.freeze
- # rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
- Ci::Build.find_by(id: build_id).try do |build|
- process_build(build)
- end
+ return unless build = Ci::Build.find_by(id: build_id) # rubocop: disable CodeReuse/ActiveRecord
+ return unless build.project
+ return if build.project.pending_delete?
+
+ process_build(build)
end
- # rubocop: enable CodeReuse/ActiveRecord
private
diff --git a/app/workers/ci/create_downstream_pipeline_worker.rb b/app/workers/ci/create_downstream_pipeline_worker.rb
new file mode 100644
index 00000000000..6d4cd2539c1
--- /dev/null
+++ b/app/workers/ci/create_downstream_pipeline_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ class CreateDownstreamPipelineWorker # rubocop:disable Scalability/IdempotentWorker
+ include ::ApplicationWorker
+ include ::PipelineQueue
+
+ sidekiq_options retry: 3
+ worker_resource_boundary :cpu
+
+ def perform(bridge_id)
+ ::Ci::Bridge.find_by_id(bridge_id).try do |bridge|
+ ::Ci::CreateDownstreamPipelineService
+ .new(bridge.project, bridge.user)
+ .execute(bridge)
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/delete_unit_tests_worker.rb b/app/workers/ci/delete_unit_tests_worker.rb
index d5bb72ce80c..01d909773d2 100644
--- a/app/workers/ci/delete_unit_tests_worker.rb
+++ b/app/workers/ci/delete_unit_tests_worker.rb
@@ -10,7 +10,7 @@ module Ci
include CronjobQueue
# rubocop:enable Scalability/CronWorkerContext
- feature_category :continuous_integration
+ feature_category :code_testing
idempotent!
def perform
diff --git a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
index 2af07cf6f93..cde3d71286e 100644
--- a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
+++ b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
@@ -14,7 +14,7 @@ module Ci
deduplicate :until_executed, including_scheduled: true
idempotent!
- feature_category :continuous_integration
+ feature_category :build_artifacts
def perform
service = ::Ci::PipelineArtifacts::DestroyAllExpiredService.new
diff --git a/app/workers/ci/stuck_builds/drop_running_worker.rb b/app/workers/ci/stuck_builds/drop_running_worker.rb
new file mode 100644
index 00000000000..db571fdc38d
--- /dev/null
+++ b/app/workers/ci/stuck_builds/drop_running_worker.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Ci
+ module StuckBuilds
+ class DropRunningWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+
+ idempotent!
+
+ # rubocop:disable Scalability/CronWorkerContext
+ # This is an instance-wide cleanup query, so there's no meaningful
+ # scope to consider this in the context of.
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ data_consistency :always
+
+ feature_category :continuous_integration
+
+ def perform
+ try_obtain_lease do
+ Ci::StuckBuilds::DropRunningService.new.execute
+ end
+ end
+
+ private
+
+ def lease_timeout
+ 30.minutes
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/stuck_builds/drop_scheduled_worker.rb b/app/workers/ci/stuck_builds/drop_scheduled_worker.rb
new file mode 100644
index 00000000000..923841771cf
--- /dev/null
+++ b/app/workers/ci/stuck_builds/drop_scheduled_worker.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Ci
+ module StuckBuilds
+ class DropScheduledWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+
+ idempotent!
+
+ # rubocop:disable Scalability/CronWorkerContext
+ # This is an instance-wide cleanup query, so there's no meaningful
+ # scope to consider this in the context of.
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ data_consistency :always
+
+ feature_category :continuous_integration
+
+ def perform
+ try_obtain_lease do
+ Ci::StuckBuilds::DropScheduledService.new.execute
+ end
+ end
+
+ private
+
+ def lease_timeout
+ 30.minutes
+ end
+ end
+ end
+end
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
index 9adc026ced2..7274ecf62f9 100644
--- a/app/workers/cleanup_container_repository_worker.rb
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -28,8 +28,8 @@ class CleanupContainerRepositoryWorker
end
result = Projects::ContainerRepository::CleanupTagsService
- .new(project, current_user, params)
- .execute(container_repository)
+ .new(container_repository, current_user, params)
+ .execute
if run_by_container_expiration_policy? && result[:status] == :success
container_repository.reset_expiration_policy_started_at!
diff --git a/app/workers/concerns/dependency_proxy/cleanup_worker.rb b/app/workers/concerns/dependency_proxy/cleanup_worker.rb
new file mode 100644
index 00000000000..b668634f233
--- /dev/null
+++ b/app/workers/concerns/dependency_proxy/cleanup_worker.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ module CleanupWorker
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ def perform_work
+ return unless artifact
+
+ log_metadata(artifact)
+
+ artifact.destroy!
+ rescue StandardError
+ artifact&.error!
+ end
+
+ def max_running_jobs
+ ::Gitlab::CurrentSettings.dependency_proxy_ttl_group_policy_worker_capacity
+ end
+
+ def remaining_work_count
+ expired_artifacts.limit(max_running_jobs + 1).count
+ end
+
+ private
+
+ def model
+ raise NotImplementedError
+ end
+
+ def log_metadata
+ raise NotImplementedError
+ end
+
+ def log_cleanup_item
+ raise NotImplementedError
+ end
+
+ def artifact
+ strong_memoize(:artifact) do
+ model.transaction do
+ to_delete = next_item
+
+ if to_delete
+ to_delete.processing!
+ log_cleanup_item(to_delete)
+ end
+
+ to_delete
+ end
+ end
+ end
+
+ def expired_artifacts
+ model.expired
+ end
+
+ def next_item
+ expired_artifacts.lock_next_by(:updated_at).first
+ end
+ end
+end
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
index a377b7a2000..e1f404b250d 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -26,8 +26,7 @@ module Gitlab
object = representation_class.from_json_hash(hash)
# To better express in the logs what object is being imported.
- self.github_id = object.attributes.fetch(:github_id)
-
+ self.github_identifiers = object.github_identifiers
info(project.id, message: 'starting importer')
importer_class.new(object, project, client).execute
@@ -35,10 +34,10 @@ module Gitlab
Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported)
info(project.id, message: 'importer finished')
- rescue KeyError => e
+ rescue NoMethodError => e
# This exception will be more useful in development when a new
# Representation is created but the developer forgot to add a
- # `:github_id` field.
+ # `:github_identifiers` field.
Gitlab::Import::ImportFailureService.track(
project_id: project.id,
error_source: importer_class.name,
@@ -72,7 +71,7 @@ module Gitlab
private
- attr_accessor :github_id
+ attr_accessor :github_identifiers
def info(project_id, extra = {})
Logger.info(log_attributes(project_id, extra))
@@ -82,7 +81,7 @@ module Gitlab
extra.merge(
project_id: project_id,
importer: importer_class.name,
- github_id: github_id
+ github_identifiers: github_identifiers
)
end
end
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index eebea30655c..6f91418e38c 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -46,8 +46,14 @@ module WorkerAttributes
set_class_attribute(:feature_category, :not_owned)
end
+ # Special case: if a worker is not owned, get the feature category
+ # (if present) from the calling context.
def get_feature_category
- get_class_attribute(:feature_category)
+ feature_category = get_class_attribute(:feature_category)
+
+ return feature_category unless feature_category == :not_owned
+
+ Gitlab::ApplicationContext.current_context_attribute('meta.feature_category') || feature_category
end
def feature_category_not_owned?
diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
index 433ed5e0ea4..69f5906f54c 100644
--- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
+++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
@@ -21,6 +21,7 @@ module ContainerExpirationPolicies
cleanup_tags_service_original_size
cleanup_tags_service_before_truncate_size
cleanup_tags_service_after_truncate_size
+ cleanup_tags_service_cached_tags_count
cleanup_tags_service_before_delete_size
cleanup_tags_service_deleted_size
].freeze
@@ -147,13 +148,27 @@ module ContainerExpirationPolicies
log_extra_metadata_on_done(field, value)
end
+ log_truncate(result)
+ log_cache_ratio(result)
+ log_extra_metadata_on_done(:running_jobs_count, running_jobs_count)
+ end
+
+ def log_cache_ratio(result)
+ tags_count = result.payload[:cleanup_tags_service_after_truncate_size]
+ cached_tags_count = result.payload[:cleanup_tags_service_cached_tags_count]
+
+ return unless tags_count && cached_tags_count && tags_count != 0
+
+ log_extra_metadata_on_done(:cleanup_tags_service_cache_hit_ratio, cached_tags_count / tags_count.to_f)
+ end
+
+ def log_truncate(result)
before_truncate_size = result.payload[:cleanup_tags_service_before_truncate_size]
after_truncate_size = result.payload[:cleanup_tags_service_after_truncate_size]
truncated = before_truncate_size &&
after_truncate_size &&
before_truncate_size != after_truncate_size
log_extra_metadata_on_done(:cleanup_tags_service_truncated, !!truncated)
- log_extra_metadata_on_done(:running_jobs_count, running_jobs_count)
end
def policy
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index a791fe5d350..5fcbd74ddad 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -45,8 +45,6 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
# not perfomed with a delay
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63635#note_603771207
def use_replica_if_available(&blk)
- return yield unless ::Gitlab::Database::LoadBalancing.enable?
-
::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&blk)
end
diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb
index 4bea4fc872e..8481fd0a2ab 100644
--- a/app/workers/create_note_diff_file_worker.rb
+++ b/app/workers/create_note_diff_file_worker.rb
@@ -10,8 +10,10 @@ class CreateNoteDiffFileWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :code_review
def perform(diff_note_id)
- diff_note = DiffNote.find(diff_note_id)
+ return unless diff_note_id.present?
- diff_note.create_diff_file
+ diff_note = DiffNote.find_by_id(diff_note_id) # rubocop: disable CodeReuse/ActiveRecord
+
+ diff_note&.create_diff_file
end
end
diff --git a/app/workers/database/drop_detached_partitions_worker.rb b/app/workers/database/drop_detached_partitions_worker.rb
index f9c8ce57a36..1e4dc20a0d2 100644
--- a/app/workers/database/drop_detached_partitions_worker.rb
+++ b/app/workers/database/drop_detached_partitions_worker.rb
@@ -10,7 +10,7 @@ module Database
idempotent!
def perform
- Gitlab::Database::Partitioning::DetachedPartitionDropper.new.perform
+ Gitlab::Database::Partitioning.drop_detached_partitions
ensure
Gitlab::Database::Partitioning::PartitionMonitoring.new.report_metrics
end
diff --git a/app/workers/dependency_proxy/cleanup_blob_worker.rb b/app/workers/dependency_proxy/cleanup_blob_worker.rb
new file mode 100644
index 00000000000..054bc5854a3
--- /dev/null
+++ b/app/workers/dependency_proxy/cleanup_blob_worker.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class CleanupBlobWorker
+ include ApplicationWorker
+ include LimitedCapacity::Worker
+ include Gitlab::Utils::StrongMemoize
+ include DependencyProxy::CleanupWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+
+ queue_namespace :dependency_proxy_blob
+ feature_category :dependency_proxy
+ urgency :low
+ worker_resource_boundary :unknown
+ idempotent!
+
+ private
+
+ def model
+ DependencyProxy::Blob
+ end
+
+ def log_metadata(blob)
+ log_extra_metadata_on_done(:dependency_proxy_blob_id, blob.id)
+ log_extra_metadata_on_done(:group_id, blob.group_id)
+ end
+
+ def log_cleanup_item(blob)
+ logger.info(
+ structured_payload(
+ group_id: blob.group_id,
+ dependency_proxy_blob_id: blob.id
+ )
+ )
+ end
+ end
+end
diff --git a/app/workers/dependency_proxy/cleanup_manifest_worker.rb b/app/workers/dependency_proxy/cleanup_manifest_worker.rb
new file mode 100644
index 00000000000..1186efa2034
--- /dev/null
+++ b/app/workers/dependency_proxy/cleanup_manifest_worker.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class CleanupManifestWorker
+ include ApplicationWorker
+ include LimitedCapacity::Worker
+ include Gitlab::Utils::StrongMemoize
+ include DependencyProxy::CleanupWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+
+ queue_namespace :dependency_proxy_manifest
+ feature_category :dependency_proxy
+ urgency :low
+ worker_resource_boundary :unknown
+ idempotent!
+
+ private
+
+ def model
+ DependencyProxy::Manifest
+ end
+
+ def log_metadata(manifest)
+ log_extra_metadata_on_done(:dependency_proxy_manifest_id, manifest.id)
+ log_extra_metadata_on_done(:group_id, manifest.group_id)
+ end
+
+ def log_cleanup_item(manifest)
+ logger.info(
+ structured_payload(
+ group_id: manifest.group_id,
+ dependency_proxy_manifest_id: manifest.id
+ )
+ )
+ end
+ end
+end
diff --git a/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb b/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb
new file mode 100644
index 00000000000..fed469e6dc8
--- /dev/null
+++ b/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class ImageTtlGroupPolicyWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ data_consistency :always
+
+ feature_category :dependency_proxy
+
+ UPDATE_BATCH_SIZE = 100
+
+ def perform
+ DependencyProxy::ImageTtlGroupPolicy.enabled.each do |policy|
+ # Technical Debt: change to read_before https://gitlab.com/gitlab-org/gitlab/-/issues/341536
+ qualified_blobs = policy.group.dependency_proxy_blobs.active.updated_before(policy.ttl)
+ qualified_manifests = policy.group.dependency_proxy_manifests.active.updated_before(policy.ttl)
+
+ enqueue_blob_cleanup_job if expire_artifacts(qualified_blobs, DependencyProxy::Blob)
+ enqueue_manifest_cleanup_job if expire_artifacts(qualified_manifests, DependencyProxy::Manifest)
+ end
+
+ log_counts
+ end
+
+ private
+
+ def expire_artifacts(artifacts, model)
+ rows_updated = false
+
+ artifacts.each_batch(of: UPDATE_BATCH_SIZE) do |batch|
+ rows = batch.update_all(status: :expired)
+ rows_updated ||= rows > 0
+ end
+
+ rows_updated
+ end
+
+ def enqueue_blob_cleanup_job
+ DependencyProxy::CleanupBlobWorker.perform_with_capacity
+ end
+
+ def enqueue_manifest_cleanup_job
+ DependencyProxy::CleanupManifestWorker.perform_with_capacity
+ end
+
+ def log_counts
+ use_replica_if_available do
+ expired_blob_count = DependencyProxy::Blob.expired.count
+ expired_manifest_count = DependencyProxy::Manifest.expired.count
+ processing_blob_count = DependencyProxy::Blob.processing.count
+ processing_manifest_count = DependencyProxy::Manifest.processing.count
+ error_blob_count = DependencyProxy::Blob.error.count
+ error_manifest_count = DependencyProxy::Manifest.error.count
+
+ log_extra_metadata_on_done(:expired_dependency_proxy_blob_count, expired_blob_count)
+ log_extra_metadata_on_done(:expired_dependency_proxy_manifest_count, expired_manifest_count)
+ log_extra_metadata_on_done(:processing_dependency_proxy_blob_count, processing_blob_count)
+ log_extra_metadata_on_done(:processing_dependency_proxy_manifest_count, processing_manifest_count)
+ log_extra_metadata_on_done(:error_dependency_proxy_blob_count, error_blob_count)
+ log_extra_metadata_on_done(:error_dependency_proxy_manifest_count, error_manifest_count)
+ end
+ end
+
+ def use_replica_if_available(&block)
+ ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&block)
+ end
+ end
+end
diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb
index 65d387f73ed..295703cc1c3 100644
--- a/app/workers/expire_build_artifacts_worker.rb
+++ b/app/workers/expire_build_artifacts_worker.rb
@@ -10,7 +10,7 @@ class ExpireBuildArtifactsWorker # rubocop:disable Scalability/IdempotentWorker
include CronjobQueue
# rubocop:enable Scalability/CronWorkerContext
- feature_category :continuous_integration
+ feature_category :build_artifacts
def perform
service = Ci::JobArtifacts::DestroyAllExpiredService.new
diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb
index 96378acca08..77b8f59e365 100644
--- a/app/workers/expire_build_instance_artifacts_worker.rb
+++ b/app/workers/expire_build_instance_artifacts_worker.rb
@@ -7,7 +7,7 @@ class ExpireBuildInstanceArtifactsWorker # rubocop:disable Scalability/Idempoten
sidekiq_options retry: 3
- feature_category :continuous_integration
+ feature_category :build_artifacts
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb
index 401fe1dc1e5..7374f650546 100644
--- a/app/workers/expire_job_cache_worker.rb
+++ b/app/workers/expire_job_cache_worker.rb
@@ -10,11 +10,9 @@ class ExpireJobCacheWorker # rubocop:disable Scalability/IdempotentWorker
queue_namespace :pipeline_cache
urgency :high
- # This worker should be idempotent, but we're switching to data_consistency
- # :sticky and there is an ongoing incompatibility, so it needs to be disabled for
- # now. The following line can be uncommented and this comment removed once
- # https://gitlab.com/gitlab-org/gitlab/-/issues/325291 is resolved.
- # idempotent!
+
+ deduplicate :until_executing, including_scheduled: true
+ idempotent!
# rubocop: disable CodeReuse/ActiveRecord
def perform(job_id)
diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
index 006b79dbff4..5197c1e1e88 100644
--- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
@@ -18,36 +18,28 @@ module Gitlab
# project - An instance of Project.
def import(_, project)
+ @project = project
project.after_import
- report_import_time(project)
+ report_import_time
end
- def report_import_time(project)
- duration = Time.zone.now - project.created_at
+ private
- histogram.observe({ project: project.full_path }, duration)
- counter.increment
+ attr_reader :project
+
+ def report_import_time
+ metrics.track_finished_import
info(
project.id,
message: "GitHub project import finished",
- duration_s: duration.round(2),
+ duration_s: metrics.duration.round(2),
object_counts: ::Gitlab::GithubImport::ObjectCounter.summary(project)
)
end
- def histogram
- @histogram ||= Gitlab::Metrics.histogram(
- :github_importer_total_duration_seconds,
- 'Total time spent importing GitHub projects, in seconds'
- )
- end
-
- def counter
- @counter ||= Gitlab::Metrics.counter(
- :github_importer_imported_projects,
- 'The number of imported GitHub projects'
- )
+ def metrics
+ @metrics ||= Gitlab::Import::Metrics.new(:github_importer, project)
end
end
end
diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
index 715c39caf42..cc6a2255160 100644
--- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
@@ -31,6 +31,22 @@ module Gitlab
project.import_state.refresh_jid_expiration
ImportPullRequestsWorker.perform_async(project.id)
+ rescue StandardError => e
+ Gitlab::Import::ImportFailureService.track(
+ project_id: project.id,
+ error_source: self.class.name,
+ exception: e,
+ fail_import: abort_on_failure,
+ metrics: true
+ )
+
+ raise(e)
+ end
+
+ private
+
+ def abort_on_failure
+ true
end
end
end
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
index d76d36531d1..71d0247bae0 100644
--- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
@@ -27,6 +27,22 @@ module Gitlab
{ waiter.key => waiter.jobs_remaining },
:pull_requests_merged_by
)
+ rescue StandardError => e
+ Gitlab::Import::ImportFailureService.track(
+ project_id: project.id,
+ error_source: self.class.name,
+ exception: e,
+ fail_import: abort_on_failure,
+ metrics: true
+ )
+
+ raise(e)
+ end
+
+ private
+
+ def abort_on_failure
+ true
end
end
end
diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
index 227b7c304b0..3e914cc7590 100644
--- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
@@ -33,6 +33,17 @@ module Gitlab
counter.increment
ImportBaseDataWorker.perform_async(project.id)
+
+ rescue StandardError => e
+ Gitlab::Import::ImportFailureService.track(
+ project_id: project.id,
+ error_source: self.class.name,
+ exception: e,
+ fail_import: abort_on_failure,
+ metrics: true
+ )
+
+ raise(e)
end
def counter
diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb
index e0c4502ed1a..22e2a8e95f4 100644
--- a/app/workers/issue_placement_worker.rb
+++ b/app/workers/issue_placement_worker.rb
@@ -31,7 +31,7 @@ class IssuePlacementWorker
# while preserving creation order.
to_place = Issue
.relative_positioning_query_base(issue)
- .where(relative_position: nil)
+ .with_null_relative_position
.order({ created_at: :asc }, { id: :asc })
.limit(QUERY_LIMIT + 1)
.to_a
diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb
index 49e65d59e83..470fba1227d 100644
--- a/app/workers/namespaces/in_product_marketing_emails_worker.rb
+++ b/app/workers/namespaces/in_product_marketing_emails_worker.rb
@@ -8,7 +8,7 @@ module Namespaces
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :subgroups
+ feature_category :experimentation_activation
urgency :low
def perform
diff --git a/app/workers/namespaces/onboarding_issue_created_worker.rb b/app/workers/namespaces/onboarding_issue_created_worker.rb
index 81d105ab19c..aab5767e0f1 100644
--- a/app/workers/namespaces/onboarding_issue_created_worker.rb
+++ b/app/workers/namespaces/onboarding_issue_created_worker.rb
@@ -8,7 +8,7 @@ module Namespaces
sidekiq_options retry: 3
- feature_category :issue_tracking
+ feature_category :onboarding
urgency :low
deduplicate :until_executing
diff --git a/app/workers/namespaces/onboarding_pipeline_created_worker.rb b/app/workers/namespaces/onboarding_pipeline_created_worker.rb
index f9a6b734586..4172e286474 100644
--- a/app/workers/namespaces/onboarding_pipeline_created_worker.rb
+++ b/app/workers/namespaces/onboarding_pipeline_created_worker.rb
@@ -8,7 +8,7 @@ module Namespaces
sidekiq_options retry: 3
- feature_category :subgroups
+ feature_category :onboarding
urgency :low
deduplicate :until_executing
diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/namespaces/onboarding_progress_worker.rb
index b77db1aec5e..77a31d85a9a 100644
--- a/app/workers/namespaces/onboarding_progress_worker.rb
+++ b/app/workers/namespaces/onboarding_progress_worker.rb
@@ -8,7 +8,7 @@ module Namespaces
sidekiq_options retry: 3
- feature_category :product_analytics
+ feature_category :onboarding
worker_resource_boundary :cpu
urgency :low
diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/namespaces/onboarding_user_added_worker.rb
index 6a189e81b95..4d17cf9a6e2 100644
--- a/app/workers/namespaces/onboarding_user_added_worker.rb
+++ b/app/workers/namespaces/onboarding_user_added_worker.rb
@@ -8,7 +8,7 @@ module Namespaces
sidekiq_options retry: 3
- feature_category :users
+ feature_category :onboarding
urgency :low
idempotent!
diff --git a/app/workers/packages/composer/cache_cleanup_worker.rb b/app/workers/packages/composer/cache_cleanup_worker.rb
index 19babf63967..c80d6ea45d8 100644
--- a/app/workers/packages/composer/cache_cleanup_worker.rb
+++ b/app/workers/packages/composer/cache_cleanup_worker.rb
@@ -14,19 +14,7 @@ module Packages
idempotent!
def perform
- ::Packages::Composer::CacheFile.without_namespace.find_in_batches do |cache_files|
- cache_files.each(&:destroy)
- rescue ActiveRecord::RecordNotFound
- # ignore. likely due to object already being deleted.
- end
-
- ::Packages::Composer::CacheFile.expired.find_in_batches do |cache_files|
- cache_files.each(&:destroy)
- rescue ActiveRecord::RecordNotFound
- # ignore. likely due to object already being deleted.
- end
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(e)
+ # no-op: to be removed after 14.5 https://gitlab.com/gitlab-org/gitlab/-/issues/333694
end
end
end
diff --git a/app/workers/packages/composer/cache_update_worker.rb b/app/workers/packages/composer/cache_update_worker.rb
index 874993a1325..5600af6ce24 100644
--- a/app/workers/packages/composer/cache_update_worker.rb
+++ b/app/workers/packages/composer/cache_update_worker.rb
@@ -7,20 +7,14 @@ module Packages
data_consistency :always
- sidekiq_options retry: 3
+ sidekiq_options retry: false
feature_category :package_registry
idempotent!
- def perform(project_id, package_name, last_page_sha)
- project = Project.find_by_id(project_id)
-
- return unless project
-
- Gitlab::Composer::Cache.new(project: project, name: package_name, last_page_sha: last_page_sha).execute
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(e, project_id: project_id)
+ def perform(*args)
+ # no-op: to be removed after 14.5 https://gitlab.com/gitlab-org/gitlab/-/issues/333694
end
end
end
diff --git a/app/workers/packages/debian/generate_distribution_worker.rb b/app/workers/packages/debian/generate_distribution_worker.rb
index b9b157d25d2..1eff3ea02dd 100644
--- a/app/workers/packages/debian/generate_distribution_worker.rb
+++ b/app/workers/packages/debian/generate_distribution_worker.rb
@@ -2,7 +2,7 @@
module Packages
module Debian
- class GenerateDistributionWorker # rubocop:disable Scalability/IdempotentWorker
+ class GenerateDistributionWorker
include ApplicationWorker
data_consistency :always
diff --git a/app/workers/pages_remove_worker.rb b/app/workers/pages_remove_worker.rb
deleted file mode 100644
index 4de99b8654d..00000000000
--- a/app/workers/pages_remove_worker.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: remove this worker https://gitlab.com/gitlab-org/gitlab/-/issues/340641
-class PagesRemoveWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
- feature_category :pages
- loggable_arguments 0
-
- def perform(project_id)
- # no-op
- end
-end
diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb
index 322f92d376b..c67f3860a50 100644
--- a/app/workers/pipeline_hooks_worker.rb
+++ b/app/workers/pipeline_hooks_worker.rb
@@ -12,9 +12,10 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
- Ci::Pipeline
- .find_by(id: pipeline_id)
- .try(:execute_hooks)
+ pipeline = Ci::Pipeline.find_by(id: pipeline_id)
+ return unless pipeline
+
+ Ci::Pipelines::HookService.new(pipeline).execute
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb
index 9cd471a5ab6..9370b361068 100644
--- a/app/workers/pipeline_process_worker.rb
+++ b/app/workers/pipeline_process_worker.rb
@@ -14,7 +14,7 @@ class PipelineProcessWorker
loggable_arguments 1
idempotent!
- deduplicate :until_executing, feature_flag: :ci_idempotent_pipeline_process_worker
+ deduplicate :until_executing
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index dd0f14a5cab..12042ebc4f0 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -27,8 +27,9 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
user,
ref: schedule.ref)
.execute!(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
- rescue Ci::CreatePipelineService::CreateError
- # no-op. This is a user operation error such as corrupted .gitlab-ci.yml.
+ rescue Ci::CreatePipelineService::CreateError => e
+ # This is a user operation error such as corrupted .gitlab-ci.yml. Log the error for debugging purpose.
+ log_extra_metadata_on_done(:pipeline_creation_error, e)
rescue StandardError => e
error(schedule, e)
end
@@ -37,10 +38,16 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
def error(schedule, error)
failed_creation_counter.increment
+ log_error(schedule, error)
+ track_error(schedule, error)
+ end
+ def log_error(schedule, error)
Gitlab::AppLogger.error "Failed to create a scheduled pipeline. " \
"schedule_id: #{schedule.id} message: #{error.message}"
+ end
+ def track_error(schedule, error)
Gitlab::ErrorTracking
.track_and_raise_for_dev_exception(error,
issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231',
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index a2b2686c8d5..72004f7568c 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -2,6 +2,7 @@
class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
+ include ExclusiveLeaseGuard
# rubocop:disable Scalability/CronWorkerContext
# This is an instance-wide cleanup query, so there's no meaningful
@@ -12,25 +13,19 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker
data_consistency :always
feature_category :continuous_integration
- worker_resource_boundary :cpu
-
- EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease'
def perform
- return unless try_obtain_lease
-
- Ci::StuckBuilds::DropService.new.execute
+ Ci::StuckBuilds::DropRunningWorker.perform_in(20.minutes)
+ Ci::StuckBuilds::DropScheduledWorker.perform_in(40.minutes)
- remove_lease
+ try_obtain_lease do
+ Ci::StuckBuilds::DropPendingService.new.execute
+ end
end
private
- def try_obtain_lease
- @uuid = Gitlab::ExclusiveLease.new(EXCLUSIVE_LEASE_KEY, timeout: 30.minutes).try_obtain
- end
-
- def remove_lease
- Gitlab::ExclusiveLease.cancel(EXCLUSIVE_LEASE_KEY, @uuid)
+ def lease_timeout
+ 30.minutes
end
end