summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-04-20 10:00:54 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-04-20 10:00:54 +0000
commit3cccd102ba543e02725d247893729e5c73b38295 (patch)
treef36a04ec38517f5deaaacb5acc7d949688d1e187 /app
parent205943281328046ef7b4528031b90fbda70c75ac (diff)
downloadgitlab-ce-3cccd102ba543e02725d247893729e5c73b38295.tar.gz
Add latest changes from gitlab-org/gitlab@14-10-stable-eev14.10.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/auth_buttons/alicloud_64.pngbin0 -> 3538 bytes
-rw-r--r--app/assets/images/checkmark.pngbin596 -> 0 bytes
-rw-r--r--app/assets/images/learn_gitlab/graduation_hat.svg1
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql2
-rw-r--r--app/assets/javascripts/admin/topics/components/remove_avatar.vue13
-rw-r--r--app/assets/javascripts/admin/topics/index.js3
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue39
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue39
-rw-r--r--app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue52
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue112
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js5
-rw-r--r--app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue77
-rw-r--r--app/assets/javascripts/admin/users/constants.js6
-rw-r--r--app/assets/javascripts/admin/users/index.js47
-rw-r--r--app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue8
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql2
-rw-r--r--app/assets/javascripts/api.js53
-rw-r--r--app/assets/javascripts/api/alert_management_alerts_api.js62
-rw-r--r--app/assets/javascripts/api/projects_api.js2
-rw-r--r--app/assets/javascripts/attention_requests/components/navigation_popover.vue4
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js59
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_kroki.js2
-rw-r--r--app/assets/javascripts/behaviors/secret_values.js4
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js31
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js2
-rw-r--r--app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js111
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js22
-rw-r--r--app/assets/javascripts/blob/line_highlighter.js2
-rw-r--r--app/assets/javascripts/blob/pdf/pdf_viewer.vue2
-rw-r--r--app/assets/javascripts/blob/viewer/index.js8
-rw-r--r--app/assets/javascripts/boards/boards_util.js3
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue30
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue12
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue54
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue47
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue11
-rw-r--r--app/assets/javascripts/boards/components/issuable_title.vue21
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue12
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/item_count.vue2
-rw-r--r--app/assets/javascripts/boards/components/toggle_focus.vue9
-rw-r--r--app/assets/javascripts/boards/config_toggle.js25
-rw-r--r--app/assets/javascripts/boards/graphql/group_projects.query.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js94
-rw-r--r--app/assets/javascripts/boards/mount_filtered_search_issue_boards.js42
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js50
-rw-r--r--app/assets/javascripts/boards/new_board.js29
-rw-r--r--app/assets/javascripts/boards/toggle_epics_swimlanes.js1
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js18
-rw-r--r--app/assets/javascripts/boards/toggle_labels.js1
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js4
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue19
-rw-r--r--app/assets/javascripts/ci_variable_list/ci_variable_list.js8
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql2
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js8
-rw-r--r--app/assets/javascripts/clusters/forms/stores/state.js2
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js2
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js2
-rw-r--r--app/assets/javascripts/clusters_list/clusters_util.js13
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_empty_state.vue47
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_token.vue35
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue4
-rw-r--r--app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue21
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue102
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_empty_state.vue40
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_view_all.vue67
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue2
-rw-r--r--app/assets/javascripts/clusters_list/constants.js56
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql2
-rw-r--r--app/assets/javascripts/clusters_list/index.js2
-rw-r--r--app/assets/javascripts/code_navigation/components/app.vue8
-rw-r--r--app/assets/javascripts/code_navigation/store/actions.js6
-rw-r--r--app/assets/javascripts/code_navigation/store/mutations.js3
-rw-r--r--app/assets/javascripts/code_navigation/store/state.js1
-rw-r--r--app/assets/javascripts/code_navigation/utils/dom_utils.js31
-rw-r--r--app/assets/javascripts/code_navigation/utils/index.js17
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue10
-rw-r--r--app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue146
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue13
-rw-r--r--app/assets/javascripts/content_editor/components/loading_indicator.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue32
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/media.vue51
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js43
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js56
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js11
-rw-r--r--app/assets/javascripts/content_editor/services/code_block_language_loader.js283
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js19
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js10
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js11
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js8
-rw-r--r--app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js4
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js24
-rw-r--r--app/assets/javascripts/contextual_sidebar.js25
-rw-r--r--app/assets/javascripts/crm/components/contact_form.vue224
-rw-r--r--app/assets/javascripts/crm/components/form.vue67
-rw-r--r--app/assets/javascripts/crm/components/new_organization_form.vue164
-rw-r--r--app/assets/javascripts/crm/contacts/bundle.js (renamed from app/assets/javascripts/crm/contacts_bundle.js)0
-rw-r--r--app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue78
-rw-r--r--app/assets/javascripts/crm/contacts/components/contacts_root.vue (renamed from app/assets/javascripts/crm/components/contacts_root.vue)93
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/create_contact.mutation.graphql (renamed from app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql)0
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql (renamed from app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql)0
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql (renamed from app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql)0
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/update_contact.mutation.graphql (renamed from app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql)0
-rw-r--r--app/assets/javascripts/crm/contacts/routes.js (renamed from app/assets/javascripts/crm/routes.js)6
-rw-r--r--app/assets/javascripts/crm/organizations/bundle.js (renamed from app/assets/javascripts/crm/organizations_bundle.js)0
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/create_organization.mutation.graphql (renamed from app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql)0
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql (renamed from app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql)0
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql (renamed from app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql)0
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql10
-rw-r--r--app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue80
-rw-r--r--app/assets/javascripts/crm/organizations/components/organizations_root.vue (renamed from app/assets/javascripts/crm/components/organizations_root.vue)58
-rw-r--r--app/assets/javascripts/crm/organizations/routes.js20
-rw-r--r--app/assets/javascripts/deprecated_notes.js89
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue26
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue49
-rw-r--r--app/assets/javascripts/diffs/components/app.vue62
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue4
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue7
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue12
-rw-r--r--app/assets/javascripts/diffs/components/no_changes.vue2
-rw-r--r--app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js4
-rw-r--r--app/assets/javascripts/diffs/store/actions.js30
-rw-r--r--app/assets/javascripts/diffs/utils/performance.js2
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar.vue70
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar_button.vue89
-rw-r--r--app/assets/javascripts/editor/constants.js3
-rw-r--r--app/assets/javascripts/editor/graphql/get_item.query.graphql9
-rw-r--r--app/assets/javascripts/editor/graphql/get_items.query.graphql5
-rw-r--r--app/assets/javascripts/editor/graphql/update_item.mutation.graphql3
-rw-r--r--app/assets/javascripts/editor/schema/ci.json19
-rw-r--r--app/assets/javascripts/emoji/awards_app/index.js2
-rw-r--r--app/assets/javascripts/emoji/index.js9
-rw-r--r--app/assets/javascripts/emoji/support/unicode_support_map.js2
-rw-r--r--app/assets/javascripts/environments/components/empty_state.vue31
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue68
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue2
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue13
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js2
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js2
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js10
-rw-r--r--app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js2
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js4
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service.js2
-rw-r--r--app/assets/javascripts/flash.js4
-rw-r--r--app/assets/javascripts/google_cloud/components/app.vue2
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js47
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js2
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/page_info.fragment.graphql (renamed from app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql)0
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/page_info_cursors_only.fragment.graphql (renamed from app/assets/javascripts/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql)0
-rw-r--r--app/assets/javascripts/graphql_shared/possibleTypes.json1
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json129
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql2
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue2
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue6
-rw-r--r--app/assets/javascripts/groups/components/groups.vue2
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue4
-rw-r--r--app/assets/javascripts/groups/components/item_type_icon.vue9
-rw-r--r--app/assets/javascripts/groups/constants.js2
-rw-r--r--app/assets/javascripts/header.js5
-rw-r--r--app/assets/javascripts/header_search/components/app.vue25
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue74
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue6
-rw-r--r--app/assets/javascripts/header_search/constants.js8
-rw-r--r--app/assets/javascripts/header_search/store/actions.js4
-rw-r--r--app/assets/javascripts/header_search/store/getters.js1
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue4
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/description.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue2
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown_button.vue4
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js2
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/actions.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/actions.js6
-rw-r--r--app/assets/javascripts/ide/stores/plugins/terminal.js8
-rw-r--r--app/assets/javascripts/image_diff/helpers/init_image_diff.js2
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue133
-rw-r--r--app/assets/javascripts/import_entities/constants.js41
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue2
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue14
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js3
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutations.js6
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue25
-rw-r--r--app/assets/javascripts/incidents/constants.js1
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue41
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue145
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue19
-rw-r--r--app/assets/javascripts/integrations/edit/index.js7
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue23
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue9
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue63
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue74
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue97
-rw-r--r--app/assets/javascripts/invite_members/constants.js5
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js78
-rw-r--r--app/assets/javascripts/invite_members/utils/response_message_parser.js34
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue1
-rw-r--r--app/assets/javascripts/issuable/components/issue_milestone.vue2
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js6
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js14
-rw-r--r--app/assets/javascripts/issues/index.js12
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue15
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue95
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues.query.graphql2
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql2
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js7
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue22
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue143
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue18
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description_template.vue9
-rw-r--r--app/assets/javascripts/issues/show/components/fields/title.vue9
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue21
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue30
-rw-r--r--app/assets/javascripts/issues/show/components/locked_warning.vue7
-rw-r--r--app/assets/javascripts/issues/show/index.js2
-rw-r--r--app/assets/javascripts/issues/show/mixins/update.js1
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue4
-rw-r--r--app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue22
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue30
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue5
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue9
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue8
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js4
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue12
-rw-r--r--app/assets/javascripts/jira_import/index.js1
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue42
-rw-r--r--app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue122
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue6
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/duration_cell.vue18
-rw-r--r--app/assets/javascripts/jobs/components/table/constants.js3
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/cache_config.js30
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql1
-rw-r--r--app/assets/javascripts/jobs/components/table/index.js2
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue92
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue50
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue8
-rw-r--r--app/assets/javascripts/jobs/store/utils.js2
-rw-r--r--app/assets/javascripts/lib/gfm/index.js38
-rw-r--r--app/assets/javascripts/lib/graphql.js5
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue32
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js12
-rw-r--r--app/assets/javascripts/lib/utils/css_utils.js4
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js14
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js22
-rw-r--r--app/assets/javascripts/lib/utils/datetime/timeago_utility.js48
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js11
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/formatter_factory.js52
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js108
-rw-r--r--app/assets/javascripts/lib/utils/webpack.js2
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue2
-rw-r--r--app/assets/javascripts/main.js3
-rw-r--r--app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue2
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue5
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue10
-rw-r--r--app/assets/javascripts/members/constants.js18
-rw-r--r--app/assets/javascripts/members/utils.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/utils.js4
-rw-r--r--app/assets/javascripts/merge_request.js6
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue13
-rw-r--r--app/assets/javascripts/milestones/components/milestone_results_section.vue12
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js4
-rw-r--r--app/assets/javascripts/monitoring/components/charts/bar.vue8
-rw-r--r--app/assets/javascripts/monitoring/components/create_dashboard_modal.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue10
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue4
-rw-r--r--app/assets/javascripts/monitoring/queries/get_annotations.query.graphql (renamed from app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql)0
-rw-r--r--app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql (renamed from app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql)0
-rw-r--r--app/assets/javascripts/monitoring/queries/get_environments.query.graphql (renamed from app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql)0
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js12
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js16
-rw-r--r--app/assets/javascripts/monitoring/utils.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/actions.js25
-rw-r--r--app/assets/javascripts/mr_notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutations.js3
-rw-r--r--app/assets/javascripts/mr_popover/components/mr_popover.vue4
-rw-r--r--app/assets/javascripts/network/branch_graph.js4
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue2
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue48
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue9
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue8
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue22
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue3
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue13
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue12
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue1
-rw-r--r--app/assets/javascripts/notes/components/timeline_toggle.vue6
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue2
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js4
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js16
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue33
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue19
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue104
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list.vue42
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue67
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue84
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js29
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js39
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/index.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js33
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/index.js78
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js200
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue0
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue177
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/router.js35
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue54
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue3
-rw-r--r--app/assets/javascripts/pager.js19
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js2
-rw-r--r--app/assets/javascripts/pages/admin/admin.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_downloader.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js1
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js88
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/index/index.js8
-rw-r--r--app/assets/javascripts/pages/groups/crm/contacts/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/crm/organizations/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js28
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js26
-rw-r--r--app/assets/javascripts/pages/groups/harbor/repositories/index.js8
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue4
-rw-r--r--app/assets/javascripts/pages/import/history/components/import_error_details.vue43
-rw-r--r--app/assets/javascripts/pages/import/history/components/import_history_app.vue199
-rw-r--r--app/assets/javascripts/pages/import/history/index.js21
-rw-r--r--app/assets/javascripts/pages/import/history/utils/error_messages.js3
-rw-r--r--app/assets/javascripts/pages/profiles/preferences/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js32
-rw-r--r--app/assets/javascripts/pages/projects/harbor/repositories/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue8
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue24
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue100
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js12
-rw-r--r--app/assets/javascripts/pages/projects/network/network.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js18
-rw-r--r--app/assets/javascripts/pages/projects/project.js2
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js54
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue16
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue20
-rw-r--r--app/assets/javascripts/pages/projects/snippets/show/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/tags/new/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/wikis/show/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/length_validator.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue92
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue146
-rw-r--r--app/assets/javascripts/pages/shared/wikis/edit.js4
-rw-r--r--app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js5
-rw-r--r--app/assets/javascripts/pages/shared/wikis/show.js27
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js2
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue37
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue27
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue89
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue14
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue106
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue21
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js4
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue16
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue3
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/input.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue39
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_tabs.vue50
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue81
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue)60
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue22
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue8
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js77
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js44
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/pipelines_store.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/actions.js1
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/utils.js2
-rw-r--r--app/assets/javascripts/profile/preferences/components/diffs_colors.vue107
-rw-r--r--app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue231
-rw-r--r--app/assets/javascripts/profile/preferences/components/integration_view.vue56
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue15
-rw-r--r--app/assets/javascripts/profile/preferences/profile_preferences_diffs_colors.js21
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue54
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue74
-rw-r--r--app/assets/javascripts/projects/commit_box/info/constants.js7
-rw-r--r--app/assets/javascripts/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql14
-rw-r--r--app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql19
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js3
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js5
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js34
-rw-r--r--app/assets/javascripts/projects/commit_box/info/utils.js14
-rw-r--r--app/assets/javascripts/projects/components/shared/delete_button.vue1
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js6
-rw-r--r--app/assets/javascripts/projects/new/components/deployment_target_select.vue33
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue35
-rw-r--r--app/assets/javascripts/projects/new/constants.js5
-rw-r--r--app/assets/javascripts/projects/new/index.js1
-rw-r--r--app/assets/javascripts/projects/project_new.js47
-rw-r--r--app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue4
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue2
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue287
-rw-r--r--app/assets/javascripts/releases/components/app_index_apollo_client.vue275
-rw-r--r--app/assets/javascripts/releases/components/release_block_footer.vue2
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination.vue20
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue37
-rw-r--r--app/assets/javascripts/releases/components/releases_sort.vue71
-rw-r--r--app/assets/javascripts/releases/components/releases_sort_apollo_client.vue91
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql92
-rw-r--r--app/assets/javascripts/releases/mount_index.js52
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/actions.js65
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/index.js10
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutation_types.js4
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutations.js44
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/state.js24
-rw-r--r--app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue2
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue8
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue6
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/actions.js6
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue3
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js1
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue36
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue10
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue2
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue2
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue48
-rw-r--r--app/assets/javascripts/runner/admin_runners/index.js17
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue19
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue13
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token.vue74
-rw-r--r--app/assets/javascripts/runner/components/runner_assigned_item.vue10
-rw-r--r--app/assets/javascripts/runner/components/runner_bulk_delete.vue111
-rw-r--r--app/assets/javascripts/runner/components/runner_delete_button.vue85
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs.vue2
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue90
-rw-r--r--app/assets/javascripts/runner/components/runner_pause_button.vue2
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue2
-rw-r--r--app/assets/javascripts/runner/components/runner_status_badge.vue31
-rw-r--r--app/assets/javascripts/runner/components/runner_status_popover.vue75
-rw-r--r--app/assets/javascripts/runner/components/runner_update_form.vue6
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/paused_token_config.js28
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/status_token_config.js4
-rw-r--r--app/assets/javascripts/runner/constants.js53
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql4
-rw-r--r--app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql3
-rw-r--r--app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql3
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners.query.graphql5
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/list/local_state.js63
-rw-r--r--app/assets/javascripts/runner/graphql/list/typedefs.graphql3
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue30
-rw-r--r--app/assets/javascripts/runner/group_runners/index.js4
-rw-r--r--app/assets/javascripts/runner/runner_search_utils.js49
-rw-r--r--app/assets/javascripts/runner/utils.js3
-rw-r--r--app/assets/javascripts/search/store/actions.js34
-rw-r--r--app/assets/javascripts/search/store/utils.js2
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue4
-rw-r--r--app/assets/javascripts/search_settings/components/search_settings.vue45
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue9
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js29
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue41
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card_badge.vue40
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue31
-rw-r--r--app/assets/javascripts/security_configuration/index.js2
-rw-r--r--app/assets/javascripts/security_configuration/utils.js4
-rw-r--r--app/assets/javascripts/serverless/components/missing_prometheus.vue2
-rw-r--r--app/assets/javascripts/serverless/components/url.vue2
-rw-r--r--app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js20
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue65
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue43
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/attention_requested_toggle.vue32
-rw-r--r--app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/escalation_status.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue15
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue28
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue2
-rw-r--r--app/assets/javascripts/sidebar/graphql.js3
-rw-r--r--app/assets/javascripts/sidebar/queries/issuable_labels.subscription.graphql22
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebar_details.query.graphql (renamed from app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebar_details_mr.query.graphql (renamed from app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/update_status.mutation.graphql (renamed from app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js4
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue10
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue8
-rw-r--r--app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql (renamed from app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql)0
-rw-r--r--app/assets/javascripts/snippets/mutations/create_snippet.mutation.graphql (renamed from app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql)0
-rw-r--r--app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql (renamed from app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql)0
-rw-r--r--app/assets/javascripts/snippets/mutations/update_snippet.mutation.graphql (renamed from app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql)0
-rw-r--r--app/assets/javascripts/sortable/constants.js19
-rw-r--r--app/assets/javascripts/sortable/sortable_config.js8
-rw-r--r--app/assets/javascripts/sortable/utils.js (renamed from app/assets/javascripts/boards/mixins/sortable_default_options.js)14
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue1
-rw-r--r--app/assets/javascripts/terraform/components/empty_state.vue23
-rw-r--r--app/assets/javascripts/terraform/components/states_table_actions.vue3
-rw-r--r--app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql2
-rw-r--r--app/assets/javascripts/tracking/tracking.js70
-rw-r--r--app/assets/javascripts/user_lists/components/user_list_form.vue2
-rw-r--r--app/assets/javascripts/users_select/index.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue26
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js39
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js82
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js55
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue11
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js12
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/service.js43
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue26
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/line_numbers.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue119
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue266
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/actions.js85
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/index.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js39
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/state.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue50
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue103
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js25
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue169
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js28
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue1
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue7
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js22
-rw-r--r--app/assets/javascripts/vue_shared/translate.js2
-rw-r--r--app/assets/javascripts/webpack.js2
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue12
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue93
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue73
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue83
-rw-r--r--app/assets/javascripts/work_items/components/work_item_title.vue73
-rw-r--r--app/assets/javascripts/work_items/constants.js9
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql9
-rw-r--r--app/assets/javascripts/work_items/graphql/delete_work_item.mutation.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js32
-rw-r--r--app/assets/javascripts/work_items/graphql/resolvers.js29
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql56
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/widget.fragment.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql15
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql8
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue135
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue82
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss324
-rw-r--r--app/assets/stylesheets/bootstrap_migration_components.scss216
-rw-r--r--app/assets/stylesheets/bootstrap_migration_reset.scss94
-rw-r--r--app/assets/stylesheets/bootstrap_migration_variables.scss15
-rw-r--r--app/assets/stylesheets/components/milestone_combobox.scss9
-rw-r--r--app/assets/stylesheets/framework/awards.scss10
-rw-r--r--app/assets/stylesheets/framework/broadcast_messages.scss4
-rw-r--r--app/assets/stylesheets/framework/common.scss3
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss21
-rw-r--r--app/assets/stylesheets/framework/header.scss12
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss4
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss26
-rw-r--r--app/assets/stylesheets/framework/tables.scss2
-rw-r--r--app/assets/stylesheets/framework/variables.scss1
-rw-r--r--app/assets/stylesheets/highlight/diff_custom_colors_addition.scss36
-rw-r--r--app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss36
-rw-r--r--app/assets/stylesheets/highlight/hljs.scss125
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss36
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/white.scss5
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss2
-rw-r--r--app/assets/stylesheets/notify_base.scss3
-rw-r--r--app/assets/stylesheets/notify_enhanced.scss30
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/import.scss10
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect_users.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/learn_gitlab.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss14
-rw-r--r--app/assets/stylesheets/pages/clusters.scss8
-rw-r--r--app/assets/stylesheets/pages/issues.scss4
-rw-r--r--app/assets/stylesheets/pages/search.scss30
-rw-r--r--app/assets/stylesheets/pages/settings.scss7
-rw-r--r--app/assets/stylesheets/snippets.scss4
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss53
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss44
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss17
-rw-r--r--app/assets/stylesheets/themes/_dark.scss2
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss4
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss5
-rw-r--r--app/assets/stylesheets/utilities.scss5
-rw-r--r--app/components/diffs/base_component.rb10
-rw-r--r--app/components/diffs/overflow_warning_component.html.haml9
-rw-r--r--app/components/diffs/overflow_warning_component.rb73
-rw-r--r--app/components/diffs/stats_component.html.haml1
-rw-r--r--app/components/diffs/stats_component.rb67
-rw-r--r--app/components/pajamas/alert_component.html.haml13
-rw-r--r--app/components/pajamas/alert_component.rb45
-rw-r--r--app/controllers/admin/application_settings_controller.rb3
-rw-r--r--app/controllers/admin/background_jobs_controller.rb2
-rw-r--r--app/controllers/admin/background_migrations_controller.rb8
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb6
-rw-r--r--app/controllers/admin/dashboard_controller.rb2
-rw-r--r--app/controllers/admin/health_check_controller.rb2
-rw-r--r--app/controllers/admin/integrations_controller.rb4
-rw-r--r--app/controllers/admin/plan_limits_controller.rb10
-rw-r--r--app/controllers/admin/requests_profiles_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb3
-rw-r--r--app/controllers/admin/spam_logs_controller.rb2
-rw-r--r--app/controllers/admin/system_info_controller.rb2
-rw-r--r--app/controllers/admin/version_check_controller.rb2
-rw-r--r--app/controllers/application_controller.rb21
-rw-r--r--app/controllers/autocomplete_controller.rb1
-rw-r--r--app/controllers/clusters/clusters_controller.rb14
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb16
-rw-r--r--app/controllers/concerns/integrations/params.rb2
-rw-r--r--app/controllers/concerns/sessionless_authentication.rb25
-rw-r--r--app/controllers/concerns/wiki_actions.rb12
-rw-r--r--app/controllers/dashboard/application_controller.rb2
-rw-r--r--app/controllers/explore/projects_controller.rb5
-rw-r--r--app/controllers/graphql_controller.rb8
-rw-r--r--app/controllers/groups/boards_controller.rb6
-rw-r--r--app/controllers/groups/children_controller.rb3
-rw-r--r--app/controllers/groups/crm/organizations_controller.rb4
-rw-r--r--app/controllers/groups/email_campaigns_controller.rb7
-rw-r--r--app/controllers/groups/group_links_controller.rb24
-rw-r--r--app/controllers/groups/group_members_controller.rb5
-rw-r--r--app/controllers/groups/releases_controller.rb4
-rw-r--r--app/controllers/groups/runners_controller.rb2
-rw-r--r--app/controllers/groups/settings/integrations_controller.rb4
-rw-r--r--app/controllers/groups_controller.rb17
-rw-r--r--app/controllers/help_controller.rb2
-rw-r--r--app/controllers/ide_controller.rb2
-rw-r--r--app/controllers/import/base_controller.rb10
-rw-r--r--app/controllers/import/bitbucket_controller.rb23
-rw-r--r--app/controllers/import/github_controller.rb26
-rw-r--r--app/controllers/import/gitlab_groups_controller.rb5
-rw-r--r--app/controllers/import/history_controller.rb5
-rw-r--r--app/controllers/jira_connect/application_controller.rb2
-rw-r--r--app/controllers/jira_connect/events_controller.rb4
-rw-r--r--app/controllers/jira_connect/subscriptions_controller.rb4
-rw-r--r--app/controllers/jwt_controller.rb2
-rw-r--r--app/controllers/oauth/jira_dvcs/authorizations_controller.rb (renamed from app/controllers/oauth/jira/authorizations_controller.rb)6
-rw-r--r--app/controllers/profiles/chat_names_controller.rb2
-rw-r--r--app/controllers/profiles/notifications_controller.rb2
-rw-r--r--app/controllers/profiles/preferences_controller.rb2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb24
-rw-r--r--app/controllers/projects/artifacts_controller.rb2
-rw-r--r--app/controllers/projects/boards_controller.rb6
-rw-r--r--app/controllers/projects/branches_controller.rb6
-rw-r--r--app/controllers/projects/commit_controller.rb6
-rw-r--r--app/controllers/projects/compare_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb6
-rw-r--r--app/controllers/projects/google_cloud/base_controller.rb6
-rw-r--r--app/controllers/projects/graphs_controller.rb2
-rw-r--r--app/controllers/projects/group_links_controller.rb21
-rw-r--r--app/controllers/projects/incidents_controller.rb5
-rw-r--r--app/controllers/projects/issues_controller.rb22
-rw-r--r--app/controllers/projects/jobs_controller.rb14
-rw-r--r--app/controllers/projects/learn_gitlab_controller.rb5
-rw-r--r--app/controllers/projects/merge_requests_controller.rb36
-rw-r--r--app/controllers/projects/milestones_controller.rb1
-rw-r--r--app/controllers/projects/packages/infrastructure_registry_controller.rb1
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb4
-rw-r--r--app/controllers/projects/pipelines/tests_controller.rb12
-rw-r--r--app/controllers/projects/pipelines_controller.rb8
-rw-r--r--app/controllers/projects/project_members_controller.rb2
-rw-r--r--app/controllers/projects/refs_controller.rb12
-rw-r--r--app/controllers/projects/releases_controller.rb3
-rw-r--r--app/controllers/projects/security/configuration_controller.rb1
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb2
-rw-r--r--app/controllers/projects/services_controller.rb4
-rw-r--r--app/controllers/projects/snippets_controller.rb4
-rw-r--r--app/controllers/projects/static_site_editor_controller.rb24
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/controllers/projects/uploads_controller.rb2
-rw-r--r--app/controllers/projects/usage_quotas_controller.rb2
-rw-r--r--app/controllers/projects/web_ide_schemas_controller.rb2
-rw-r--r--app/controllers/projects/web_ide_terminals_controller.rb10
-rw-r--r--app/controllers/projects/work_items_controller.rb6
-rw-r--r--app/controllers/projects_controller.rb33
-rw-r--r--app/controllers/sandbox_controller.rb2
-rw-r--r--app/controllers/search_controller.rb12
-rw-r--r--app/controllers/sent_notifications_controller.rb2
-rw-r--r--app/controllers/sessions_controller.rb2
-rw-r--r--app/controllers/snippets/blobs_controller.rb1
-rw-r--r--app/controllers/uploads_controller.rb7
-rw-r--r--app/controllers/users_controller.rb9
-rw-r--r--app/events/ci/pipeline_created_event.rb1
-rw-r--r--app/experiments/application_experiment.rb10
-rw-r--r--app/experiments/ios_specific_templates_experiment.rb30
-rw-r--r--app/experiments/logged_out_marketing_header_experiment.rb9
-rw-r--r--app/experiments/new_project_sast_enabled_experiment.rb15
-rw-r--r--app/experiments/video_tutorials_continuous_onboarding_experiment.rb6
-rw-r--r--app/finders/bulk_imports/entities_finder.rb17
-rw-r--r--app/finders/bulk_imports/imports_finder.rb17
-rw-r--r--app/finders/ci/jobs_finder.rb2
-rw-r--r--app/finders/concerns/finder_methods.rb23
-rw-r--r--app/finders/keys_finder.rb10
-rw-r--r--app/finders/packages/build_infos_for_many_packages_finder.rb92
-rw-r--r--app/finders/packages/group_packages_finder.rb6
-rw-r--r--app/finders/packages/packages_finder.rb6
-rw-r--r--app/finders/releases/group_releases_finder.rb44
-rw-r--r--app/finders/user_recent_events_finder.rb16
-rw-r--r--app/finders/users_finder.rb10
-rw-r--r--app/graphql/graphql_triggers.rb4
-rw-r--r--app/graphql/mutations/ci/job/retry.rb18
-rw-r--r--app/graphql/mutations/ci/pipeline/cancel.rb2
-rw-r--r--app/graphql/mutations/environments/canary_ingress/update.rb13
-rw-r--r--app/graphql/mutations/notes/update/note.rb3
-rw-r--r--app/graphql/mutations/saved_replies/base.rb2
-rw-r--r--app/graphql/mutations/saved_replies/destroy.rb23
-rw-r--r--app/graphql/mutations/saved_replies/update.rb2
-rw-r--r--app/graphql/mutations/todos/mark_all_done.rb29
-rw-r--r--app/graphql/mutations/user_preferences/update.rb17
-rw-r--r--app/graphql/mutations/work_items/create.rb2
-rw-r--r--app/graphql/mutations/work_items/create_from_task.rb2
-rw-r--r--app/graphql/mutations/work_items/delete.rb2
-rw-r--r--app/graphql/mutations/work_items/update.rb2
-rw-r--r--app/graphql/queries/container_registry/get_container_repositories.query.graphql2
-rw-r--r--app/graphql/queries/releases/all_releases.query.graphql109
-rw-r--r--app/graphql/resolvers/base_issues_resolver.rb13
-rw-r--r--app/graphql/resolvers/base_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb1
-rw-r--r--app/graphql/resolvers/groups_resolver.rb2
-rw-r--r--app/graphql/resolvers/work_item_resolver.rb2
-rw-r--r--app/graphql/resolvers/work_items/types_resolver.rb10
-rw-r--r--app/graphql/types/base_object.rb7
-rw-r--r--app/graphql/types/ci/job_kind_enum.rb12
-rw-r--r--app/graphql/types/ci/job_type.rb8
-rw-r--r--app/graphql/types/ci/runner_upgrade_status_type_enum.rb21
-rw-r--r--app/graphql/types/container_repository_type.rb1
-rw-r--r--app/graphql/types/dependency_proxy/manifest_type.rb4
-rw-r--r--app/graphql/types/dependency_proxy/manifest_type_enum.rb11
-rw-r--r--app/graphql/types/issue_connection.rb15
-rw-r--r--app/graphql/types/issue_sort_enum.rb2
-rw-r--r--app/graphql/types/mutation_type.rb1
-rw-r--r--app/graphql/types/repository/blob_type.rb4
-rw-r--r--app/graphql/types/subscription_type.rb3
-rw-r--r--app/graphql/types/user_interface.rb3
-rw-r--r--app/helpers/admin/background_migrations_helper.rb10
-rw-r--r--app/helpers/application_settings_helper.rb59
-rw-r--r--app/helpers/auth_helper.rb1
-rw-r--r--app/helpers/boards_helper.rb30
-rw-r--r--app/helpers/broadcast_messages_helper.rb22
-rw-r--r--app/helpers/button_helper.rb2
-rw-r--r--app/helpers/ci/jobs_helper.rb4
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb4
-rw-r--r--app/helpers/ci/pipelines_helper.rb6
-rw-r--r--app/helpers/ci/runners_helper.rb30
-rw-r--r--app/helpers/clusters_helper.rb1
-rw-r--r--app/helpers/colors_helper.rb23
-rw-r--r--app/helpers/commits_helper.rb4
-rw-r--r--app/helpers/diff_helper.rb69
-rw-r--r--app/helpers/emails_helper.rb17
-rw-r--r--app/helpers/environment_helper.rb2
-rw-r--r--app/helpers/environments_helper.rb2
-rw-r--r--app/helpers/external_link_helper.rb2
-rw-r--r--app/helpers/groups/group_members_helper.rb32
-rw-r--r--app/helpers/ide_helper.rb8
-rw-r--r--app/helpers/invite_members_helper.rb3
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/helpers/issues_helper.rb12
-rw-r--r--app/helpers/merge_requests_helper.rb6
-rw-r--r--app/helpers/namespaces_helper.rb9
-rw-r--r--app/helpers/packages_helper.rb2
-rw-r--r--app/helpers/preferences_helper.rb16
-rw-r--r--app/helpers/projects/alert_management_helper.rb5
-rw-r--r--app/helpers/projects/pipeline_helper.rb16
-rw-r--r--app/helpers/projects/project_members_helper.rb6
-rw-r--r--app/helpers/projects/security/configuration_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb20
-rw-r--r--app/helpers/routing/projects_helper.rb4
-rw-r--r--app/helpers/routing/pseudonymization_helper.rb4
-rw-r--r--app/helpers/search_helper.rb19
-rw-r--r--app/helpers/snippets_helper.rb4
-rw-r--r--app/helpers/sorting_helper.rb17
-rw-r--r--app/helpers/submodule_helper.rb2
-rw-r--r--app/helpers/timeboxes_helper.rb15
-rw-r--r--app/helpers/users/callouts_helper.rb5
-rw-r--r--app/helpers/users/group_callouts_helper.rb2
-rw-r--r--app/helpers/wiki_helper.rb14
-rw-r--r--app/helpers/workhorse_helper.rb4
-rw-r--r--app/mailers/emails/merge_requests.rb9
-rw-r--r--app/mailers/emails/profile.rb11
-rw-r--r--app/mailers/emails/reviews.rb4
-rw-r--r--app/mailers/previews/notify_preview.rb4
-rw-r--r--app/models/alert_management/alert.rb5
-rw-r--r--app/models/alert_management/metric_image.rb25
-rw-r--r--app/models/analytics/cycle_analytics/aggregation.rb40
-rw-r--r--app/models/application_setting.rb16
-rw-r--r--app/models/application_setting_implementation.rb30
-rw-r--r--app/models/award_emoji.rb8
-rw-r--r--app/models/blob.rb6
-rw-r--r--app/models/blob_viewer/balsamiq.rb14
-rw-r--r--app/models/broadcast_message.rb13
-rw-r--r--app/models/bulk_import.rb9
-rw-r--r--app/models/bulk_imports/entity.rb13
-rw-r--r--app/models/bulk_imports/export_status.rb6
-rw-r--r--app/models/bulk_imports/tracker.rb5
-rw-r--r--app/models/ci/bridge.rb20
-rw-r--r--app/models/ci/build.rb36
-rw-r--r--app/models/ci/job_artifact.rb5
-rw-r--r--app/models/ci/namespace_mirror.rb20
-rw-r--r--app/models/ci/pipeline.rb14
-rw-r--r--app/models/ci/processable.rb6
-rw-r--r--app/models/ci/secure_file.rb8
-rw-r--r--app/models/clusters/agent_token.rb2
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/commit_status.rb2
-rw-r--r--app/models/concerns/batch_nullify_dependent_associations.rb27
-rw-r--r--app/models/concerns/bulk_users_by_email_load.rb24
-rw-r--r--app/models/concerns/featurable.rb38
-rw-r--r--app/models/concerns/from_set_operator.rb7
-rw-r--r--app/models/concerns/issuable.rb21
-rw-r--r--app/models/concerns/issuable_link.rb2
-rw-r--r--app/models/concerns/metric_image_uploading.rb54
-rw-r--r--app/models/concerns/sensitive_serializable_hash.rb7
-rw-r--r--app/models/concerns/spammable.rb3
-rw-r--r--app/models/concerns/taskable.rb8
-rw-r--r--app/models/container_repository.rb142
-rw-r--r--app/models/custom_emoji.rb13
-rw-r--r--app/models/customer_relations/contact.rb32
-rw-r--r--app/models/customer_relations/issue_contact.rb16
-rw-r--r--app/models/customer_relations/organization.rb28
-rw-r--r--app/models/deploy_token.rb5
-rw-r--r--app/models/deployment.rb13
-rw-r--r--app/models/discussion.rb8
-rw-r--r--app/models/environment.rb76
-rw-r--r--app/models/environment_status.rb2
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb2
-rw-r--r--app/models/event.rb4
-rw-r--r--app/models/group.rb81
-rw-r--r--app/models/group_group_link.rb13
-rw-r--r--app/models/groups/feature_setting.rb24
-rw-r--r--app/models/integration.rb98
-rw-r--r--app/models/integrations/base_chat_notification.rb5
-rw-r--r--app/models/integrations/base_issue_tracker.rb13
-rw-r--r--app/models/integrations/base_third_party_wiki.rb39
-rw-r--r--app/models/integrations/buildkite.rb4
-rw-r--r--app/models/integrations/confluence.rb15
-rw-r--r--app/models/integrations/emails_on_push.rb4
-rw-r--r--app/models/integrations/field.rb6
-rw-r--r--app/models/integrations/jira.rb25
-rw-r--r--app/models/integrations/pipelines_email.rb5
-rw-r--r--app/models/integrations/prometheus.rb6
-rw-r--r--app/models/integrations/shimo.rb17
-rw-r--r--app/models/issue.rb22
-rw-r--r--app/models/key.rb12
-rw-r--r--app/models/member.rb30
-rw-r--r--app/models/members/project_member.rb9
-rw-r--r--app/models/merge_request.rb28
-rw-r--r--app/models/milestone.rb8
-rw-r--r--app/models/namespace.rb5
-rw-r--r--app/models/namespace/root_storage_statistics.rb8
-rw-r--r--app/models/namespaces/traversal/linear.rb27
-rw-r--r--app/models/note.rb58
-rw-r--r--app/models/onboarding_progress.rb3
-rw-r--r--app/models/packages/package.rb4
-rw-r--r--app/models/packages/package_file.rb2
-rw-r--r--app/models/preloaders/group_root_ancestor_preloader.rb32
-rw-r--r--app/models/programming_language.rb7
-rw-r--r--app/models/project.rb68
-rw-r--r--app/models/project_feature.rb26
-rw-r--r--app/models/project_group_link.rb1
-rw-r--r--app/models/project_import_state.rb23
-rw-r--r--app/models/project_setting.rb11
-rw-r--r--app/models/projects/build_artifacts_size_refresh.rb2
-rw-r--r--app/models/projects/topic.rb4
-rw-r--r--app/models/repository.rb22
-rw-r--r--app/models/repository_language.rb4
-rw-r--r--app/models/review.rb4
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/models/suggestion.rb6
-rw-r--r--app/models/todo.rb6
-rw-r--r--app/models/user.rb121
-rw-r--r--app/models/user_custom_attribute.rb10
-rw-r--r--app/models/user_preference.rb3
-rw-r--r--app/models/users/callout.rb3
-rw-r--r--app/models/users/group_callout.rb4
-rw-r--r--app/models/users/in_product_marketing_email.rb7
-rw-r--r--app/models/vulnerability.rb2
-rw-r--r--app/models/wiki.rb97
-rw-r--r--app/models/wiki_page.rb6
-rw-r--r--app/models/work_items/type.rb2
-rw-r--r--app/policies/alert_management/alert_policy.rb12
-rw-r--r--app/policies/environment_policy.rb6
-rw-r--r--app/policies/project_member_policy.rb7
-rw-r--r--app/policies/project_policy.rb4
-rw-r--r--app/policies/suggestion_policy.rb4
-rw-r--r--app/policies/user_policy.rb1
-rw-r--r--app/presenters/README.md8
-rw-r--r--app/presenters/ci/bridge_presenter.rb4
-rw-r--r--app/presenters/ci/build_presenter.rb6
-rw-r--r--app/presenters/ci/build_runner_presenter.rb57
-rw-r--r--app/presenters/clusterable_presenter.rb4
-rw-r--r--app/presenters/commit_status_presenter.rb2
-rw-r--r--app/presenters/dev_ops_report/metric_presenter.rb44
-rw-r--r--app/presenters/event_presenter.rb2
-rw-r--r--app/presenters/gitlab/blame_presenter.rb22
-rw-r--r--app/presenters/instance_clusterable_presenter.rb5
-rw-r--r--app/presenters/issue_presenter.rb16
-rw-r--r--app/presenters/label_presenter.rb2
-rw-r--r--app/presenters/pages_domain_presenter.rb2
-rw-r--r--app/presenters/projects/security/configuration_presenter.rb3
-rw-r--r--app/serializers/deployment_entity.rb1
-rw-r--r--app/serializers/environment_entity.rb2
-rw-r--r--app/serializers/environment_status_entity.rb2
-rw-r--r--app/serializers/group_link/group_group_link_entity.rb16
-rw-r--r--app/serializers/group_link/group_link_entity.rb27
-rw-r--r--app/serializers/group_link/project_group_link_entity.rb12
-rw-r--r--app/serializers/issue_entity.rb2
-rw-r--r--app/serializers/member_user_entity.rb3
-rw-r--r--app/serializers/merge_request_noteable_entity.rb2
-rw-r--r--app/serializers/merge_request_serializer.rb2
-rw-r--r--app/services/alert_management/metric_images/upload_service.rb46
-rw-r--r--app/services/audit_event_service.rb7
-rw-r--r--app/services/auth/container_registry_authentication_service.rb6
-rw-r--r--app/services/base_container_service.rb4
-rw-r--r--app/services/bulk_imports/relation_export_service.rb5
-rw-r--r--app/services/ci/after_requeue_job_service.rb12
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb10
-rw-r--r--app/services/ci/create_pipeline_service.rb1
-rw-r--r--app/services/ci/job_artifacts/destroy_all_expired_service.rb12
-rw-r--r--app/services/ci/job_artifacts/destroy_batch_service.rb6
-rw-r--r--app/services/ci/job_artifacts/update_unknown_locked_status_service.rb79
-rw-r--r--app/services/ci/play_build_service.rb5
-rw-r--r--app/services/ci/register_job_service.rb3
-rw-r--r--app/services/ci/retry_build_service.rb94
-rw-r--r--app/services/ci/retry_job_service.rb94
-rw-r--r--app/services/ci/retry_pipeline_service.rb2
-rw-r--r--app/services/concerns/deploy_token_methods.rb5
-rw-r--r--app/services/concerns/incident_management/usage_data.rb5
-rw-r--r--app/services/concerns/members/bulk_create_users.rb12
-rw-r--r--app/services/database/consistency_check_service.rb109
-rw-r--r--app/services/deployments/update_environment_service.rb8
-rw-r--r--app/services/emails/base_service.rb4
-rw-r--r--app/services/emails/create_service.rb1
-rw-r--r--app/services/environments/stop_service.rb6
-rw-r--r--app/services/event_create_service.rb5
-rw-r--r--app/services/files/base_service.rb2
-rw-r--r--app/services/files/create_service.rb3
-rw-r--r--app/services/files/update_service.rb3
-rw-r--r--app/services/git/branch_push_service.rb7
-rw-r--r--app/services/groups/create_service.rb5
-rw-r--r--app/services/groups/deploy_tokens/create_service.rb2
-rw-r--r--app/services/groups/transfer_service.rb33
-rw-r--r--app/services/import/github_service.rb2
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/build_service.rb34
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/create_service.rb19
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb6
-rw-r--r--app/services/issuable_base_service.rb14
-rw-r--r--app/services/issuable_links/create_service.rb26
-rw-r--r--app/services/issuable_links/destroy_service.rb6
-rw-r--r--app/services/issue_links/create_service.rb2
-rw-r--r--app/services/issue_links/destroy_service.rb2
-rw-r--r--app/services/issues/update_service.rb1
-rw-r--r--app/services/jira/requests/base.rb2
-rw-r--r--app/services/loose_foreign_keys/process_deleted_records_service.rb2
-rw-r--r--app/services/members/create_service.rb63
-rw-r--r--app/services/members/creator_service.rb64
-rw-r--r--app/services/members/groups/creator_service.rb12
-rw-r--r--app/services/members/invite_service.rb55
-rw-r--r--app/services/members/projects/creator_service.rb25
-rw-r--r--app/services/merge_requests/base_service.rb2
-rw-r--r--app/services/merge_requests/update_service.rb1
-rw-r--r--app/services/namespaces/in_product_marketing_emails_service.rb5
-rw-r--r--app/services/namespaces/invite_team_email_service.rb61
-rw-r--r--app/services/notes/create_service.rb2
-rw-r--r--app/services/notes/update_service.rb14
-rw-r--r--app/services/notification_service.rb30
-rw-r--r--app/services/packages/rubygems/metadata_extraction_service.rb6
-rw-r--r--app/services/projects/apple_target_platform_detector_service.rb58
-rw-r--r--app/services/projects/container_repository/third_party/delete_tags_service.rb2
-rw-r--r--app/services/projects/create_service.rb13
-rw-r--r--app/services/projects/deploy_tokens/create_service.rb2
-rw-r--r--app/services/projects/import_export/export_service.rb46
-rw-r--r--app/services/projects/operations/update_service.rb3
-rw-r--r--app/services/projects/participants_service.rb6
-rw-r--r--app/services/projects/record_target_platforms_service.rb29
-rw-r--r--app/services/projects/refresh_build_artifacts_size_statistics_service.rb2
-rw-r--r--app/services/projects/transfer_service.rb14
-rw-r--r--app/services/quick_actions/interpret_service.rb7
-rw-r--r--app/services/resource_access_tokens/create_service.rb1
-rw-r--r--app/services/suggestions/apply_service.rb2
-rw-r--r--app/services/users/destroy_service.rb4
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb4
-rw-r--r--app/services/users/registrations_build_service.rb2
-rw-r--r--app/services/users/saved_replies/destroy_service.rb23
-rw-r--r--app/services/users/saved_replies/update_service.rb5
-rw-r--r--app/services/web_hook_service.rb16
-rw-r--r--app/uploaders/ci/secure_file_uploader.rb2
-rw-r--r--app/uploaders/metric_image_uploader.rb20
-rw-r--r--app/validators/gitlab/emoji_name_validator.rb19
-rw-r--r--app/validators/key_restriction_validator.rb15
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml32
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml164
-rw-r--r--app/views/admin/application_settings/_eks.html.haml8
-rw-r--r--app/views/admin/application_settings/_email.html.haml30
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml11
-rw-r--r--app/views/admin/application_settings/_floc.html.haml7
-rw-r--r--app/views/admin/application_settings/_git_lfs_limits.html.haml11
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml7
-rw-r--r--app/views/admin/application_settings/_grafana.html.haml11
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml7
-rw-r--r--app/views/admin/application_settings/_kroki.html.haml13
-rw-r--r--app/views/admin/application_settings/_localization.html.haml12
-rw-r--r--app/views/admin/application_settings/_mailgun.html.haml7
-rw-r--r--app/views/admin/application_settings/_note_limits.html.haml4
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml24
-rw-r--r--app/views/admin/application_settings/_pages.html.haml36
-rw-r--r--app/views/admin/application_settings/_performance.html.haml13
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml9
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml7
-rw-r--r--app/views/admin/application_settings/_protected_paths.html.haml11
-rw-r--r--app/views/admin/application_settings/_registry.html.haml28
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml30
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml10
-rw-r--r--app/views/admin/application_settings/_sentry.html.haml6
-rw-r--r--app/views/admin/application_settings/_signup.html.haml4
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml6
-rw-r--r--app/views/admin/application_settings/_sourcegraph.html.haml12
-rw-r--r--app/views/admin/application_settings/_spam.html.haml50
-rw-r--r--app/views/admin/application_settings/_terms.html.haml8
-rw-r--r--app/views/admin/application_settings/_third_party_offers.html.haml7
-rw-r--r--app/views/admin/application_settings/_usage.html.haml99
-rw-r--r--app/views/admin/application_settings/_users_api_limits.html.haml4
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml24
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml4
-rw-r--r--app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml11
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml3
-rw-r--r--app/views/admin/application_settings/general.html.haml13
-rw-r--r--app/views/admin/application_settings/repository.html.haml2
-rw-r--r--app/views/admin/application_settings/service_usage_data.html.haml27
-rw-r--r--app/views/admin/applications/_delete_form.html.haml3
-rw-r--r--app/views/admin/applications/_form.html.haml4
-rw-r--r--app/views/admin/applications/index.html.haml77
-rw-r--r--app/views/admin/background_migrations/_migration.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml32
-rw-r--r--app/views/admin/broadcast_messages/_preview.html.haml3
-rw-r--r--app/views/admin/dashboard/_security_newsletter_callout.html.haml13
-rw-r--r--app/views/admin/groups/_form.html.haml4
-rw-r--r--app/views/admin/groups/index.html.haml3
-rw-r--r--app/views/admin/hooks/_form.html.haml32
-rw-r--r--app/views/admin/hooks/edit.html.haml2
-rw-r--r--app/views/admin/hooks/index.html.haml2
-rw-r--r--app/views/admin/identities/index.html.haml2
-rw-r--r--app/views/admin/impersonation_tokens/index.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml6
-rw-r--r--app/views/admin/runners/edit.html.haml5
-rw-r--r--app/views/admin/topics/_form.html.haml2
-rw-r--r--app/views/admin/users/_access_levels.html.haml20
-rw-r--r--app/views/admin/users/_modals.html.haml20
-rw-r--r--app/views/admin/users/_users.html.haml7
-rw-r--r--app/views/admin/users/keys.html.haml1
-rw-r--r--app/views/admin/users/projects.html.haml2
-rw-r--r--app/views/admin/users/show.html.haml5
-rw-r--r--app/views/clusters/clusters/_banner.html.haml12
-rw-r--r--app/views/clusters/clusters/_deprecation_alert.html.haml7
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml6
-rw-r--r--app/views/clusters/clusters/_sidebar.html.haml14
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml11
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml8
-rw-r--r--app/views/clusters/clusters/connect.html.haml10
-rw-r--r--app/views/clusters/clusters/new.html.haml10
-rw-r--r--app/views/clusters/clusters/new_cluster_docs.html.haml13
-rw-r--r--app/views/dashboard/milestones/index.html.haml35
-rw-r--r--app/views/devise/sessions/_new_base.html.haml17
-rw-r--r--app/views/devise/shared/_email_opted_in.html.haml3
-rw-r--r--app/views/errors/_footer.html.haml4
-rw-r--r--app/views/groups/_group_admin_settings.html.haml23
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml4
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml4
-rw-r--r--app/views/groups/_subgroups_and_projects.html.haml3
-rw-r--r--app/views/groups/crm/contacts/index.html.haml8
-rw-r--r--app/views/groups/crm/organizations/index.html.haml8
-rw-r--r--app/views/groups/dependency_proxies/show.html.haml2
-rw-r--r--app/views/groups/edit.html.haml1
-rw-r--r--app/views/groups/group_members/index.html.haml6
-rw-r--r--app/views/groups/harbor/repositories/index.html.haml6
-rw-r--r--app/views/groups/milestones/index.html.haml43
-rw-r--r--app/views/groups/runners/_settings.html.haml186
-rw-r--r--app/views/groups/runners/_sort_dropdown.html.haml11
-rw-r--r--app/views/groups/settings/_export.html.haml28
-rw-r--r--app/views/groups/settings/_permissions.html.haml2
-rw-r--r--app/views/groups/settings/_remove_button.html.haml3
-rw-r--r--app/views/groups/settings/_transfer.html.haml3
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml18
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml1
-rw-r--r--app/views/groups/show.html.haml21
-rw-r--r--app/views/import/_githubish_status.html.haml2
-rw-r--r--app/views/import/github/new.html.haml3
-rw-r--r--app/views/import/github/status.html.haml2
-rw-r--r--app/views/import/history/index.html.haml4
-rw-r--r--app/views/import/shared/_errors.html.haml5
-rw-r--r--app/views/jira_connect/users/show.html.haml14
-rw-r--r--app/views/layouts/_diffs_colors_css.haml20
-rw-r--r--app/views/layouts/_header_search.html.haml7
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/_snowplow.html.haml2
-rw-r--r--app/views/layouts/_startup_css.haml3
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/errors.html.haml4
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml1
-rw-r--r--app/views/layouts/header/_default.html.haml2
-rw-r--r--app/views/layouts/header/_registration_enabled_callout.html.haml8
-rw-r--r--app/views/layouts/header/_storage_enforcement_banner.html.haml7
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml15
-rw-r--r--app/views/notify/_note_email.html.haml17
-rw-r--r--app/views/notify/_note_email.text.erb7
-rw-r--r--app/views/notify/issue_due_email.html.haml4
-rw-r--r--app/views/notify/issue_moved_email.html.haml8
-rw-r--r--app/views/notify/issue_status_changed_email.html.haml2
-rw-r--r--app/views/notify/merge_request_status_email.html.haml4
-rw-r--r--app/views/notify/merge_request_status_email.text.haml6
-rw-r--r--app/views/notify/merge_request_unmergeable_email.html.haml2
-rw-r--r--app/views/notify/merge_request_unmergeable_email.text.haml6
-rw-r--r--app/views/notify/merged_merge_request_email.html.haml2
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml6
-rw-r--r--app/views/notify/new_email_address_added_email.erb5
-rw-r--r--app/views/notify/new_email_address_added_email.haml6
-rw-r--r--app/views/notify/new_merge_request_email.html.haml23
-rw-r--r--app/views/notify/new_review_email.html.haml14
-rw-r--r--app/views/notify/new_review_email.text.erb4
-rw-r--r--app/views/notify/push_to_merge_request_email.html.haml11
-rw-r--r--app/views/notify/service_desk_new_note_email.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml11
-rw-r--r--app/views/profiles/keys/_form.html.haml6
-rw-r--r--app/views/profiles/keys/index.html.haml2
-rw-r--r--app/views/profiles/notifications/_email_settings.html.haml4
-rw-r--r--app/views/profiles/notifications/show.html.haml24
-rw-r--r--app/views/profiles/preferences/show.html.haml65
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml6
-rw-r--r--app/views/projects/_deletion_failed.html.haml4
-rw-r--r--app/views/projects/_files.html.haml2
-rw-r--r--app/views/projects/_import_project_pane.html.haml8
-rw-r--r--app/views/projects/_last_push.html.haml5
-rw-r--r--app/views/projects/_new_project_fields.html.haml35
-rw-r--r--app/views/projects/_new_project_initialize_with_sast.html.haml16
-rw-r--r--app/views/projects/_transfer.html.haml5
-rw-r--r--app/views/projects/alert_management/details.html.haml2
-rw-r--r--app/views/projects/blame/show.html.haml2
-rw-r--r--app/views/projects/blob/_upload.html.haml4
-rw-r--r--app/views/projects/blob/edit.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_balsamiq.html.haml1
-rw-r--r--app/views/projects/branches/_branch.html.haml1
-rw-r--r--app/views/projects/branches/_panel.html.haml2
-rw-r--r--app/views/projects/branches/index.html.haml5
-rw-r--r--app/views/projects/branches/new.html.haml4
-rw-r--r--app/views/projects/commit/_commit_box.html.haml6
-rw-r--r--app/views/projects/commit/show.html.haml1
-rw-r--r--app/views/projects/commits/_commits.html.haml5
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/compare/show.html.haml1
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml7
-rw-r--r--app/views/projects/diffs/_file.html.haml1
-rw-r--r--app/views/projects/diffs/_line.html.haml2
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml4
-rw-r--r--app/views/projects/diffs/_stats.html.haml1
-rw-r--r--app/views/projects/diffs/_text_file.html.haml2
-rw-r--r--app/views/projects/diffs/_warning.html.haml13
-rw-r--r--app/views/projects/edit.html.haml6
-rw-r--r--app/views/projects/empty.html.haml1
-rw-r--r--app/views/projects/forks/error.html.haml5
-rw-r--r--app/views/projects/forks/index.html.haml13
-rw-r--r--app/views/projects/harbor/repositories/index.html.haml6
-rw-r--r--app/views/projects/hooks/edit.html.haml2
-rw-r--r--app/views/projects/hooks/index.html.haml2
-rw-r--r--app/views/projects/imports/new.html.haml2
-rw-r--r--app/views/projects/issues/_alert_moved_from_service_desk.html.haml6
-rw-r--r--app/views/projects/issues/_form.html.haml4
-rw-r--r--app/views/projects/issues/_new_branch.html.haml8
-rw-r--r--app/views/projects/jobs/index.html.haml4
-rw-r--r--app/views/projects/mattermosts/_no_teams.html.haml6
-rw-r--r--app/views/projects/merge_requests/_form.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml5
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml2
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml5
-rw-r--r--app/views/projects/milestones/index.html.haml46
-rw-r--r--app/views/projects/milestones/show.html.haml6
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml6
-rw-r--r--app/views/projects/mirrors/_ssh_host_keys.html.haml2
-rw-r--r--app/views/projects/no_repo.html.haml2
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml19
-rw-r--r--app/views/projects/pages_domains/_form.html.haml3
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml7
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml2
-rw-r--r--app/views/projects/pipelines/index.html.haml2
-rw-r--r--app/views/projects/pipelines/show.html.haml6
-rw-r--r--app/views/projects/project_members/index.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml2
-rw-r--r--app/views/projects/runners/_runner.html.haml6
-rw-r--r--app/views/projects/security/configuration/show.html.haml1
-rw-r--r--app/views/projects/services/_form.html.haml7
-rw-r--r--app/views/projects/services/prometheus/_help.html.haml3
-rw-r--r--app/views/projects/services/prometheus/_top.html.haml3
-rw-r--r--app/views/projects/settings/_archive.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml17
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml39
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/projects/settings/operations/_configuration_banner.html.haml24
-rw-r--r--app/views/projects/settings/operations/_prometheus.html.haml2
-rw-r--r--app/views/projects/settings/operations/show.html.haml2
-rw-r--r--app/views/projects/settings/packages_and_registries/show.html.haml2
-rw-r--r--app/views/projects/show.html.haml2
-rw-r--r--app/views/projects/tags/index.html.haml13
-rw-r--r--app/views/projects/tracings/show.html.haml2
-rw-r--r--app/views/registrations/welcome/show.html.haml2
-rw-r--r--app/views/search/results/_blob_highlight.html.haml4
-rw-r--r--app/views/shared/_allow_request_access.html.haml3
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml6
-rw-r--r--app/views/shared/_broadcast_message.html.haml37
-rw-r--r--app/views/shared/_global_alert.html.haml21
-rw-r--r--app/views/shared/_import_form.html.haml5
-rw-r--r--app/views/shared/_milestones_sort_dropdown.html.haml26
-rw-r--r--app/views/shared/_no_password.html.haml5
-rw-r--r--app/views/shared/_no_ssh.html.haml5
-rw-r--r--app/views/shared/_outdated_browser.html.haml3
-rw-r--r--app/views/shared/_project_limit.html.haml5
-rw-r--r--app/views/shared/_prometheus_configuration_banner.html.haml (renamed from app/views/projects/services/prometheus/_configuration_banner.html.haml)13
-rw-r--r--app/views/shared/_service_ping_consent.html.haml4
-rw-r--r--app/views/shared/_two_factor_auth_recovery_settings_check.html.haml9
-rw-r--r--app/views/shared/access_tokens/_form.html.haml4
-rw-r--r--app/views/shared/boards/_show.html.haml2
-rw-r--r--app/views/shared/boards/_switcher.html.haml12
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml7
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml10
-rw-r--r--app/views/shared/doorkeeper/applications/_form.html.haml23
-rw-r--r--app/views/shared/empty_states/_issues.html.haml2
-rw-r--r--app/views/shared/empty_states/_milestones.html.haml8
-rw-r--r--app/views/shared/empty_states/_milestones_tab.html.haml17
-rw-r--r--app/views/shared/errors/_gitaly_unavailable.html.haml5
-rw-r--r--app/views/shared/groups/_dropdown.html.haml27
-rw-r--r--app/views/shared/hook_logs/_content.html.haml13
-rw-r--r--app/views/shared/integrations/edit.html.haml2
-rw-r--r--app/views/shared/issuable/_assignees.html.haml2
-rw-r--r--app/views/shared/issuable/_form.html.haml5
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml2
-rw-r--r--app/views/shared/issuable/_merge_request_assignees.html.haml2
-rw-r--r--app/views/shared/issuable/_merge_request_reviewers.html.haml2
-rw-r--r--app/views/shared/issuable/_reviewers.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml357
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--app/views/shared/issuable/form/_contribution.html.haml15
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml6
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml10
-rw-r--r--app/views/shared/milestones/_milestone_complete_alert.html.haml5
-rw-r--r--app/views/shared/projects/_list.html.haml4
-rw-r--r--app/views/shared/runners/_runner_type_alert.html.haml24
-rw-r--r--app/views/shared/tokens/_scopes_form.html.haml15
-rw-r--r--app/views/shared/web_hooks/_form.html.haml141
-rw-r--r--app/views/shared/web_hooks/_hook_errors.html.haml18
-rw-r--r--app/views/shared/wikis/_form.html.haml2
-rw-r--r--app/views/shared/wikis/_main_links.html.haml2
-rw-r--r--app/views/shared/wikis/show.html.haml3
-rw-r--r--app/views/users/show.html.haml2
-rw-r--r--app/workers/all_queues.yml65
-rw-r--r--app/workers/bulk_import_worker.rb22
-rw-r--r--app/workers/bulk_imports/entity_worker.rb37
-rw-r--r--app/workers/bulk_imports/export_request_worker.rb10
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb41
-rw-r--r--app/workers/bulk_imports/relation_export_worker.rb4
-rw-r--r--app/workers/bulk_imports/stuck_import_worker.rb31
-rw-r--r--app/workers/ci/update_locked_unknown_artifacts_worker.rb26
-rw-r--r--app/workers/concerns/chaos_queue.rb2
-rw-r--r--app/workers/concerns/git_garbage_collect_methods.rb6
-rw-r--r--app/workers/concerns/packages/cleanup_artifact_worker.rb6
-rw-r--r--app/workers/concerns/reactive_cacheable_worker.rb5
-rw-r--r--app/workers/concerns/worker_attributes.rb8
-rw-r--r--app/workers/container_registry/migration/enqueuer_worker.rb107
-rw-r--r--app/workers/container_registry/migration/guard_worker.rb52
-rw-r--r--app/workers/database/batched_background_migration/ci_database_worker.rb4
-rw-r--r--app/workers/database/batched_background_migration/single_database_worker.rb6
-rw-r--r--app/workers/database/batched_background_migration_worker.rb4
-rw-r--r--app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb28
-rw-r--r--app/workers/database/ci_project_mirrors_consistency_check_worker.rb28
-rw-r--r--app/workers/delete_stored_files_worker.rb2
-rw-r--r--app/workers/environments/auto_stop_worker.rb6
-rw-r--r--app/workers/flush_counter_increments_worker.rb5
-rw-r--r--app/workers/namespaces/invite_team_email_worker.rb22
-rw-r--r--app/workers/namespaces/root_statistics_worker.rb9
-rw-r--r--app/workers/object_storage/background_move_worker.rb2
-rw-r--r--app/workers/object_storage/migrate_uploads_worker.rb2
-rw-r--r--app/workers/project_export_worker.rb16
-rw-r--r--app/workers/projects/record_target_platforms_worker.rb55
-rw-r--r--app/workers/quality/test_data_cleanup_worker.rb33
1397 files changed, 16964 insertions, 9565 deletions
diff --git a/app/assets/images/auth_buttons/alicloud_64.png b/app/assets/images/auth_buttons/alicloud_64.png
new file mode 100644
index 00000000000..bd67a199e13
--- /dev/null
+++ b/app/assets/images/auth_buttons/alicloud_64.png
Binary files differ
diff --git a/app/assets/images/checkmark.png b/app/assets/images/checkmark.png
deleted file mode 100644
index 6e47fda5cdc..00000000000
--- a/app/assets/images/checkmark.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/learn_gitlab/graduation_hat.svg b/app/assets/images/learn_gitlab/graduation_hat.svg
deleted file mode 100644
index 998d8d9b935..00000000000
--- a/app/assets/images/learn_gitlab/graduation_hat.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="16" height="17" xmlns="http://www.w3.org/2000/svg"><path fill="#fffff" d="M1.53 7.639l-.476.88.476-.88zm0-1.758L1.054 5l.476.88zm2.257 2.982h1v-.596l-.523-.283-.477.879zm8.424 0l-.476-.88-.524.284v.596h1zm2.257-1.224l.477.88-.477-.88zm0-1.758l-.476.879.476-.88zM8.476 2.632l-.477.88.477-.88zm-.953 0l.476.88-.476-.88zM2.007 6.76l-.953-1.758c-1.396.756-1.396 2.76 0 3.516l.953-1.758zm2.257 1.224L2.007 6.76l-.953 1.758L3.31 9.742l.953-1.758zm.523 1.995V8.863h-2v1.116h2zM8 12.5c-1.949 0-3.212-1.289-3.212-2.52h-2c0 2.656 2.51 4.52 5.212 4.52v-2zm3.212-2.52c0 1.231-1.262 2.52-3.212 2.52v2c2.704 0 5.212-1.864 5.212-4.52h-2zm0-1.117v1.116h2V8.863h-2zm2.78-2.103l-2.256 1.223.953 1.759 2.257-1.224-.953-1.758zm0 0l.954 1.758c1.396-.757 1.396-2.76 0-3.516l-.953 1.758zM8 3.51l5.993 3.249.953-1.758-5.993-3.249L8 3.511zm0 0l.953-1.758a2 2 0 00-1.906 0L8 3.511zM2.007 6.76l5.992-3.25-.953-1.758-5.992 3.249.953 1.758z"/><path fill="#fffff" d="M7.228 7.541c-.187-.112-.277-.427-.201-.704.076-.276.288-.41.475-.297L11 8.644v5.316c0 .298-.163.54-.365.54-.2 0-.364-.242-.364-.54V9.37L7.228 7.54z"/></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
index cdc8a952ead..a5fc70b9ca6 100644
--- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
+++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query accessTokensGetProjects(
$search: String = ""
diff --git a/app/assets/javascripts/admin/topics/components/remove_avatar.vue b/app/assets/javascripts/admin/topics/components/remove_avatar.vue
index 5e94d6185e0..a54c30a8336 100644
--- a/app/assets/javascripts/admin/topics/components/remove_avatar.vue
+++ b/app/assets/javascripts/admin/topics/components/remove_avatar.vue
@@ -1,6 +1,6 @@
<script>
import { uniqueId } from 'lodash';
-import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import csrf from '~/lib/utils/csrf';
@@ -8,11 +8,12 @@ export default {
components: {
GlButton,
GlModal,
+ GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
- inject: ['path'],
+ inject: ['path', 'name'],
data() {
return {
modalId: uniqueId('remove-topic-avatar-'),
@@ -25,8 +26,8 @@ export default {
},
i18n: {
remove: __('Remove avatar'),
- title: __('Confirm remove avatar'),
- body: __('Avatar will be removed. Are you sure?'),
+ title: __('Remove topic avatar'),
+ body: __('Topic avatar for %{name} will be removed. This cannot be undone.'),
},
modal: {
actionPrimary: {
@@ -57,7 +58,9 @@ export default {
:modal-id="modalId"
size="sm"
@primary="deleteApplication"
- >{{ $options.i18n.body }}
+ ><gl-sprintf :message="$options.i18n.body"
+ ><template #name>{{ name }}</template></gl-sprintf
+ >
<form ref="deleteForm" method="post" :action="path">
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js
index 8fbcadf3369..09e9b20f220 100644
--- a/app/assets/javascripts/admin/topics/index.js
+++ b/app/assets/javascripts/admin/topics/index.js
@@ -8,12 +8,13 @@ export default () => {
return false;
}
- const { path } = el.dataset;
+ const { path, name } = el.dataset;
return new Vue({
el,
provide: {
path,
+ name,
},
render(h) {
return h(RemoveAvatar);
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index e6dde5898e7..ae0c6731271 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -1,9 +1,11 @@
<script>
-import SharedDeleteAction from './shared/shared_delete_action.vue';
+import { GlDropdownItem } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default {
components: {
- SharedDeleteAction,
+ GlDropdownItem,
},
props: {
username: {
@@ -20,17 +22,32 @@ export default {
default: () => [],
},
},
+ methods: {
+ onClick() {
+ const { username, paths, userDeletionObstacles } = this;
+ eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, {
+ username,
+ blockPath: paths.block,
+ deletePath: paths.delete,
+ userDeletionObstacles,
+ i18n: {
+ title: s__('AdminUsers|Delete User %{username}?'),
+ primaryButtonLabel: s__('AdminUsers|Delete user'),
+ messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests,
+ and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss,
+ consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
+ it cannot be undone or recovered.`),
+ },
+ });
+ },
+ },
};
</script>
<template>
- <shared-delete-action
- modal-type="delete"
- :username="username"
- :paths="paths"
- :delete-path="paths.delete"
- :user-deletion-obstacles="userDeletionObstacles"
- >
- <slot></slot>
- </shared-delete-action>
+ <gl-dropdown-item @click="onClick">
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </gl-dropdown-item>
</template>
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 bd920a91516..a39df1cbfb6 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
@@ -1,9 +1,11 @@
<script>
-import SharedDeleteAction from './shared/shared_delete_action.vue';
+import { GlDropdownItem } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default {
components: {
- SharedDeleteAction,
+ GlDropdownItem,
},
props: {
username: {
@@ -20,17 +22,32 @@ export default {
default: () => [],
},
},
+ methods: {
+ onClick() {
+ const { username, paths, userDeletionObstacles } = this;
+ eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, {
+ username,
+ blockPath: paths.block,
+ deletePath: paths.deleteWithContributions,
+ userDeletionObstacles,
+ i18n: {
+ title: s__('AdminUsers|Delete User %{username} and contributions?'),
+ primaryButtonLabel: s__('AdminUsers|Delete user and contributions'),
+ messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
+ merge requests, and groups linked to them. To avoid data loss,
+ consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
+ it cannot be undone or recovered.`),
+ },
+ });
+ },
+ },
};
</script>
<template>
- <shared-delete-action
- modal-type="delete-with-contributions"
- :username="username"
- :paths="paths"
- :delete-path="paths.deleteWithContributions"
- :user-deletion-obstacles="userDeletionObstacles"
- >
- <slot></slot>
- </shared-delete-action>
+ <gl-dropdown-item @click="onClick">
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </gl-dropdown-item>
</template>
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
deleted file mode 100644
index c9f29b55dbf..00000000000
--- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<script>
-import { GlDropdownItem } from '@gitlab/ui';
-
-export default {
- components: {
- GlDropdownItem,
- },
- props: {
- username: {
- type: String,
- required: true,
- },
- paths: {
- type: Object,
- required: true,
- },
- deletePath: {
- type: String,
- required: true,
- },
- modalType: {
- type: String,
- required: true,
- },
- userDeletionObstacles: {
- type: Array,
- required: true,
- },
- },
- computed: {
- modalAttributes() {
- return {
- 'data-block-user-url': this.paths.block,
- 'data-delete-user-url': this.deletePath,
- 'data-gl-modal-action': this.modalType,
- 'data-username': this.username,
- 'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles),
- };
- },
- },
-};
-</script>
-
-<template>
- <div class="js-delete-user-modal-button" v-bind="{ ...modalAttributes }">
- <gl-dropdown-item>
- <span class="gl-text-red-500">
- <slot></slot>
- </span>
- </gl-dropdown-item>
- </div>
-</template>
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 d7c08096376..31fe86775ee 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
@@ -1,8 +1,8 @@
<script>
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
-import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
+import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from './delete_user_modal_event_hub';
export default {
components: {
@@ -13,47 +13,23 @@ export default {
UserDeletionObstaclesList,
},
props: {
- title: {
- type: String,
- required: true,
- },
- content: {
- type: String,
- required: true,
- },
- action: {
- type: String,
- required: true,
- },
- secondaryAction: {
- type: String,
- required: true,
- },
- deleteUserUrl: {
- type: String,
- required: true,
- },
- blockUserUrl: {
- type: String,
- required: true,
- },
- username: {
- type: String,
- required: true,
- },
csrfToken: {
type: String,
required: true,
},
- userDeletionObstacles: {
- type: String,
- required: false,
- default: '[]',
- },
},
data() {
return {
enteredUsername: '',
+ username: '',
+ blockPath: '',
+ deletePath: '',
+ userDeletionObstacles: [],
+ i18n: {
+ title: '',
+ primaryButtonLabel: '',
+ messageBody: '',
+ },
};
},
computed: {
@@ -61,75 +37,80 @@ export default {
return this.username.trim();
},
modalTitle() {
- return sprintf(this.title, { username: this.trimmedUsername }, false);
- },
- secondaryButtonLabel() {
- return s__('AdminUsers|Block user');
+ return sprintf(this.i18n.title, { username: this.trimmedUsername }, false);
},
canSubmit() {
- return this.enteredUsername === this.trimmedUsername;
+ return this.enteredUsername && this.enteredUsername === this.trimmedUsername;
},
- obstacles() {
- try {
- return JSON.parse(this.userDeletionObstacles);
- } catch (e) {
- Sentry.captureException(e);
- }
- return [];
+ secondaryButtonLabel() {
+ return s__('AdminUsers|Block user');
},
},
+ mounted() {
+ eventHub.$on(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
+ },
+ destroyed() {
+ eventHub.$off(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
+ },
methods: {
- show() {
+ onOpenEvent({ username, blockPath, deletePath, userDeletionObstacles, i18n }) {
+ this.username = username;
+ this.blockPath = blockPath;
+ this.deletePath = deletePath;
+ this.userDeletionObstacles = userDeletionObstacles;
+ this.i18n = i18n;
+ this.openModal();
+ },
+ openModal() {
this.$refs.modal.show();
},
+ onSubmit() {
+ this.$refs.form.submit();
+ this.enteredUsername = '';
+ },
onCancel() {
this.enteredUsername = '';
this.$refs.modal.hide();
},
onSecondaryAction() {
const { form } = this.$refs;
-
- form.action = this.blockUserUrl;
+ form.action = this.blockPath;
this.$refs.method.value = 'put';
-
form.submit();
},
- onSubmit() {
- this.$refs.form.submit();
- this.enteredUsername = '';
- },
},
};
</script>
-
<template>
<gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger">
<p>
- <gl-sprintf :message="content">
+ <gl-sprintf :message="i18n.messageBody">
<template #username>
- <strong>{{ trimmedUsername }}</strong>
+ <strong data-testid="message-username">{{ trimmedUsername }}</strong>
</template>
- <template #strong="props">
- <strong>{{ props.content }}</strong>
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<user-deletion-obstacles-list
- v-if="obstacles.length"
- :obstacles="obstacles"
+ v-if="userDeletionObstacles.length"
+ :obstacles="userDeletionObstacles"
:user-name="trimmedUsername"
/>
<p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
<template #username>
- <code class="gl-white-space-pre-wrap">{{ trimmedUsername }}</code>
+ <code data-testid="confirm-username" class="gl-white-space-pre-wrap">{{
+ trimmedUsername
+ }}</code>
</template>
</gl-sprintf>
</p>
- <form ref="form" :action="deleteUserUrl" method="post" @submit.prevent>
+ <form ref="form" :action="deletePath" method="post" @submit.prevent>
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
<gl-form-input
@@ -140,6 +121,7 @@ export default {
autocomplete="off"
/>
</form>
+
<template #modal-footer>
<gl-button @click="onCancel">{{ __('Cancel') }}</gl-button>
<gl-button
@@ -148,10 +130,10 @@ export default {
variant="danger"
@click="onSecondaryAction"
>
- {{ secondaryAction }}
+ {{ secondaryButtonLabel }}
</gl-button>
<gl-button :disabled="!canSubmit" category="primary" variant="danger" @click="onSubmit">{{
- action
+ i18n.primaryButtonLabel
}}</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js b/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js
new file mode 100644
index 00000000000..001061dcc6b
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js
@@ -0,0 +1,5 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
+
+export const EVENT_OPEN_DELETE_USER_MODAL = Symbol('OPEN');
diff --git a/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue b/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue
deleted file mode 100644
index 1dfea3f1e7b..00000000000
--- a/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue
+++ /dev/null
@@ -1,77 +0,0 @@
-<script>
-import DeleteUserModal from './delete_user_modal.vue';
-
-export default {
- components: { DeleteUserModal },
- props: {
- modalConfiguration: {
- required: true,
- type: Object,
- },
- csrfToken: {
- required: true,
- type: String,
- },
- selector: {
- required: true,
- type: String,
- },
- },
- data() {
- return {
- currentModalData: null,
- };
- },
- computed: {
- activeModal() {
- return Boolean(this.currentModalData);
- },
-
- modalProps() {
- const { glModalAction: requestedAction } = this.currentModalData;
- return {
- ...this.modalConfiguration[requestedAction],
- ...this.currentModalData,
- csrfToken: this.csrfToken,
- };
- },
- },
-
- mounted() {
- /*
- * Here we're looking for every button that needs to launch a modal
- * on click, and then attaching a click event handler to show the modal
- * if it's correctly configured.
- *
- * TODO: Replace this with integrated modal components https://gitlab.com/gitlab-org/gitlab/-/issues/320922
- */
- document.querySelectorAll(this.selector).forEach((button) => {
- button.addEventListener('click', (e) => {
- if (!button.dataset.glModalAction) return;
-
- e.preventDefault();
- this.show(button.dataset);
- });
- });
- },
-
- methods: {
- show(modalData) {
- const { glModalAction: requestedAction } = modalData;
-
- if (!this.modalConfiguration[requestedAction]) {
- throw new Error(`Modal action ${requestedAction} has no configuration in HTML`);
- }
-
- this.currentModalData = modalData;
-
- return this.$nextTick().then(() => {
- this.$refs.modal.show();
- });
- },
- },
-};
-</script>
-<template>
- <delete-user-modal v-if="activeModal" ref="modal" v-bind="modalProps" />
-</template>
diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js
index 4636c8705a5..9cd61d6b1db 100644
--- a/app/assets/javascripts/admin/users/constants.js
+++ b/app/assets/javascripts/admin/users/constants.js
@@ -20,9 +20,3 @@ export const I18N_USER_ACTIONS = {
ban: s__('AdminUsers|Ban user'),
unban: s__('AdminUsers|Unban user'),
};
-
-export const CONFIRM_DELETE_BUTTON_SELECTOR = '.js-delete-user-modal-button';
-
-export const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts';
-
-export const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal';
diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js
index 0c485d2a239..2bd37d3fffe 100644
--- a/app/assets/javascripts/admin/users/index.js
+++ b/app/assets/javascripts/admin/users/index.js
@@ -4,13 +4,8 @@ import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
import AdminUsersApp from './components/app.vue';
-import ModalManager from './components/modals/user_modal_manager.vue';
+import DeleteUserModal from './components/modals/delete_user_modal.vue';
import UserActions from './components/user_actions.vue';
-import {
- CONFIRM_DELETE_BUTTON_SELECTOR,
- MODAL_TEXTS_CONTAINER_SELECTOR,
- MODAL_MANAGER_SELECTOR,
-} from './constants';
Vue.use(VueApollo);
@@ -46,43 +41,13 @@ export const initAdminUserActions = (el = document.querySelector('#js-admin-user
initApp(el, UserActions, 'user', { showButtonLabels: true });
export const initDeleteUserModals = () => {
- const modalsMountElement = document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR);
-
- if (!modalsMountElement) {
- return;
- }
-
- const modalConfiguration = Array.from(modalsMountElement.children).reduce((accumulator, node) => {
- const { modal, ...config } = node.dataset;
-
- return {
- ...accumulator,
- [modal]: {
- title: node.dataset.title,
- ...config,
- content: node.innerHTML,
- },
- };
- }, {});
-
- // eslint-disable-next-line no-new
- new Vue({
- el: MODAL_MANAGER_SELECTOR,
+ return new Vue({
functional: true,
- methods: {
- show(...args) {
- this.$refs.manager.show(...args);
- },
- },
- render(h) {
- return h(ModalManager, {
- ref: 'manager',
+ render: (createElement) =>
+ createElement(DeleteUserModal, {
props: {
- selector: CONFIRM_DELETE_BUTTON_SELECTOR,
- modalConfiguration,
csrfToken: csrf.token,
},
- });
- },
- });
+ }),
+ }).$mount();
};
diff --git a/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue
index 400326e41e1..b9501107e37 100644
--- a/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue
+++ b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue
@@ -43,7 +43,7 @@ export default {
{{ s__('ServicePing|Turn on service ping to review instance-level analytics.') }}
</p>
- <gl-button category="primary" variant="success" :href="primaryButtonPath">
+ <gl-button category="primary" variant="confirm" :href="primaryButtonPath">
{{ s__('ServicePing|Turn on service ping') }}
</gl-button>
</template>
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 b3ae671d611..b2b033de75d 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -11,6 +11,7 @@ import {
import { debounce } from 'lodash';
import { filterBySearchTerm } from '~/analytics/shared/utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale';
import getProjects from '../graphql/projects.query.graphql';
@@ -204,6 +205,7 @@ export default {
return getIdFromGraphQLId(project.id);
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
@@ -227,7 +229,7 @@ export default {
:entity-id="getEntityId(selectedProjects[0])"
:entity-name="selectedProjects[0].name"
:size="16"
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
:alt="selectedProjects[0].name"
class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2"
/>
@@ -255,7 +257,7 @@ export default {
:entity-id="getEntityId(project)"
:entity-name="project.name"
:src="project.avatarUrl"
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
<div>
<div data-testid="project-name">{{ project.name }}</div>
@@ -279,7 +281,7 @@ export default {
:entity-id="getEntityId(project)"
:entity-name="project.name"
:src="project.avatarUrl"
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
<div>
<div data-testid="project-name">{{ project.name }}</div>
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index dde429ab278..71b7ca29bad 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -19,6 +19,7 @@ export const toYmd = (date) => dateFormat(date, dateFormats.isoDate);
* @returns {Object}
*/
export const extractFilterQueryParameters = (url = '') => {
+ /* eslint-disable camelcase */
const {
source_branch_name = null,
target_branch_name = null,
@@ -27,6 +28,7 @@ export const extractFilterQueryParameters = (url = '') => {
assignee_username = [],
label_name = [],
} = urlQueryToFilter(url);
+ /* eslint-enable camelcase */
return {
selectedSourceBranch: source_branch_name,
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql
index 2a5546efb68..f9311626cc3 100644
--- a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
#import "../fragments/count.fragment.graphql"
query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) {
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql
index 7c02ac49a42..d7638458b03 100644
--- a/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
#import "../fragments/count.fragment.graphql"
query getUsersCount($first: Int, $after: String) {
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 35fc64d43e5..64812e52849 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -42,6 +42,7 @@ const Api = {
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
+ projectProtectedBranchesNamePath: '/api/:version/projects/:id/protected_branches/:name',
projectSearchPath: '/api/:version/projects/:id/search',
projectSharePath: '/api/:version/projects/:id/share',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
@@ -93,6 +94,7 @@ const Api = {
notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
secureFilesPath: '/api/:version/projects/:project_id/secure_files',
+ dependencyProxyPath: '/api/:version/groups/:id/dependency_proxy/cache',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -154,13 +156,7 @@ const Api = {
});
},
- addGroupMembersByUserId(id, data) {
- const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
-
- return axios.post(url, data);
- },
-
- inviteGroupMembersByEmail(id, data) {
+ inviteGroupMembers(id, data) {
const url = Api.buildUrl(this.groupInvitationsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
@@ -234,7 +230,7 @@ const Api = {
return axios
.get(url, {
- params: Object.assign(defaults, options),
+ params: { ...defaults, ...options },
})
.then(({ data, headers }) => {
callback(data);
@@ -256,13 +252,7 @@ const Api = {
.then(({ data }) => data);
},
- addProjectMembersByUserId(id, data) {
- const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id));
-
- return axios.post(url, data);
- },
-
- inviteProjectMembersByEmail(id, data) {
+ inviteProjectMembers(id, data) {
const url = Api.buildUrl(this.projectInvitationsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
@@ -371,6 +361,14 @@ const Api = {
.then(({ data }) => data);
},
+ projectProtectedBranch(id, branchName) {
+ const url = Api.buildUrl(Api.projectProtectedBranchesNamePath)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':name', branchName);
+
+ return axios.get(url).then(({ data }) => data);
+ },
+
projectSearch(id, options = {}) {
const url = Api.buildUrl(Api.projectSearchPath).replace(':id', encodeURIComponent(id));
@@ -445,7 +443,7 @@ const Api = {
},
// Return group projects list. Filtered by query
- groupProjects(groupId, query, options, callback) {
+ groupProjects(groupId, query, options, callback = () => {}, useCustomErrorHandler = false) {
const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId);
const defaults = {
search: query,
@@ -455,14 +453,21 @@ const Api = {
.get(url, {
params: { ...defaults, ...options },
})
- .then(({ data }) => (callback ? callback(data) : data))
- .catch(() => {
+ .then(({ data, headers }) => {
+ callback(data);
+
+ return { data, headers };
+ })
+ .catch((error) => {
+ if (useCustomErrorHandler) {
+ throw error;
+ }
+
createFlash({
message: __('Something went wrong while fetching projects'),
});
- if (callback) {
- callback();
- }
+
+ callback();
});
},
@@ -992,6 +997,12 @@ const Api = {
return result;
},
+
+ deleteDependencyProxyCacheList(groupId, options = {}) {
+ const url = Api.buildUrl(this.dependencyProxyPath).replace(':id', groupId);
+
+ return axios.delete(url, { params: { ...options } });
+ },
};
export default Api;
diff --git a/app/assets/javascripts/api/alert_management_alerts_api.js b/app/assets/javascripts/api/alert_management_alerts_api.js
new file mode 100644
index 00000000000..fa66ca5b3dd
--- /dev/null
+++ b/app/assets/javascripts/api/alert_management_alerts_api.js
@@ -0,0 +1,62 @@
+import axios from '~/lib/utils/axios_utils';
+import { buildApiUrl } from '~/api/api_utils';
+import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
+
+const ALERT_METRIC_IMAGES_PATH =
+ '/api/:version/projects/:id/alert_management_alerts/:alert_iid/metric_images';
+const ALERT_SINGLE_METRIC_IMAGE_PATH =
+ '/api/:version/projects/:id/alert_management_alerts/:alert_iid/metric_images/:image_id';
+
+export function fetchAlertMetricImages({ alertIid, id }) {
+ const metricImagesUrl = buildApiUrl(ALERT_METRIC_IMAGES_PATH)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':alert_iid', encodeURIComponent(alertIid));
+
+ return axios.get(metricImagesUrl);
+}
+
+export function uploadAlertMetricImage({ alertIid, id, file, url = null, urlText = null }) {
+ const options = { headers: { ...ContentTypeMultipartFormData } };
+ const metricImagesUrl = buildApiUrl(ALERT_METRIC_IMAGES_PATH)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':alert_iid', encodeURIComponent(alertIid));
+
+ // Construct multipart form data
+ const formData = new FormData();
+ formData.append('file', file);
+ if (url) {
+ formData.append('url', url);
+ }
+ if (urlText) {
+ formData.append('url_text', urlText);
+ }
+
+ return axios.post(metricImagesUrl, formData, options);
+}
+
+export function updateAlertMetricImage({ alertIid, id, imageId, url = null, urlText = null }) {
+ const metricImagesUrl = buildApiUrl(ALERT_SINGLE_METRIC_IMAGE_PATH)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':alert_iid', encodeURIComponent(alertIid))
+ .replace(':image_id', encodeURIComponent(imageId));
+
+ // Construct multipart form data
+ const formData = new FormData();
+ if (url != null) {
+ formData.append('url', url);
+ }
+ if (urlText != null) {
+ formData.append('url_text', urlText);
+ }
+
+ return axios.put(metricImagesUrl, formData);
+}
+
+export function deleteAlertMetricImage({ alertIid, id, imageId }) {
+ const individualMetricImageUrl = buildApiUrl(ALERT_SINGLE_METRIC_IMAGE_PATH)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':alert_iid', encodeURIComponent(alertIid))
+ .replace(':image_id', encodeURIComponent(imageId));
+
+ return axios.delete(individualMetricImageUrl);
+}
diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index b018db9a02d..7666f558eb5 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -2,6 +2,8 @@ import { DEFAULT_PER_PAGE } from '~/api';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
+export * from './alert_management_alerts_api';
+
const PROJECTS_PATH = '/api/:version/projects.json';
const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id';
diff --git a/app/assets/javascripts/attention_requests/components/navigation_popover.vue b/app/assets/javascripts/attention_requests/components/navigation_popover.vue
index 1542bc9a7e9..804eda8f321 100644
--- a/app/assets/javascripts/attention_requests/components/navigation_popover.vue
+++ b/app/assets/javascripts/attention_requests/components/navigation_popover.vue
@@ -82,7 +82,9 @@ export default {
return 'bottom';
},
},
- docsPage: helpPagePath('development/code_review.html'),
+ docsPage: helpPagePath('user/project/merge_requests/index.md', {
+ anchor: 'request-attention-to-a-merge-request',
+ }),
};
</script>
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 8fd03d3132d..29204020058 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -1,10 +1,4 @@
-import {
- initEmojiMap,
- getEmojiInfo,
- emojiFallbackImageSrc,
- emojiImageTag,
- FALLBACK_EMOJI_KEY,
-} from '../emoji';
+import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
import isEmojiUnicodeSupported from '../emoji/support';
class GlEmoji extends HTMLElement {
@@ -22,10 +16,6 @@ class GlEmoji extends HTMLElement {
if (emojiInfo) {
if (name !== emojiInfo.name) {
- if (emojiInfo.name === FALLBACK_EMOJI_KEY && this.innerHTML) {
- return; // When fallback emoji is used, but there is a <img> provided, use the <img> instead
- }
-
({ name } = emojiInfo);
this.dataset.name = emojiInfo.name;
}
@@ -43,34 +33,29 @@ class GlEmoji extends HTMLElement {
this.childNodes &&
Array.prototype.every.call(this.childNodes, (childNode) => childNode.nodeType === 3);
- if (
- emojiUnicode &&
- isEmojiUnicode &&
- !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)
- ) {
- const hasImageFallback = fallbackSrc && fallbackSrc.length > 0;
- const hasCssSpriteFallback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
+ const hasImageFallback = fallbackSrc?.length > 0;
+ const hasCssSpriteFallback = fallbackSpriteClass?.length > 0;
- // CSS sprite fallback takes precedence over image fallback
- if (hasCssSpriteFallback) {
- if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) {
- const emojiSpriteLinkTag = document.createElement('link');
- emojiSpriteLinkTag.setAttribute('rel', 'stylesheet');
- emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path);
- document.head.appendChild(emojiSpriteLinkTag);
- gon.emoji_sprites_css_added = true;
- }
- // IE 11 doesn't like adding multiple at once :(
- this.classList.add('emoji-icon');
- this.classList.add(fallbackSpriteClass);
- } else if (hasImageFallback) {
- this.innerHTML = '';
- this.appendChild(emojiImageTag(name, fallbackSrc));
- } else {
- const src = emojiFallbackImageSrc(name);
- this.innerHTML = '';
- this.appendChild(emojiImageTag(name, src));
+ if (emojiUnicode && isEmojiUnicode && isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) {
+ // noop
+ } else if (hasCssSpriteFallback) {
+ if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) {
+ const emojiSpriteLinkTag = document.createElement('link');
+ emojiSpriteLinkTag.setAttribute('rel', 'stylesheet');
+ emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path);
+ document.head.appendChild(emojiSpriteLinkTag);
+ gon.emoji_sprites_css_added = true;
}
+ // IE 11 doesn't like adding multiple at once :(
+ this.classList.add('emoji-icon');
+ this.classList.add(fallbackSpriteClass);
+ } else if (hasImageFallback) {
+ this.innerHTML = '';
+ this.appendChild(emojiImageTag(name, fallbackSrc));
+ } else {
+ const src = emojiFallbackImageSrc(name);
+ this.innerHTML = '';
+ this.appendChild(emojiImageTag(name, src));
}
});
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 6236e3bdefc..063393c9cd1 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -26,7 +26,7 @@ $.fn.renderGFM = function renderGFM() {
const mrPopoverElements = this.find('.gfm-merge_request').get();
if (mrPopoverElements.length) {
- import(/* webpackChunkName: 'MrPopoverBundle' */ '../../mr_popover')
+ import(/* webpackChunkName: 'MrPopoverBundle' */ '~/mr_popover')
.then(({ default: initMRPopovers }) => {
initMRPopovers(mrPopoverElements);
})
diff --git a/app/assets/javascripts/behaviors/markdown/render_kroki.js b/app/assets/javascripts/behaviors/markdown/render_kroki.js
index 7843df0cd8e..abe71694d73 100644
--- a/app/assets/javascripts/behaviors/markdown/render_kroki.js
+++ b/app/assets/javascripts/behaviors/markdown/render_kroki.js
@@ -51,7 +51,7 @@ export function renderKroki(krokiImages) {
return;
}
- const parent = krokiImage.closest('.js-markdown-code');
+ const parent = krokiImage.parentElement;
// A single Kroki image is processed multiple times for some reason,
// so this condition ensures we only create one alert per Kroki image
diff --git a/app/assets/javascripts/behaviors/secret_values.js b/app/assets/javascripts/behaviors/secret_values.js
index a34d5dcaef8..b6ed14611cd 100644
--- a/app/assets/javascripts/behaviors/secret_values.js
+++ b/app/assets/javascripts/behaviors/secret_values.js
@@ -1,5 +1,5 @@
-import { parseBoolean } from '../lib/utils/common_utils';
-import { n__ } from '../locale';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { n__ } from '~/locale';
export default class SecretValues {
constructor({
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index e58c51104c5..6124befd3b6 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -4,7 +4,7 @@ import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
-import Sidebar from '../../right_sidebar';
+import Sidebar from '~/right_sidebar';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
import {
keysFor,
@@ -33,10 +33,37 @@ export default class ShortcutsIssuable extends Shortcuts {
Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText);
Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue);
Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), ShortcutsIssuable.copyBranchName);
+
+ /**
+ * We're attaching a global focus event listener on document for
+ * every markdown input field.
+ */
+ $(document).on(
+ 'focus',
+ '.js-vue-markdown-field .js-gfm-input',
+ ShortcutsIssuable.handleMarkdownFieldFocus,
+ );
+ }
+
+ /**
+ * This event handler preserves last focused markdown input field.
+ * @param {Object} event
+ */
+ static handleMarkdownFieldFocus({ currentTarget }) {
+ ShortcutsIssuable.$lastFocusedReplyField = $(currentTarget);
}
static replyWithSelectedText() {
- const $replyField = $('.js-main-target-form .js-vue-comment-form');
+ let $replyField = $('.js-main-target-form .js-vue-comment-form');
+
+ // Ensure that markdown input is still present in the DOM
+ // otherwise fall back to main comment input field.
+ if (
+ ShortcutsIssuable.$lastFocusedReplyField &&
+ isElementVisible(ShortcutsIssuable.$lastFocusedReplyField?.get(0))
+ ) {
+ $replyField = ShortcutsIssuable.$lastFocusedReplyField;
+ }
if (!$replyField.length || $replyField.is(':hidden') /* Other tab selected in MR */) {
return false;
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index 7d8e4dd490c..e0ef49b60d3 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -1,6 +1,6 @@
import Mousetrap from 'mousetrap';
import { visitUrl, constructWebIDEPath } from '~/lib/utils/url_utility';
-import findAndFollowLink from '../../lib/utils/navigation_utility';
+import findAndFollowLink from '~/lib/utils/navigation_utility';
import {
keysFor,
GO_TO_PROJECT_OVERVIEW,
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
index 59c1d2654bc..b2801f9118d 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
@@ -1,5 +1,5 @@
import Mousetrap from 'mousetrap';
-import findAndFollowLink from '../../lib/utils/navigation_utility';
+import findAndFollowLink from '~/lib/utils/navigation_utility';
import { keysFor, EDIT_WIKI_PAGE } from './keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
deleted file mode 100644
index 313bec7e01a..00000000000
--- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import { template as _template } from 'lodash';
-import sqljs from 'sql.js';
-import axios from '~/lib/utils/axios_utils';
-import { successCodes } from '~/lib/utils/http_status';
-
-const PREVIEW_TEMPLATE = _template(`
- <div class="card">
- <div class="card-header"><%- name %></div>
- <div class="card-body">
- <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/>
- </div>
- </div>
-`);
-
-class BalsamiqViewer {
- constructor(viewer) {
- this.viewer = viewer;
- }
-
- loadFile(endpoint) {
- return axios
- .get(endpoint, {
- responseType: 'arraybuffer',
- validateStatus(status) {
- return status !== successCodes.OK;
- },
- })
- .then(({ data }) => {
- this.renderFile(data);
- })
- .catch((e) => {
- throw new Error(e);
- });
- }
-
- renderFile(fileBuffer) {
- const container = document.createElement('ul');
-
- this.initDatabase(fileBuffer);
-
- const previews = this.getPreviews();
- previews.forEach((preview) => {
- const renderedPreview = this.renderPreview(preview);
-
- container.appendChild(renderedPreview);
- });
-
- container.classList.add('list-inline');
- container.classList.add('previews');
-
- this.viewer.appendChild(container);
- }
-
- initDatabase(data) {
- const previewBinary = new Uint8Array(data);
-
- this.database = new sqljs.Database(previewBinary);
- }
-
- getPreviews() {
- const thumbnails = this.database.exec('SELECT * FROM thumbnails');
-
- return thumbnails[0].values.map(BalsamiqViewer.parsePreview);
- }
-
- getResource(resourceID) {
- const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`);
-
- return resources[0];
- }
-
- renderPreview(preview) {
- const previewElement = document.createElement('li');
-
- previewElement.classList.add('preview');
- previewElement.innerHTML = this.renderTemplate(preview);
-
- return previewElement;
- }
-
- renderTemplate(preview) {
- const resource = this.getResource(preview.resourceID);
- const name = BalsamiqViewer.parseTitle(resource);
- const { image } = preview;
-
- const template = PREVIEW_TEMPLATE({
- name,
- image,
- });
-
- return template;
- }
-
- static parsePreview(preview) {
- return JSON.parse(preview[1]);
- }
-
- /*
- * resource = {
- * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'],
- * values: [['id', 'branchId', 'attributes', 'data']],
- * }
- *
- * 'attributes' being a JSON string containing the `name` property.
- */
- static parseTitle(resource) {
- return JSON.parse(resource.values[0][2]).name;
- }
-}
-
-export default BalsamiqViewer;
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
deleted file mode 100644
index af8e8a4cd3d..00000000000
--- a/app/assets/javascripts/blob/balsamiq_viewer.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-import BalsamiqViewer from './balsamiq/balsamiq_viewer';
-
-function onError() {
- const flash = createFlash({
- message: __('Balsamiq file could not be loaded.'),
- });
-
- return flash;
-}
-
-export default function loadBalsamiqFile() {
- const viewer = document.getElementById('js-balsamiq-viewer');
-
- if (!(viewer instanceof Element)) return;
-
- const { endpoint } = viewer.dataset;
-
- const balsamiqViewer = new BalsamiqViewer(viewer);
- balsamiqViewer.loadFile(endpoint).catch(onError);
-}
diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js
index a1f59aa1b54..a8932f8c73b 100644
--- a/app/assets/javascripts/blob/line_highlighter.js
+++ b/app/assets/javascripts/blob/line_highlighter.js
@@ -37,6 +37,7 @@ const LineHighlighter = function (options = {}) {
options.fileHolderSelector = options.fileHolderSelector || '.file-holder';
options.scrollFileHolder = options.scrollFileHolder || false;
options.hash = options.hash || window.location.hash;
+ options.scrollBehavior = options.scrollBehavior || 'smooth';
this.options = options;
this._hash = options.hash;
@@ -74,6 +75,7 @@ LineHighlighter.prototype.highlightHash = function (newHash) {
// Scroll to the first highlighted line on initial load
// Add an offset of -100 for some context
offset: -100,
+ behavior: this.options.scrollBehavior,
});
}
}
diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue
index a1a62abeb6f..e07e415d6cf 100644
--- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue
+++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import PdfLab from '../../pdf/index.vue';
+import PdfLab from '~/pdf/index.vue';
export default {
components: {
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 1bda7d4e3f0..a6eed4ecae3 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -11,14 +11,12 @@ import {
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { fixTitle } from '~/tooltips';
-import axios from '../../lib/utils/axios_utils';
-import { handleLocationHash } from '../../lib/utils/common_utils';
-import eventHub from '../../notes/event_hub';
+import axios from '~/lib/utils/axios_utils';
+import { handleLocationHash } from '~/lib/utils/common_utils';
+import eventHub from '~/notes/event_hub';
const loadRichBlobViewer = (type) => {
switch (type) {
- case 'balsamiq':
- return import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer');
case 'notebook':
return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer');
case 'openapi':
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 96cc774a280..9fca9860282 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -111,7 +111,7 @@ export function fullLabelId(label) {
export function formatIssueInput(issueInput, boardConfig) {
const { labelIds = [], assigneeIds = [] } = issueInput;
- const { labels, assigneeId, milestoneId } = boardConfig;
+ const { labels, assigneeId, milestoneId, weight } = boardConfig;
return {
...issueInput,
@@ -121,6 +121,7 @@ export function formatIssueInput(issueInput, boardConfig) {
: issueInput?.milestoneId,
labelIds: [...labelIds, ...(labels?.map((l) => fullLabelId(l)) || [])],
assigneeIds: [...assigneeIds, ...(assigneeId ? [fullUserId(assigneeId)] : [])],
+ weight: weight > -1 ? weight : undefined,
};
}
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 28f4a267077..858aabb0f05 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -2,11 +2,13 @@
import { mapActions, mapGetters } from 'vuex';
import BoardContent from '~/boards/components/board_content.vue';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
+import BoardTopBar from '~/boards/components/board_top_bar.vue';
export default {
components: {
BoardContent,
BoardSettingsSidebar,
+ BoardTopBar,
},
inject: ['disabled'],
computed: {
@@ -23,6 +25,7 @@ export default {
<template>
<div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }">
+ <board-top-bar />
<board-content :disabled="disabled" />
<board-settings-sidebar />
</div>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index aee61a5b2a5..814ff16efec 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -13,7 +13,7 @@ import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { ListType } from '../constants';
import BoardBlockedIcon from './board_blocked_icon.vue';
import IssueDueDate from './issue_due_date.vue';
@@ -240,7 +240,7 @@ export default {
class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end"
>
<div
- class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container"
+ class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden"
>
<gl-loading-icon v-if="item.isLoading" size="md" class="mt-3" />
<span
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 27ea2e7a608..1d6a71aca47 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -4,7 +4,7 @@ import { sortBy } from 'lodash';
import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
-import defaultSortableConfig from '~/sortable/sortable_config';
+import { defaultSortableOptions } from '~/sortable/constants';
import { DraggableItemTypes } from '../constants';
import BoardColumn from './board_column.vue';
@@ -43,7 +43,7 @@ export default {
},
draggableOptions() {
const options = {
- ...defaultSortableConfig,
+ ...defaultSortableOptions,
disabled: this.disabled,
draggable: '.is-draggable',
fallbackOnBody: false,
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 95d4fd5bc0a..aeb2cee590d 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -4,7 +4,10 @@ import { mapActions } from 'vuex';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ FILTERED_SEARCH_TERM,
+ FILTER_ANY,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { AssigneeFilterType } from '~/boards/constants';
@@ -42,6 +45,7 @@ export default {
search,
milestoneTitle,
iterationId,
+ iterationCadenceId,
types,
weight,
epicId,
@@ -95,10 +99,20 @@ export default {
});
}
- if (iterationId) {
+ let iterationData = null;
+
+ if (iterationId && iterationCadenceId) {
+ iterationData = `${iterationId}&${iterationCadenceId}`;
+ } else if (iterationCadenceId) {
+ iterationData = `${FILTER_ANY}&${iterationCadenceId}`;
+ } else if (iterationId) {
+ iterationData = iterationId;
+ }
+
+ if (iterationData) {
filteredSearchValue.push({
type: 'iteration',
- value: { data: iterationId, operator: '=' },
+ value: { data: iterationData, operator: '=' },
});
}
@@ -228,9 +242,12 @@ export default {
epicId,
myReactionEmoji,
iterationId,
+ iterationCadenceId,
releaseTag,
confidential,
} = this.filterParams;
+ let iteration = iterationId;
+ let cadence = iterationCadenceId;
let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
@@ -251,6 +268,10 @@ export default {
);
}
+ if (iterationId?.includes('&')) {
+ [iteration, cadence] = iterationId.split('&');
+ }
+
return mapValues(
{
...notParams,
@@ -259,7 +280,8 @@ export default {
assignee_username: assigneeUsername,
assignee_id: assigneeId,
milestone_title: milestoneTitle,
- iteration_id: iterationId,
+ iteration_id: iteration,
+ iteration_cadence_id: cadence,
search,
types,
weight,
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 5fcf9514708..a874c9e070a 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -48,7 +48,7 @@ export default {
fullPath: {
default: '',
},
- rootPath: {
+ boardBaseUrl: {
default: '',
},
},
@@ -209,7 +209,7 @@ export default {
if (this.isDeleteForm) {
try {
await this.deleteBoard();
- visitUrl(this.rootPath);
+ visitUrl(this.boardBaseUrl);
} catch {
this.setError({ message: this.$options.i18n.deleteErrorMessage });
} finally {
@@ -289,7 +289,7 @@ export default {
<p v-if="isDeleteForm" data-testid="delete-confirmation-message">
{{ $options.i18n.deleteConfirmationMessage }}
</p>
- <form v-else class="js-board-config-modal" data-testid="board-form-wrapper" @submit.prevent>
+ <form v-else data-testid="board-form-wrapper" @submit.prevent>
<div v-if="!readonly" class="gl-mb-5" data-testid="board-form">
<label class="gl-font-weight-bold gl-font-lg" for="board-new-name">
{{ $options.i18n.titleFieldLabel }}
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 1024be61359..47f25f34d0c 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -2,9 +2,9 @@
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import { sprintf, __ } from '~/locale';
-import defaultSortableConfig from '~/sortable/sortable_config';
+import { defaultSortableOptions } from '~/sortable/constants';
+import { sortableStart, sortableEnd } from '~/sortable/utils';
import Tracking from '~/tracking';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import { toggleFormEventPrefix, DraggableItemTypes } from '../constants';
@@ -121,7 +121,7 @@ export default {
},
treeRootOptions() {
const options = {
- ...defaultSortableConfig,
+ ...defaultSortableOptions,
fallbackOnBody: false,
group: 'board-list',
tag: 'ul',
@@ -287,7 +287,7 @@ export default {
:data-board-type="list.listType"
:class="{ 'bg-danger-100': boardItemsSizeExceedsMax }"
draggable=".board-card"
- class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list"
+ class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
@end="handleDragOnEnd"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 46b28d20da9..9f70c84931f 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -18,7 +18,7 @@ import Tracking from '~/tracking';
import { formatDate } from '~/lib/utils/datetime_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
-import AccessorUtilities from '../../lib/utils/accessor';
+import AccessorUtilities from '~/lib/utils/accessor';
import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
import ItemCount from './item_count.vue';
@@ -57,6 +57,9 @@ export default {
currentUserId: {
default: null,
},
+ canCreateEpic: {
+ default: false,
+ },
},
props: {
list: {
@@ -129,7 +132,7 @@ export default {
return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard;
},
isNewEpicShown() {
- return this.isEpicBoard && this.listType !== ListType.closed;
+ return this.isEpicBoard && this.canCreateEpic && this.listType !== ListType.closed;
},
isSettingsShown() {
return (
@@ -262,7 +265,7 @@ export default {
'gl-py-2': list.collapsed && isSwimlanesHeader,
'gl-flex-direction-column': list.collapsed,
}"
- class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
+ class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3"
>
<gl-button
v-gl-tooltip.hover
@@ -443,12 +446,11 @@ export default {
ref="settingsBtn"
v-gl-tooltip.hover
:aria-label="$options.i18n.listSettings"
- class="no-drag js-board-settings-button"
+ class="no-drag"
:title="$options.i18n.listSettings"
icon="settings"
@click="openSidebarSettings"
/>
- <gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip>
</gl-button-group>
</h3>
</header>
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
new file mode 100644
index 00000000000..f90ac1e9079
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -0,0 +1,54 @@
+<script>
+import { mapGetters } from 'vuex';
+import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
+import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
+import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue';
+import ConfigToggle from './config_toggle.vue';
+import NewBoardButton from './new_board_button.vue';
+import ToggleFocus from './toggle_focus.vue';
+
+export default {
+ components: {
+ BoardAddNewColumnTrigger,
+ BoardsSelector,
+ IssueBoardFilteredSearch,
+ ConfigToggle,
+ NewBoardButton,
+ ToggleFocus,
+ ToggleLabels: () => import('ee_component/boards/components/toggle_labels.vue'),
+ ToggleEpicsSwimlanes: () => import('ee_component/boards/components/toggle_epics_swimlanes.vue'),
+ EpicBoardFilteredSearch: () =>
+ import('ee_component/boards/components/epic_filtered_search.vue'),
+ },
+ inject: ['swimlanesFeatureAvailable', 'canAdminList', 'isSignedIn'],
+ computed: {
+ ...mapGetters(['isEpicBoard']),
+ },
+};
+</script>
+
+<template>
+ <div class="issues-filters">
+ <div
+ class="issues-details-filters filtered-search-block gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row row-content-block second-block"
+ >
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0! mb-md-2 mb-sm-0 gl-w-full"
+ >
+ <boards-selector />
+ <new-board-button />
+ <epic-board-filtered-search v-if="isEpicBoard" />
+ <issue-board-filtered-search v-else />
+ </div>
+ <div
+ class="filter-dropdown-container gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-align-items-flex-start"
+ >
+ <toggle-labels />
+ <toggle-epics-swimlanes v-if="swimlanesFeatureAvailable && isSignedIn" />
+ <config-toggle />
+ <board-add-new-column-trigger v-if="canAdminList" />
+ <toggle-focus />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 91fdfd668fc..2951eda1112 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -40,37 +40,21 @@ export default {
directives: {
GlModalDirective,
},
- inject: ['fullPath'],
+ inject: [
+ 'boardBaseUrl',
+ 'fullPath',
+ 'canAdminBoard',
+ 'multipleIssueBoardsAvailable',
+ 'hasMissingBoards',
+ 'scopedIssueBoardFeatureEnabled',
+ 'weights',
+ ],
props: {
throttleDuration: {
type: Number,
default: 200,
required: false,
},
- boardBaseUrl: {
- type: String,
- required: true,
- },
- hasMissingBoards: {
- type: Boolean,
- required: true,
- },
- canAdminBoard: {
- type: Boolean,
- required: true,
- },
- multipleIssueBoardsAvailable: {
- type: Boolean,
- required: true,
- },
- scopedIssueBoardFeatureEnabled: {
- type: Boolean,
- required: true,
- },
- weights: {
- type: Array,
- required: true,
- },
},
data() {
return {
@@ -255,11 +239,12 @@ export default {
</script>
<template>
- <div class="boards-switcher js-boards-selector gl-mr-3">
- <span class="boards-selector-wrapper js-boards-selector-wrapper">
+ <div class="boards-switcher gl-mr-3" data-testid="boards-selector">
+ <span class="boards-selector-wrapper">
<gl-dropdown
+ data-testid="boards-dropdown"
data-qa-selector="boards_dropdown"
- toggle-class="dropdown-menu-toggle js-dropdown-toggle"
+ toggle-class="dropdown-menu-toggle"
menu-class="flex-column dropdown-extended-height"
:loading="isBoardLoading"
:text="board.name"
@@ -292,8 +277,8 @@ export default {
<gl-dropdown-item
v-for="recentBoard in recentBoards"
:key="`recent-${recentBoard.id}`"
- class="js-dropdown-item"
:href="`${boardBaseUrl}/${recentBoard.id}`"
+ data-testid="dropdown-item"
>
{{ recentBoard.name }}
</gl-dropdown-item>
@@ -308,8 +293,8 @@ export default {
<gl-dropdown-item
v-for="otherBoard in filteredBoards"
:key="otherBoard.id"
- class="js-dropdown-item"
:href="`${boardBaseUrl}/${otherBoard.id}`"
+ data-testid="dropdown-item"
>
{{ otherBoard.name }}
</gl-dropdown-item>
@@ -347,7 +332,7 @@ export default {
<gl-dropdown-item
v-if="showDelete"
v-gl-modal-directive="'board-config-modal'"
- class="text-danger js-delete-board"
+ class="text-danger"
@click.prevent="showPage('delete')"
>
{{ s__('IssueBoards|Delete board') }}
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
index f39e4d90357..4746f598ab7 100644
--- a/app/assets/javascripts/boards/components/config_toggle.vue
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -14,16 +14,7 @@ export default {
GlModalDirective,
},
mixins: [Tracking.mixin()],
- props: {
- canAdminList: {
- type: Boolean,
- required: true,
- },
- hasScope: {
- type: Boolean,
- required: true,
- },
- },
+ inject: ['canAdminList', 'hasScope'],
computed: {
buttonText() {
return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope');
diff --git a/app/assets/javascripts/boards/components/issuable_title.vue b/app/assets/javascripts/boards/components/issuable_title.vue
deleted file mode 100644
index 40627a9fab8..00000000000
--- a/app/assets/javascripts/boards/components/issuable_title.vue
+++ /dev/null
@@ -1,21 +0,0 @@
-<script>
-export default {
- props: {
- title: {
- type: String,
- required: true,
- },
- refPath: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div data-testid="issue-title">
- <p class="gl-font-weight-bold">{{ title }}</p>
- <p class="gl-mb-0">{{ refPath }}</p>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
index 6bfdbb674a2..bab6fe26978 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -41,17 +41,7 @@ export default {
confidential: __('Confidential'),
},
components: { BoardFilteredSearch },
- inject: ['isSignedIn', 'releasesFetchPath'],
- props: {
- fullPath: {
- type: String,
- required: true,
- },
- boardType: {
- type: String,
- required: true,
- },
- },
+ inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'boardType'],
computed: {
isGroupBoard() {
return this.boardType === BoardType.group;
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 1ab7deebfaf..9312db06efe 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -43,7 +43,7 @@ export default {
<gl-tooltip
:target="() => $refs.issueTimeEstimate"
placement="bottom"
- class="js-issue-time-estimate"
+ data-testid="issue-time-estimate"
>
<span class="gl-font-weight-bold gl-display-block">{{ $options.i18n.timeEstimate }}</span>
{{ title }}
diff --git a/app/assets/javascripts/boards/components/item_count.vue b/app/assets/javascripts/boards/components/item_count.vue
index 9b1ff254766..a11c23e5625 100644
--- a/app/assets/javascripts/boards/components/item_count.vue
+++ b/app/assets/javascripts/boards/components/item_count.vue
@@ -29,7 +29,7 @@ export default {
<span :class="{ 'text-danger': issuesExceedMax }" data-testid="board-items-count">
{{ itemsSize }}
</span>
- <span v-if="isMaxLimitSet" class="js-max-issue-size">
+ <span v-if="isMaxLimitSet" class="max-issue-size">
{{ maxIssueCount }}
</span>
</div>
diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue
index 49f5e7d20a9..71612e0742f 100644
--- a/app/assets/javascripts/boards/components/toggle_focus.vue
+++ b/app/assets/javascripts/boards/components/toggle_focus.vue
@@ -10,12 +10,6 @@ export default {
directives: {
GlTooltip,
},
- props: {
- issueBoardsContentSelector: {
- type: String,
- required: true,
- },
- },
data() {
return {
isFullscreen: false,
@@ -25,7 +19,7 @@ export default {
toggleFocusMode() {
hide(this.$refs.toggleFocusModeButton);
- const issueBoardsContent = document.querySelector(this.issueBoardsContentSelector);
+ const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board');
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
@@ -44,7 +38,6 @@ export default {
v-gl-tooltip
category="tertiary"
:icon="isFullscreen ? 'minimize' : 'maximize'"
- class="js-focus-mode-btn"
data-qa-selector="focus_mode_button"
:title="$options.i18n.toggleFocusMode"
:aria-label="$options.i18n.toggleFocusMode"
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
deleted file mode 100644
index 1e54c2511b8..00000000000
--- a/app/assets/javascripts/boards/config_toggle.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import ConfigToggle from './components/config_toggle.vue';
-
-export default () => {
- const el = document.querySelector('.js-board-config');
-
- if (!el) {
- return;
- }
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- name: 'ConfigToggleRoot',
- render(h) {
- return h(ConfigToggle, {
- props: {
- canAdminList: parseBoolean(el.dataset.canAdminList),
- hasScope: parseBoolean(el.dataset.hasScope),
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
index 0da14d0b872..e0a3cb0ee21 100644
--- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) {
group(fullPath: $fullPath) {
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index b31b56e6839..77c5994b5a1 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,22 +1,19 @@
import PortalVue from 'portal-vue';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-
-import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
-import toggleLabels from 'ee_else_ce/boards/toggle_labels';
-import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
import BoardApp from '~/boards/components/board_app.vue';
import '~/boards/filters/due_date_filters';
import { issuableTypes } from '~/boards/constants';
-import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores';
-import toggleFocusMode from '~/boards/toggle_focus';
-import { NavigationType, isLoggedIn, parseBoolean } from '~/lib/utils/common_utils';
+import {
+ NavigationType,
+ isLoggedIn,
+ parseBoolean,
+ convertObjectPropsToCamelCase,
+} from '~/lib/utils/common_utils';
+import { queryToObject } from '~/lib/utils/url_utility';
import { fullBoardId } from './boards_util';
-import boardConfigToggle from './config_toggle';
-import initNewBoard from './new_board';
import { gqlClient } from './graphql';
-import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
Vue.use(VueApollo);
Vue.use(PortalVue);
@@ -28,6 +25,12 @@ const apolloProvider = new VueApollo({
function mountBoardApp(el) {
const { boardId, groupId, fullPath, rootPath } = el.dataset;
+ const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
+
+ const initialFilterParams = {
+ ...convertObjectPropsToCamelCase(rawFilterParams),
+ };
+
store.dispatch('fetchBoard', {
fullPath,
fullBoardId: fullBoardId(boardId),
@@ -54,26 +57,41 @@ function mountBoardApp(el) {
boardId,
groupId: Number(groupId),
rootPath,
+ fullPath,
+ initialFilterParams,
+ boardBaseUrl: el.dataset.boardBaseUrl,
+ boardType: el.dataset.parent,
currentUserId: gon.current_user_id || null,
- canUpdate: parseBoolean(el.dataset.canUpdate),
- canAdminList: parseBoolean(el.dataset.canAdminList),
+ boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null,
labelsManagePath: el.dataset.labelsManagePath,
labelsFilterBasePath: el.dataset.labelsFilterBasePath,
+ releasesFetchPath: el.dataset.releasesFetchPath,
timeTrackingLimitToHours: parseBoolean(el.dataset.timeTrackingLimitToHours),
+ issuableType: issuableTypes.issue,
+ emailsDisabled: parseBoolean(el.dataset.emailsDisabled),
+ hasScope: parseBoolean(el.dataset.hasScope),
+ hasMissingBoards: parseBoolean(el.dataset.hasMissingBoards),
+ weights: el.dataset.weights ? JSON.parse(el.dataset.weights) : [],
+ // Permissions
+ canUpdate: parseBoolean(el.dataset.canUpdate),
+ canAdminList: parseBoolean(el.dataset.canAdminList),
+ canAdminBoard: parseBoolean(el.dataset.canAdminBoard),
+ allowLabelCreate: parseBoolean(el.dataset.canUpdate),
+ allowLabelEdit: parseBoolean(el.dataset.canUpdate),
+ isSignedIn: isLoggedIn(),
+ // Features
multipleAssigneesFeatureAvailable: parseBoolean(el.dataset.multipleAssigneesFeatureAvailable),
epicFeatureAvailable: parseBoolean(el.dataset.epicFeatureAvailable),
iterationFeatureAvailable: parseBoolean(el.dataset.iterationFeatureAvailable),
weightFeatureAvailable: parseBoolean(el.dataset.weightFeatureAvailable),
- boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null,
scopedLabelsAvailable: parseBoolean(el.dataset.scopedLabels),
milestoneListsAvailable: parseBoolean(el.dataset.milestoneListsAvailable),
assigneeListsAvailable: parseBoolean(el.dataset.assigneeListsAvailable),
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),
+ swimlanesFeatureAvailable: gon.licensed_features?.swimlanes,
+ multipleIssueBoardsAvailable: parseBoolean(el.dataset.multipleBoardsAvailable),
+ scopedIssueBoardFeatureEnabled: parseBoolean(el.dataset.scopedIssueBoardFeatureEnabled),
},
render: (createComponent) => createComponent(BoardApp),
});
@@ -92,47 +110,5 @@ export default () => {
}
});
- const { releasesFetchPath, epicFeatureAvailable, iterationFeatureAvailable } = $boardApp.dataset;
- initBoardsFilteredSearch(
- apolloProvider,
- isLoggedIn(),
- releasesFetchPath,
- parseBoolean(epicFeatureAvailable),
- parseBoolean(iterationFeatureAvailable),
- );
-
mountBoardApp($boardApp);
-
- const createColumnTriggerEl = document.querySelector('.js-create-column-trigger');
- if (createColumnTriggerEl) {
- // eslint-disable-next-line no-new
- new Vue({
- el: createColumnTriggerEl,
- name: 'BoardAddNewColumnTriggerRoot',
- components: {
- BoardAddNewColumnTrigger,
- },
- store,
- render(createElement) {
- return createElement('board-add-new-column-trigger');
- },
- });
- }
-
- boardConfigToggle();
- initNewBoard();
-
- toggleFocusMode();
- toggleLabels();
-
- if (gon.licensed_features?.swimlanes) {
- toggleEpicsSwimlanes();
- }
-
- mountMultipleBoardsSwitcher({
- fullPath: $boardApp.dataset.fullPath,
- rootPath: $boardApp.dataset.boardsEndpoint,
- allowScopedLabels: $boardApp.dataset.scopedLabels,
- labelsManagePath: $boardApp.dataset.labelsManagePath,
- });
};
diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
deleted file mode 100644
index bb659eb075a..00000000000
--- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import Vue from 'vue';
-import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue';
-import store from '~/boards/stores';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { queryToObject } from '~/lib/utils/url_utility';
-
-export default (
- apolloProvider,
- isSignedIn,
- releasesFetchPath,
- epicFeatureAvailable,
- iterationFeatureAvailable,
-) => {
- const el = document.getElementById('js-issue-board-filtered-search');
- const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
-
- const initialFilterParams = {
- ...convertObjectPropsToCamelCase(rawFilterParams, {}),
- };
-
- if (!el) {
- return null;
- }
-
- return new Vue({
- el,
- name: 'BoardFilteredSearchRoot',
- provide: {
- initialFilterParams,
- isSignedIn,
- releasesFetchPath,
- epicFeatureAvailable,
- iterationFeatureAvailable,
- },
- store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
- apolloProvider,
- render: (createElement) =>
- createElement(IssueBoardFilteredSearch, {
- props: { fullPath: store.state?.fullPath || '', boardType: store.state?.boardType || '' },
- }),
- });
-};
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
deleted file mode 100644
index 0bc9cfbd867..00000000000
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
-import store from '~/boards/stores';
-import createDefaultClient from '~/lib/graphql';
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-Vue.use(VueApollo);
-
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-export default (params = {}) => {
- const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
- const { dataset } = boardsSwitcherElement;
- return new Vue({
- el: boardsSwitcherElement,
- name: 'BoardsSelectorRoot',
- components: {
- BoardsSelector,
- },
- apolloProvider,
- store,
- provide: {
- fullPath: params.fullPath,
- rootPath: params.rootPath,
- allowScopedLabels: params.allowScopedLabels,
- labelsManagePath: params.labelsManagePath,
- allowLabelCreate: parseBoolean(dataset.canAdminBoard),
- },
- data() {
- const boardsSelectorProps = {
- ...dataset,
- hasMissingBoards: parseBoolean(dataset.hasMissingBoards),
- canAdminBoard: parseBoolean(dataset.canAdminBoard),
- multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable),
- scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled),
- weights: JSON.parse(dataset.weights),
- };
-
- return { boardsSelectorProps };
- },
- render(createElement) {
- return createElement(BoardsSelector, {
- props: this.boardsSelectorProps,
- });
- },
- });
-};
diff --git a/app/assets/javascripts/boards/new_board.js b/app/assets/javascripts/boards/new_board.js
deleted file mode 100644
index 34f2fea79a9..00000000000
--- a/app/assets/javascripts/boards/new_board.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { getExperimentVariant } from '~/experimentation/utils';
-import { CANDIDATE_VARIANT } from '~/experimentation/constants';
-import NewBoardButton from './components/new_board_button.vue';
-
-export default () => {
- if (getExperimentVariant('prominent_create_board_btn') !== CANDIDATE_VARIANT) {
- return;
- }
-
- const el = document.querySelector('.js-new-board');
-
- if (!el) {
- return;
- }
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- provide: {
- multipleIssueBoardsAvailable: parseBoolean(el.dataset.multipleIssueBoardsAvailable),
- canAdminBoard: parseBoolean(el.dataset.canAdminBoard),
- },
- render(h) {
- return h(NewBoardButton);
- },
- });
-};
diff --git a/app/assets/javascripts/boards/toggle_epics_swimlanes.js b/app/assets/javascripts/boards/toggle_epics_swimlanes.js
deleted file mode 100644
index 2d1ec238274..00000000000
--- a/app/assets/javascripts/boards/toggle_epics_swimlanes.js
+++ /dev/null
@@ -1 +0,0 @@
-export default () => {};
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
deleted file mode 100644
index 8f057e192dd..00000000000
--- a/app/assets/javascripts/boards/toggle_focus.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Vue from 'vue';
-import ToggleFocus from './components/toggle_focus.vue';
-
-export default () => {
- const issueBoardsContentSelector = '.content-wrapper > .js-focus-mode-board';
-
- return new Vue({
- el: '#js-toggle-focus-btn',
- name: 'ToggleFocusRoot',
- render(h) {
- return h(ToggleFocus, {
- props: {
- issueBoardsContentSelector,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/boards/toggle_labels.js b/app/assets/javascripts/boards/toggle_labels.js
deleted file mode 100644
index 2d1ec238274..00000000000
--- a/app/assets/javascripts/boards/toggle_labels.js
+++ /dev/null
@@ -1 +0,0 @@
-export default () => {};
diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js
index 31cf9a18077..17fd3939441 100644
--- a/app/assets/javascripts/branches/divergence_graph.js
+++ b/app/assets/javascripts/branches/divergence_graph.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import createFlash from '~/flash';
-import axios from '../lib/utils/axios_utils';
-import { __ } from '../locale';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
import DivergenceGraph from './components/divergence_graph.vue';
export function createGraphVueApp(el, data, maxCommits, defaultBranch) {
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index 4156717908d..5e5d799d627 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -1,10 +1,9 @@
<script>
-import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { GlTable, GlButton, GlBadge, GlTooltipDirective, GlAvatarLink, GlAvatar } from '@gitlab/ui';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
i18n: {
@@ -21,7 +20,8 @@ export default {
GlBadge,
ClipboardButton,
TooltipOnTruncate,
- UserAvatarLink,
+ GlAvatarLink,
+ GlAvatar,
TimeAgoTooltip,
},
directives: {
@@ -102,13 +102,14 @@ export default {
</template>
<template #cell(owner)="{ item }">
<span class="trigger-owner sr-only">{{ item.owner.name }}</span>
- <user-avatar-link
+ <gl-avatar-link
v-if="item.owner"
- :link-href="item.owner.path"
- :img-src="item.owner.avatarUrl"
- :tooltip-text="item.owner.name"
- :img-alt="item.owner.name"
- />
+ v-gl-tooltip
+ :href="item.owner.path"
+ :title="item.owner.name"
+ >
+ <gl-avatar :size="24" :src="item.owner.avatarUrl" />
+ </gl-avatar-link>
</template>
<template #cell(lastUsed)="{ item }">
<time-ago-tooltip v-if="item.lastUsed" :time="item.lastUsed" />
diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
index 055e2f83e33..574a5e7fd99 100644
--- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
-import SecretValues from '../behaviors/secret_values';
-import CreateItemDropdown from '../create_item_dropdown';
-import { parseBoolean } from '../lib/utils/common_utils';
-import { s__ } from '../locale';
+import SecretValues from '~/behaviors/secret_values';
+import CreateItemDropdown from '~/create_item_dropdown';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments');
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 2e198c59926..be2366108b3 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -405,7 +405,7 @@ export default {
<gl-button
ref="updateOrAddVariable"
:disabled="!canSubmit"
- variant="success"
+ variant="confirm"
category="primary"
data-qa-selector="ci_variable_save_button"
@click="updateOrAddVariable"
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
index 3610662afc0..d7a8e447071 100644
--- 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
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
#import "../fragments/cluster_agent_token.fragment.graphql"
query getClusterAgent(
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 8dcab55ac61..a8fef372637 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -4,10 +4,10 @@ import Vue from 'vue';
import createFlash from '~/flash';
import AccessorUtilities from '~/lib/utils/accessor';
import initProjectSelectDropdown from '~/project_select';
-import Poll from '../lib/utils/poll';
-import { s__ } from '../locale';
-import PersistentUserCallout from '../persistent_user_callout';
-import initSettingsPanels from '../settings_panels';
+import Poll from '~/lib/utils/poll';
+import { s__ } from '~/locale';
+import PersistentUserCallout from '~/persistent_user_callout';
+import initSettingsPanels from '~/settings_panels';
import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
diff --git a/app/assets/javascripts/clusters/forms/stores/state.js b/app/assets/javascripts/clusters/forms/stores/state.js
index 74a00b97603..7d6ac1925d8 100644
--- a/app/assets/javascripts/clusters/forms/stores/state.js
+++ b/app/assets/javascripts/clusters/forms/stores/state.js
@@ -1,4 +1,4 @@
-import { parseBoolean } from '../../../lib/utils/common_utils';
+import { parseBoolean } from '~/lib/utils/common_utils';
export default (initialState = {}) => {
return {
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 7300bb3137a..072b9827f5a 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -1,4 +1,4 @@
-import axios from '../../lib/utils/axios_utils';
+import axios from '~/lib/utils/axios_utils';
export default class ClusterService {
constructor(options = {}) {
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index db6e7bad6cc..6fb850f009a 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -1,4 +1,4 @@
-import { parseBoolean } from '../../lib/utils/common_utils';
+import { parseBoolean } from '~/lib/utils/common_utils';
export default class ClusterStore {
constructor() {
diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js
index c78c93fe1ba..e7ad2f45c75 100644
--- a/app/assets/javascripts/clusters_list/clusters_util.js
+++ b/app/assets/javascripts/clusters_list/clusters_util.js
@@ -1,10 +1,11 @@
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 -`;
+ return `helm repo add gitlab https://charts.gitlab.io
+helm repo update
+helm upgrade --install gitlab-agent gitlab/gitlab-agent \\
+ --namespace gitlab-agent \\
+ --create-namespace \\
+ --set config.token=${agentToken} \\
+ --set config.kasAddress=${kasAddress}`;
}
export function getAgentConfigPath(clusterAgentName) {
diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
index f54f7b11414..2f45ef8a862 100644
--- a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
@@ -1,55 +1,30 @@
<script>
-import { GlButton, GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
+import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { INSTALL_AGENT_MODAL_ID, I18N_AGENTS_EMPTY_STATE } from '../constants';
+import { I18N_AGENTS_EMPTY_STATE } from '../constants';
export default {
i18n: I18N_AGENTS_EMPTY_STATE,
- modalId: INSTALL_AGENT_MODAL_ID,
agentDocsUrl: helpPagePath('user/clusters/agent/index'),
components: {
- GlButton,
GlEmptyState,
GlLink,
GlSprintf,
},
- directives: {
- GlModalDirective,
- },
inject: ['emptyStateImage'],
- props: {
- isChildComponent: {
- default: false,
- required: false,
- type: Boolean,
- },
- },
};
</script>
<template>
- <gl-empty-state :svg-path="emptyStateImage" title="" class="agents-empty-state">
- <template #description>
- <p class="gl-text-left">
- <gl-sprintf :message="$options.i18n.introText">
- <template #link="{ content }">
- <gl-link :href="$options.agentDocsUrl">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </template>
-
- <template #actions>
- <gl-button
- v-if="!isChildComponent"
- v-gl-modal-directive="$options.modalId"
- category="primary"
- variant="confirm"
- >
- {{ $options.i18n.buttonText }}
- </gl-button>
+ <gl-empty-state :svg-path="emptyStateImage" :svg-height="100">
+ <template #title>
+ <gl-sprintf :message="$options.i18n.introText">
+ <template #link="{ content }">
+ <gl-link :href="$options.agentDocsUrl">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
</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
index 1144ce68e2c..2decdb5307b 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -37,7 +37,7 @@ export default {
anchor: 'update-the-agent-version',
}),
configHelpLink: helpPagePath('user/clusters/agent/install/index', {
- anchor: 'create-an-agent-without-configuration-file',
+ anchor: 'create-an-agent-configuration-file',
}),
inject: ['gitlabVersion'],
props: {
diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue
index eab3fc3ed63..751ad9795dd 100644
--- a/app/assets/javascripts/clusters_list/components/agent_token.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_token.vue
@@ -8,11 +8,8 @@ import { I18N_AGENT_TOKEN } from '../constants';
export default {
i18n: I18N_AGENT_TOKEN,
- basicInstallPath: helpPagePath('user/clusters/agent/install/index', {
- anchor: 'install-the-agent-into-the-cluster',
- }),
advancedInstallPath: helpPagePath('user/clusters/agent/install/index', {
- anchor: 'advanced-installation',
+ anchor: 'advanced-installation-method',
}),
components: {
GlAlert,
@@ -43,27 +40,7 @@ export default {
<template>
<div>
- <p>
- <strong>{{ $options.i18n.tokenTitle }}</strong>
- </p>
-
- <p>
- <gl-sprintf :message="$options.i18n.tokenBody">
- <template #link="{ content }">
- <gl-link :href="$options.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 class="gl-mb-3">{{ $options.i18n.tokenLabel }}</p>
<p>
<gl-form-input-group readonly :value="agentToken" :select-on-click="true">
@@ -78,6 +55,14 @@ export default {
</p>
<p>
+ {{ $options.i18n.tokenSubtitle }}
+ </p>
+
+ <gl-alert :dismissible="false" variant="warning" class="gl-mb-5">
+ {{ $options.i18n.tokenSingleUseWarningTitle }}
+ </gl-alert>
+
+ <p>
<strong>{{ $options.i18n.basicInstallTitle }}</strong>
</p>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index 70b9b8ac3c9..89b18ed6d06 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -20,7 +20,7 @@ export default {
'ClusterAgents|We would love to learn more about your experience with the GitLab Agent.',
),
feedbackBannerButton: s__('ClusterAgents|Give feedback'),
- error: s__('ClusterAgents|An error occurred while loading your Agents'),
+ error: s__('ClusterAgents|An error occurred while loading your agents'),
},
AGENT_FEEDBACK_ISSUE,
AGENT_FEEDBACK_KEY,
@@ -208,7 +208,7 @@ export default {
</div>
</div>
- <agent-empty-state v-else :is-child-component="isChildComponent" />
+ <agent-empty-state v-else />
</section>
<gl-alert v-else variant="danger" :dismissible="false">
diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
index 662cf2a7e36..bde76c46b4b 100644
--- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
+++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
@@ -3,6 +3,7 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ GlDropdownText,
GlSearchBoxByType,
GlSprintf,
} from '@gitlab/ui';
@@ -15,6 +16,7 @@ export default {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ GlDropdownText,
GlSearchBoxByType,
GlSprintf,
},
@@ -73,13 +75,24 @@ export default {
this.clearSearch();
this.focusSearch();
},
+ onKeyEnter() {
+ if (!this.searchTerm?.length) {
+ return;
+ }
+ this.$refs.dropdown.hide();
+ this.selectAgent(this.searchTerm);
+ },
},
};
</script>
<template>
- <gl-dropdown :text="dropdownText" :loading="isRegistering" @shown="handleShow">
+ <gl-dropdown ref="dropdown" :text="dropdownText" :loading="isRegistering" @shown="handleShow">
<template #header>
- <gl-search-box-by-type ref="searchInput" v-model.trim="searchTerm" />
+ <gl-search-box-by-type
+ ref="searchInput"
+ v-model.trim="searchTerm"
+ @keydown.enter.stop.prevent="onKeyEnter"
+ />
</template>
<gl-dropdown-item
v-for="agent in filteredResults"
@@ -90,9 +103,9 @@ export default {
>
{{ agent }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
+ <gl-dropdown-text v-if="!filteredResults.length" ref="noMatchingResults">{{
$options.i18n.noResults
- }}</gl-dropdown-item>
+ }}</gl-dropdown-text>
<template v-if="shouldRenderCreateButton">
<gl-dropdown-divider />
<gl-dropdown-item data-testid="create-config-button" @click="selectAgent(searchTerm)">
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
index ccb973f1eb8..8fd759bd3e9 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -1,13 +1,5 @@
<script>
-import {
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlModalDirective,
- GlTooltipDirective,
- GlDropdownDivider,
- GlDropdownSectionHeader,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlModalDirective, GlTooltip } from '@gitlab/ui';
import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants';
@@ -15,37 +7,40 @@ export default {
i18n: CLUSTERS_ACTIONS,
INSTALL_AGENT_MODAL_ID,
components: {
- GlButton,
GlDropdown,
GlDropdownItem,
- GlDropdownDivider,
- GlDropdownSectionHeader,
+ GlTooltip,
},
directives: {
GlModalDirective,
- GlTooltip: GlTooltipDirective,
},
inject: [
'newClusterPath',
'addClusterPath',
+ 'newClusterDocsPath',
'canAddCluster',
'displayClusterAgents',
'certificateBasedClustersEnabled',
],
computed: {
- tooltip() {
- const { connectWithAgent, connectExistingCluster, dropdownDisabledHint } = this.$options.i18n;
+ shouldTriggerModal() {
+ return this.canAddCluster && this.displayClusterAgents;
+ },
+ defaultActionText() {
+ const { connectCluster, connectWithAgent, connectClusterDeprecated } = this.$options.i18n;
- if (!this.canAddCluster) {
- return dropdownDisabledHint;
- } else if (this.displayClusterAgents) {
- return connectWithAgent;
+ if (!this.displayClusterAgents) {
+ return connectClusterDeprecated;
+ } else if (!this.certificateBasedClustersEnabled) {
+ return connectCluster;
}
-
- return connectExistingCluster;
+ return connectWithAgent;
},
- shouldTriggerModal() {
- return this.canAddCluster && this.displayClusterAgents;
+ defaultActionUrl() {
+ if (this.displayClusterAgents) {
+ return null;
+ }
+ return this.addClusterPath;
},
},
};
@@ -53,46 +48,51 @@ export default {
<template>
<div class="nav-controls gl-ml-auto">
+ <gl-tooltip
+ v-if="!canAddCluster"
+ :target="() => $refs.dropdown.$el"
+ :title="$options.i18n.dropdownDisabledHint"
+ />
+
<gl-dropdown
- v-if="certificateBasedClustersEnabled"
ref="dropdown"
v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID"
- v-gl-tooltip="tooltip"
+ data-qa-selector="clusters_actions_button"
category="primary"
variant="confirm"
- :text="$options.i18n.actionsButton"
+ :text="defaultActionText"
:disabled="!canAddCluster"
- :split="displayClusterAgents"
+ :split-href="defaultActionUrl"
+ split
right
>
- <template v-if="displayClusterAgents">
- <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
- data-testid="connect-new-agent-link"
- >
- {{ $options.i18n.connectWithAgent }}
+ <gl-dropdown-item
+ v-if="displayClusterAgents"
+ :href="newClusterDocsPath"
+ data-testid="create-cluster-link"
+ @click.stop
+ >
+ {{ $options.i18n.createCluster }}
+ </gl-dropdown-item>
+
+ <template v-if="displayClusterAgents && certificateBasedClustersEnabled">
+ <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
+ {{ $options.i18n.createClusterCertificate }}
+ </gl-dropdown-item>
+
+ <gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop>
+ {{ $options.i18n.connectClusterCertificate }}
</gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header>
</template>
- <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
- {{ $options.i18n.createNewCluster }}
- </gl-dropdown-item>
- <gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop>
- {{ $options.i18n.connectExistingCluster }}
+ <gl-dropdown-item
+ v-if="certificateBasedClustersEnabled && !displayClusterAgents"
+ :href="newClusterPath"
+ data-testid="new-cluster-link"
+ @click.stop
+ >
+ {{ $options.i18n.createClusterDeprecated }}
</gl-dropdown-item>
</gl-dropdown>
- <gl-button
- v-else
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
- v-gl-tooltip="tooltip"
- :disabled="!canAddCluster"
- category="primary"
- variant="confirm"
- >
- {{ $options.i18n.connectWithAgent }}
- </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
index 76bec05cfc7..f4134ab5072 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
@@ -1,6 +1,5 @@
<script>
-import { GlEmptyState, GlButton, GlLink, GlSprintf, GlAlert } from '@gitlab/ui';
-import { mapState } from 'vuex';
+import { GlEmptyState, GlLink, GlSprintf, GlAlert } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { I18N_CLUSTERS_EMPTY_STATE } from '../constants';
@@ -8,35 +7,24 @@ export default {
i18n: I18N_CLUSTERS_EMPTY_STATE,
components: {
GlEmptyState,
- GlButton,
GlLink,
GlSprintf,
GlAlert,
},
- inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'addClusterPath'],
- props: {
- isChildComponent: {
- default: false,
- required: false,
- type: Boolean,
- },
- },
+ inject: ['emptyStateHelpText', 'clustersEmptyStateImage'],
clustersHelpUrl: helpPagePath('user/infrastructure/clusters/index', {
anchor: 'certificate-based-kubernetes-integration-deprecated',
}),
blogPostUrl:
'https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/',
- computed: {
- ...mapState(['canAddCluster']),
- },
};
</script>
<template>
<div>
- <gl-empty-state :svg-path="clustersEmptyStateImage" title="">
- <template #description>
- <p class="gl-text-left">
+ <gl-empty-state :svg-path="clustersEmptyStateImage" :svg-height="100">
+ <template #title>
+ <p>
<gl-sprintf :message="$options.i18n.introText">
<template #link="{ content }">
<gl-link :href="$options.clustersHelpUrl">{{ content }}</gl-link>
@@ -48,28 +36,12 @@ export default {
{{ emptyStateHelpText }}
</p>
</template>
-
- <template #actions>
- <gl-button
- v-if="!isChildComponent"
- data-testid="integration-primary-button"
- data-qa-selector="add_kubernetes_cluster_link"
- category="primary"
- variant="confirm"
- :disabled="!canAddCluster"
- :href="addClusterPath"
- >
- {{ $options.i18n.buttonText }}
- </gl-button>
- </template>
</gl-empty-state>
<gl-alert variant="warning" :dismissible="false">
<gl-sprintf :message="$options.i18n.alertText">
<template #link="{ content }">
- <gl-link :href="$options.blogPostUrl" target="_blank">
- {{ content }}
- </gl-link>
+ <gl-link :href="$options.blogPostUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
index b730c0adfa2..73ca804e111 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
@@ -1,22 +1,7 @@
<script>
-import {
- GlCard,
- GlSprintf,
- GlPopover,
- GlLink,
- GlButton,
- GlBadge,
- GlLoadingIcon,
- GlModalDirective,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlCard, GlSprintf, GlPopover, GlLink, GlBadge, GlLoadingIcon } from '@gitlab/ui';
import { mapState } from 'vuex';
-import {
- AGENT_CARD_INFO,
- CERTIFICATE_BASED_CARD_INFO,
- MAX_CLUSTERS_LIST,
- INSTALL_AGENT_MODAL_ID,
-} from '../constants';
+import { AGENT_CARD_INFO, CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST } from '../constants';
import Clusters from './clusters.vue';
import Agents from './agents.vue';
@@ -26,23 +11,16 @@ export default {
GlSprintf,
GlPopover,
GlLink,
- GlButton,
GlBadge,
GlLoadingIcon,
Clusters,
Agents,
},
- directives: {
- GlModalDirective,
- GlTooltip: GlTooltipDirective,
- },
MAX_CLUSTERS_LIST,
- INSTALL_AGENT_MODAL_ID,
i18n: {
agent: AGENT_CARD_INFO,
certificate: CERTIFICATE_BASED_CARD_INFO,
},
- inject: ['addClusterPath', 'canAddCluster'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -93,14 +71,6 @@ export default {
return cardTitle;
},
- installAgentTooltip() {
- return this.canAddCluster ? '' : this.$options.i18n.agent.installAgentDisabledHint;
- },
- connectExistingClusterTooltip() {
- return this.canAddCluster
- ? ''
- : this.$options.i18n.certificate.connectExistingClusterDisabledHint;
- },
},
methods: {
cardFooterNumber(number) {
@@ -177,21 +147,6 @@ export default {
><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf
></gl-link
>
- <div
- v-gl-tooltip="installAgentTooltip"
- class="gl-display-inline-block"
- tabindex="-1"
- data-testid="install-agent-button-tooltip"
- >
- <gl-button
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
- class="gl-ml-4"
- category="secondary"
- variant="confirm"
- :disabled="!canAddCluster"
- >{{ $options.i18n.agent.actionText }}</gl-button
- >
- </div>
</template>
</gl-card>
@@ -214,7 +169,7 @@ export default {
<gl-badge variant="warning">{{ $options.i18n.certificate.badgeText }}</gl-badge>
</template>
- <clusters :limit="$options.MAX_CLUSTERS_LIST" :is-child-component="true" />
+ <clusters :limit="$options.MAX_CLUSTERS_LIST" />
<template #footer>
<gl-link
@@ -226,22 +181,6 @@ export default {
><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf
></gl-link
>
- <div
- v-gl-tooltip="connectExistingClusterTooltip"
- class="gl-display-inline-block"
- tabindex="-1"
- data-testid="connect-existing-cluster-button-tooltip"
- >
- <gl-button
- category="secondary"
- data-qa-selector="connect_existing_cluster_button"
- variant="confirm"
- class="gl-ml-4"
- :href="addClusterPath"
- :disabled="!canAddCluster"
- >{{ $options.i18n.certificate.actionText }}</gl-button
- >
- </div>
</template>
</gl-card>
</div>
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
index ae0affe4c8b..3b39c3aac45 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -31,7 +31,7 @@ export default {
EVENT_LABEL_MODAL,
enableKasPath: helpPagePath('administration/clusters/kas'),
registerAgentPath: helpPagePath('user/clusters/agent/install/index', {
- anchor: 'register-an-agent-with-gitlab',
+ anchor: 'register-the-agent-with-gitlab',
}),
components: {
AvailableAgentsDropdown,
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index c914ee518b2..4a168e811aa 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -90,26 +90,20 @@ export const I18N_AGENT_TABLE = {
export const I18N_AGENT_TOKEN = {
copyToken: s__('ClusterAgents|Copy token'),
copyCommand: s__('ClusterAgents|Copy command'),
- tokenTitle: s__('ClusterAgents|Registration token'),
-
- tokenBody: s__(
- `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`,
- ),
+ tokenLabel: s__('ClusterAgents|Agent access token:'),
tokenSingleUseWarningTitle: s__(
'ClusterAgents|You cannot see this token again after you close this window.',
),
- tokenSingleUseWarningBody: s__(
- `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`,
- ),
+ tokenSubtitle: s__('ClusterAgents|The agent uses the token to connect with GitLab.'),
- basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
- basicInstallBody: __(
- `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.`,
+ basicInstallTitle: s__('ClusterAgents|Install using Helm (recommended)'),
+ basicInstallBody: s__(
+ 'ClusterAgents|From a terminal, connect to your cluster and run this command. The token is included in the command.',
),
advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'),
advancedInstallBody: s__(
- 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.',
+ 'ClusterAgents|%{linkStart}View the documentation%{linkEnd} for advanced installation. Ensure you have your access token available.',
),
};
@@ -118,20 +112,15 @@ export const I18N_AGENT_MODAL = {
close: __('Close'),
cancel: __('Cancel'),
- modalTitle: s__('ClusterAgents|Connect a cluster through an agent'),
+ modalTitle: s__('ClusterAgents|Connect a Kubernetes cluster'),
modalBody: s__(
'ClusterAgents|Add an agent configuration file to %{linkStart}this repository%{linkEnd} and select it, or create a new one to register with GitLab:',
),
enableKasText: s__(
"ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.",
),
- altText: s__('ClusterAgents|GitLab Agent for Kubernetes'),
+ altText: s__('ClusterAgents|GitLab agent for Kubernetes'),
learnMoreLink: s__('ClusterAgents|How do I register an agent?'),
- 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. %{linkStart}What are registration tokens?%{linkEnd}`,
- ),
registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'),
unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
};
@@ -180,16 +169,14 @@ export const AGENT_STATUSES = {
export const I18N_AGENTS_EMPTY_STATE = {
introText: s__(
- 'ClusterIntegration|Use the %{linkStart}GitLab Agent%{linkEnd} to safely connect your Kubernetes clusters to GitLab. You can deploy your applications, run your pipelines, use Review Apps, and much more.',
+ 'ClusterIntegration|Use the %{linkStart}GitLab agent%{linkEnd} to safely connect your Kubernetes clusters to GitLab. You can deploy your applications, run your pipelines, use Review Apps, and much more.',
),
- buttonText: s__('ClusterAgents|Connect with the GitLab Agent'),
};
export const I18N_CLUSTERS_EMPTY_STATE = {
introText: s__(
'ClusterIntegration|Connect your cluster to GitLab through %{linkStart}cluster certificates%{linkEnd}.',
),
- buttonText: s__('ClusterIntegration|Connect with a certificate'),
alertText: s__(
'ClusterIntegration|The certificate-based method to connect clusters to GitLab was %{linkStart}deprecated%{linkEnd} in GitLab 14.5.',
),
@@ -201,19 +188,15 @@ export const AGENT_CARD_INFO = {
emptyTitle: s__('ClusterAgents|No agents'),
tooltip: {
label: s__('ClusterAgents|Recommended'),
- title: s__('ClusterAgents|GitLab Agent'),
+ title: s__('ClusterAgents|GitLab agent'),
text: sprintf(
s__(
- 'ClusterAgents|The GitLab Agent provides an increased level of security when connecting Kubernetes clusters to GitLab. %{linkStart}Learn more about the GitLab Agent.%{linkEnd}',
+ 'ClusterAgents|The GitLab agent provides an increased level of security when connecting Kubernetes clusters to GitLab. %{linkStart}Learn more about the GitLab agent.%{linkEnd}',
),
),
link: helpPagePath('user/clusters/agent/index'),
},
- actionText: s__('ClusterAgents|Install a new agent'),
footerText: sprintf(s__('ClusterAgents|View all %{number} agents')),
- installAgentDisabledHint: s__(
- 'ClusterAgents|Requires a Maintainer or greater role to install new agents',
- ),
};
export const CERTIFICATE_BASED_CARD_INFO = {
@@ -222,12 +205,8 @@ export const CERTIFICATE_BASED_CARD_INFO = {
s__('ClusterAgents|%{number} of %{total} clusters connected through cluster certificates'),
),
emptyTitle: s__('ClusterAgents|No clusters connected through cluster certificates'),
- actionText: s__('ClusterAgents|Connect existing cluster'),
footerText: sprintf(s__('ClusterAgents|View all %{number} clusters')),
badgeText: s__('ClusterAgents|Deprecated'),
- connectExistingClusterDisabledHint: s__(
- 'ClusterAgents|Requires a maintainer or greater role to connect existing clusters',
- ),
};
export const MAX_CLUSTERS_LIST = 6;
@@ -252,12 +231,13 @@ export const CERTIFICATE_TAB = {
export const CLUSTERS_TABS = [ALL_TAB, AGENT_TAB, CERTIFICATE_TAB];
export const CLUSTERS_ACTIONS = {
- actionsButton: s__('ClusterAgents|Actions'),
- createNewCluster: s__('ClusterAgents|Create a new cluster'),
- connectWithAgent: s__('ClusterAgents|Connect with an agent'),
- connectExistingCluster: s__('ClusterAgents|Connect with a certificate'),
- agent: s__('ClusterAgents|Agent'),
- certificate: s__('ClusterAgents|Certificate'),
+ connectCluster: s__('ClusterAgents|Connect a cluster'),
+ connectWithAgent: s__('ClusterAgents|Connect a cluster (agent)'),
+ connectClusterDeprecated: s__('ClusterAgents|Connect a cluster (deprecated)'),
+ createClusterDeprecated: s__('ClusterAgents|Create a cluster (deprecated)'),
+ createCluster: s__('ClusterAgents|Create a cluster'),
+ createClusterCertificate: s__('ClusterAgents|Create a cluster (certificate - deprecated)'),
+ connectClusterCertificate: s__('ClusterAgents|Connect a cluster (certificate - deprecated)'),
dropdownDisabledHint: s__(
'ClusterAgents|Requires a Maintainer or greater role to perform these actions',
),
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
index 7743ffba5de..76920a0aef4 100644
--- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
#import "../fragments/cluster_agent.fragment.graphql"
query getAgents(
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index 27eebc9d891..f6dfb96ffd9 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -25,6 +25,7 @@ export default () => {
kasAddress,
newClusterPath,
addClusterPath,
+ newClusterDocsPath,
emptyStateHelpText,
clustersEmptyStateImage,
canAddCluster,
@@ -43,6 +44,7 @@ export default () => {
kasAddress,
newClusterPath,
addClusterPath,
+ newClusterDocsPath,
emptyStateHelpText,
clustersEmptyStateImage,
canAddCluster: parseBoolean(canAddCluster),
diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue
index 5c77f087d63..81edbb4182e 100644
--- a/app/assets/javascripts/code_navigation/components/app.vue
+++ b/app/assets/javascripts/code_navigation/components/app.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapState } from 'vuex';
-import eventHub from '../../notes/event_hub';
+import eventHub from '~/notes/event_hub';
import Popover from './popover.vue';
export default {
@@ -23,6 +23,11 @@ export default {
required: false,
default: null,
},
+ wrapTextNodes: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapState([
@@ -37,6 +42,7 @@ export default {
const initialData = {
blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }],
definitionPathPrefix: this.pathPrefix,
+ wrapTextNodes: this.wrapTextNodes,
};
this.setInitialData(initialData);
}
diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js
index 0b6b8437db5..562b78a891a 100644
--- a/app/assets/javascripts/code_navigation/store/actions.js
+++ b/app/assets/javascripts/code_navigation/store/actions.js
@@ -22,7 +22,7 @@ export default {
...d,
definitionLineNumber: parseInt(d.definition_path?.split('#L').pop() || 0, 10),
};
- addInteractionClass(path, d);
+ addInteractionClass({ path, d, wrapTextNodes: state.wrapTextNodes });
}
return acc;
}, {});
@@ -34,7 +34,9 @@ export default {
},
showBlobInteractionZones({ state }, path) {
if (state.data && state.data[path]) {
- Object.values(state.data[path]).forEach((d) => addInteractionClass(path, d));
+ Object.values(state.data[path]).forEach((d) =>
+ addInteractionClass({ path, d, wrapTextNodes: state.wrapTextNodes }),
+ );
}
},
showDefinition({ commit, state }, { target: el }) {
diff --git a/app/assets/javascripts/code_navigation/store/mutations.js b/app/assets/javascripts/code_navigation/store/mutations.js
index 07b190c7476..98beffe231c 100644
--- a/app/assets/javascripts/code_navigation/store/mutations.js
+++ b/app/assets/javascripts/code_navigation/store/mutations.js
@@ -1,9 +1,10 @@
import * as types from './mutation_types';
export default {
- [types.SET_INITIAL_DATA](state, { blobs, definitionPathPrefix }) {
+ [types.SET_INITIAL_DATA](state, { blobs, definitionPathPrefix, wrapTextNodes }) {
state.blobs = blobs;
state.definitionPathPrefix = definitionPathPrefix;
+ state.wrapTextNodes = wrapTextNodes;
},
[types.REQUEST_DATA](state) {
state.loading = true;
diff --git a/app/assets/javascripts/code_navigation/store/state.js b/app/assets/javascripts/code_navigation/store/state.js
index 569d2f7b319..17505b8392c 100644
--- a/app/assets/javascripts/code_navigation/store/state.js
+++ b/app/assets/javascripts/code_navigation/store/state.js
@@ -2,6 +2,7 @@ export default () => ({
blobs: [],
loading: false,
data: null,
+ wrapTextNodes: false,
currentDefinition: null,
currentDefinitionPosition: null,
currentBlobPath: null,
diff --git a/app/assets/javascripts/code_navigation/utils/dom_utils.js b/app/assets/javascripts/code_navigation/utils/dom_utils.js
new file mode 100644
index 00000000000..1a65c1a64a2
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/utils/dom_utils.js
@@ -0,0 +1,31 @@
+const TEXT_NODE = 3;
+
+const isTextNode = ({ nodeType }) => nodeType === TEXT_NODE;
+
+const isBlank = (str) => !str || /^\s*$/.test(str);
+
+const isMatch = (s1, s2) => !isBlank(s1) && s1.trim() === s2.trim();
+
+const createSpan = (content) => {
+ const span = document.createElement('span');
+ span.innerText = content;
+ return span;
+};
+
+const wrapSpacesWithSpans = (text) => text.replace(/ /g, createSpan(' ').outerHTML);
+
+const wrapTextWithSpan = (el, text) => {
+ if (isTextNode(el) && isMatch(el.textContent, text)) {
+ const newEl = createSpan(text.trim());
+ el.replaceWith(newEl);
+ }
+};
+
+const wrapNodes = (text) => {
+ const wrapper = createSpan();
+ wrapper.innerHTML = wrapSpacesWithSpans(text);
+ wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text));
+ return wrapper.childNodes;
+};
+
+export { wrapNodes, isTextNode };
diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js
index 6c078891ed4..0d72153d8fe 100644
--- a/app/assets/javascripts/code_navigation/utils/index.js
+++ b/app/assets/javascripts/code_navigation/utils/index.js
@@ -1,9 +1,11 @@
+import { wrapNodes, isTextNode } from './dom_utils';
+
export const cachedData = new Map();
export const getCurrentHoverElement = () => cachedData.get('current');
export const setCurrentHoverElement = (el) => cachedData.set('current', el);
-export const addInteractionClass = (path, d) => {
+export const addInteractionClass = ({ path, d, wrapTextNodes }) => {
const lineNumber = d.start_line + 1;
const lines = document
.querySelector(`[data-path="${path}"]`)
@@ -12,13 +14,24 @@ export const addInteractionClass = (path, d) => {
lines.forEach((line) => {
let charCount = 0;
+
+ if (wrapTextNodes) {
+ line.childNodes.forEach((elm) => {
+ if (isTextNode(elm)) {
+ // Highlight.js does not wrap all text nodes by default
+ // We need all text nodes to be wrapped in order to append code nav attributes
+ elm.replaceWith(...wrapNodes(elm.textContent));
+ }
+ });
+ }
+
const el = [...line.childNodes].find(({ textContent }) => {
if (charCount === d.start_char) return true;
charCount += textContent.length;
return false;
});
- if (el) {
+ if (el && !isTextNode(el)) {
el.setAttribute('data-char-index', d.start_char);
el.setAttribute('data-line-index', d.start_line);
el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation');
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
index 59066162960..32d9159ee34 100644
--- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -1,8 +1,8 @@
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
-import Api from '../../api';
-import { __ } from '../../locale';
+import Api from '~/api';
+import { __ } from '~/locale';
import state from '../state';
import Dropdown from './dropdown.vue';
@@ -87,7 +87,7 @@ export default {
},
showWarning() {
if (this.warningText) {
- this.warningText.classList.remove('hidden');
+ this.warningText.classList.remove('gl-display-none');
}
if (this.createBtn) {
@@ -120,7 +120,7 @@ export default {
:selected-project="selectedProject"
@click="selectProject"
/>
- <p class="text-muted mt-1 mb-0">
+ <p class="gl-text-gray-600 gl-mt-1 gl-mb-0">
<template v-if="projects.length">
{{ $options.i18n.privateForkSelected }}
</template>
@@ -134,7 +134,7 @@ export default {
</template>
<gl-link
:href="helpPagePath"
- class="w-auto p-0 d-inline-block text-primary bg-transparent"
+ class="gl-w-auto gl-p-0 gl-display-inline-block gl-bg-transparent"
target="_blank"
>
<span class="sr-only">{{ $options.i18n.readMore }}</span>
diff --git a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue
new file mode 100644
index 00000000000..87f22a27856
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue
@@ -0,0 +1,146 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { BubbleMenu } from '@tiptap/vue-2';
+import codeBlockLanguageLoader from '../services/code_block_language_loader';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Diagram from '../extensions/diagram';
+import Frontmatter from '../extensions/frontmatter';
+import EditorStateObserver from './editor_state_observer.vue';
+
+const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
+
+export default {
+ components: {
+ BubbleMenu,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ EditorStateObserver,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['tiptapEditor'],
+ data() {
+ return {
+ selectedLanguage: {},
+ filterTerm: '',
+ filteredLanguages: [],
+ };
+ },
+ watch: {
+ filterTerm: {
+ handler(val) {
+ this.filteredLanguages = codeBlockLanguageLoader.filterLanguages(val);
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ shouldShow: ({ editor }) => {
+ return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type));
+ },
+
+ getSelectedLanguage() {
+ const { language } = this.tiptapEditor.getAttributes(this.getCodeBlockType());
+
+ this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language);
+ },
+
+ async setSelectedLanguage(language) {
+ this.selectedLanguage = language;
+
+ await codeBlockLanguageLoader.loadLanguages([language.syntax]);
+
+ this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax });
+ },
+
+ tippyOnBeforeUpdate(tippy, props) {
+ if (props.getReferenceClientRect) {
+ // eslint-disable-next-line no-param-reassign
+ props.getReferenceClientRect = () => {
+ const { view } = this.tiptapEditor;
+ const { from } = this.tiptapEditor.state.selection;
+
+ for (let { node } = view.domAtPos(from); node; node = node.parentElement) {
+ if (node.nodeName?.toLowerCase() === 'pre') {
+ return node.getBoundingClientRect();
+ }
+ }
+
+ return new DOMRect(-1000, -1000, 0, 0);
+ };
+ }
+ },
+
+ deleteCodeBlock() {
+ this.tiptapEditor.chain().focus().deleteNode(this.getCodeBlockType()).run();
+ },
+
+ getCodeBlockType() {
+ return (
+ CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)) ||
+ CodeBlockHighlight.name
+ );
+ },
+ },
+};
+</script>
+<template>
+ <bubble-menu
+ data-testid="code-block-bubble-menu"
+ class="gl-shadow gl-rounded-base"
+ :editor="tiptapEditor"
+ plugin-key="bubbleMenuCodeBlock"
+ :should-show="shouldShow"
+ :tippy-options="{ onBeforeUpdate: tippyOnBeforeUpdate }"
+ >
+ <editor-state-observer @transaction="getSelectedLanguage">
+ <gl-button-group>
+ <gl-dropdown contenteditable="false" boundary="viewport" :text="selectedLanguage.label">
+ <template #header>
+ <gl-search-box-by-type
+ v-model="filterTerm"
+ :clear-button-title="__('Clear')"
+ :placeholder="__('Search')"
+ />
+ </template>
+
+ <template #highlighted-items>
+ <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true">
+ {{ selectedLanguage.label }}
+ </gl-dropdown-item>
+ </template>
+
+ <gl-dropdown-item
+ v-for="language in filteredLanguages"
+ v-show="selectedLanguage.syntax !== language.syntax"
+ :key="language.syntax"
+ @click="setSelectedLanguage(language)"
+ >
+ {{ language.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <gl-button
+ v-gl-tooltip
+ variant="default"
+ category="primary"
+ size="medium"
+ :aria-label="__('Delete code block')"
+ :title="__('Delete code block')"
+ icon="remove"
+ @click="deleteCodeBlock"
+ />
+ </gl-button-group>
+ </editor-state-observer>
+ </bubble-menu>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index a942c9f1149..5b3f4f4ddf2 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -5,6 +5,7 @@ import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
+import CodeBlockBubbleMenu from './code_block_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue';
@@ -16,6 +17,7 @@ export default {
TiptapEditorContent,
TopToolbar,
FormattingBubbleMenu,
+ CodeBlockBubbleMenu,
EditorStateObserver,
},
props: {
@@ -89,6 +91,7 @@ export default {
<top-toolbar ref="toolbar" class="gl-mb-4" />
<div class="gl-relative">
<formatting-bubble-menu />
+ <code-block-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
<loading-indicator />
</div>
diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
index 14a553ff30b..103079534bc 100644
--- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
@@ -3,6 +3,10 @@ import { GlButtonGroup } from '@gitlab/ui';
import { BubbleMenu } from '@tiptap/vue-2';
import { BUBBLE_MENU_TRACKING_ACTION } from '../constants';
import trackUIControl from '../services/track_ui_control';
+import Code from '../extensions/code';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Diagram from '../extensions/diagram';
+import Frontmatter from '../extensions/frontmatter';
import ToolbarButton from './toolbar_button.vue';
export default {
@@ -16,6 +20,14 @@ export default {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value });
},
+
+ shouldShow: ({ editor, from, to }) => {
+ if (from === to) return false;
+
+ const exclude = [Code.name, CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
+
+ return !exclude.some((type) => editor.isActive(type));
+ },
},
};
</script>
@@ -24,6 +36,7 @@ export default {
data-testid="formatting-bubble-menu"
class="gl-shadow gl-rounded-base"
:editor="tiptapEditor"
+ :should-show="shouldShow"
>
<gl-button-group>
<toolbar-button
diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue
index 5b9383d6e11..620324adb06 100644
--- a/app/assets/javascripts/content_editor/components/loading_indicator.vue
+++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue
@@ -30,6 +30,7 @@ export default {
>
<div
v-if="isLoading"
+ data-testid="content-editor-loading-indicator"
class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
>
<div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue
deleted file mode 100644
index 5b81e5fddcc..00000000000
--- a/app/assets/javascripts/content_editor/components/wrappers/image.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { NodeViewWrapper } from '@tiptap/vue-2';
-
-export default {
- name: 'ImageWrapper',
- components: {
- NodeViewWrapper,
- GlLoadingIcon,
- },
- props: {
- node: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-<template>
- <node-view-wrapper class="gl-display-inline-block">
- <span class="gl-relative">
- <img
- data-testid="image"
- class="gl-max-w-full gl-h-auto"
- :title="node.attrs.title"
- :class="{ 'gl-opacity-5': node.attrs.uploading }"
- :src="node.attrs.src"
- />
- <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" />
- </span>
- </node-view-wrapper>
-</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/media.vue b/app/assets/javascripts/content_editor/components/wrappers/media.vue
new file mode 100644
index 00000000000..37119bdd066
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/media.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { NodeViewWrapper } from '@tiptap/vue-2';
+
+const tagNameMap = {
+ image: 'img',
+ video: 'video',
+ audio: 'audio',
+};
+
+export default {
+ name: 'MediaWrapper',
+ components: {
+ NodeViewWrapper,
+ GlLoadingIcon,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ tagName() {
+ return tagNameMap[this.node.type.name] || 'img';
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-inline-block">
+ <span class="gl-relative" :class="{ [`media-container ${tagName}-container`]: true }">
+ <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" />
+ <component
+ :is="tagName"
+ data-testid="media"
+ :class="{
+ 'gl-max-w-full gl-h-auto': tagName !== 'audio',
+ 'gl-opacity-5': node.attrs.uploading,
+ }"
+ :title="node.attrs.title || node.attrs.alt"
+ :alt="node.attrs.alt"
+ :src="node.attrs.src"
+ controls="true"
+ />
+ <a v-if="tagName !== 'img'" :href="node.attrs.canonicalSrc || node.attrs.src" @click.prevent>
+ {{ node.attrs.title || node.attrs.alt }}
+ </a>
+ </span>
+ </node-view-wrapper>
+</template>
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 204ac07d401..61f379fc0a2 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,10 +1,21 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
-import { lowlight } from 'lowlight/lib/all';
+import { textblockTypeInputRule } from '@tiptap/core';
+import codeBlockLanguageLoader from '../services/code_block_language_loader';
const extractLanguage = (element) => element.getAttribute('lang');
+export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
+export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
export default CodeBlockLowlight.extend({
isolating: true,
+ exitOnArrowDown: false,
+
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ languageLoader: codeBlockLanguageLoader,
+ };
+ },
addAttributes() {
return {
@@ -18,16 +29,40 @@ export default CodeBlockLowlight.extend({
},
};
},
+ addInputRules() {
+ const { languageLoader } = this.options;
+ const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {};
+
+ return [
+ textblockTypeInputRule({
+ find: backtickInputRegex,
+ type: this.type,
+ getAttributes,
+ }),
+ textblockTypeInputRule({
+ find: tildeInputRegex,
+ type: this.type,
+ getAttributes,
+ }),
+ ];
+ },
+ parseHTML() {
+ return [
+ ...(this.parent?.() || []),
+ {
+ tag: 'div.markdown-code-block',
+ skip: true,
+ },
+ ];
+ },
renderHTML({ HTMLAttributes }) {
return [
'pre',
{
...HTMLAttributes,
- class: `content-editor-code-block ${HTMLAttributes.class}`,
+ class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`,
},
['code', {}, 0],
];
},
-}).configure({
- lowlight,
});
diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js
new file mode 100644
index 00000000000..d192b815092
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/diagram.js
@@ -0,0 +1,56 @@
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import CodeBlockHighlight from './code_block_highlight';
+
+export default CodeBlockHighlight.extend({
+ name: 'diagram',
+
+ isolating: true,
+
+ addAttributes() {
+ return {
+ language: {
+ default: null,
+ parseHTML: (element) => {
+ return element.dataset.diagram;
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ tag: '[data-diagram]',
+ getContent(element, schema) {
+ const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', ''));
+ const node = schema.node('paragraph', {}, [schema.text(source)]);
+ return node.content;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes: { language, ...HTMLAttributes } }) {
+ return [
+ 'div',
+ [
+ 'pre',
+ {
+ language,
+ class: `content-editor-code-block code highlight`,
+ ...HTMLAttributes,
+ },
+ ['code', {}, 0],
+ ],
+ ];
+ },
+
+ addCommands() {
+ return {};
+ },
+
+ addInputRules() {
+ return [];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 519f7f168ce..311db8151cb 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,6 +1,6 @@
import { Image } from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
-import ImageWrapper from '../components/wrappers/image.vue';
+import MediaWrapper from '../components/wrappers/media.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const resolveImageEl = (element) =>
@@ -78,6 +78,6 @@ export default Image.extend({
];
},
addNodeView() {
- return VueNodeViewRenderer(ImageWrapper);
+ return VueNodeViewRenderer(MediaWrapper);
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
index 0062bc563db..2c5269377c5 100644
--- a/app/assets/javascripts/content_editor/extensions/playable.js
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -1,6 +1,8 @@
/* eslint-disable @gitlab/require-i18n-strings */
import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import MediaWrapper from '../components/wrappers/media.vue';
const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
@@ -11,6 +13,9 @@ export default Node.create({
addAttributes() {
return {
+ uploading: {
+ default: false,
+ },
src: {
default: null,
parseHTML: (element) => {
@@ -60,7 +65,11 @@ export default Node.create({
...this.extraElementAttrs,
},
],
- ['a', { href: node.attrs.src }, node.attrs.alt],
+ ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''],
];
},
+
+ addNodeView() {
+ return VueNodeViewRenderer(MediaWrapper);
+ },
});
diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js
new file mode 100644
index 00000000000..081400cfd9a
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js
@@ -0,0 +1,283 @@
+import { lowlight } from 'lowlight/lib/core';
+import { __, sprintf } from '~/locale';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+// List of languages referenced from https://github.com/wooorm/lowlight#data
+const CODE_BLOCK_LANGUAGES = [
+ { syntax: '1c', label: '1C:Enterprise' },
+ { syntax: 'abnf', label: 'Augmented Backus-Naur Form' },
+ { syntax: 'accesslog', label: 'Apache Access Log' },
+ { syntax: 'actionscript', variants: 'as', label: 'ActionScript' },
+ { syntax: 'ada', label: 'Ada' },
+ { syntax: 'angelscript', variants: 'asc', label: 'AngelScript' },
+ { syntax: 'apache', variants: 'apacheconf', label: 'Apache config' },
+ { syntax: 'applescript', variants: 'osascript', label: 'AppleScript' },
+ { syntax: 'arcade', label: 'ArcGIS Arcade' },
+ { syntax: 'arduino', variants: 'ino', label: 'Arduino' },
+ { syntax: 'armasm', variants: 'arm', label: 'ARM Assembly' },
+ { syntax: 'asciidoc', variants: 'adoc', label: 'AsciiDoc' },
+ { syntax: 'aspectj', label: 'AspectJ' },
+ { syntax: 'autohotkey', variants: 'ahk', label: 'AutoHotkey' },
+ { syntax: 'autoit', label: 'AutoIt' },
+ { syntax: 'avrasm', label: 'AVR Assembly' },
+ { syntax: 'awk', label: 'Awk' },
+ { syntax: 'axapta', variants: 'x++', label: 'X++' },
+ { syntax: 'bash', variants: 'sh', label: 'Bash' },
+ { syntax: 'basic', label: 'BASIC' },
+ { syntax: 'bnf', label: 'Backus-Naur Form' },
+ { syntax: 'brainfuck', variants: 'bf', label: 'Brainfuck' },
+ { syntax: 'c', variants: 'h', label: 'C' },
+ { syntax: 'cal', label: 'C/AL' },
+ { syntax: 'capnproto', variants: 'capnp', label: "Cap'n Proto" },
+ { syntax: 'ceylon', label: 'Ceylon' },
+ { syntax: 'clean', variants: 'icl, dcl', label: 'Clean' },
+ { syntax: 'clojure', variants: 'clj, edn', label: 'Clojure' },
+ { syntax: 'clojure-repl', label: 'Clojure REPL' },
+ { syntax: 'cmake', variants: 'cmake.in', label: 'CMake' },
+ { syntax: 'coffeescript', variants: 'coffee, cson, iced', label: 'CoffeeScript' },
+ { syntax: 'coq', label: 'Coq' },
+ { syntax: 'cos', variants: 'cls', label: 'Caché Object Script' },
+ { syntax: 'cpp', variants: 'cc, c++, h++, hpp, hh, hxx, cxx', label: 'C++' },
+ { syntax: 'crmsh', variants: 'crm, pcmk', label: 'crmsh' },
+ { syntax: 'crystal', variants: 'cr', label: 'Crystal' },
+ { syntax: 'csharp', variants: 'cs, c#', label: 'C#' },
+ { syntax: 'csp', label: 'CSP' },
+ { syntax: 'css', label: 'CSS' },
+ { syntax: 'd', label: 'D' },
+ { syntax: 'dart', label: 'Dart' },
+ { syntax: 'delphi', variants: 'dpr, dfm, pas, pascal', label: 'Delphi' },
+ { syntax: 'diff', variants: 'patch', label: 'Diff' },
+ { syntax: 'django', variants: 'jinja', label: 'Django' },
+ { syntax: 'dns', variants: 'bind, zone', label: 'DNS Zone' },
+ { syntax: 'dockerfile', variants: 'docker', label: 'Dockerfile' },
+ { syntax: 'dos', variants: 'bat, cmd', label: 'Batch file (DOS)' },
+ { syntax: 'dsconfig', label: 'DSConfig' },
+ { syntax: 'dts', label: 'Device Tree' },
+ { syntax: 'dust', variants: 'dst', label: 'Dust' },
+ { syntax: 'ebnf', label: 'Extended Backus-Naur Form' },
+ { syntax: 'elixir', variants: 'ex, exs', label: 'Elixir' },
+ { syntax: 'elm', label: 'Elm' },
+ { syntax: 'erb', label: 'ERB' },
+ { syntax: 'erlang', variants: 'erl', label: 'Erlang' },
+ { syntax: 'erlang-repl', label: 'Erlang REPL' },
+ { syntax: 'excel', variants: 'xlsx, xls', label: 'Excel formulae' },
+ { syntax: 'fix', label: 'FIX' },
+ { syntax: 'flix', label: 'Flix' },
+ { syntax: 'fortran', variants: 'f90, f95', label: 'Fortran' },
+ { syntax: 'fsharp', variants: 'fs, f#', label: 'F#' },
+ { syntax: 'gams', variants: 'gms', label: 'GAMS' },
+ { syntax: 'gauss', variants: 'gss', label: 'GAUSS' },
+ { syntax: 'gcode', variants: 'nc', label: 'G-code (ISO 6983)' },
+ { syntax: 'gherkin', variants: 'feature', label: 'Gherkin' },
+ { syntax: 'glsl', label: 'GLSL' },
+ { syntax: 'gml', label: 'GML' },
+ { syntax: 'go', variants: 'golang', label: 'Go' },
+ { syntax: 'golo', label: 'Golo' },
+ { syntax: 'gradle', label: 'Gradle' },
+ { syntax: 'graphql', variants: 'gql', label: 'GraphQL' },
+ { syntax: 'groovy', label: 'Groovy' },
+ { syntax: 'haml', label: 'HAML' },
+ {
+ syntax: 'handlebars',
+ variants: 'hbs, html.hbs, html.handlebars, htmlbars',
+ label: 'Handlebars',
+ },
+ { syntax: 'haskell', variants: 'hs', label: 'Haskell' },
+ { syntax: 'haxe', variants: 'hx', label: 'Haxe' },
+ { syntax: 'hsp', label: 'HSP' },
+ { syntax: 'http', variants: 'https', label: 'HTTP' },
+ { syntax: 'hy', variants: 'hylang', label: 'Hy' },
+ { syntax: 'inform7', variants: 'i7', label: 'Inform 7' },
+ { syntax: 'ini', variants: 'toml', label: 'TOML, also INI' },
+ { syntax: 'irpf90', label: 'IRPF90' },
+ { syntax: 'isbl', label: 'ISBL' },
+ { syntax: 'java', variants: 'jsp', label: 'Java' },
+ { syntax: 'javascript', variants: 'js, jsx, mjs, cjs', label: 'Javascript' },
+ { syntax: 'jboss-cli', variants: 'wildfly-cli', label: 'JBoss CLI' },
+ { syntax: 'json', label: 'JSON' },
+ { syntax: 'julia', label: 'Julia' },
+ { syntax: 'julia-repl', variants: 'jldoctest', label: 'Julia REPL' },
+ { syntax: 'kotlin', variants: 'kt, kts', label: 'Kotlin' },
+ { syntax: 'lasso', variants: 'ls, lassoscript', label: 'Lasso' },
+ { syntax: 'latex', variants: 'tex', label: 'LaTeX' },
+ { syntax: 'ldif', label: 'LDIF' },
+ { syntax: 'leaf', label: 'Leaf' },
+ { syntax: 'less', label: 'Less' },
+ { syntax: 'lisp', label: 'Lisp' },
+ { syntax: 'livecodeserver', label: 'LiveCode' },
+ { syntax: 'livescript', variants: 'ls', label: 'LiveScript' },
+ { syntax: 'llvm', label: 'LLVM IR' },
+ { syntax: 'lsl', label: 'LSL (Linden Scripting Language)' },
+ { syntax: 'lua', label: 'Lua' },
+ { syntax: 'makefile', variants: 'mk, mak, make', label: 'Makefile' },
+ { syntax: 'markdown', variants: 'md, mkdown, mkd', label: 'Markdown' },
+ { syntax: 'mathematica', variants: 'mma, wl', label: 'Mathematica' },
+ { syntax: 'matlab', label: 'Matlab' },
+ { syntax: 'maxima', label: 'Maxima' },
+ { syntax: 'mel', label: 'MEL' },
+ { syntax: 'mercury', variants: 'm, moo', label: 'Mercury' },
+ { syntax: 'mipsasm', variants: 'mips', label: 'MIPS Assembly' },
+ { syntax: 'mizar', label: 'Mizar' },
+ { syntax: 'mojolicious', label: 'Mojolicious' },
+ { syntax: 'monkey', label: 'Monkey' },
+ { syntax: 'moonscript', variants: 'moon', label: 'MoonScript' },
+ { syntax: 'n1ql', label: 'N1QL' },
+ { syntax: 'nestedtext', variants: 'nt', label: 'Nested Text' },
+ { syntax: 'nginx', variants: 'nginxconf', label: 'Nginx config' },
+ { syntax: 'nim', label: 'Nim' },
+ { syntax: 'nix', variants: 'nixos', label: 'Nix' },
+ { syntax: 'node-repl', label: 'Node REPL' },
+ { syntax: 'nsis', label: 'NSIS' },
+ {
+ syntax: 'objectivec',
+ variants: 'mm, objc, obj-c, obj-c++, objective-c++',
+ label: 'Objective-C',
+ },
+ { syntax: 'ocaml', variants: 'ml', label: 'OCaml' },
+ { syntax: 'openscad', variants: 'scad', label: 'OpenSCAD' },
+ { syntax: 'oxygene', label: 'Oxygene' },
+ { syntax: 'parser3', label: 'Parser3' },
+ { syntax: 'perl', variants: 'pl, pm', label: 'Perl' },
+ { syntax: 'pf', variants: 'pf.conf', label: 'Packet Filter config' },
+ { syntax: 'pgsql', variants: 'postgres, postgresql', label: 'PostgreSQL' },
+ { syntax: 'php', label: 'PHP' },
+ { syntax: 'php-template', label: 'PHP template' },
+ { syntax: 'plaintext', variants: 'text, txt', label: 'Plain text' },
+ { syntax: 'pony', label: 'Pony' },
+ { syntax: 'powershell', variants: 'pwsh, ps, ps1', label: 'PowerShell' },
+ { syntax: 'processing', variants: 'pde', label: 'Processing' },
+ { syntax: 'profile', label: 'Python profiler' },
+ { syntax: 'prolog', label: 'Prolog' },
+ { syntax: 'properties', label: '.properties' },
+ { syntax: 'protobuf', label: 'Protocol Buffers' },
+ { syntax: 'puppet', variants: 'pp', label: 'Puppet' },
+ { syntax: 'purebasic', variants: 'pb, pbi', label: 'PureBASIC' },
+ { syntax: 'python', variants: 'py, gyp, ipython', label: 'Python' },
+ { syntax: 'python-repl', variants: 'pycon', label: 'Python REPL' },
+ { syntax: 'q', variants: 'k, kdb', label: 'Q' },
+ { syntax: 'qml', variants: 'qt', label: 'QML' },
+ { syntax: 'r', label: 'R' },
+ { syntax: 'reasonml', variants: 're', label: 'ReasonML' },
+ { syntax: 'rib', label: 'RenderMan RIB' },
+ { syntax: 'roboconf', variants: 'graph, instances', label: 'Roboconf' },
+ { syntax: 'routeros', variants: 'mikrotik', label: 'Microtik RouterOS script' },
+ { syntax: 'rsl', label: 'RenderMan RSL' },
+ { syntax: 'ruby', variants: 'rb, gemspec, podspec, thor, irb', label: 'Ruby' },
+ { syntax: 'ruleslanguage', label: 'Oracle Rules Language' },
+ { syntax: 'rust', variants: 'rs', label: 'Rust' },
+ { syntax: 'sas', label: 'SAS' },
+ { syntax: 'scala', label: 'Scala' },
+ { syntax: 'scheme', label: 'Scheme' },
+ { syntax: 'scilab', variants: 'sci', label: 'Scilab' },
+ { syntax: 'scss', label: 'SCSS' },
+ { syntax: 'shell', variants: 'console, shellsession', label: 'Shell Session' },
+ { syntax: 'smali', label: 'Smali' },
+ { syntax: 'smalltalk', variants: 'st', label: 'Smalltalk' },
+ { syntax: 'sml', variants: 'ml', label: 'SML (Standard ML)' },
+ { syntax: 'sqf', label: 'SQF' },
+ { syntax: 'sql', label: 'SQL' },
+ { syntax: 'stan', variants: 'stanfuncs', label: 'Stan' },
+ { syntax: 'stata', variants: 'do, ado', label: 'Stata' },
+ { syntax: 'step21', variants: 'p21, step, stp', label: 'STEP Part 21' },
+ { syntax: 'stylus', variants: 'styl', label: 'Stylus' },
+ { syntax: 'subunit', label: 'SubUnit' },
+ { syntax: 'swift', label: 'Swift' },
+ { syntax: 'taggerscript', label: 'Tagger Script' },
+ { syntax: 'tap', label: 'Test Anything Protocol' },
+ { syntax: 'tcl', variants: 'tk', label: 'Tcl' },
+ { syntax: 'thrift', label: 'Thrift' },
+ { syntax: 'tp', label: 'TP' },
+ { syntax: 'twig', variants: 'craftcms', label: 'Twig' },
+ { syntax: 'typescript', variants: 'ts, tsx', label: 'TypeScript' },
+ { syntax: 'vala', label: 'Vala' },
+ { syntax: 'vbnet', variants: 'vb', label: 'Visual Basic .NET' },
+ { syntax: 'vbscript', variants: 'vbs', label: 'VBScript' },
+ { syntax: 'vbscript-html', label: 'VBScript in HTML' },
+ { syntax: 'verilog', variants: 'v, sv, svh', label: 'Verilog' },
+ { syntax: 'vhdl', label: 'VHDL' },
+ { syntax: 'vim', label: 'Vim Script' },
+ { syntax: 'wasm', label: 'WebAssembly' },
+ { syntax: 'wren', label: 'Wren' },
+ { syntax: 'x86asm', label: 'Intel x86 Assembly' },
+ { syntax: 'xl', variants: 'tao', label: 'XL' },
+ {
+ syntax: 'xml',
+ variants: 'html, xhtml, rss, atom, xjb, xsd, xsl, plist, wsf, svg',
+ label: 'HTML, XML',
+ },
+ { syntax: 'xquery', variants: 'xpath, xq', label: 'XQuery' },
+ { syntax: 'yaml', variants: 'yml', label: 'YAML' },
+ { syntax: 'zephir', variants: 'zep', label: 'Zephir' },
+];
+/* eslint-enable @gitlab/require-i18n-strings */
+
+const codeBlockLanguageLoader = {
+ lowlight,
+
+ allLanguages: CODE_BLOCK_LANGUAGES,
+
+ findLanguageBySyntax(value) {
+ const lowercaseValue = value?.toLowerCase() || 'plaintext';
+ return (
+ this.allLanguages.find(
+ ({ syntax, variants }) =>
+ syntax === lowercaseValue || variants?.toLowerCase().split(', ').includes(lowercaseValue),
+ ) || {
+ syntax: lowercaseValue,
+ label: sprintf(__(`Custom (%{language})`), { language: lowercaseValue }),
+ }
+ );
+ },
+
+ filterLanguages(value) {
+ if (!value) return this.allLanguages;
+
+ const lowercaseValue = value?.toLowerCase() || '';
+ return this.allLanguages.filter(
+ ({ syntax, label, variants }) =>
+ syntax.toLowerCase().includes(lowercaseValue) ||
+ label.toLowerCase().includes(lowercaseValue) ||
+ variants?.toLowerCase().includes(lowercaseValue),
+ );
+ },
+
+ isLanguageLoaded(language) {
+ return this.lowlight.registered(language);
+ },
+
+ loadLanguagesFromDOM(domTree) {
+ const languages = [];
+
+ domTree.querySelectorAll('pre').forEach((preElement) => {
+ languages.push(preElement.getAttribute('lang'));
+ });
+
+ return this.loadLanguages(languages);
+ },
+
+ loadLanguageFromInputRule(match) {
+ const { syntax } = this.findLanguageBySyntax(match[1]);
+
+ this.loadLanguages([syntax]);
+
+ return { language: syntax };
+ },
+
+ loadLanguages(languageList = []) {
+ const loaders = languageList
+ .filter((languageName) => !this.isLanguageLoaded(languageName))
+ .map((languageName) => {
+ return import(
+ /* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}`
+ )
+ .then(({ default: language }) => {
+ this.lowlight.registerLanguage(languageName, language);
+ })
+ .catch(() => false);
+ });
+
+ return Promise.all(loaders);
+ },
+};
+
+export default codeBlockLanguageLoader;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index c5638da2daf..56badf965ee 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -3,11 +3,12 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
- constructor({ tiptapEditor, serializer, deserializer, eventHub }) {
+ constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._deserializer = deserializer;
this._eventHub = eventHub;
+ this._languageLoader = languageLoader;
}
get tiptapEditor() {
@@ -34,23 +35,33 @@ export class ContentEditor {
}
async setSerializedContent(serializedContent) {
- const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this;
+ const {
+ _tiptapEditor: editor,
+ _deserializer: deserializer,
+ _eventHub: eventHub,
+ _languageLoader: languageLoader,
+ } = this;
const { doc, tr } = editor.state;
const selection = TextSelection.create(doc, 0, doc.content.size);
try {
eventHub.$emit(LOADING_CONTENT_EVENT);
- const { document } = await deserializer.deserialize({
+ const result = await deserializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
- if (document) {
+ if (Object.keys(result).length !== 0) {
+ const { document, dom } = result;
+
+ await languageLoader.loadLanguagesFromDOM(dom);
+
tr.setSelection(selection)
.replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
}
+
eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) {
eventHub.$emit(LOADING_ERROR_EVENT, e);
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 d9d39a387d0..af19a0ab0e4 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -1,5 +1,6 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
+import { lowlight } from 'lowlight/lib/core';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
@@ -14,6 +15,7 @@ import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
+import Diagram from '../extensions/diagram';
import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
@@ -58,6 +60,7 @@ import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
+import languageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
@@ -91,12 +94,13 @@ export const createContentEditor = ({
BulletList,
Code,
ColorChip,
- CodeBlockHighlight,
+ CodeBlockHighlight.configure({ lowlight, languageLoader }),
DescriptionItem,
DescriptionList,
Details,
DetailsContent,
Document,
+ Diagram,
Division,
Dropcursor,
Emoji,
@@ -105,7 +109,7 @@ export const createContentEditor = ({
FootnoteDefinition,
FootnoteReference,
FootnotesSection,
- Frontmatter,
+ Frontmatter.configure({ lowlight }),
Gapcursor,
HardBreak,
Heading,
@@ -144,5 +148,5 @@ export const createContentEditor = ({
const serializer = createMarkdownSerializer({ serializerConfig });
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer });
+ return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader });
};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index eaaf69c3068..c2be7bc9195 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -13,6 +13,7 @@ import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division';
+import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
@@ -48,6 +49,7 @@ import Video from '../extensions/video';
import WordBreak from '../extensions/word_break';
import {
isPlainURL,
+ renderCodeBlock,
renderHardBreak,
renderTable,
renderTableCell,
@@ -130,13 +132,8 @@ const defaultSerializerConfig = {
}
},
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
- [CodeBlockHighlight.name]: (state, node) => {
- state.write(`\`\`\`${node.attrs.language || ''}\n`);
- state.text(node.textContent, false);
- state.ensureNewLine();
- state.write('```');
- state.closeBlock(node);
- },
+ [CodeBlockHighlight.name]: renderCodeBlock,
+ [Diagram.name]: renderCodeBlock,
[Division.name]: (state, node) => {
if (node.attrs.className?.includes('js-markdown-code')) {
state.renderInline(node);
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 5fdd294aa96..3e48434c6f9 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -341,3 +341,11 @@ export function renderImage(state, node) {
export function renderPlayable(state, node) {
renderImage(state, node);
}
+
+export function renderCodeBlock(state, node) {
+ state.write(`\`\`\`${node.attrs.language || ''}\n`);
+ state.text(node.textContent, false);
+ state.ensureNewLine();
+ state.write('```');
+ state.closeBlock(node);
+}
diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
index eb1e4885ba6..b844b414343 100644
--- a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
+++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
@@ -8,12 +8,12 @@ import {
INPUT_RULE_TRACKING_ACTION,
} from '../constants';
-const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => {
+const trackKeyboardShortcut = (contentType, commandFn, shortcut) => (...args) => {
Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property: `${contentType}.${shortcut}`,
});
- return commandFn();
+ return commandFn(...args);
};
const trackInputRule = (contentType, inputRule) => {
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index 1abecb8f414..ed2c4b39131 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -5,6 +5,16 @@ import { extractFilename, readFileAsDataURL } from './utils';
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
+ audio: [
+ 'audio/basic',
+ 'audio/mid',
+ 'audio/mpeg',
+ 'audio/x-aiff',
+ 'audio/ogg',
+ 'audio/vorbis',
+ 'audio/vnd.wav',
+ ],
+ video: ['video/mp4', 'video/quicktime'],
};
const extractAttachmentLinkUrl = (html) => {
@@ -50,11 +60,11 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return extractAttachmentLinkUrl(rendered);
};
-const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
+const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
- editor.commands.setImage({ uploading: true, src: encodedSrc });
+ editor.commands.insertContent({ type, attrs: { uploading: true, src: encodedSrc } });
const { state } = view;
const position = state.selection.from - 1;
@@ -74,7 +84,7 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
eventHub.$emit('alert', {
- message: __('An error occurred while uploading the image. Please try again.'),
+ message: __('An error occurred while uploading the file. Please try again.'),
variant: VARIANT_DANGER,
});
}
@@ -114,10 +124,12 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
- if (acceptedMimes.image.includes(file?.type)) {
- uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
+ for (const [type, mimes] of Object.entries(acceptedMimes)) {
+ if (mimes.includes(file?.type)) {
+ uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
- return true;
+ return true;
+ }
}
uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index d1a68e80608..f2ff77daf02 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -2,8 +2,6 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import { debounce } from 'lodash';
import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
@@ -114,7 +112,26 @@ export default class ContextualSidebar {
this.toggleCollapsedSidebar(collapse, true);
}
- initInviteMembersModal();
- initInviteMembersTrigger();
+ const modalEl = document.querySelector('.js-invite-members-modal');
+ if (modalEl) {
+ import(
+ /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal'
+ )
+ .then(({ default: initInviteMembersModal }) => {
+ initInviteMembersModal();
+ })
+ .catch(() => {});
+
+ const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger');
+ if (inviteTriggers) {
+ import(
+ /* webpackChunkName: 'initInviteMembersTrigger' */ '~/invite_members/init_invite_members_trigger'
+ )
+ .then(({ default: initInviteMembersTrigger }) => {
+ initInviteMembersTrigger();
+ })
+ .catch(() => {});
+ }
+ }
}
}
diff --git a/app/assets/javascripts/crm/components/contact_form.vue b/app/assets/javascripts/crm/components/contact_form.vue
deleted file mode 100644
index 81ae5c246be..00000000000
--- a/app/assets/javascripts/crm/components/contact_form.vue
+++ /dev/null
@@ -1,224 +0,0 @@
-<script>
-import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { produce } from 'immer';
-import { __, s__ } from '~/locale';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_GROUP } from '~/graphql_shared/constants';
-import createContactMutation from './queries/create_contact.mutation.graphql';
-import updateContactMutation from './queries/update_contact.mutation.graphql';
-import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
-
-export default {
- components: {
- GlAlert,
- GlButton,
- GlDrawer,
- GlFormGroup,
- GlFormInput,
- },
- inject: ['groupFullPath', 'groupId'],
- props: {
- drawerOpen: {
- type: Boolean,
- required: true,
- },
- contact: {
- type: Object,
- required: false,
- default: () => {},
- },
- },
- data() {
- return {
- firstName: '',
- lastName: '',
- phone: '',
- email: '',
- description: '',
- submitting: false,
- errorMessages: [],
- };
- },
- computed: {
- invalid() {
- const { firstName, lastName, email } = this;
-
- return firstName.trim() === '' || lastName.trim() === '' || email.trim() === '';
- },
- editMode() {
- return Boolean(this.contact);
- },
- title() {
- return this.editMode ? this.$options.i18n.editTitle : this.$options.i18n.newTitle;
- },
- buttonLabel() {
- return this.editMode
- ? this.$options.i18n.editButtonLabel
- : this.$options.i18n.createButtonLabel;
- },
- mutation() {
- return this.editMode ? updateContactMutation : createContactMutation;
- },
- variables() {
- const { contact, firstName, lastName, phone, email, description, editMode, groupId } = this;
-
- const variables = {
- input: {
- firstName,
- lastName,
- phone,
- email,
- description,
- },
- };
-
- if (editMode) {
- variables.input.id = contact.id;
- } else {
- variables.input.groupId = convertToGraphQLId(TYPE_GROUP, groupId);
- }
-
- return variables;
- },
- },
- mounted() {
- if (this.editMode) {
- const { contact } = this;
-
- this.firstName = contact.firstName || '';
- this.lastName = contact.lastName || '';
- this.phone = contact.phone || '';
- this.email = contact.email || '';
- this.description = contact.description || '';
- }
- },
- methods: {
- save() {
- const { mutation, variables, updateCache, close } = this;
-
- this.submitting = true;
-
- return this.$apollo
- .mutate({
- mutation,
- variables,
- update: updateCache,
- })
- .then(({ data }) => {
- if (
- data.customerRelationsContactCreate?.errors.length === 0 ||
- data.customerRelationsContactUpdate?.errors.length === 0
- ) {
- close(true);
- }
-
- this.submitting = false;
- })
- .catch(() => {
- this.errorMessages = [this.$options.i18n.somethingWentWrong];
- this.submitting = false;
- });
- },
- close(success) {
- this.$emit('close', success);
- },
- updateCache(store, { data }) {
- const mutationData =
- data.customerRelationsContactCreate || data.customerRelationsContactUpdate;
-
- if (mutationData?.errors.length > 0) {
- this.errorMessages = mutationData.errors;
- return;
- }
-
- const queryArgs = {
- query: getGroupContactsQuery,
- variables: { groupFullPath: this.groupFullPath },
- };
-
- const sourceData = store.readQuery(queryArgs);
-
- queryArgs.data = produce(sourceData, (draftState) => {
- draftState.group.contacts.nodes = [
- ...sourceData.group.contacts.nodes.filter(({ id }) => id !== this.contact?.id),
- mutationData.contact,
- ];
- });
-
- store.writeQuery(queryArgs);
- },
- getDrawerHeaderHeight() {
- const wrapperEl = document.querySelector('.content-wrapper');
-
- if (wrapperEl) {
- return `${wrapperEl.offsetTop}px`;
- }
-
- return '';
- },
- },
- i18n: {
- createButtonLabel: s__('Crm|Create new contact'),
- editButtonLabel: __('Save changes'),
- cancel: __('Cancel'),
- firstName: s__('Crm|First name'),
- lastName: s__('Crm|Last name'),
- email: s__('Crm|Email'),
- phone: s__('Crm|Phone number (optional)'),
- description: s__('Crm|Description (optional)'),
- newTitle: s__('Crm|New contact'),
- editTitle: s__('Crm|Edit contact'),
- somethingWentWrong: __('Something went wrong. Please try again.'),
- },
-};
-</script>
-
-<template>
- <gl-drawer
- class="gl-drawer-responsive"
- :open="drawerOpen"
- :header-height="getDrawerHeaderHeight()"
- @close="close(false)"
- >
- <template #title>
- <h3>{{ title }}</h3>
- </template>
- <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
- <ul class="gl-mb-0! gl-ml-5">
- <li v-for="error in errorMessages" :key="error">
- {{ error }}
- </li>
- </ul>
- </gl-alert>
- <form @submit.prevent="save">
- <gl-form-group :label="$options.i18n.firstName" label-for="contact-first-name">
- <gl-form-input id="contact-first-name" v-model="firstName" />
- </gl-form-group>
- <gl-form-group :label="$options.i18n.lastName" label-for="contact-last-name">
- <gl-form-input id="contact-last-name" v-model="lastName" />
- </gl-form-group>
- <gl-form-group :label="$options.i18n.email" label-for="contact-email">
- <gl-form-input id="contact-email" v-model="email" />
- </gl-form-group>
- <gl-form-group :label="$options.i18n.phone" label-for="contact-phone">
- <gl-form-input id="contact-phone" v-model="phone" />
- </gl-form-group>
- <gl-form-group :label="$options.i18n.description" label-for="contact-description">
- <gl-form-input id="contact-description" v-model="description" />
- </gl-form-group>
- <span class="gl-float-right">
- <gl-button data-testid="cancel-button" @click="close(false)">
- {{ $options.i18n.cancel }}
- </gl-button>
- <gl-button
- variant="confirm"
- :disabled="invalid"
- :loading="submitting"
- data-testid="save-contact-button"
- type="submit"
- >{{ buttonLabel }}</gl-button
- >
- </span>
- </form>
- </gl-drawer>
-</template>
diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/form.vue
index b24de1e95e8..4f94898ff63 100644
--- a/app/assets/javascripts/crm/components/form.vue
+++ b/app/assets/javascripts/crm/components/form.vue
@@ -61,11 +61,6 @@ export default {
required: false,
default: null,
},
- existingModel: {
- type: Object,
- required: false,
- default: () => ({}),
- },
additionalCreateParams: {
type: Object,
required: false,
@@ -76,25 +71,42 @@ export default {
required: false,
default: () => MSG_SAVE_CHANGES,
},
+ existingId: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
- const initialModel = this.fields.reduce(
- (map, field) =>
- Object.assign(map, {
- [field.name]: this.existingModel ? this.existingModel[field.name] : null,
- }),
- {},
- );
-
return {
- model: initialModel,
+ model: null,
submitting: false,
errorMessages: [],
+ records: [],
+ loading: true,
};
},
+ apollo: {
+ records: {
+ query() {
+ return this.getQuery.query;
+ },
+ variables() {
+ return this.getQuery.variables;
+ },
+ update(data) {
+ this.records = getPropValueByPath(data, this.getQueryNodePath).nodes || [];
+ this.setInitialModel();
+ this.loading = false;
+ },
+ error() {
+ this.errorMessages = [MSG_ERROR];
+ },
+ },
+ },
computed: {
isEditMode() {
- return this.existingModel?.id;
+ return this.existingId;
},
isInvalid() {
const { fields, model } = this;
@@ -115,13 +127,24 @@ export default {
);
if (isEditMode) {
- return { input: { id: this.existingModel.id, ...variables } };
+ return { input: { id: this.existingId, ...variables } };
}
return { input: { ...additionalCreateParams, ...variables } };
},
},
methods: {
+ setInitialModel() {
+ const existingModel = this.records.find(({ id }) => id === this.existingId);
+
+ this.model = this.fields.reduce(
+ (map, field) =>
+ Object.assign(map, {
+ [field.name]: !this.isEditMode || !existingModel ? null : existingModel[field.name],
+ }),
+ {},
+ );
+ },
formatValue(model, field) {
if (!isEmpty(model[field.name]) && field.input?.type === 'number') {
return parseFloat(model[field.name]);
@@ -173,7 +196,7 @@ export default {
const sourceData = store.readQuery(getQuery);
const newData = produce(sourceData, (draftState) => {
- getPropValueByPath(draftState, getQueryNodePath).nodes.push(getFirstPropertyValue(result));
+ getPropValueByPath(draftState, getQueryNodePath).nodes.push(this.getPayload(result));
});
store.writeQuery({
@@ -185,6 +208,14 @@ export default {
const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`;
return field.label + optionalSuffix;
},
+ getPayload(data) {
+ if (!data) return null;
+
+ const keys = Object.keys(data);
+ if (keys[0] === '__typename') return data[keys[1]];
+
+ return data[keys[0]];
+ },
},
MSG_CANCEL,
INDEX_ROUTE_NAME,
@@ -192,7 +223,7 @@ export default {
</script>
<template>
- <mounting-portal mount-to="#js-crm-form-portal" append>
+ <mounting-portal v-if="!loading" mount-to="#js-crm-form-portal" append>
<gl-drawer class="gl-drawer-responsive gl-absolute" :open="drawerOpen" @close="close(false)">
<template #title>
<h3>{{ title }}</h3>
diff --git a/app/assets/javascripts/crm/components/new_organization_form.vue b/app/assets/javascripts/crm/components/new_organization_form.vue
deleted file mode 100644
index 3b11edc6935..00000000000
--- a/app/assets/javascripts/crm/components/new_organization_form.vue
+++ /dev/null
@@ -1,164 +0,0 @@
-<script>
-import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { produce } from 'immer';
-import { __, s__ } from '~/locale';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_GROUP } from '~/graphql_shared/constants';
-import createOrganization from './queries/create_organization.mutation.graphql';
-import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
-
-export default {
- components: {
- GlAlert,
- GlButton,
- GlDrawer,
- GlFormGroup,
- GlFormInput,
- },
- inject: ['groupFullPath', 'groupId'],
- props: {
- drawerOpen: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return {
- name: '',
- defaultRate: null,
- description: '',
- submitting: false,
- errorMessages: [],
- };
- },
- computed: {
- invalid() {
- return this.name.trim() === '';
- },
- },
- methods: {
- save() {
- this.submitting = true;
- return this.$apollo
- .mutate({
- mutation: createOrganization,
- variables: {
- input: {
- groupId: convertToGraphQLId(TYPE_GROUP, this.groupId),
- name: this.name,
- defaultRate: this.defaultRate ? parseFloat(this.defaultRate) : null,
- description: this.description,
- },
- },
- update: this.updateCache,
- })
- .then(({ data }) => {
- if (data.customerRelationsOrganizationCreate.errors.length === 0) this.close(true);
-
- this.submitting = false;
- })
- .catch(() => {
- this.errorMessages = [this.$options.i18n.somethingWentWrong];
- this.submitting = false;
- });
- },
- close(success) {
- this.$emit('close', success);
- },
- updateCache(store, { data: { customerRelationsOrganizationCreate } }) {
- if (customerRelationsOrganizationCreate.errors.length > 0) {
- this.errorMessages = customerRelationsOrganizationCreate.errors;
- return;
- }
-
- const variables = {
- groupFullPath: this.groupFullPath,
- };
- const sourceData = store.readQuery({
- query: getGroupOrganizationsQuery,
- variables,
- });
-
- const data = produce(sourceData, (draftState) => {
- draftState.group.organizations.nodes = [
- ...sourceData.group.organizations.nodes,
- customerRelationsOrganizationCreate.organization,
- ];
- });
-
- store.writeQuery({
- query: getGroupOrganizationsQuery,
- variables,
- data,
- });
- },
- getDrawerHeaderHeight() {
- const wrapperEl = document.querySelector('.content-wrapper');
-
- if (wrapperEl) {
- return `${wrapperEl.offsetTop}px`;
- }
-
- return '';
- },
- },
- i18n: {
- buttonLabel: s__('Crm|Create organization'),
- cancel: __('Cancel'),
- name: __('Name'),
- defaultRate: s__('Crm|Default rate (optional)'),
- description: __('Description (optional)'),
- title: s__('Crm|New Organization'),
- somethingWentWrong: __('Something went wrong. Please try again.'),
- },
-};
-</script>
-
-<template>
- <gl-drawer
- class="gl-drawer-responsive"
- :open="drawerOpen"
- :header-height="getDrawerHeaderHeight()"
- @close="close(false)"
- >
- <template #title>
- <h4>{{ $options.i18n.title }}</h4>
- </template>
- <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
- <ul class="gl-mb-0! gl-ml-5">
- <li v-for="error in errorMessages" :key="error">
- {{ error }}
- </li>
- </ul>
- </gl-alert>
- <form @submit.prevent="save">
- <gl-form-group :label="$options.i18n.name" label-for="organization-name">
- <gl-form-input id="organization-name" v-model="name" />
- </gl-form-group>
- <gl-form-group :label="$options.i18n.defaultRate" label-for="organization-default-rate">
- <gl-form-input
- id="organization-default-rate"
- v-model="defaultRate"
- type="number"
- step="0.01"
- />
- </gl-form-group>
- <gl-form-group :label="$options.i18n.description" label-for="organization-description">
- <gl-form-input id="organization-description" v-model="description" />
- </gl-form-group>
- <span class="gl-float-right">
- <gl-button data-testid="cancel-button" @click="close(false)">
- {{ $options.i18n.cancel }}
- </gl-button>
- <gl-button
- variant="confirm"
- :disabled="invalid"
- :loading="submitting"
- data-testid="create-new-organization-button"
- type="submit"
- >{{ $options.i18n.buttonLabel }}</gl-button
- >
- </span>
- </form>
- </gl-drawer>
-</template>
diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts/bundle.js
index f49ec64210f..f49ec64210f 100644
--- a/app/assets/javascripts/crm/contacts_bundle.js
+++ b/app/assets/javascripts/crm/contacts/bundle.js
diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
new file mode 100644
index 00000000000..58eaabfbb7f
--- /dev/null
+++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
@@ -0,0 +1,78 @@
+<script>
+import { s__, __ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants';
+import ContactForm from '../../components/form.vue';
+import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql';
+import createContactMutation from './graphql/create_contact.mutation.graphql';
+import updateContactMutation from './graphql/update_contact.mutation.graphql';
+
+export default {
+ components: {
+ ContactForm,
+ },
+ inject: ['groupFullPath', 'groupId'],
+ props: {
+ isEditMode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ contactGraphQLId() {
+ if (!this.isEditMode) return null;
+
+ return convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id);
+ },
+ groupGraphQLId() {
+ return convertToGraphQLId(TYPE_GROUP, this.groupId);
+ },
+ mutation() {
+ if (this.isEditMode) return updateContactMutation;
+
+ return createContactMutation;
+ },
+ getQuery() {
+ return {
+ query: getGroupContactsQuery,
+ variables: { groupFullPath: this.groupFullPath },
+ };
+ },
+ title() {
+ if (this.isEditMode) return s__('Crm|Edit contact');
+
+ return s__('Crm|New contact');
+ },
+ successMessage() {
+ if (this.isEditMode) return s__('Crm|Contact has been updated.');
+
+ return s__('Crm|Contact has been added.');
+ },
+ additionalCreateParams() {
+ return { groupId: this.groupGraphQLId };
+ },
+ },
+ fields: [
+ { name: 'firstName', label: __('First name'), required: true },
+ { name: 'lastName', label: __('Last name'), required: true },
+ { name: 'email', label: __('Email'), required: true },
+ { name: 'phone', label: __('Phone') },
+ { name: 'description', label: __('Description') },
+ ],
+};
+</script>
+
+<template>
+ <contact-form
+ :drawer-open="true"
+ :get-query="getQuery"
+ get-query-node-path="group.contacts"
+ :mutation="mutation"
+ :additional-create-params="additionalCreateParams"
+ :existing-id="contactGraphQLId"
+ :fields="$options.fields"
+ :title="title"
+ :success-message="successMessage"
+ />
+</template>
diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue
index 178ce84c64d..17be3800256 100644
--- a/app/assets/javascripts/crm/components/contacts_root.vue
+++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue
@@ -2,11 +2,9 @@
import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
-import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_CRM_CONTACT } from '~/graphql_shared/constants';
-import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants';
-import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
-import ContactForm from './contact_form.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
+import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql';
export default {
components: {
@@ -14,12 +12,11 @@ export default {
GlButton,
GlLoadingIcon,
GlTable,
- ContactForm,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['groupFullPath', 'groupIssuesPath', 'canAdminCrmContact'],
+ inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath'],
data() {
return {
contacts: [],
@@ -48,50 +45,20 @@ export default {
isLoading() {
return this.$apollo.queries.contacts.loading;
},
- showNewForm() {
- return this.$route.name === NEW_ROUTE_NAME;
- },
- showEditForm() {
- return !this.isLoading && this.$route.name === EDIT_ROUTE_NAME;
- },
canAdmin() {
return parseBoolean(this.canAdminCrmContact);
},
- editingContact() {
- return this.contacts.find(
- (contact) => contact.id === convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id),
- );
- },
},
methods: {
extractContacts(data) {
const contacts = data?.group?.contacts?.nodes || [];
return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName));
},
- displayNewForm() {
- if (this.showNewForm) return;
-
- this.$router.push({ name: NEW_ROUTE_NAME });
- },
- hideNewForm(success) {
- if (success) this.$toast.show(s__('Crm|Contact has been added'));
-
- this.$router.replace({ name: INDEX_ROUTE_NAME });
- },
- hideEditForm(success) {
- if (success) this.$toast.show(s__('Crm|Contact has been updated'));
-
- this.editingContactId = 0;
- this.$router.replace({ name: INDEX_ROUTE_NAME });
- },
getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_contact_id=${value}`;
},
- edit(value) {
- if (this.showEditForm) return;
-
- this.editingContactId = value;
- this.$router.push({ name: EDIT_ROUTE_NAME, params: { id: value } });
+ getEditRoute(id) {
+ return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
},
},
fields: [
@@ -119,10 +86,12 @@ export default {
emptyText: s__('Crm|No contacts found'),
issuesButtonLabel: __('View issues'),
editButtonLabel: __('Edit'),
- title: s__('Crm|Customer Relations Contacts'),
+ title: s__('Crm|Customer relations contacts'),
newContact: s__('Crm|New contact'),
errorText: __('Something went wrong. Please try again.'),
},
+ EDIT_ROUTE_NAME,
+ NEW_ROUTE_NAME,
};
</script>
@@ -137,24 +106,15 @@ export default {
<h2 class="gl-font-size-h2 gl-my-0">
{{ $options.i18n.title }}
</h2>
- <div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end">
- <gl-button
- v-if="canAdmin"
- variant="confirm"
- data-testid="new-contact-button"
- @click="displayNewForm"
- >
- {{ $options.i18n.newContact }}
- </gl-button>
+ <div v-if="canAdmin">
+ <router-link :to="{ name: $options.NEW_ROUTE_NAME }">
+ <gl-button variant="confirm" data-testid="new-contact-button">
+ {{ $options.i18n.newContact }}
+ </gl-button>
+ </router-link>
</div>
</div>
- <contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" />
- <contact-form
- v-if="showEditForm"
- :contact="editingContact"
- :drawer-open="showEditForm"
- @close="hideEditForm"
- />
+ <router-view />
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table
v-else
@@ -164,23 +124,24 @@ export default {
:empty-text="$options.i18n.emptyText"
show-empty
>
- <template #cell(id)="data">
+ <template #cell(id)="{ value: id }">
<gl-button
v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
class="gl-mr-3"
data-testid="issues-link"
icon="issues"
:aria-label="$options.i18n.issuesButtonLabel"
- :href="getIssuesPath(groupIssuesPath, data.value)"
- />
- <gl-button
- v-if="canAdmin"
- v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
- data-testid="edit-contact-button"
- icon="pencil"
- :aria-label="$options.i18n.editButtonLabel"
- @click="edit(data.value)"
+ :href="getIssuesPath(groupIssuesPath, id)"
/>
+ <router-link :to="getEditRoute(id)">
+ <gl-button
+ v-if="canAdmin"
+ v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
+ data-testid="edit-contact-button"
+ icon="pencil"
+ :aria-label="$options.i18n.editButtonLabel"
+ />
+ </router-link>
</template>
</gl-table>
</div>
diff --git a/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql b/app/assets/javascripts/crm/contacts/components/graphql/create_contact.mutation.graphql
index e0192459609..e0192459609 100644
--- a/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql
+++ b/app/assets/javascripts/crm/contacts/components/graphql/create_contact.mutation.graphql
diff --git a/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql
index cef4083b446..cef4083b446 100644
--- a/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql
+++ b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql
diff --git a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql
index 2a8150e42e3..2a8150e42e3 100644
--- a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql
+++ b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql
diff --git a/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql b/app/assets/javascripts/crm/contacts/components/graphql/update_contact.mutation.graphql
index f55f6a10e0a..f55f6a10e0a 100644
--- a/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql
+++ b/app/assets/javascripts/crm/contacts/components/graphql/update_contact.mutation.graphql
diff --git a/app/assets/javascripts/crm/routes.js b/app/assets/javascripts/crm/contacts/routes.js
index 12aa17d73b6..18768e1c775 100644
--- a/app/assets/javascripts/crm/routes.js
+++ b/app/assets/javascripts/crm/contacts/routes.js
@@ -1,4 +1,5 @@
-import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants';
+import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants';
+import ContactFormWrapper from './components/contact_form_wrapper.vue';
export default [
{
@@ -8,9 +9,12 @@ export default [
{
name: NEW_ROUTE_NAME,
path: '/new',
+ component: ContactFormWrapper,
},
{
name: EDIT_ROUTE_NAME,
path: '/:id/edit',
+ component: ContactFormWrapper,
+ props: { isEditMode: true },
},
];
diff --git a/app/assets/javascripts/crm/organizations_bundle.js b/app/assets/javascripts/crm/organizations/bundle.js
index 828d7cd426c..828d7cd426c 100644
--- a/app/assets/javascripts/crm/organizations_bundle.js
+++ b/app/assets/javascripts/crm/organizations/bundle.js
diff --git a/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql b/app/assets/javascripts/crm/organizations/components/graphql/create_organization.mutation.graphql
index 2cc7e53ee9b..2cc7e53ee9b 100644
--- a/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql
+++ b/app/assets/javascripts/crm/organizations/components/graphql/create_organization.mutation.graphql
diff --git a/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql
index 4adc5742d3a..4adc5742d3a 100644
--- a/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql
+++ b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql
diff --git a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
index e8d8109431e..e8d8109431e 100644
--- a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
+++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
diff --git a/app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql b/app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql
new file mode 100644
index 00000000000..a4c46d1f0fa
--- /dev/null
+++ b/app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./crm_organization_fields.fragment.graphql"
+
+mutation updateOrganization($input: CustomerRelationsOrganizationUpdateInput!) {
+ customerRelationsOrganizationUpdate(input: $input) {
+ organization {
+ ...OrganizationFragment
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
new file mode 100644
index 00000000000..38468e1f4e4
--- /dev/null
+++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
@@ -0,0 +1,80 @@
+<script>
+import { s__, __ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_CRM_ORGANIZATION, TYPE_GROUP } from '~/graphql_shared/constants';
+import OrganizationForm from '../../components/form.vue';
+import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql';
+import createOrganizationMutation from './graphql/create_organization.mutation.graphql';
+import updateOrganizationMutation from './graphql/update_organization.mutation.graphql';
+
+export default {
+ components: {
+ OrganizationForm,
+ },
+ inject: ['groupFullPath', 'groupId'],
+ props: {
+ isEditMode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ organizationGraphQLId() {
+ if (!this.isEditMode) return null;
+
+ return convertToGraphQLId(TYPE_CRM_ORGANIZATION, this.$route.params.id);
+ },
+ groupGraphQLId() {
+ return convertToGraphQLId(TYPE_GROUP, this.groupId);
+ },
+ mutation() {
+ if (this.isEditMode) return updateOrganizationMutation;
+
+ return createOrganizationMutation;
+ },
+ getQuery() {
+ return {
+ query: getGroupOrganizationsQuery,
+ variables: { groupFullPath: this.groupFullPath },
+ };
+ },
+ title() {
+ if (this.isEditMode) return s__('Crm|Edit organization');
+
+ return s__('Crm|New organization');
+ },
+ successMessage() {
+ if (this.isEditMode) return s__('Crm|Organization has been updated.');
+
+ return s__('Crm|Organization has been added.');
+ },
+ additionalCreateParams() {
+ return { groupId: this.groupGraphQLId };
+ },
+ },
+ fields: [
+ { name: 'name', label: __('Name'), required: true },
+ {
+ name: 'defaultRate',
+ label: s__('Crm|Default rate'),
+ input: { type: 'number', step: '0.01' },
+ },
+ { name: 'description', label: __('Description') },
+ ],
+};
+</script>
+
+<template>
+ <organization-form
+ :drawer-open="true"
+ :get-query="getQuery"
+ get-query-node-path="group.organizations"
+ :mutation="mutation"
+ :additional-create-params="additionalCreateParams"
+ :existing-id="organizationGraphQLId"
+ :fields="$options.fields"
+ :title="title"
+ :success-message="successMessage"
+ />
+</template>
diff --git a/app/assets/javascripts/crm/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue
index 9370c6377e9..522e29eb2af 100644
--- a/app/assets/javascripts/crm/components/organizations_root.vue
+++ b/app/assets/javascripts/crm/organizations/components/organizations_root.vue
@@ -3,9 +3,8 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@
import { parseBoolean } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME } from '../constants';
-import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
-import NewOrganizationForm from './new_organization_form.vue';
+import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
+import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql';
export default {
components: {
@@ -13,7 +12,6 @@ export default {
GlButton,
GlLoadingIcon,
GlTable,
- NewOrganizationForm,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -21,8 +19,8 @@ export default {
inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'],
data() {
return {
- error: false,
organizations: [],
+ error: false,
};
},
apollo: {
@@ -47,10 +45,7 @@ export default {
isLoading() {
return this.$apollo.queries.organizations.loading;
},
- showNewForm() {
- return this.$route.name === NEW_ROUTE_NAME;
- },
- canCreateNew() {
+ canAdmin() {
return parseBoolean(this.canAdminCrmOrganization);
},
},
@@ -62,15 +57,8 @@ export default {
getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_organization_id=${value}`;
},
- displayNewForm() {
- if (this.showNewForm) return;
-
- this.$router.push({ name: NEW_ROUTE_NAME });
- },
- hideNewForm(success) {
- if (success) this.$toast.show(this.$options.i18n.organizationAdded);
-
- this.$router.replace({ name: INDEX_ROUTE_NAME });
+ getEditRoute(id) {
+ return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
},
},
fields: [
@@ -79,7 +67,7 @@ export default {
{ key: 'description', sortable: true },
{
key: 'id',
- label: __('Issues'),
+ label: '',
formatter: (id) => {
return getIdFromGraphQLId(id);
},
@@ -88,11 +76,13 @@ export default {
i18n: {
emptyText: s__('Crm|No organizations found'),
issuesButtonLabel: __('View issues'),
- title: s__('Crm|Customer Relations Organizations'),
+ editButtonLabel: __('Edit'),
+ title: s__('Crm|Customer relations organizations'),
newOrganization: s__('Crm|New organization'),
errorText: __('Something went wrong. Please try again.'),
- organizationAdded: s__('Crm|Organization has been added'),
},
+ EDIT_ROUTE_NAME,
+ NEW_ROUTE_NAME,
};
</script>
@@ -108,15 +98,17 @@ export default {
{{ $options.i18n.title }}
</h2>
<div
- v-if="canCreateNew"
+ v-if="canAdmin"
class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"
>
- <gl-button variant="confirm" data-testid="new-organization-button" @click="displayNewForm">
- {{ $options.i18n.newOrganization }}
- </gl-button>
+ <router-link :to="{ name: $options.NEW_ROUTE_NAME }">
+ <gl-button variant="confirm" data-testid="new-organization-button">
+ {{ $options.i18n.newOrganization }}
+ </gl-button>
+ </router-link>
</div>
</div>
- <new-organization-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" />
+ <router-view />
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table
v-else
@@ -126,14 +118,24 @@ export default {
:empty-text="$options.i18n.emptyText"
show-empty
>
- <template #cell(id)="data">
+ <template #cell(id)="{ value: id }">
<gl-button
v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
+ class="gl-mr-3"
data-testid="issues-link"
icon="issues"
:aria-label="$options.i18n.issuesButtonLabel"
- :href="getIssuesPath(groupIssuesPath, data.value)"
+ :href="getIssuesPath(groupIssuesPath, id)"
/>
+ <router-link :to="getEditRoute(id)">
+ <gl-button
+ v-if="canAdmin"
+ v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
+ data-testid="edit-organization-button"
+ icon="pencil"
+ :aria-label="$options.i18n.editButtonLabel"
+ />
+ </router-link>
</template>
</gl-table>
</div>
diff --git a/app/assets/javascripts/crm/organizations/routes.js b/app/assets/javascripts/crm/organizations/routes.js
new file mode 100644
index 00000000000..85bd3b32877
--- /dev/null
+++ b/app/assets/javascripts/crm/organizations/routes.js
@@ -0,0 +1,20 @@
+import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants';
+import OrganizationFormWrapper from './components/organization_form_wrapper.vue';
+
+export default [
+ {
+ name: INDEX_ROUTE_NAME,
+ path: '/',
+ },
+ {
+ name: NEW_ROUTE_NAME,
+ path: '/new',
+ component: OrganizationFormWrapper,
+ },
+ {
+ name: EDIT_ROUTE_NAME,
+ path: '/:id/edit',
+ component: OrganizationFormWrapper,
+ props: { isEditMode: true },
+ },
+];
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 0707ae02872..73d872cf962 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-restricted-properties, babel/camelcase,
+/* eslint-disable no-restricted-properties, camelcase,
no-unused-expressions, default-case,
consistent-return, no-param-reassign,
no-shadow, no-useless-escape,
@@ -143,7 +143,7 @@ export default class Notes {
// resolve a discussion
this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general)
- this.$wrapperEl.on('click', '.js-note-delete', this.removeNote);
+ this.$wrapperEl.on('ajax:success', '.js-note-delete', this.removeNote);
// delete note attachment
this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment);
// update the file name when an attachment is selected
@@ -188,7 +188,7 @@ export default class Notes {
cleanBinding() {
this.$wrapperEl.off('click', '.js-note-edit');
this.$wrapperEl.off('click', '.note-edit-cancel');
- this.$wrapperEl.off('click', '.js-note-delete');
+ this.$wrapperEl.off('ajax:success', '.js-note-delete');
this.$wrapperEl.off('click', '.js-note-attachment-delete');
this.$wrapperEl.off('click', '.js-discussion-reply-button');
this.$wrapperEl.off('click', '.js-add-diff-note-button');
@@ -827,50 +827,53 @@ export default class Notes {
*/
removeNote(e) {
const $note = $(e.currentTarget).closest('.note');
- const noteElId = $note.attr('id');
- $(`.note[id="${noteElId}"]`).each((i, el) => {
- // A same note appears in the "Discussion" and in the "Changes" tab, we have
- // to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
- // where $('#noteId') would return only one.
- const $note = $(el);
- const $notes = $note.closest('.discussion-notes');
- const discussionId = $('.notes', $notes).data('discussionId');
-
- $note.remove();
-
- // check if this is the last note for this line
- if ($notes.find('.note').length === 0) {
- const notesTr = $notes.closest('tr');
-
- // "Discussions" tab
- $notes.closest('.timeline-entry').remove();
-
- $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
-
- // The notes tr can contain multiple lists of notes, like on the parallel diff
- // notesTr does not exist for image diffs
- if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) {
- const $diffFile = $notes.closest('.diff-file');
- if ($diffFile.length > 0) {
- const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', {
- detail: {
- // badgeNumber's start with 1 and index starts with 0
- badgeNumber: $notes.index() + 1,
- },
- });
- $diffFile[0].dispatchEvent(removeBadgeEvent);
- }
+ $note.one('ajax:complete', () => {
+ const noteElId = $note.attr('id');
+ $(`.note[id="${noteElId}"]`).each((i, el) => {
+ // A same note appears in the "Discussion" and in the "Changes" tab, we have
+ // to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
+ // where $('#noteId') would return only one.
+ const $note = $(el);
+ const $notes = $note.closest('.discussion-notes');
+ const discussionId = $('.notes', $notes).data('discussionId');
+
+ $note.remove();
+
+ // check if this is the last note for this line
+ if ($notes.find('.note').length === 0) {
+ const notesTr = $notes.closest('tr');
+
+ // "Discussions" tab
+ $notes.closest('.timeline-entry').remove();
+
+ $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
+
+ // The notes tr can contain multiple lists of notes, like on the parallel diff
+ // notesTr does not exist for image diffs
+ if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) {
+ const $diffFile = $notes.closest('.diff-file');
+ if ($diffFile.length > 0) {
+ const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', {
+ detail: {
+ // badgeNumber's start with 1 and index starts with 0
+ badgeNumber: $notes.index() + 1,
+ },
+ });
+
+ $diffFile[0].dispatchEvent(removeBadgeEvent);
+ }
- $notes.remove();
- } else if (notesTr.length > 0) {
- notesTr.remove();
+ $notes.remove();
+ } else if (notesTr.length > 0) {
+ notesTr.remove();
+ }
}
- }
- });
+ });
- Notes.checkMergeRequestStatus();
- return this.updateNotesCount(-1);
+ Notes.checkMergeRequestStatus();
+ return this.updateNotesCount(-1);
+ });
}
/**
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 7fefbab977d..618096c5bea 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import createFlash from '~/flash';
import { s__ } from '~/locale';
@@ -26,15 +26,14 @@ export default {
components: {
ApolloMutation,
DesignNote,
+ DesignNotePin,
DesignNoteSignedOut,
- ReplyPlaceholder,
DesignReplyForm,
- GlIcon,
- GlLoadingIcon,
+ GlButton,
GlLink,
- ToggleRepliesWidget,
+ ReplyPlaceholder,
TimeAgoTooltip,
- DesignNotePin,
+ ToggleRepliesWidget,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -239,18 +238,17 @@ export default {
@error="$emit('update-note-error', $event)"
>
<template v-if="isLoggedIn && discussion.resolvable" #resolve-discussion>
- <button
+ <gl-button
v-gl-tooltip
- :class="{ 'is-active': discussion.resolved }"
- :title="resolveCheckboxText"
:aria-label="resolveCheckboxText"
- class="line-resolve-btn note-action-button gl-mr-3"
+ :icon="resolveIconName"
+ :title="resolveCheckboxText"
+ :loading="isResolving"
+ category="tertiary"
data-testid="resolve-button"
+ size="small"
@click.stop="toggleResolvedStatus"
- >
- <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" />
- <gl-loading-icon v-else size="sm" inline />
- </button>
+ />
</template>
<template v-if="discussion.resolved" #resolved-status>
<p class="gl-text-gray-500 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message">
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index 1e1f5135290..5fb5989e11a 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -1,11 +1,17 @@
<script>
-import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+import {
+ GlAvatar,
+ GlAvatarLink,
+ GlButton,
+ GlLink,
+ GlSafeHtmlDirective,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
import { hasErrors } from '../../utils/cache_update';
import { findNoteId, extractDesignNoteId } from '../../utils/design_management_utils';
@@ -16,13 +22,14 @@ export default {
editCommentLabel: __('Edit comment'),
},
components: {
- UserAvatarLink,
- TimelineEntryItem,
- TimeAgoTooltip,
- DesignReplyForm,
ApolloMutation,
- GlIcon,
+ DesignReplyForm,
+ GlAvatar,
+ GlAvatarLink,
+ GlButton,
GlLink,
+ TimeAgoTooltip,
+ TimelineEntryItem,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -86,18 +93,17 @@ export default {
<template>
<timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form">
- <user-avatar-link
- :link-href="author.webUrl"
- :img-src="author.avatarUrl"
- :img-alt="author.username"
- :img-size="40"
- />
+ <gl-avatar-link :href="author.webUrl" class="gl-float-left gl-mr-3">
+ <gl-avatar :size="32" :src="author.avatarUrl" :entity-name="author.username" />
+ </gl-avatar-link>
+
<div class="gl-display-flex gl-justify-content-space-between">
<div>
<gl-link
v-once
:href="author.webUrl"
class="js-user-link"
+ data-testid="user-link"
:data-user-id="authorId"
:data-username="author.username"
>
@@ -117,24 +123,25 @@ export default {
</div>
<div class="gl-display-flex gl-align-items-baseline">
<slot name="resolve-discussion"></slot>
- <button
+ <gl-button
v-if="isEditButtonVisible"
v-gl-tooltip
- type="button"
- :title="$options.i18n.editCommentLabel"
:aria-label="$options.i18n.editCommentLabel"
- class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
+ :title="$options.i18n.editCommentLabel"
+ category="tertiary"
+ data-testid="note-edit"
+ icon="pencil"
+ size="small"
@click="isEditing = true"
- >
- <gl-icon name="pencil" class="link-highlight" />
- </button>
+ />
</div>
</div>
<template v-if="!isEditing">
<div
v-safe-html="note.bodyHtml"
- class="note-text js-note-text md"
+ class="note-text md"
data-qa-selector="note_content"
+ data-testid="note-text"
></div>
<slot name="resolved-status"></slot>
</template>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 5707e4d67f9..c86f2c8451c 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -21,7 +21,7 @@ import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_wi
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 notesEventHub from '~/notes/event_hub';
import {
TREE_LIST_WIDTH_STORAGE_KEY,
INITIAL_TREE_WIDTH,
@@ -347,36 +347,34 @@ export default {
this.setHighlightedRow(id.split('diff-content').pop().slice(1));
}
- if (window.gon?.features?.diffSettingsUsageData) {
- const events = [];
+ const events = [];
- if (this.renderTreeList) {
- events.push(TRACKING_FILE_BROWSER_TREE);
- } else {
- events.push(TRACKING_FILE_BROWSER_LIST);
- }
-
- if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) {
- events.push(TRACKING_DIFF_VIEW_INLINE);
- } else {
- events.push(TRACKING_DIFF_VIEW_PARALLEL);
- }
+ if (this.renderTreeList) {
+ events.push(TRACKING_FILE_BROWSER_TREE);
+ } else {
+ events.push(TRACKING_FILE_BROWSER_LIST);
+ }
- if (this.showWhitespace) {
- events.push(TRACKING_WHITESPACE_SHOW);
- } else {
- events.push(TRACKING_WHITESPACE_HIDE);
- }
+ if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) {
+ events.push(TRACKING_DIFF_VIEW_INLINE);
+ } else {
+ events.push(TRACKING_DIFF_VIEW_PARALLEL);
+ }
- if (this.viewDiffsFileByFile) {
- events.push(TRACKING_SINGLE_FILE_MODE);
- } else {
- events.push(TRACKING_MULTIPLE_FILES_MODE);
- }
+ if (this.showWhitespace) {
+ events.push(TRACKING_WHITESPACE_SHOW);
+ } else {
+ events.push(TRACKING_WHITESPACE_HIDE);
+ }
- queueRedisHllEvents(events);
+ if (this.viewDiffsFileByFile) {
+ events.push(TRACKING_SINGLE_FILE_MODE);
+ } else {
+ events.push(TRACKING_MULTIPLE_FILES_MODE);
}
+ queueRedisHllEvents(events);
+
this.subscribeToVirtualScrollingEvents();
},
beforeCreate() {
@@ -534,10 +532,8 @@ export default {
if (delta >= 0 && delta < 1000) {
this.disableVirtualScroller();
- if (window.gon?.features?.usageDataDiffSearches) {
- api.trackRedisHllUserEvent('i_code_review_user_searches_diff');
- api.trackRedisCounterEvent('diff_searches');
- }
+ api.trackRedisHllUserEvent('i_code_review_user_searches_diff');
+ api.trackRedisCounterEvent('diff_searches');
}
}
});
@@ -574,12 +570,8 @@ export default {
this.scrollVirtualScrollerToIndex(index);
}
},
- async scrollVirtualScrollerToIndex(index) {
+ scrollVirtualScrollerToIndex(index) {
this.virtualScrollCurrentIndex = index;
-
- await this.$nextTick();
-
- this.virtualScrollCurrentIndex = -1;
},
scrollVirtualScrollerToDiffNote() {
const id = window?.location?.hash;
@@ -705,7 +697,7 @@ export default {
</dynamic-scroller-item>
</template>
</pre-renderer>
- <virtual-scroller-scroll-sync :index="virtualScrollCurrentIndex" />
+ <virtual-scroller-scroll-sync v-model="virtualScrollCurrentIndex" />
</template>
</dynamic-scroller>
<template v-else>
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index ba10f6deb29..42f4ea8eb58 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -103,7 +103,7 @@ export default {
>
<div
v-if="commit.signature_html"
- v-safe-html:[$options.safeHtmlConfig]="commit.signature_html"
+ v-html="commit.signature_html /* eslint-disable-line vue/no-v-html */"
></div>
<commit-pipeline-status
v-if="commit.pipeline_status_path"
@@ -137,7 +137,7 @@ export default {
:link-href="authorUrl"
:img-src="authorAvatar"
:img-alt="authorName"
- :img-size="40"
+ :img-size="32"
class="avatar-cell d-none d-sm-block"
/>
</div>
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 2b871680d5e..4dfd672f99b 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
-import { setUrlParams } from '../../lib/utils/url_utility';
+import { setUrlParams } from '~/lib/utils/url_utility';
import { EVT_EXPAND_ALL_FILES } from '../constants';
import eventHub from '../event_hub';
import CompareDropdownLayout from './compare_dropdown_layout.vue';
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 7ed5713ebfa..b4bffdcb07f 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -9,9 +9,9 @@ import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue';
import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue';
-import NoteForm from '../../notes/components/note_form.vue';
-import eventHub from '../../notes/event_hub';
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import NoteForm from '~/notes/components/note_form.vue';
+import eventHub from '~/notes/event_hub';
+import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
import { getDiffMode } from '../store/utils';
import DiffDiscussions from './diff_discussions.vue';
@@ -170,7 +170,6 @@ export default {
<note-form
v-if="diffFileCommentForm"
ref="noteForm"
- :is-editing="false"
:save-button-title="__('Comment')"
class="diff-comment-form new-note discussion-form discussion-form-container"
@handleFormUpdateAddToReview="addToReview"
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 47a05ce11cc..b39b50c4cdc 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -2,7 +2,7 @@
import { GlIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
-import noteableDiscussion from '../../notes/components/noteable_discussion.vue';
+import noteableDiscussion from '~/notes/components/noteable_discussion.vue';
export default {
components: {
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 4e77bf81c1e..d8f27a967df 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -17,7 +17,7 @@ import { diffViewerErrors } from '~/ide/constants';
import { scrollToElement } from '~/lib/utils/common_utils';
import { sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import notesEventHub from '../../notes/event_hub';
+import notesEventHub from '~/notes/event_hub';
import {
DIFF_FILE_AUTOMATIC_COLLAPSE,
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 495c87a695c..8cdbd2b7dbc 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -340,6 +340,7 @@ export default {
:title="__('Copy file path')"
:text="diffFile.file_path"
:gfm="gfmCopyText"
+ size="small"
data-testid="diff-file-copy-clipboard"
category="tertiary"
data-track-action="click_copy_file_button"
@@ -392,6 +393,7 @@ export default {
/>
<gl-dropdown
v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle"
+ size="small"
right
toggle-class="btn-icon js-diff-more-actions"
class="gl-pt-0!"
@@ -400,7 +402,7 @@ export default {
@hidden="setMoreActionsShown(false)"
>
<template #button-content>
- <gl-icon name="ellipsis_v" class="mr-0" />
+ <gl-icon name="ellipsis_v" class="mr-0" :size="12" />
<span class="sr-only">{{ $options.i18n.optionsDropdownTitle }}</span>
</template>
<gl-dropdown-item
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index e2f3f9cad7b..a077c8ae3af 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -74,6 +74,7 @@ export default {
v-for="note in notesInGutter"
:key="note.id"
:img-src="note.author.avatar_url"
+ :size="24"
:tooltip-text="getTooltipText(note)"
lazy
class="diff-comment-avatar js-diff-comment-avatar"
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 9d355c96af1..7a30740e31b 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -4,13 +4,10 @@ import { s__ } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue';
-import {
- commentLineOptions,
- formatLineRange,
-} from '../../notes/components/multiline_comment_utils';
-import noteForm from '../../notes/components/note_form.vue';
-import autosave from '../../notes/mixins/autosave';
+import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
+import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils';
+import noteForm from '~/notes/components/note_form.vue';
+import autosave from '~/notes/mixins/autosave';
import {
DIFF_NOTE_TYPE,
INLINE_DIFF_LINES_KEY,
@@ -221,7 +218,6 @@ export default {
</div>
<note-form
ref="noteForm"
- :is-editing="false"
:line-code="line.line_code"
:line="line"
:lines="commentLines"
diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue
index ab518fcfb16..42af2ab7880 100644
--- a/app/assets/javascripts/diffs/components/no_changes.vue
+++ b/app/assets/javascripts/diffs/components/no_changes.vue
@@ -61,7 +61,7 @@ export default {
</gl-sprintf>
</div>
<div class="text-center">
- <gl-button :href="getNoteableData.new_blob_path" variant="success" category="primary">{{
+ <gl-button :href="getNoteableData.new_blob_path" variant="confirm" category="primary">{{
__('Create commit')
}}</gl-button>
</div>
diff --git a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js
index 984c6f8c0c9..d44dffecc38 100644
--- a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js
+++ b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js
@@ -2,6 +2,9 @@ import { handleLocationHash } from '~/lib/utils/common_utils';
export default {
inject: ['vscrollParent'],
+ model: {
+ prop: 'index',
+ },
props: {
index: {
type: Number,
@@ -39,6 +42,7 @@ export default {
methods: {
scrollToIndex(index) {
this.vscrollParent.scrollToItem(index);
+ this.$emit('update', -1);
setTimeout(() => {
handleLocationHash();
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index e967be23f42..d5cd4af4d06 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -13,7 +13,7 @@ import httpStatusCodes from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
-import notesEventHub from '../../notes/event_hub';
+import notesEventHub from '~/notes/event_hub';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
@@ -376,9 +376,7 @@ export const setInlineDiffViewType = ({ commit }) => {
const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
- if (window.gon?.features?.diffSettingsUsageData) {
- queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_INLINE]);
- }
+ queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_INLINE]);
};
export const setParallelDiffViewType = ({ commit }) => {
@@ -388,9 +386,7 @@ export const setParallelDiffViewType = ({ commit }) => {
const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
- if (window.gon?.features?.diffSettingsUsageData) {
- queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_PARALLEL]);
- }
+ queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_PARALLEL]);
};
export const showCommentForm = ({ commit }, { lineCode, fileHash }) => {
@@ -576,7 +572,7 @@ export const setRenderTreeList = ({ commit }, { renderTreeList, trackClick = tru
localStorage.setItem(TREE_LIST_STORAGE_KEY, renderTreeList);
- if (window.gon?.features?.diffSettingsUsageData && trackClick) {
+ if (trackClick) {
const events = [TRACKING_CLICK_FILE_BROWSER_SETTING];
if (renderTreeList) {
@@ -600,7 +596,7 @@ export const setShowWhitespace = async (
commit(types.SET_SHOW_WHITESPACE, showWhitespace);
notesEventHub.$emit('refetchDiffData');
- if (window.gon?.features?.diffSettingsUsageData && trackClick) {
+ if (trackClick) {
const events = [TRACKING_CLICK_WHITESPACE_SETTING];
if (showWhitespace) {
@@ -827,18 +823,16 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
export const setFileByFile = ({ state, commit }, { fileByFile }) => {
commit(types.SET_FILE_BY_FILE, fileByFile);
- if (window.gon?.features?.diffSettingsUsageData) {
- const events = [TRACKING_CLICK_SINGLE_FILE_SETTING];
-
- if (fileByFile) {
- events.push(TRACKING_SINGLE_FILE_MODE);
- } else {
- events.push(TRACKING_MULTIPLE_FILES_MODE);
- }
+ const events = [TRACKING_CLICK_SINGLE_FILE_SETTING];
- queueRedisHllEvents(events);
+ if (fileByFile) {
+ events.push(TRACKING_SINGLE_FILE_MODE);
+ } else {
+ events.push(TRACKING_MULTIPLE_FILES_MODE);
}
+ queueRedisHllEvents(events);
+
return axios
.put(state.endpointUpdateUser, {
view_diffs_file_by_file: fileByFile,
diff --git a/app/assets/javascripts/diffs/utils/performance.js b/app/assets/javascripts/diffs/utils/performance.js
index 50bf17001a6..ad768c333e2 100644
--- a/app/assets/javascripts/diffs/utils/performance.js
+++ b/app/assets/javascripts/diffs/utils/performance.js
@@ -7,7 +7,7 @@ import {
MR_DIFFS_MARK_DIFF_FILES_END,
MR_DIFFS_MEASURE_FILE_TREE_DONE,
MR_DIFFS_MEASURE_DIFF_FILES_DONE,
-} from '../../performance/constants';
+} from '~/performance/constants';
import {
EVT_PERF_MARK_FILE_TREE_START,
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
new file mode 100644
index 00000000000..1427f2df461
--- /dev/null
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
@@ -0,0 +1,70 @@
+<script>
+import { isEmpty } from 'lodash';
+import { GlButtonGroup } from '@gitlab/ui';
+import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
+import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants';
+import SourceEditorToolbarButton from './source_editor_toolbar_button.vue';
+
+export default {
+ name: 'SourceEditorToolbar',
+ components: {
+ SourceEditorToolbarButton,
+ GlButtonGroup,
+ },
+ data() {
+ return {
+ items: [],
+ };
+ },
+ apollo: {
+ items: {
+ query: getToolbarItemsQuery,
+ update(data) {
+ return this.setDefaultGroup(data?.items?.nodes);
+ },
+ },
+ },
+ computed: {
+ isVisible() {
+ return this.items.length;
+ },
+ },
+ methods: {
+ setDefaultGroup(nodes = []) {
+ return nodes.map((item) => {
+ return {
+ ...item,
+ group:
+ (this.$options.groups.includes(item.group) && item.group) || EDITOR_TOOLBAR_RIGHT_GROUP,
+ };
+ });
+ },
+ getGroupItems(group) {
+ return this.items.filter((item) => item.group === group);
+ },
+ hasGroupItems(group) {
+ return !isEmpty(this.getGroupItems(group));
+ },
+ },
+ groups: [EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP],
+};
+</script>
+<template>
+ <section
+ v-if="isVisible"
+ id="se-toolbar"
+ class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-justify-content-space-between gl-align-items-center"
+ >
+ <template v-for="group in $options.groups">
+ <gl-button-group v-if="hasGroupItems(group)" :key="group">
+ <template v-for="item in getGroupItems(group)">
+ <source-editor-toolbar-button
+ :key="item.id"
+ :button="item"
+ @click="$emit('click', item)"
+ />
+ </template>
+ </gl-button-group>
+ </template>
+ </section>
+</template>
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
new file mode 100644
index 00000000000..2595d67af34
--- /dev/null
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
@@ -0,0 +1,89 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql';
+import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql';
+
+export default {
+ name: 'SourceEditorToolbarButton',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ button: {
+ type: Object,
+ required: false,
+ default() {
+ return {};
+ },
+ },
+ },
+ data() {
+ return {
+ buttonItem: this.button,
+ };
+ },
+ apollo: {
+ buttonItem: {
+ query: getToolbarItemQuery,
+ variables() {
+ return {
+ id: this.button.id,
+ };
+ },
+ update({ item }) {
+ return item;
+ },
+ skip() {
+ return !this.button.id;
+ },
+ },
+ },
+ computed: {
+ icon() {
+ return this.buttonItem.selected
+ ? this.buttonItem.selectedIcon || this.buttonItem.icon
+ : this.buttonItem.icon;
+ },
+ label() {
+ return this.buttonItem.selected
+ ? this.buttonItem.selectedLabel || this.buttonItem.label
+ : this.buttonItem.label;
+ },
+ },
+ methods: {
+ clickHandler() {
+ if (this.buttonItem.onClick) {
+ this.buttonItem.onClick();
+ }
+ this.$apollo.mutate({
+ mutation: updateToolbarItemMutation,
+ variables: {
+ id: this.buttonItem.id,
+ propsToUpdate: {
+ selected: !this.buttonItem.selected,
+ },
+ },
+ });
+ this.$emit('click');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-button
+ v-gl-tooltip.hover
+ :category="buttonItem.category"
+ :variant="buttonItem.variant"
+ type="button"
+ :selected="buttonItem.selected"
+ :icon="icon"
+ :title="label"
+ :aria-label="label"
+ @click="clickHandler"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index 2ae9c377683..361122d8890 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -12,6 +12,9 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
+export const EDITOR_TOOLBAR_LEFT_GROUP = 'left';
+export const EDITOR_TOOLBAR_RIGHT_GROUP = 'right';
+
export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__(
'SourceEditor|"el" parameter is required for createInstance()',
);
diff --git a/app/assets/javascripts/editor/graphql/get_item.query.graphql b/app/assets/javascripts/editor/graphql/get_item.query.graphql
new file mode 100644
index 00000000000..7c8bc09f7b0
--- /dev/null
+++ b/app/assets/javascripts/editor/graphql/get_item.query.graphql
@@ -0,0 +1,9 @@
+query ToolbarItem($id: String!) {
+ item(id: $id) @client {
+ id
+ label
+ icon
+ selected
+ group
+ }
+}
diff --git a/app/assets/javascripts/editor/graphql/get_items.query.graphql b/app/assets/javascripts/editor/graphql/get_items.query.graphql
new file mode 100644
index 00000000000..bfac816d276
--- /dev/null
+++ b/app/assets/javascripts/editor/graphql/get_items.query.graphql
@@ -0,0 +1,5 @@
+query ToolbarItems {
+ items @client {
+ nodes
+ }
+}
diff --git a/app/assets/javascripts/editor/graphql/update_item.mutation.graphql b/app/assets/javascripts/editor/graphql/update_item.mutation.graphql
new file mode 100644
index 00000000000..f8424c65181
--- /dev/null
+++ b/app/assets/javascripts/editor/graphql/update_item.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateItem($id: String!, $propsToUpdate: Item!) {
+ updateToolbarItem(id: $id, propsToUpdate: $propsToUpdate) @client
+}
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 1c56327c03c..fe3229ac91b 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -187,6 +187,21 @@
}
]
},
+ "coverage_report": {
+ "type": "object",
+ "description": "Used to collect coverage reports from the job.",
+ "properties": {
+ "coverage_format": {
+ "description": "Code coverage format used by the test framework.",
+ "enum": ["cobertura"]
+ },
+ "path": {
+ "description": "Path to the coverage report file that should be parsed.",
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
"codequality": {
"$ref": "#/definitions/string_file_list",
"description": "Path to file or list of files with code quality report(s) (such as Code Climate)."
@@ -1276,7 +1291,7 @@
},
"pipeline_variables": {
"type": "boolean",
- "description": "Variables added for manual pipeline runs are passed to downstream pipelines.",
+ "description": "Variables added for manual pipeline runs and scheduled pipelines are passed to downstream pipelines.",
"default": false
}
}
@@ -1392,7 +1407,7 @@
},
"pipeline_variables": {
"type": "boolean",
- "description": "Variables added for manual pipeline runs are passed to downstream pipelines.",
+ "description": "Variables added for manual pipeline runs and scheduled pipelines are passed to downstream pipelines.",
"default": false
}
}
diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js
index 0986533dcd1..931407f4cf7 100644
--- a/app/assets/javascripts/emoji/awards_app/index.js
+++ b/app/assets/javascripts/emoji/awards_app/index.js
@@ -5,6 +5,8 @@ import AwardsList from '~/vue_shared/components/awards_list.vue';
import createstore from './store';
export default (el) => {
+ if (!el) return null;
+
const {
dataset: { path },
} = el;
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index aaae1624bee..4fdcdcc1b04 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -245,5 +245,12 @@ export function glEmojiTag(inputName, options) {
? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" `
: '';
- return `<gl-emoji ${fallbackSpriteAttribute}data-name="${escape(name)}"></gl-emoji>`;
+ const fallbackUrl = opts.url;
+ const fallbackSrcAttribute = fallbackUrl
+ ? `data-fallback-src="${fallbackUrl}" data-unicode-version="custom"`
+ : '';
+
+ return `<gl-emoji ${fallbackSrcAttribute}${fallbackSpriteAttribute}data-name="${escape(
+ name,
+ )}"></gl-emoji>`;
}
diff --git a/app/assets/javascripts/emoji/support/unicode_support_map.js b/app/assets/javascripts/emoji/support/unicode_support_map.js
index d90a774c293..9642993bd7d 100644
--- a/app/assets/javascripts/emoji/support/unicode_support_map.js
+++ b/app/assets/javascripts/emoji/support/unicode_support_map.js
@@ -1,4 +1,4 @@
-import AccessorUtilities from '../../lib/utils/accessor';
+import AccessorUtilities from '~/lib/utils/accessor';
const GL_EMOJI_VERSION = '0.2.0';
diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue
index 36b9b647af7..563fa6c96fb 100644
--- a/app/assets/javascripts/environments/components/empty_state.vue
+++ b/app/assets/javascripts/environments/components/empty_state.vue
@@ -1,4 +1,7 @@
<script>
+import { s__ } from '~/locale';
+import { ENVIRONMENTS_SCOPE } from '../constants';
+
export default {
name: 'EnvironmentsEmptyState',
props: {
@@ -6,6 +9,25 @@ export default {
type: String,
required: true,
},
+ scope: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ title() {
+ return this.$options.i18n.title[this.scope];
+ },
+ },
+ i18n: {
+ title: {
+ [ENVIRONMENTS_SCOPE.AVAILABLE]: s__("Environments|You don't have any environments."),
+ [ENVIRONMENTS_SCOPE.STOPPED]: s__("Environments|You don't have any stopped environments."),
+ },
+ content: s__(
+ 'Environments|Environments are places where code gets deployed, such as staging or production.',
+ ),
+ link: s__('Environments|How do I create an environment?'),
},
};
</script>
@@ -13,14 +35,11 @@ export default {
<div class="empty-state">
<div class="text-content">
<h4 class="js-blank-state-title">
- {{ s__("Environments|You don't have any environments right now") }}
+ {{ title }}
</h4>
<p>
- {{
- s__(`Environments|Environments are places where
- code gets deployed, such as staging or production.`)
- }}
- <a :href="helpPath"> {{ s__('Environments|More information') }} </a>
+ {{ $options.i18n.content }}
+ <a :href="helpPath"> {{ $options.i18n.link }} </a>
</p>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index cfe35d26b94..7ffe8140a21 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,12 +1,20 @@
<script>
-import { GlDropdown, GlTooltipDirective, GlIcon, GlLink, GlSprintf, GlBadge } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlTooltipDirective,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ GlBadge,
+ GlAvatar,
+ GlAvatarLink,
+} from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __, s__, sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
@@ -41,7 +49,8 @@ export default {
StopComponent,
TerminalButtonComponent,
TooltipOnTruncate,
- UserAvatarLink,
+ GlAvatar,
+ GlAvatarLink,
CiIcon,
},
directives: {
@@ -649,22 +658,27 @@ export default {
class="table-section deployment-column d-none d-md-block"
:class="tableData.deploy.spacing"
role="gridcell"
- data-testid="enviornment-deployment-id-cell"
+ data-testid="environment-deployment-id-cell"
>
<span v-if="shouldRenderDeploymentID" class="text-break-word">
{{ deploymentInternalId }}
</span>
- <span v-if="!isFolder && deploymentHasUser" class="text-break-word">
+ <span
+ v-if="!isFolder && deploymentHasUser"
+ class="text-break-word gl-display-inline-flex gl-align-items-center"
+ >
<gl-sprintf :message="s__('Environments|by %{avatar}')">
<template #avatar>
- <user-avatar-link
- :link-href="deploymentUser.web_url"
- :img-src="deploymentUser.avatar_url"
- :img-alt="userImageAltDescription"
- :tooltip-text="deploymentUser.username"
- class="js-deploy-user-container float-none"
- />
+ <gl-avatar-link :href="deploymentUser.web_url" class="gl-ml-2">
+ <gl-avatar
+ :src="deploymentUser.avatar_url"
+ :entity-name="deploymentUser.username"
+ :title="deploymentUser.username"
+ :alt="userImageAltDescription"
+ :size="24"
+ />
+ </gl-avatar-link>
</template>
</gl-sprintf>
</span>
@@ -753,20 +767,24 @@ export default {
<ci-icon class="gl-mr-2" :status="upcomingDeployment.deployable.status" />
</gl-link>
</div>
- <div class="gl-display-flex">
- <span v-if="upcomingDeployment.user" class="text-break-word">
- <gl-sprintf :message="s__('Environments|by %{avatar}')">
- <template #avatar>
- <user-avatar-link
- :link-href="upcomingDeployment.user.web_url"
- :img-src="upcomingDeployment.user.avatar_url"
- :img-alt="upcomingDeploymentUserImageAltDescription"
- :tooltip-text="upcomingDeployment.user.username"
+ <span
+ v-if="upcomingDeployment.user"
+ class="text-break-word gl-display-inline-flex gl-align-items-center gl-mt-2"
+ >
+ <gl-sprintf :message="s__('Environments|by %{avatar}')">
+ <template #avatar>
+ <gl-avatar-link :href="upcomingDeployment.user.web_url" class="gl-ml-2">
+ <gl-avatar
+ :src="upcomingDeployment.user.avatar_url"
+ :alt="upcomingDeploymentUserImageAltDescription"
+ :entity-name="upcomingDeployment.user.username"
+ :title="upcomingDeployment.user.username"
+ :size="24"
/>
- </template>
- </gl-sprintf>
- </span>
- </div>
+ </gl-avatar-link>
+ </template>
+ </gl-sprintf>
+ </span>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index c7008c03099..f44182e822b 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -253,7 +253,7 @@ export default {
@change="resetPolling"
/>
</template>
- <empty-state v-else :help-path="helpPagePath" />
+ <empty-state v-else :help-path="helpPagePath" :scope="scope" />
<gl-pagination
align="center"
:total-items="totalItems"
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index f35fabccae7..f5e9d612316 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -2,6 +2,7 @@
import {
GlCollapse,
GlDropdown,
+ GlBadge,
GlButton,
GlLink,
GlSprintf,
@@ -26,6 +27,7 @@ export default {
components: {
GlCollapse,
GlDropdown,
+ GlBadge,
GlButton,
GlLink,
GlSprintf,
@@ -74,6 +76,7 @@ export default {
'Environments|There are no deployments for this environment yet. %{linkStart}Learn more about setting up deployments.%{linkEnd}',
),
autoStopIn: s__('Environment|Auto stop %{time}'),
+ tierTooltip: s__('Environment|Deployment tier'),
},
data() {
return { visible: false };
@@ -100,6 +103,9 @@ export default {
hasDeployment() {
return Boolean(this.environment?.upcomingDeployment || this.environment?.lastDeployment);
},
+ tier() {
+ return this.lastDeployment?.tierInYaml;
+ },
hasOpenedAlert() {
return this.environment?.hasOpenedAlert;
},
@@ -206,6 +212,13 @@ export default {
>
{{ displayName }}
</gl-link>
+ <gl-badge
+ v-if="tier"
+ v-gl-tooltip
+ :title="$options.i18n.tierTooltip"
+ class="gl-ml-3 gl-font-monospace"
+ >{{ tier }}</gl-badge
+ >
</div>
<div class="gl-display-flex gl-align-items-center">
<p v-if="canShowAutoStopDate" class="gl-font-sm gl-text-gray-700 gl-mr-5 gl-mb-0">
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 206381e0b7e..4e5fe511f8a 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import Translate from '../../vue_shared/translate';
+import Translate from '~/vue_shared/translate';
import environmentsFolderApp from './environments_folder_view.vue';
Vue.use(Translate);
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index a7866c1e778..722bb78bcf9 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -24,7 +24,7 @@ const mapNestedEnvironment = (env) => ({
__typename: 'NestedLocalEnvironment',
});
const mapEnvironment = (env) => ({
- ...convertObjectPropsToCamelCase(env),
+ ...convertObjectPropsToCamelCase(env, { deep: true }),
__typename: 'LocalEnvironment',
});
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 0f9741784d6..8957a3074ed 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -4,11 +4,11 @@
import { isEqual, isFunction, omitBy } from 'lodash';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
-import Poll from '../../lib/utils/poll';
-import { getParameterByName } from '../../lib/utils/url_utility';
-import { s__, __ } from '../../locale';
-import tabs from '../../vue_shared/components/navigation_tabs.vue';
-import tablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
+import Poll from '~/lib/utils/poll';
+import { getParameterByName } from '~/lib/utils/url_utility';
+import { s__, __ } from '~/locale';
+import tabs from '~/vue_shared/components/navigation_tabs.vue';
+import tablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import container from '../components/container.vue';
import environmentTable from '../components/environments_table.vue';
import eventHub from '../event_hub';
diff --git a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
index a76c8e445ed..55e2536e283 100644
--- a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
@@ -4,7 +4,7 @@
* Components need to have `scope`, `page` and `requestData`
*/
import { validateParams } from '~/pipelines/utils';
-import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils';
+import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default {
methods: {
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
index 747f368b671..b26a96499ba 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -1,6 +1,6 @@
import createFlash from '~/flash';
-import axios from '../lib/utils/axios_utils';
-import { __ } from '../locale';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
export const getSelector = (highlightId) => `.js-feature-highlight[data-highlight=${highlightId}]`;
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
index c3514198ad9..c147dd20c84 100644
--- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
@@ -1,4 +1,4 @@
-import AccessorUtilities from '../../lib/utils/accessor';
+import AccessorUtilities from '~/lib/utils/accessor';
import RecentSearchesServiceError from './recent_searches_service_error';
class RecentSearchesService {
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index fa605f8c056..24ec16bf20e 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -94,10 +94,10 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => {
*
* 1. Render a new alert
*
- * import { createAlert, ALERT_VARIANTS } from '~/flash';
+ * import { createAlert, VARIANT_WARNING } from '~/flash';
*
* createAlert({ message: 'My error message' });
- * createAlert({ message: 'My warning message', variant: ALERT_VARIANTS.WARNING });
+ * createAlert({ message: 'My warning message', variant: VARIANT_WARNING });
*
* 2. Dismiss this alert programmatically
*
diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue
index 03b256297f6..b3d773e6bee 100644
--- a/app/assets/javascripts/google_cloud/components/app.vue
+++ b/app/assets/javascripts/google_cloud/components/app.vue
@@ -45,7 +45,7 @@ export default {
},
methods: {
feedbackUrl(template) {
- return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new?issuable_template=${template}`;
+ return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`;
},
},
};
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index f42152006d2..a44a5b30e1e 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -232,35 +232,40 @@ export const trackTransaction = (transactionDetails) => {
pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData);
};
-export const trackAddToCartUsageTab = () => {
+export const pushEECproductAddToCartEvent = () => {
if (!isSupported()) {
return;
}
- const getStartedButton = document.querySelector('.js-buy-additional-minutes');
- getStartedButton.addEventListener('click', () => {
- window.dataLayer.push({
- event: 'EECproductAddToCart',
- ecommerce: {
- currencyCode: 'USD',
- add: {
- products: [
- {
- name: 'CI/CD Minutes',
- id: '0003',
- price: '10',
- brand: 'GitLab',
- category: 'DevOps',
- variant: 'add-on',
- quantity: 1,
- },
- ],
- },
+ window.dataLayer.push({
+ event: 'EECproductAddToCart',
+ ecommerce: {
+ currencyCode: 'USD',
+ add: {
+ products: [
+ {
+ name: 'CI/CD Minutes',
+ id: '0003',
+ price: '10',
+ brand: 'GitLab',
+ category: 'DevOps',
+ variant: 'add-on',
+ quantity: 1,
+ },
+ ],
},
- });
+ },
});
};
+export const trackAddToCartUsageTab = () => {
+ const getStartedButton = document.querySelector('.js-buy-additional-minutes');
+ if (!getStartedButton) {
+ return;
+ }
+ getStartedButton.addEventListener('click', pushEECproductAddToCartEvent);
+};
+
export const trackCombinedGroupProjectForm = () => {
if (!isSupported()) {
return;
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 4ebb49b4756..22fa2912881 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -3,6 +3,7 @@ export const MINIMUM_SEARCH_LENGTH = 3;
export const TYPE_BOARD = 'Board';
export const TYPE_CI_RUNNER = 'Ci::Runner';
export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact';
+export const TYPE_CRM_ORGANIZATION = 'CustomerRelations::Organization';
export const TYPE_DISCUSSION = 'Discussion';
export const TYPE_EPIC = 'Epic';
export const TYPE_EPIC_BOARD = 'Boards::EpicBoard';
@@ -19,3 +20,4 @@ export const TYPE_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPE_SITE_PROFILE = 'DastSiteProfile';
export const TYPE_USER = 'User';
export const TYPE_VULNERABILITY = 'Vulnerability';
+export const TYPE_WORK_ITEM = 'WorkItem';
diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/page_info.fragment.graphql
index e6f5d7db11a..e6f5d7db11a 100644
--- a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/page_info.fragment.graphql
diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/page_info_cursors_only.fragment.graphql
index 22bcefbecd3..22bcefbecd3 100644
--- a/app/assets/javascripts/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/page_info_cursors_only.fragment.graphql
diff --git a/app/assets/javascripts/graphql_shared/possibleTypes.json b/app/assets/javascripts/graphql_shared/possibleTypes.json
deleted file mode 100644
index 01116067887..00000000000
--- a/app/assets/javascripts/graphql_shared/possibleTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest","WorkItem"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"Todoable":["AlertManagementAlert","BoardEpic","Commit","Design","Epic","EpicIssue","Issue","MergeRequest"],"User":["MergeRequestAssignee","MergeRequestAuthor","MergeRequestParticipant","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]} \ No newline at end of file
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
new file mode 100644
index 00000000000..3d6360fc4f8
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -0,0 +1,129 @@
+{
+ "AlertManagementIntegration": [
+ "AlertManagementHttpIntegration",
+ "AlertManagementPrometheusIntegration"
+ ],
+ "CurrentUserTodos": [
+ "BoardEpic",
+ "Design",
+ "Epic",
+ "EpicIssue",
+ "Issue",
+ "MergeRequest"
+ ],
+ "DependencyLinkMetadata": [
+ "NugetDependencyLinkMetadata"
+ ],
+ "DesignFields": [
+ "Design",
+ "DesignAtVersion"
+ ],
+ "Entry": [
+ "Blob",
+ "Submodule",
+ "TreeEntry"
+ ],
+ "Eventable": [
+ "BoardEpic",
+ "Epic"
+ ],
+ "Issuable": [
+ "Epic",
+ "Issue",
+ "MergeRequest",
+ "WorkItem"
+ ],
+ "JobNeedUnion": [
+ "CiBuildNeed",
+ "CiJob"
+ ],
+ "MemberInterface": [
+ "GroupMember",
+ "ProjectMember"
+ ],
+ "NoteableInterface": [
+ "AlertManagementAlert",
+ "BoardEpic",
+ "Design",
+ "Epic",
+ "EpicIssue",
+ "Issue",
+ "MergeRequest",
+ "Snippet",
+ "Vulnerability"
+ ],
+ "NoteableType": [
+ "Design",
+ "Issue",
+ "MergeRequest"
+ ],
+ "OrchestrationPolicy": [
+ "ScanExecutionPolicy",
+ "ScanResultPolicy"
+ ],
+ "PackageFileMetadata": [
+ "ConanFileMetadata",
+ "HelmFileMetadata"
+ ],
+ "PackageMetadata": [
+ "ComposerMetadata",
+ "ConanMetadata",
+ "MavenMetadata",
+ "NugetMetadata",
+ "PypiMetadata"
+ ],
+ "ResolvableInterface": [
+ "Discussion",
+ "Note"
+ ],
+ "Service": [
+ "BaseService",
+ "JiraService"
+ ],
+ "TimeboxReportInterface": [
+ "Iteration",
+ "Milestone"
+ ],
+ "Todoable": [
+ "AlertManagementAlert",
+ "BoardEpic",
+ "Commit",
+ "Design",
+ "Epic",
+ "EpicIssue",
+ "Issue",
+ "MergeRequest"
+ ],
+ "User": [
+ "MergeRequestAssignee",
+ "MergeRequestAuthor",
+ "MergeRequestParticipant",
+ "MergeRequestReviewer",
+ "UserCore"
+ ],
+ "VulnerabilityDetail": [
+ "VulnerabilityDetailBase",
+ "VulnerabilityDetailBoolean",
+ "VulnerabilityDetailCode",
+ "VulnerabilityDetailCommit",
+ "VulnerabilityDetailDiff",
+ "VulnerabilityDetailFileLocation",
+ "VulnerabilityDetailInt",
+ "VulnerabilityDetailList",
+ "VulnerabilityDetailMarkdown",
+ "VulnerabilityDetailModuleLocation",
+ "VulnerabilityDetailTable",
+ "VulnerabilityDetailText",
+ "VulnerabilityDetailUrl"
+ ],
+ "VulnerabilityLocation": [
+ "VulnerabilityLocationClusterImageScanning",
+ "VulnerabilityLocationContainerScanning",
+ "VulnerabilityLocationCoverageFuzzing",
+ "VulnerabilityLocationDast",
+ "VulnerabilityLocationDependencyScanning",
+ "VulnerabilityLocationGeneric",
+ "VulnerabilityLocationSast",
+ "VulnerabilityLocationSecretDetection"
+ ]
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql
index 58b7b4c898d..b59bd781537 100644
--- a/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getProjects(
$search: String!
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 5f169832ee4..042d818338a 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import { n__ } from '../../locale';
+import { n__ } from '~/locale';
import { MAX_CHILDREN_COUNT } from '../constants';
export default {
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 707008ec493..4f21f68fa65 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants';
import eventHub from '../event_hub';
@@ -112,6 +113,7 @@ export default {
},
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
@@ -131,7 +133,7 @@ export default {
>
<div class="folder-toggle-wrap gl-mr-2 d-flex align-items-center">
<item-caret :is-group-open="group.isOpen" />
- <item-type-icon :item-type="group.type" :is-group-open="group.isOpen" />
+ <item-type-icon :item-type="group.type" />
</div>
<gl-loading-icon
v-if="group.isChildrenLoading"
@@ -145,7 +147,7 @@ export default {
:aria-label="group.name"
>
<gl-avatar
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
:entity-name="group.name"
:src="group.avatarUrl"
:alt="group.name"
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 18a6d487703..313c8dadd1f 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -1,6 +1,6 @@
<script>
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
-import { getParameterByName } from '../../lib/utils/url_utility';
+import { getParameterByName } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
export default {
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 3620c884c5f..2aa812250a0 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -55,7 +55,7 @@ export default {
:title="__('Subgroups')"
:value="item.subgroupCount"
css-class="number-subgroups gl-ml-5"
- icon-name="folder-o"
+ icon-name="subgroup"
data-testid="subgroups-count"
/>
<item-stats-value
@@ -63,7 +63,7 @@ export default {
:title="__('Projects')"
:value="item.projectCount"
css-class="number-projects gl-ml-5"
- icon-name="bookmark"
+ icon-name="project"
data-testid="projects-count"
/>
<item-stats-value
diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue
index c3787c2df21..7821e604700 100644
--- a/app/assets/javascripts/groups/components/item_type_icon.vue
+++ b/app/assets/javascripts/groups/components/item_type_icon.vue
@@ -11,18 +11,13 @@ export default {
type: String,
required: true,
},
- isGroupOpen: {
- type: Boolean,
- required: true,
- default: false,
- },
},
computed: {
iconClass() {
if (this.itemType === ITEM_TYPE.GROUP) {
- return this.isGroupOpen ? 'folder-open' : 'folder-o';
+ return 'subgroup';
}
- return 'bookmark';
+ return 'project';
},
},
};
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 005bac1e7b5..cacba2dfd23 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,4 +1,4 @@
-import { __, s__ } from '../locale';
+import { __, s__ } from '~/locale';
export const MAX_CHILDREN_COUNT = 20;
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index c2ef6414716..360a8d3bf8d 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -95,15 +95,10 @@ function trackShowUserDropdownLink(trackEvent, elToTrack, el) {
export function initNavUserDropdownTracking() {
const el = document.querySelector('.js-nav-user-dropdown');
const buyEl = document.querySelector('.js-buy-pipeline-minutes-link');
- const upgradeEl = document.querySelector('.js-upgrade-plan-link');
if (el && buyEl) {
trackShowUserDropdownLink('show_buy_ci_minutes', buyEl, el);
}
-
- if (el && upgradeEl) {
- trackShowUserDropdownLink('show_upgrade_link', upgradeEl, el);
- }
}
requestIdleCallback(initStatusTriggers);
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 36fc48a2ba8..4406cacdf3f 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -11,6 +11,7 @@ import {
SEARCH_BOX_INDEX,
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@@ -50,7 +51,7 @@ export default {
},
computed: {
...mapState(['search', 'loading']),
- ...mapGetters(['searchQuery', 'searchOptions']),
+ ...mapGetters(['searchQuery', 'searchOptions', 'autocompleteGroupedSearchOptions']),
searchText: {
get() {
return this.search;
@@ -66,14 +67,20 @@ export default {
return this.currentFocusedOption?.html_id;
},
isLoggedIn() {
- return gon?.current_username;
+ return Boolean(gon?.current_username);
},
showSearchDropdown() {
- return this.showDropdown && this.isLoggedIn;
+ const hasResultsUnderMinCharacters =
+ this.searchText?.length === 1 ? this?.autocompleteGroupedSearchOptions?.length > 0 : true;
+
+ return this.showDropdown && this.isLoggedIn && hasResultsUnderMinCharacters;
},
showDefaultItems() {
return !this.searchText;
},
+ showShortcuts() {
+ return this.searchText && this.searchText?.length >= SEARCH_SHORTCUTS_MIN_CHARACTERS;
+ },
defaultIndex() {
if (this.showDefaultItems) {
return SEARCH_BOX_INDEX;
@@ -105,6 +112,9 @@ export default {
count: this.searchOptions.length,
});
},
+ headerSearchActivityDescriptor() {
+ return this.showDropdown ? 'is-active' : 'is-not-active';
+ },
},
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
@@ -136,13 +146,15 @@ export default {
v-outside="closeDropdown"
role="search"
:aria-label="$options.i18n.searchGitlab"
- class="header-search gl-relative"
+ class="header-search gl-relative gl-rounded-base"
+ :class="headerSearchActivityDescriptor"
>
<gl-search-box-by-type
id="search"
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
+ data-qa-selector="search_term_field"
autocomplete="off"
:placeholder="$options.i18n.searchGitlab"
:aria-activedescendant="currentFocusedId"
@@ -182,7 +194,10 @@ export default {
:current-focused-option="currentFocusedOption"
/>
<template v-else>
- <header-search-scoped-items :current-focused-option="currentFocusedOption" />
+ <header-search-scoped-items
+ v-if="showShortcuts"
+ :current-focused-option="currentFocusedOption"
+ />
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
</template>
</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
index c0e2c18bece..025c48f355d 100644
--- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -11,7 +11,18 @@ import {
import { mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import highlight from '~/lib/utils/highlight';
-import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+
+import {
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+ LARGE_AVATAR_PX,
+ SMALL_AVATAR_PX,
+} from '../constants';
export default {
name: 'HeaderSearchAutocompleteItems',
@@ -39,7 +50,7 @@ export default {
},
},
computed: {
- ...mapState(['search', 'loading', 'autocompleteError']),
+ ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']),
...mapGetters(['autocompleteGroupedSearchOptions']),
},
watch: {
@@ -52,6 +63,13 @@ export default {
},
},
methods: {
+ truncateNamespace(string) {
+ if (string.split(' / ').length > 2) {
+ return truncateNamespace(string);
+ }
+
+ return string;
+ },
highlightedName(val) {
return highlight(val, this.search);
},
@@ -65,15 +83,45 @@ export default {
isOptionFocused(data) {
return this.currentFocusedOption?.html_id === data.html_id;
},
+ isProjectsCategory(data) {
+ return data.category === PROJECTS_CATEGORY;
+ },
+ getEntityId(data) {
+ switch (data.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return data.group_id || data.id || this.searchContext?.group?.id;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return data.project_id || data.id || this.searchContext?.project?.id;
+ default:
+ return data.id;
+ }
+ },
+ getEntitytName(data) {
+ switch (data.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return data.group_name || data.value || data.label || this.searchContext?.group?.name;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return data.project_name || data.value || data.label || this.searchContext?.project?.name;
+ default:
+ return data.label;
+ }
+ },
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
<div>
<template v-if="!loading">
- <div v-for="option in autocompleteGroupedSearchOptions" :key="option.category">
- <gl-dropdown-divider />
+ <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category">
+ <gl-dropdown-divider v-if="index > 0" />
<gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="data in option.data"
@@ -90,12 +138,22 @@ export default {
<gl-avatar
v-if="data.avatar_url !== undefined"
:src="data.avatar_url"
- :entity-id="data.id"
- :entity-name="data.label"
+ :entity-id="getEntityId(data)"
+ :entity-name="getEntitytName(data)"
:size="avatarSize(data)"
- shape="square"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
- <span v-safe-html="highlightedName(data.label)"></span>
+ <span class="gl-display-flex gl-flex-direction-column">
+ <span
+ v-safe-html="highlightedName(data.value || data.label)"
+ class="gl-text-gray-900"
+ ></span>
+ <span
+ v-if="data.value"
+ v-safe-html="truncateNamespace(data.label)"
+ class="gl-font-sm gl-text-gray-500"
+ ></span>
+ </span>
</div>
</gl-dropdown-item>
</div>
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
index 3aebee71509..34d1bd71399 100644
--- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
@@ -7,6 +7,7 @@ export default {
name: 'HeaderSearchScopedItems',
components: {
GlDropdownItem,
+ GlDropdownDivider,
},
props: {
currentFocusedOption: {
@@ -17,7 +18,7 @@ export default {
},
computed: {
...mapState(['search']),
- ...mapGetters(['scopedSearchOptions']),
+ ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']),
},
methods: {
isOptionFocused(option) {
@@ -53,5 +54,6 @@ export default {
<span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span>
</span>
</gl-dropdown-item>
+ <gl-dropdown-divider v-if="autocompleteGroupedSearchOptions.length > 0" />
</div>
</template>
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index b2e45fcd648..045a552efb0 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -20,6 +20,12 @@ export const GROUPS_CATEGORY = 'Groups';
export const PROJECTS_CATEGORY = 'Projects';
+export const ISSUES_CATEGORY = 'Recent issues';
+
+export const MERGE_REQUEST_CATEGORY = 'Recent merge requests';
+
+export const RECENT_EPICS_CATEGORY = 'Recent epics';
+
export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
@@ -28,6 +34,8 @@ export const FIRST_DROPDOWN_INDEX = 0;
export const SEARCH_BOX_INDEX = -1;
+export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
+
export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js
index ee4c312fed0..3a86dcca409 100644
--- a/app/assets/javascripts/header_search/store/actions.js
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -5,7 +5,9 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => {
commit(types.REQUEST_AUTOCOMPLETE);
return axios
.get(getters.autocompleteQuery)
- .then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data))
+ .then(({ data }) => {
+ commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data);
+ })
.catch(() => {
commit(types.RECEIVE_AUTOCOMPLETE_ERROR);
});
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index 87dec95153f..7d08aa859fb 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -190,7 +190,6 @@ export const autocompleteGroupedSearchOptions = (state) => {
results.push(groupedOptions[option.category]);
}
});
-
return results;
};
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 44f543d9a76..38f3b094b7c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -160,7 +160,7 @@ export default {
data-testid="begin-commit-button"
@click="beginCommit"
>
- {{ __('Commit…') }}
+ {{ __('Create commit...') }}
</gl-button>
</div>
<p class="text-center bold">{{ overviewText }}</p>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 8f0e5aef456..2799ea1378e 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlPopover } from '@gitlab/ui';
-import { __, sprintf } from '../../../locale';
+import { __, sprintf } from '~/locale';
import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants';
export default {
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index e345e5dc099..45bbf93ebc9 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -166,7 +166,7 @@ export default {
}}
</p>
<gl-button
- variant="success"
+ variant="confirm"
category="primary"
:title="__('New file')"
:aria-label="__('New file')"
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 28ca1b6750f..32f87cb0a92 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -3,8 +3,8 @@
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
-import CiIcon from '../../vue_shared/components/ci_icon.vue';
-import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { rightSidebarViews } from '../constants';
import IdeStatusList from './ide_status_list.vue';
import IdeStatusMr from './ide_status_mr.vue';
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index 55ae5501cdb..8d6a0b99e0c 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlButton, GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import { throttle } from 'lodash';
import { mapActions, mapState } from 'vuex';
-import { __ } from '../../../locale';
+import { __ } from '~/locale';
import JobDescription from './detail/description.vue';
import ScrollButton from './detail/scroll_button.vue';
diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue
index 9eaeabad5ef..8fd1973267c 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/description.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import CiIcon from '../../../../vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
export default {
components: {
diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
index 6e1929a1948..bcbc68421c2 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '../../../../locale';
+import { __ } from '~/locale';
const directions = {
up: 'up',
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 796ca1349c5..7797850f097 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui';
-import CiIcon from '../../../vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Item from './item.vue';
export default {
diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
index 3699073adb8..6c26cde42e3 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
@@ -30,12 +30,12 @@ export default {
</script>
<template>
- <dropdown-button>
+ <dropdown-button class="gl-w-full!">
<span class="row gl-flex-nowrap">
<span class="col-auto flex-fill text-truncate">
<gl-icon :size="16" :aria-label="__('Current Branch')" name="branch" /> {{ branchLabel }}
</span>
- <span v-if="showMergeRequests" class="col-5 pl-0 text-truncate">
+ <span v-if="showMergeRequests" class="col-auto pl-0 text-truncate">
<gl-icon :size="16" :aria-label="__('Merge request')" name="merge-request" />
{{ mergeRequestLabel }}
</span>
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 0ec808339fb..37a405e3fac 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -2,7 +2,7 @@ import { escape } from 'lodash';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import { logError } from '~/lib/logger';
-import api from '../../../api';
+import api from '~/api';
import service from '../../services';
import * as types from '../mutation_types';
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index f46d3cbe946..20d8dc3381d 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -5,7 +5,7 @@ import {
WEBIDE_MARK_FETCH_FILES_START,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
-import { __ } from '../../../locale';
+import { __ } from '~/locale';
import { decorateFiles } from '../../lib/files';
import service from '../../services';
import * as types from '../mutation_types';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index 05e3601f381..0e7254e67be 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -1,4 +1,4 @@
-import { __ } from '../../../../locale';
+import { __ } from '~/locale';
import { COMMIT_TO_NEW_BRANCH } from './constants';
const BRANCH_SUFFIX_COUNT = 5;
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
index 8446b93d14a..3408245b245 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js
@@ -1,5 +1,5 @@
-import Api from '../../../../api';
-import { __ } from '../../../../locale';
+import Api from '~/api';
+import { __ } from '~/locale';
import { scopes } from './constants';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
index 51872993f16..62476b7fc63 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
@@ -1,8 +1,8 @@
import axios from 'axios';
import Visibility from 'visibilityjs';
-import httpStatus from '../../../../lib/utils/http_status';
-import Poll from '../../../../lib/utils/poll';
-import { __ } from '../../../../locale';
+import httpStatus from '~/lib/utils/http_status';
+import Poll from '~/lib/utils/poll';
+import { __ } from '~/locale';
import { rightSidebarViews } from '../../../constants';
import service from '../../../services';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/ide/stores/plugins/terminal.js b/app/assets/javascripts/ide/stores/plugins/terminal.js
index 94139d5bdf0..f7ed3075b0c 100644
--- a/app/assets/javascripts/ide/stores/plugins/terminal.js
+++ b/app/assets/javascripts/ide/stores/plugins/terminal.js
@@ -3,10 +3,10 @@ import terminalModule from '../modules/terminal';
function getPathsFromData(el) {
return {
- webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath,
- webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath,
- webTerminalConfigHelpPath: el.dataset.eeWebTerminalConfigHelpPath,
- webTerminalRunnersHelpPath: el.dataset.eeWebTerminalRunnersHelpPath,
+ webTerminalSvgPath: el.dataset.webTerminalSvgPath,
+ webTerminalHelpPath: el.dataset.webTerminalHelpPath,
+ webTerminalConfigHelpPath: el.dataset.webTerminalConfigHelpPath,
+ webTerminalRunnersHelpPath: el.dataset.webTerminalRunnersHelpPath,
};
}
diff --git a/app/assets/javascripts/image_diff/helpers/init_image_diff.js b/app/assets/javascripts/image_diff/helpers/init_image_diff.js
index 51168b94e6d..55e1d802201 100644
--- a/app/assets/javascripts/image_diff/helpers/init_image_diff.js
+++ b/app/assets/javascripts/image_diff/helpers/init_image_diff.js
@@ -1,4 +1,4 @@
-import ImageFile from '../../commit/image_file';
+import ImageFile from '~/commit/image_file';
import ImageDiff from '../image_diff';
import ReplacedImageDiff from '../replaced_image_diff';
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index cc6a057f587..9262a4e1e95 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -1,10 +1,66 @@
<script>
-import { GlIcon } from '@gitlab/ui';
-import STATUS_MAP from '../constants';
+import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { __, s__ } from '~/locale';
+import { STATUSES } from '../constants';
+
+const STATISTIC_ITEMS = {
+ diff_note: __('Diff notes'),
+ issue: __('Issues'),
+ label: __('Labels'),
+ milestone: __('Milestones'),
+ note: __('Notes'),
+ pull_request: s__('GithubImporter|Pull requests'),
+ pull_request_merged_by: s__('GithubImporter|PR mergers'),
+ pull_request_review: s__('GithubImporter|PR reviews'),
+ release: __('Releases'),
+};
+
+// support both camel case and snake case versions
+Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
+
+const SCHEDULED_STATUS = {
+ icon: 'status-scheduled',
+ text: __('Pending'),
+ variant: 'muted',
+};
+
+const STATUS_MAP = {
+ [STATUSES.NONE]: {
+ icon: 'status-waiting',
+ text: __('Not started'),
+ variant: 'muted',
+ },
+ [STATUSES.SCHEDULING]: SCHEDULED_STATUS,
+ [STATUSES.SCHEDULED]: SCHEDULED_STATUS,
+ [STATUSES.CREATED]: SCHEDULED_STATUS,
+ [STATUSES.STARTED]: {
+ icon: 'status-running',
+ text: __('Importing...'),
+ variant: 'info',
+ },
+ [STATUSES.FAILED]: {
+ icon: 'status-failed',
+ text: __('Failed'),
+ variant: 'danger',
+ },
+ [STATUSES.CANCELLED]: {
+ icon: 'status-stopped',
+ text: __('Cancelled'),
+ variant: 'neutral',
+ },
+};
+
+function isIncompleteImport(stats) {
+ return Object.keys(stats.fetched).some((key) => stats.fetched[key] !== stats.imported[key]);
+}
export default {
name: 'ImportStatus',
components: {
+ GlAccordion,
+ GlAccordionItem,
+ GlBadge,
GlIcon,
},
props: {
@@ -12,19 +68,88 @@ export default {
type: String,
required: true,
},
+ stats: {
+ type: Object,
+ required: false,
+ default: () => ({ fetched: {}, imported: {} }),
+ },
},
computed: {
+ knownStats() {
+ const knownStatisticKeys = Object.keys(STATISTIC_ITEMS);
+ return Object.keys(this.stats.fetched).filter((key) => knownStatisticKeys.includes(key));
+ },
+
+ hasStats() {
+ return this.stats && this.knownStats.length > 0;
+ },
+
mappedStatus() {
+ if (this.status === STATUSES.FINISHED) {
+ const isIncomplete = this.stats && isIncompleteImport(this.stats);
+ return {
+ icon: 'status-success',
+ ...(isIncomplete
+ ? {
+ text: __('Partial import'),
+ variant: 'warning',
+ }
+ : {
+ text: __('Complete'),
+ variant: 'success',
+ }),
+ };
+ }
+
return STATUS_MAP[this.status];
},
},
+
+ methods: {
+ getStatisticIconProps(key) {
+ const fetched = this.stats.fetched[key];
+ const imported = this.stats.imported[key];
+
+ if (fetched === imported) {
+ return { name: 'status-success', class: 'gl-text-green-400' };
+ } else if (imported === 0) {
+ return { name: 'status-scheduled', class: 'gl-text-gray-400' };
+ }
+
+ return { name: 'status-running', class: 'gl-text-blue-400' };
+ },
+ },
+
+ STATISTIC_ITEMS,
};
</script>
<template>
<div>
- <gl-icon :name="mappedStatus.icon" :class="mappedStatus.iconClass" :size="12" class="gl-mr-2" />
- <span>{{ mappedStatus.text }}</span>
+ <div class="gl-display-inline-block gl-w-13">
+ <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" class="gl-mr-2">
+ {{ mappedStatus.text }}
+ </gl-badge>
+ </div>
+ <gl-accordion v-if="hasStats" :header-level="3">
+ <gl-accordion-item :title="__('Details')">
+ <ul class="gl-p-0 gl-list-style-none gl-font-sm">
+ <li v-for="key in knownStats" :key="key">
+ <div class="gl-display-flex gl-w-20 gl-align-items-center">
+ <gl-icon
+ :size="12"
+ class="gl-mr-3 gl-flex-shrink-0"
+ v-bind="getStatisticIconProps(key)"
+ />
+ <span class="">{{ $options.STATISTIC_ITEMS[key] }}</span>
+ <span class="gl-ml-auto">
+ {{ stats.imported[key] || 0 }}/{{ stats.fetched[key] }}
+ </span>
+ </div>
+ </li>
+ </ul>
+ </gl-accordion-item>
+ </gl-accordion>
</div>
</template>
diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js
index 156e92e2d00..20a4d2d84b4 100644
--- a/app/assets/javascripts/import_entities/constants.js
+++ b/app/assets/javascripts/import_entities/constants.js
@@ -1,5 +1,3 @@
-import { __ } from '../locale';
-
// The `scheduling` status is only present on the client-side,
// it is used as the status when we are requesting to start an import.
@@ -13,42 +11,3 @@ export const STATUSES = {
SCHEDULING: 'scheduling',
CANCELLED: 'cancelled',
};
-
-const SCHEDULED_STATUS = {
- icon: 'status-scheduled',
- text: __('Pending'),
- iconClass: 'gl-text-orange-400',
-};
-
-const STATUS_MAP = {
- [STATUSES.NONE]: {
- icon: 'status-waiting',
- text: __('Not started'),
- iconClass: 'gl-text-gray-400',
- },
- [STATUSES.SCHEDULING]: SCHEDULED_STATUS,
- [STATUSES.SCHEDULED]: SCHEDULED_STATUS,
- [STATUSES.CREATED]: SCHEDULED_STATUS,
- [STATUSES.STARTED]: {
- icon: 'status-running',
- text: __('Importing...'),
- iconClass: 'gl-text-blue-400',
- },
- [STATUSES.FINISHED]: {
- icon: 'status-success',
- text: __('Complete'),
- iconClass: 'gl-text-green-400',
- },
- [STATUSES.FAILED]: {
- icon: 'status-failed',
- text: __('Failed'),
- iconClass: 'gl-text-red-600',
- },
- [STATUSES.CANCELLED]: {
- icon: 'status-stopped',
- text: __('Cancelled'),
- iconClass: 'gl-text-red-600',
- },
-};
-
-export default STATUS_MAP;
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index bd0f4cd5dd7..e0703a77424 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -109,7 +109,7 @@ export default {
</template>
<div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-mb-5">
<gl-button
- variant="success"
+ variant="confirm"
:loading="isImportingAnyRepo"
:disabled="!hasImportableRepos"
type="button"
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index c3d0ca4ed8c..e4090a378e1 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -69,6 +69,10 @@ export default {
return getImportStatus(this.repo);
},
+ stats() {
+ return this.repo.importedProject?.stats;
+ },
+
importTarget() {
return this.getImportTarget(this.repo.importSource.id);
},
@@ -101,11 +105,11 @@ export default {
<template>
<tr
- class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11"
+ class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top"
data-qa-selector="project_import_row"
:data-qa-source-project="repo.importSource.fullName"
>
- <td class="gl-p-4">
+ <td class="gl-p-4 gl-vertical-align-top">
<gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink"
>{{ repo.importSource.fullName }}
<gl-icon v-if="repo.importSource.providerLink" name="external-link" />
@@ -156,10 +160,10 @@ export default {
</template>
<template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
</td>
- <td class="gl-p-4" data-qa-selector="import_status_indicator">
- <import-status :status="importStatus" />
+ <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator">
+ <import-status :status="importStatus" :stats="stats" />
</td>
- <td data-testid="actions">
+ <td data-testid="actions" class="gl-vertical-align-top gl-pt-4">
<gl-button
v-if="isFinished"
class="btn btn-default"
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 110cc77b20d..5146a0eb461 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -16,12 +16,13 @@ export function initStoreFromElement(element) {
jobsPath,
importPath,
namespacesPath,
+ defaultTargetNamespace,
paginatable,
} = element.dataset;
return createStore({
initialState: {
- defaultTargetNamespace: gon.current_username,
+ defaultTargetNamespace,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
provider,
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
index 45f7a684161..163a19976de 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
@@ -113,7 +113,11 @@ export default {
updatedProjects.forEach((updatedProject) => {
const repo = state.repositories.find((p) => p.importedProject?.id === updatedProject.id);
if (repo?.importedProject) {
- repo.importedProject.importStatus = updatedProject.importStatus;
+ repo.importedProject = {
+ ...repo.importedProject,
+ stats: updatedProject.stats,
+ importStatus: updatedProject.importStatus,
+ };
}
});
},
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 324797ad645..bfc5bd823a2 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -33,6 +33,7 @@ import {
TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
TH_SEVERITY_TEST_ID,
+ TH_ESCALATION_STATUS_TEST_ID,
TH_PUBLISHED_TEST_ID,
INCIDENT_DETAILS_PATH,
trackIncidentCreateNewOptions,
@@ -67,8 +68,11 @@ export default {
{
key: 'escalationStatus',
label: s__('IncidentManagement|Status'),
- thClass: `${thClass} gl-w-eighth gl-pointer-events-none`,
- tdClass,
+ thClass: `${thClass} gl-w-eighth`,
+ tdClass: `${tdClass} sortable-cell`,
+ actualSortKey: 'ESCALATION_STATUS',
+ sortable: true,
+ thAttr: TH_ESCALATION_STATUS_TEST_ID,
},
{
key: 'createdAt',
@@ -354,7 +358,7 @@ export default {
:loading="redirecting"
:disabled="redirecting"
category="primary"
- variant="success"
+ variant="confirm"
:href="newIncidentPath"
@click="navigateToCreateNewIncident"
>
@@ -388,19 +392,24 @@ export default {
</template>
<template #cell(title)="{ item }">
- <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }">
+ <div
+ :class="{
+ 'gl-display-flex gl-align-items-center gl-max-w-full': item.state === 'closed',
+ }"
+ >
<gl-link
- v-gl-tooltip
- :title="item.title"
data-testid="incident-link"
:href="showIncidentLink(item)"
+ class="gl-min-w-0"
>
- {{ item.title }}
+ <tooltip-on-truncate :title="item.title" class="gl-text-truncate gl-display-block">
+ {{ item.title }}
+ </tooltip-on-truncate>
</gl-link>
<gl-icon
v-if="item.state === 'closed'"
name="issue-close"
- class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0"
+ class="gl-ml-2 gl-fill-blue-500 gl-flex-shrink-0"
:size="16"
data-testid="incident-closed"
/>
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index 21cdbef05a1..ee3f30de880 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -47,6 +47,7 @@ export const ESCALATION_STATUSES = {
export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
+export const TH_ESCALATION_STATUS_TEST_ID = { 'data-testid': 'incident-management-status-sort' };
export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' };
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 6e89872ff68..661299920c7 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -4,7 +4,6 @@ import axios from 'axios';
import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
@@ -18,8 +17,6 @@ import { testIntegrationSettings } from '../api';
import ActiveCheckbox from './active_checkbox.vue';
import ConfirmationModal from './confirmation_modal.vue';
import DynamicField from './dynamic_field.vue';
-import JiraIssuesFields from './jira_issues_fields.vue';
-import JiraTriggerFields from './jira_trigger_fields.vue';
import OverrideDropdown from './override_dropdown.vue';
import ResetConfirmationModal from './reset_confirmation_modal.vue';
import TriggerFields from './trigger_fields.vue';
@@ -29,8 +26,6 @@ export default {
components: {
OverrideDropdown,
ActiveCheckbox,
- JiraTriggerFields,
- JiraIssuesFields,
TriggerFields,
DynamicField,
ConfirmationModal,
@@ -54,12 +49,6 @@ export default {
GlModal: GlModalDirective,
SafeHtml,
},
- mixins: [glFeatureFlagsMixin()],
- provide() {
- return {
- hasSections: this.hasSections,
- };
- },
inject: {
helpHtml: {
default: '',
@@ -80,9 +69,6 @@ export default {
isEditable() {
return this.propsSource.editable;
},
- isJira() {
- return this.propsSource.type === 'jira';
- },
isInstanceOrGroupLevel() {
return (
this.customState.integrationLevel === integrationLevels.INSTANCE ||
@@ -98,14 +84,11 @@ export default {
disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting);
},
- sectionsEnabled() {
- return this.glFeatures.integrationFormSections;
- },
hasSections() {
- return this.sectionsEnabled && this.customState.sections.length !== 0;
+ return this.customState.sections.length !== 0;
},
fieldsWithoutSection() {
- return this.sectionsEnabled
+ return this.hasSections
? this.propsSource.fields.filter((field) => !field.section)
: this.propsSource.fields;
},
@@ -184,6 +167,9 @@ export default {
this.integrationActive = integrationActive;
},
},
+ descriptionHtmlConfig: {
+ ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
+ },
helpHtmlConfig: {
ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
ADD_TAGS: ['use'], // to support icon SVGs
@@ -229,7 +215,7 @@ export default {
<div class="row">
<div class="col-lg-4">
<h4 class="gl-mt-0">{{ section.title }}</h4>
- <p v-safe-html="section.description"></p>
+ <p v-safe-html:[$options.descriptionHtmlConfig]="section.description"></p>
</div>
<div class="col-lg-8">
@@ -257,14 +243,8 @@ export default {
:key="`${currentKey}-active-checkbox`"
@toggle-integration-active="onToggleIntegrationState"
/>
- <jira-trigger-fields
- v-if="isJira && !hasSections"
- :key="`${currentKey}-jira-trigger-fields`"
- v-bind="propsSource.triggerFieldsProps"
- :is-validated="isValidated"
- />
<trigger-fields
- v-else-if="propsSource.triggerEvents.length && !hasSections"
+ v-if="propsSource.triggerEvents.length && !hasSections"
:key="`${currentKey}-trigger-fields`"
:events="propsSource.triggerEvents"
:type="propsSource.type"
@@ -275,13 +255,6 @@ export default {
v-bind="field"
:is-validated="isValidated"
/>
- <jira-issues-fields
- v-if="isJira && !isInstanceOrGroupLevel && !hasSections"
- :key="`${currentKey}-jira-issues-fields`"
- v-bind="propsSource.jiraIssuesProps"
- :is-validated="isValidated"
- @request-jira-issue-types="onRequestJiraIssueTypes"
- />
</div>
</div>
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 7cf8e11f162..f00339c92fa 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,5 +1,5 @@
<script>
-import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { s__, __ } from '~/locale';
import JiraUpgradeCta from './jira_upgrade_cta.vue';
@@ -10,17 +10,10 @@ export default {
GlFormGroup,
GlFormCheckbox,
GlFormInput,
- GlSprintf,
- GlLink,
JiraUpgradeCta,
JiraIssueCreationVulnerabilities: () =>
import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'),
},
- inject: {
- hasSections: {
- default: false,
- },
- },
props: {
showJiraIssuesIntegration: {
type: Boolean,
@@ -52,21 +45,11 @@ export default {
required: false,
default: null,
},
- gitlabIssuesEnabled: {
- type: Boolean,
- required: false,
- default: true,
- },
upgradePlanPath: {
type: String,
required: false,
default: '',
},
- editProjectPath: {
- type: String,
- required: false,
- default: '',
- },
isValidated: {
type: Boolean,
required: false,
@@ -86,7 +69,6 @@ export default {
},
},
i18n: {
- sectionTitle: s__('JiraService|View Jira issues in GitLab'),
sectionDescription: s__(
'JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues.',
),
@@ -97,89 +79,70 @@ export default {
projectKeyLabel: s__('JiraService|Jira project key'),
projectKeyPlaceholder: s__('JiraService|For example, AB'),
requiredFieldFeedback: __('This field is required.'),
- issueTrackerConflictWarning: s__(
- 'JiraService|Displaying Jira issues while leaving GitLab issues also enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.',
- ),
},
};
</script>
<template>
<div>
- <gl-form-group
- :label="hasSections ? null : $options.i18n.sectionTitle"
- label-for="jira-issue-settings"
- >
- <div id="jira-issue-settings">
- <p v-if="!hasSections">
- {{ $options.i18n.sectionDescription }}
- </p>
- <template v-if="showJiraIssuesIntegration">
- <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" />
- <gl-form-checkbox
- v-model="enableJiraIssues"
- :disabled="isInheriting"
- data-qa-selector="service_jira_issues_enabled_checkbox"
- >
- {{ $options.i18n.enableCheckboxLabel }}
- <template #help>
- {{ $options.i18n.enableCheckboxHelp }}
- </template>
- </gl-form-checkbox>
- <template v-if="enableJiraIssues">
- <jira-issue-creation-vulnerabilities
- :project-key="projectKey"
- :initial-is-enabled="initialEnableJiraVulnerabilities"
- :initial-issue-type-id="initialVulnerabilitiesIssuetype"
- :show-full-feature="showJiraVulnerabilitiesIntegration"
- data-testid="jira-for-vulnerabilities"
- @request-jira-issue-types="$emit('request-jira-issue-types')"
- />
- <jira-upgrade-cta
- v-if="!showJiraVulnerabilitiesIntegration"
- class="gl-mt-2 gl-ml-6"
- data-testid="ultimate-upgrade-cta"
- show-ultimate-message
- :upgrade-plan-path="upgradePlanPath"
- />
- </template>
+ <template v-if="showJiraIssuesIntegration">
+ <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" />
+ <gl-form-checkbox
+ v-model="enableJiraIssues"
+ :disabled="isInheriting"
+ data-qa-selector="service_jira_issues_enabled_checkbox"
+ >
+ {{ $options.i18n.enableCheckboxLabel }}
+ <template #help>
+ {{ $options.i18n.enableCheckboxHelp }}
</template>
+ </gl-form-checkbox>
+
+ <div v-if="enableJiraIssues" class="gl-pl-6 gl-mt-3">
+ <gl-form-group
+ :label="$options.i18n.projectKeyLabel"
+ label-for="service_project_key"
+ :invalid-feedback="$options.i18n.requiredFieldFeedback"
+ :state="validProjectKey"
+ class="gl-max-w-26"
+ data-testid="project-key-form-group"
+ >
+ <gl-form-input
+ id="service_project_key"
+ v-model="projectKey"
+ name="service[project_key]"
+ data-qa-selector="service_jira_project_key_field"
+ :placeholder="$options.i18n.projectKeyPlaceholder"
+ :required="enableJiraIssues"
+ :state="validProjectKey"
+ :readonly="isInheriting"
+ />
+ </gl-form-group>
+
+ <jira-issue-creation-vulnerabilities
+ :project-key="projectKey"
+ :initial-is-enabled="initialEnableJiraVulnerabilities"
+ :initial-issue-type-id="initialVulnerabilitiesIssuetype"
+ :show-full-feature="showJiraVulnerabilitiesIntegration"
+ class="gl-mt-6"
+ data-testid="jira-for-vulnerabilities"
+ @request-jira-issue-types="$emit('request-jira-issue-types')"
+ />
<jira-upgrade-cta
- v-else
- class="gl-mt-2"
- data-testid="premium-upgrade-cta"
- show-premium-message
+ v-if="!showJiraVulnerabilitiesIntegration"
+ class="gl-mt-2 gl-ml-6"
+ data-testid="ultimate-upgrade-cta"
+ show-ultimate-message
:upgrade-plan-path="upgradePlanPath"
/>
</div>
- </gl-form-group>
- <template v-if="showJiraIssuesIntegration">
- <gl-form-group
- :label="$options.i18n.projectKeyLabel"
- label-for="service_project_key"
- :invalid-feedback="$options.i18n.requiredFieldFeedback"
- :state="validProjectKey"
- data-testid="project-key-form-group"
- >
- <gl-form-input
- id="service_project_key"
- v-model="projectKey"
- name="service[project_key]"
- data-qa-selector="service_jira_project_key_field"
- :placeholder="$options.i18n.projectKeyPlaceholder"
- :required="enableJiraIssues"
- :state="validProjectKey"
- :disabled="!enableJiraIssues"
- :readonly="isInheriting"
- />
- </gl-form-group>
- <p v-if="gitlabIssuesEnabled" data-testid="conflict-warning-text">
- <gl-sprintf :message="$options.i18n.issueTrackerConflictWarning">
- <template #link="{ content }">
- <gl-link :href="editProjectPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
</template>
+
+ <jira-upgrade-cta
+ v-else
+ data-testid="premium-upgrade-cta"
+ show-premium-message
+ :upgrade-plan-path="upgradePlanPath"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 3c06660e7c5..c7cbdff72e3 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -62,11 +62,6 @@ export default {
GlLink,
GlSprintf,
},
- inject: {
- hasSections: {
- default: false,
- },
- },
props: {
initialTriggerCommit: {
type: Boolean,
@@ -138,17 +133,7 @@ export default {
<template>
<div>
- <gl-form-group
- :label="hasSections ? null : __('Trigger')"
- label-for="service[trigger]"
- :description="
- hasSections
- ? null
- : s__(
- 'JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.',
- )
- "
- >
+ <div class="gl-mb-5">
<input name="service[commit_events]" type="hidden" :value="triggerCommit || false" />
<gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting">
{{ __('Commit') }}
@@ -162,7 +147,7 @@ export default {
<gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting">
{{ __('Merge request') }}
</gl-form-checkbox>
- </gl-form-group>
+ </div>
<gl-form-group
v-show="showTriggerSettings"
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index 3e58dd0be99..9a9aae36657 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -1,9 +1,12 @@
import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IntegrationForm from './components/integration_form.vue';
import { createStore } from './store';
+Vue.use(GlToast);
+
function parseBooleanInData(data) {
const result = {};
Object.entries(data).forEach(([key, value]) => {
@@ -19,7 +22,6 @@ function parseDatasetToProps(data) {
commentDetail,
projectKey,
upgradePlanPath,
- editProjectPath,
learnMorePath,
triggerEvents,
sections,
@@ -49,7 +51,6 @@ function parseDatasetToProps(data) {
showJiraVulnerabilitiesIntegration,
enableJiraIssues,
enableJiraVulnerabilities,
- gitlabIssuesEnabled,
} = parseBooleanInData(booleanAttributes);
return {
@@ -78,9 +79,7 @@ function parseDatasetToProps(data) {
initialEnableJiraVulnerabilities: enableJiraVulnerabilities,
initialVulnerabilitiesIssuetype: vulnerabilitiesIssuetype,
initialProjectKey: projectKey,
- gitlabIssuesEnabled,
upgradePlanPath,
- editProjectPath,
},
learnMorePath,
triggerEvents: JSON.parse(triggerEvents),
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index 04a8ec3400f..fc14b2eba6a 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -24,10 +24,6 @@ export default {
prop: 'selectedGroup',
},
props: {
- accessLevels: {
- type: Object,
- required: true,
- },
groupsFilter: {
type: String,
required: false,
@@ -58,13 +54,6 @@ export default {
isFetchResultEmpty() {
return this.groups.length === 0;
},
- defaultFetchOptions() {
- return {
- exclude_internal: true,
- active: true,
- min_access_level: this.accessLevels.Guest,
- };
- },
},
watch: {
searchTerm() {
@@ -107,9 +96,13 @@ export default {
fetchGroups() {
switch (this.groupsFilter) {
case GROUP_FILTERS.DESCENDANT_GROUPS:
- return getDescendentGroups(this.parentGroupId, this.searchTerm, this.defaultFetchOptions);
+ return getDescendentGroups(
+ this.parentGroupId,
+ this.searchTerm,
+ this.$options.defaultFetchOptions,
+ );
default:
- return getGroups(this.searchTerm, this.defaultFetchOptions);
+ return getGroups(this.searchTerm, this.$options.defaultFetchOptions);
}
},
},
@@ -118,6 +111,10 @@ export default {
searchPlaceholder: s__('GroupSelect|Search groups'),
emptySearchResult: s__('GroupSelect|No matching results'),
},
+ defaultFetchOptions: {
+ exclude_internal: true,
+ active: true,
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
index f266d978ffa..2ad4bb1a11a 100644
--- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -2,11 +2,11 @@
import { uniqueId } from 'lodash';
import Api from '~/api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
+import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants';
import eventHub from '../event_hub';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import GroupSelect from './group_select.vue';
-import InviteModalBase from './invite_modal_base.vue';
export default {
name: 'InviteMembersModal',
@@ -19,6 +19,10 @@ export default {
type: String,
required: true,
},
+ rootId: {
+ type: String,
+ required: true,
+ },
isProject: {
type: Boolean,
required: true,
@@ -147,6 +151,8 @@ export default {
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:submit-disabled="inviteDisabled"
+ :new-group-to-invite="groupToBeSharedWith.id"
+ :root-group-id="rootId"
:invalid-feedback-message="invalidFeedbackMessage"
:is-loading="isLoading"
@reset="resetFields"
@@ -155,7 +161,6 @@ export default {
<template #select>
<group-select
v-model="groupToBeSharedWith"
- :access-levels="accessLevels"
:groups-filter="groupSelectFilter"
:parent-group-id="groupSelectParentId"
:invalid-groups="invalidGroups"
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 be48a58d838..a9aa0e9b760 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -24,6 +24,7 @@ import { responseMessageFromSuccess } from '../utils/response_message_parser';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import ModalConfetti from './confetti.vue';
import MembersTokenSelect from './members_token_select.vue';
+import UserLimitNotification from './user_limit_notification.vue';
export default {
name: 'InviteMembersModal',
@@ -37,6 +38,7 @@ export default {
InviteModalBase,
MembersTokenSelect,
ModalConfetti,
+ UserLimitNotification,
},
inject: ['newProjectPath'],
props: {
@@ -44,6 +46,10 @@ export default {
type: String,
required: true,
},
+ rootId: {
+ type: String,
+ required: true,
+ },
isProject: {
type: Boolean,
required: true,
@@ -187,46 +193,28 @@ export default {
this.invalidFeedbackMessage = '';
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
- const promises = [];
- const baseData = {
+
+ const apiAddByInvite = this.isProject
+ ? Api.inviteProjectMembers.bind(Api)
+ : Api.inviteGroupMembers.bind(Api);
+
+ const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
+ const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+
+ this.trackinviteMembersForTask();
+
+ apiAddByInvite(this.id, {
format: 'json',
expires_at: expiresAt,
access_level: accessLevel,
invite_source: this.source,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
- };
-
- if (usersToInviteByEmail !== '') {
- const apiInviteByEmail = this.isProject
- ? Api.inviteProjectMembersByEmail.bind(Api)
- : Api.inviteGroupMembersByEmail.bind(Api);
-
- promises.push(
- apiInviteByEmail(this.id, {
- ...baseData,
- email: usersToInviteByEmail,
- }),
- );
- }
-
- if (usersToAddById !== '') {
- const apiAddByUserId = this.isProject
- ? Api.addProjectMembersByUserId.bind(Api)
- : Api.addGroupMembersByUserId.bind(Api);
-
- promises.push(
- apiAddByUserId(this.id, {
- ...baseData,
- user_id: usersToAddById,
- }),
- );
- }
- this.trackinviteMembersForTask();
-
- Promise.all(promises)
- .then((responses) => {
- const message = responseMessageFromSuccess(responses);
+ ...email,
+ ...userId,
+ })
+ .then((response) => {
+ const message = responseMessageFromSuccess(response);
if (message) {
this.showInvalidFeedbackMessage({
@@ -290,6 +278,8 @@ export default {
:submit-disabled="inviteDisabled"
:invalid-feedback-message="invalidFeedbackMessage"
:is-loading="isLoading"
+ :new-users-to-invite="newUsersToInvite"
+ :root-group-id="rootId"
@reset="resetFields"
@submit="sendInvite"
@access-level="onAccessLevelUpdate"
@@ -302,6 +292,11 @@ export default {
<span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span>
<modal-confetti v-if="isCelebration" />
</template>
+
+ <template #user-limit-notification>
+ <user-limit-notification />
+ </template>
+
<template #select="{ validationState, labelId }">
<members-token-select
v-model="newUsersToInvite"
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index bafbe94b8bd..d9297614a7e 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -7,7 +7,6 @@ import {
GlDatepicker,
GlLink,
GlSprintf,
- GlButton,
GlFormInput,
} from '@gitlab/ui';
import { sprintf } from '~/locale';
@@ -41,7 +40,6 @@ export default {
GlDropdown,
GlDropdownItem,
GlSprintf,
- GlButton,
GlFormInput,
ContentTransition,
},
@@ -104,6 +102,11 @@ export default {
required: false,
default: INVITE_BUTTON_TEXT,
},
+ cancelButtonText: {
+ type: String,
+ required: false,
+ default: CANCEL_BUTTON_TEXT,
+ },
currentSlot: {
type: String,
required: false,
@@ -114,6 +117,11 @@ export default {
required: false,
default: () => [],
},
+ preventCancelDefault: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
// Be sure to check out reset!
@@ -141,6 +149,22 @@ export default {
contentSlots() {
return [...DEFAULT_SLOTS, ...(this.extraSlots || [])];
},
+ actionPrimary() {
+ return {
+ text: this.submitButtonText,
+ attributes: {
+ variant: 'confirm',
+ disabled: this.submitDisabled,
+ loading: this.isLoading,
+ 'data-qa-selector': 'invite_button',
+ },
+ };
+ },
+ actionCancel() {
+ return {
+ text: this.cancelButtonText,
+ };
+ },
},
watch: {
selectedAccessLevel: {
@@ -151,7 +175,7 @@ export default {
},
},
methods: {
- reset() {
+ onReset() {
// This component isn't necessarily disposed,
// so we might need to reset it's state.
this.selectedAccessLevel = this.defaultAccessLevel;
@@ -159,14 +183,23 @@ export default {
this.$emit('reset');
},
- closeModal() {
- this.reset();
- this.$refs.modal.hide();
+ onCloseModal(e) {
+ if (this.preventCancelDefault) {
+ e.preventDefault();
+ } else {
+ this.onReset();
+ this.$refs.modal.hide();
+ }
+
+ this.$emit('cancel');
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
- submit() {
+ onSubmit(e) {
+ // We never want to hide when submitting
+ e.preventDefault();
+
this.$emit('submit', {
accessLevel: this.selectedAccessLevel,
expiresAt: this.selectedDate,
@@ -192,9 +225,11 @@ export default {
size="sm"
:title="modalTitle"
:header-close-label="$options.HEADER_CLOSE_LABEL"
- @hidden="reset"
- @close="reset"
- @hide="reset"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ @primary="onSubmit"
+ @cancel="onCloseModal"
+ @hidden="onReset"
>
<content-transition
class="gl-display-grid"
@@ -215,6 +250,8 @@ export default {
<slot name="intro-text-after"></slot>
</div>
+ <slot name="user-limit-notification"></slot>
+
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
@@ -280,22 +317,5 @@ export default {
<slot :name="key"></slot>
</template>
</content-transition>
- <template #modal-footer>
- <slot name="cancel-button">
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.CANCEL_BUTTON_TEXT }}
- </gl-button>
- </slot>
- <gl-button
- :disabled="submitDisabled"
- :loading="isLoading"
- variant="confirm"
- data-qa-selector="invite_button"
- data-testid="invite-button"
- @click="submit"
- >
- {{ submitButtonText }}
- </gl-button>
- </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index e299e3f27b3..0a191f6d406 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -117,7 +117,7 @@ export default {
this.$emit('clear');
},
},
- defaultQueryOptions: { exclude_internal: true, active: true },
+ defaultQueryOptions: { without_project_bots: true, active: true },
i18n: {
inviteTextMessage: __('Invite "%{email}" by email'),
},
diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
new file mode 100644
index 00000000000..beef1aef8a1
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__, n__, sprintf } from '~/locale';
+
+const CLOSE_TO_LIMIT_COUNT = 2;
+
+const WARNING_ALERT_TITLE = s__(
+ 'InviteMembersModal|You only have space for %{count} more %{members} in %{name}',
+);
+
+const DANGER_ALERT_TITLE = s__(
+ "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}",
+);
+
+const CLOSE_TO_LIMIT_MESSAGE = s__(
+ 'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
+);
+
+const REACHED_LIMIT_MESSAGE = s__(
+ 'InviteMembersModal|New members will be unable to participate. You can manage your members by removing ones you no longer need.',
+).concat(' ', CLOSE_TO_LIMIT_MESSAGE);
+
+export default {
+ name: 'UserLimitNotification',
+ components: { GlAlert, GlSprintf, GlLink },
+ inject: ['name', 'newTrialRegistrationPath', 'purchasePath', 'freeUsersLimit', 'membersCount'],
+ computed: {
+ reachedLimit() {
+ return this.isLimit();
+ },
+ closeToLimit() {
+ return this.isLimit(CLOSE_TO_LIMIT_COUNT);
+ },
+ warningAlertTitle() {
+ return sprintf(WARNING_ALERT_TITLE, {
+ count: this.freeUsersLimit - this.membersCount,
+ members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
+ name: this.name,
+ });
+ },
+ dangerAlertTitle() {
+ return sprintf(DANGER_ALERT_TITLE, {
+ count: this.freeUsersLimit,
+ members: this.pluralMembers(this.freeUsersLimit),
+ name: this.name,
+ });
+ },
+ variant() {
+ return this.reachedLimit ? 'danger' : 'warning';
+ },
+ title() {
+ return this.reachedLimit ? this.dangerAlertTitle : this.warningAlertTitle;
+ },
+ message() {
+ if (this.reachedLimit) {
+ return this.$options.i18n.reachedLimitMessage;
+ }
+
+ return this.$options.i18n.closeToLimitMessage;
+ },
+ },
+ methods: {
+ isLimit(deviation = 0) {
+ if (this.freeUsersLimit && this.membersCount) {
+ return this.membersCount >= this.freeUsersLimit - deviation;
+ }
+
+ return false;
+ },
+ pluralMembers(count) {
+ return n__('member', 'members', count);
+ },
+ },
+ i18n: {
+ reachedLimitMessage: REACHED_LIMIT_MESSAGE,
+ closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE,
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ v-if="reachedLimit || closeToLimit"
+ :variant="variant"
+ :dismissible="false"
+ :title="title"
+ >
+ <gl-sprintf :message="message">
+ <template #trialLink="{ content }">
+ <gl-link :href="newTrialRegistrationPath" class="gl-label-link">{{ content }}</gl-link>
+ </template>
+ <template #upgradeLink="{ content }">
+ <gl-link :href="purchasePath" class="gl-label-link">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index cf2ee508184..3cd0bfc0181 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,4 +1,4 @@
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export const SEARCH_DELAY = 200;
@@ -14,9 +14,6 @@ export const GROUP_FILTERS = {
DESCENDANT_GROUPS: 'descendant_groups',
};
-export const API_MESSAGES = {
- EMAIL_ALREADY_INVITED: __('Invite email has already been taken'),
-};
export const USERS_FILTER_ALL = 'all';
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
export const TRIGGER_ELEMENT_BUTTON = 'button';
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index e9d620cedf0..958121ad735 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -5,45 +5,47 @@ import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(GlToast);
-let initedInviteMembersModal;
+export default (function initInviteMembersModal() {
+ let inviteMembersModal;
-export default function initInviteMembersModal() {
- if (initedInviteMembersModal) {
- // if we already loaded this in another part of the dom, we don't want to do it again
- // else we will stack the modals
- return false;
- }
+ return () => {
+ if (!inviteMembersModal) {
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/344955
+ // bug lying in wait here for someone to put group and project invite in same screen
+ // once that happens we'll need to mount these differently, perhaps split
+ // group/project to each mount one, with many ways to open it.
+ const el = document.querySelector('.js-invite-members-modal');
- // https://gitlab.com/gitlab-org/gitlab/-/issues/344955
- // bug lying in wait here for someone to put group and project invite in same screen
- // once that happens we'll need to mount these differently, perhaps split
- // group/project to each mount one, with many ways to open it.
- const el = document.querySelector('.js-invite-members-modal');
+ if (!el) {
+ return false;
+ }
- if (!el) {
- return false;
- }
-
- initedInviteMembersModal = true;
-
- return new Vue({
- el,
- name: 'InviteMembersModalRoot',
- provide: {
- newProjectPath: el.dataset.newProjectPath,
- },
- render: (createElement) =>
- createElement(InviteMembersModal, {
- props: {
- ...el.dataset,
- isProject: parseBoolean(el.dataset.isProject),
- accessLevels: JSON.parse(el.dataset.accessLevels),
- defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
- tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
- projects: JSON.parse(el.dataset.projects || '[]'),
- usersFilter: el.dataset.usersFilter,
- filterId: parseInt(el.dataset.filterId, 10),
+ inviteMembersModal = new Vue({
+ el,
+ name: 'InviteMembersModalRoot',
+ provide: {
+ name: el.dataset.name,
+ newProjectPath: el.dataset.newProjectPath,
+ newTrialRegistrationPath: el.dataset.newTrialRegistrationPath,
+ purchasePath: el.dataset.purchasePath,
+ freeUsersLimit: el.dataset.freeUsersLimit && parseInt(el.dataset.freeUsersLimit, 10),
+ membersCount: el.dataset.membersCount && parseInt(el.dataset.membersCount, 10),
},
- }),
- });
-}
+ render: (createElement) =>
+ createElement(InviteMembersModal, {
+ props: {
+ ...el.dataset,
+ isProject: parseBoolean(el.dataset.isProject),
+ accessLevels: JSON.parse(el.dataset.accessLevels),
+ defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
+ tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
+ projects: JSON.parse(el.dataset.projects || '[]'),
+ usersFilter: el.dataset.usersFilter,
+ filterId: parseInt(el.dataset.filterId, 10),
+ },
+ }),
+ });
+ }
+ return inviteMembersModal;
+ };
+})();
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 52ec3be3205..db8ac303dc4 100644
--- a/app/assets/javascripts/invite_members/utils/response_message_parser.js
+++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js
@@ -1,28 +1,15 @@
import { isString } from 'lodash';
-import { API_MESSAGES } from '~/invite_members/constants';
function responseKeyedMessageParsed(keyedMessage) {
try {
const keys = Object.keys(keyedMessage);
const msg = keyedMessage[keys[0]];
- if (msg === API_MESSAGES.EMAIL_ALREADY_INVITED) {
- return '';
- }
return msg;
} catch {
return '';
}
}
-function responseMessageStringForMultiple(message) {
- return message.includes(':');
-}
-function responseMessageStringFirstPart(message) {
- const firstPart = message.split(':')[1];
- const firstMsg = firstPart.split(/ and [\w-]*$/)[0].trim();
-
- return firstMsg;
-}
export function responseMessageFromError(response) {
if (!response?.response?.data) {
@@ -33,36 +20,25 @@ export function responseMessageFromError(response) {
response: { data },
} = response;
- return (
- data.error ||
- data.message?.user?.[0] ||
- data.message?.access_level?.[0] ||
- data.message?.error ||
- data.message ||
- ''
- );
+ return data.error || data.message?.error || data.message || '';
}
export function responseMessageFromSuccess(response) {
- if (!response?.[0]?.data) {
+ if (!response?.data) {
return '';
}
- const { data } = response[0];
+ const { data } = response;
- if (data.message && !data.message.user) {
+ if (data.message) {
const { message } = data;
if (isString(message)) {
- if (responseMessageStringForMultiple(message)) {
- return responseMessageStringFirstPart(message);
- }
-
return message;
}
return responseKeyedMessageParsed(message);
}
- return data.message || data.message?.user || data.error || '';
+ return data.error || '';
}
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 269f720bac9..dadb1419649 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -102,6 +102,7 @@ export default {
:text="$options.i18n.importIssuesText"
:text-sr-only="!showLabel"
:icon="importButtonIcon"
+ class="gl-w-full gl-md-w-auto"
>
<gl-dropdown-item v-gl-modal="importModalId">
{{ $options.i18n.importCsvText }}
diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue
index 6a0c21602bd..11fc032f34f 100644
--- a/app/assets/javascripts/issuable/components/issue_milestone.vue
+++ b/app/assets/javascripts/issuable/components/issue_milestone.vue
@@ -72,7 +72,7 @@ export default {
</script>
<template>
<div ref="milestoneDetails" class="issue-milestone-details">
- <gl-icon :size="16" class="gl-mr-2" name="clock" />
+ <gl-icon :size="16" class="gl-mr-2 flex-shrink-0" name="clock" />
<span class="milestone-title d-inline-block">{{ milestone.title }}</span>
<gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
<span class="bold">{{ __('Milestone') }}</span> <br />
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 88c1748db0b..018cadad50f 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -12,6 +12,7 @@ import ZenMode from '~/zen_mode';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+const DATA_ISSUES_NEW_PATH = 'data-new-issue-path';
function organizeQuery(obj, isFallbackKey = false) {
if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) {
@@ -68,6 +69,7 @@ export default class IssuableForm {
this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search');
this.zenMode = new ZenMode();
+ this.newIssuePath = form[0].getAttribute(DATA_ISSUES_NEW_PATH);
this.titleField = this.form.find('input[name*="[title]"]');
this.descriptionField = this.form.find('textarea[name*="[description]"]');
if (!(this.titleField.length && this.descriptionField.length)) {
@@ -104,8 +106,8 @@ export default class IssuableForm {
}
initAutosave() {
- const { search } = document.location;
- const searchTerm = format(search);
+ const { search, pathname } = document.location;
+ const searchTerm = this.newIssuePath === pathname ? '' : format(search);
const fallbackKey = getFallbackKey();
this.autosave = new Autosave(
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index 247f8dd0bd6..c96af6da720 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -43,7 +43,7 @@ export default class CreateMergeRequestDropdown {
this.refInput = this.wrapperEl.querySelector('.js-ref');
this.refMessage = this.wrapperEl.querySelector('.js-ref-message');
this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
- this.unavailableButtonSpinner = this.unavailableButton.querySelector('.gl-spinner');
+ this.unavailableButtonSpinner = this.unavailableButton.querySelector('.js-create-mr-spinner');
this.unavailableButtonText = this.unavailableButton.querySelector('.text');
this.branchCreated = false;
@@ -453,7 +453,7 @@ export default class CreateMergeRequestDropdown {
removeMessage(target) {
const { input, message } = this.getTargetData(target);
const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline'];
- const messageClasses = ['text-muted', 'text-danger', 'text-success'];
+ const messageClasses = ['gl-text-gray-600', 'gl-text-red-500', 'gl-text-green-500'];
inputClasses.forEach((cssClass) => input.classList.remove(cssClass));
messageClasses.forEach((cssClass) => message.classList.remove(cssClass));
@@ -462,10 +462,10 @@ export default class CreateMergeRequestDropdown {
setUnavailableButtonState(isLoading = true) {
if (isLoading) {
- this.unavailableButtonSpinner.classList.remove('hide');
+ this.unavailableButtonSpinner.classList.remove('gl-display-none');
this.unavailableButtonText.textContent = __('Checking branch availability...');
} else {
- this.unavailableButtonSpinner.classList.add('hide');
+ this.unavailableButtonSpinner.classList.add('gl-display-none');
this.unavailableButtonText.textContent = __('New branch unavailable');
}
}
@@ -476,7 +476,7 @@ export default class CreateMergeRequestDropdown {
this.removeMessage(target);
input.classList.add('gl-field-success-outline');
- message.classList.add('text-success');
+ message.classList.add('gl-text-green-500');
message.textContent = sprintf(__('%{text} is available'), { text });
message.style.display = 'inline-block';
}
@@ -486,7 +486,7 @@ export default class CreateMergeRequestDropdown {
const text = target === 'branch' ? __('branch name') : __('source');
this.removeMessage(target);
- message.classList.add('text-muted');
+ message.classList.add('gl-text-gray-600');
message.textContent = sprintf(__('Checking %{text} availability…'), { text });
message.style.display = 'inline-block';
}
@@ -498,7 +498,7 @@ export default class CreateMergeRequestDropdown {
this.removeMessage(target);
input.classList.add('gl-field-error-outline');
- message.classList.add('text-danger');
+ message.classList.add('gl-text-red-500');
message.textContent = text;
message.style.display = 'inline-block';
}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 2ee9ac2a682..bcd729785b3 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import IssuableForm from 'ee_else_ce/issuable/issuable_form';
-import loadAwardsHandler from '~/awards_handler';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GLForm from '~/gl_form';
@@ -22,6 +21,7 @@ import MilestoneSelect from '~/milestones/milestone_select';
import initNotesApp from '~/notes';
import { store } from '~/notes/stores';
import ZenMode from '~/zen_mode';
+import initAwardsApp from '~/emoji/awards_app';
import FilteredSearchServiceDesk from './filtered_search_service_desk';
export function initFilteredSearchServiceDesk() {
@@ -72,15 +72,7 @@ export function initShow() {
initRelatedMergeRequests();
initSentryErrorStackTrace();
- const awardEmojiEl = document.getElementById('js-vue-awards-block');
-
- if (awardEmojiEl) {
- import('~/emoji/awards_app')
- .then((m) => m.default(awardEmojiEl))
- .catch(() => {});
- } else {
- loadAwardsHandler();
- }
+ initAwardsApp(document.getElementById('js-vue-awards-block'));
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default())
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 aece7372182..1139861ae78 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
@@ -1,11 +1,13 @@
<script>
import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { IssuableStatus } from '~/issues/constants';
import {
dateInWords,
getTimeRemainingInWords,
isInFuture,
isInPast,
isToday,
+ newDateAsLocaleTime,
} from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
@@ -27,7 +29,7 @@ export default {
milestoneDate() {
if (this.issue.milestone?.dueDate) {
const { dueDate, startDate } = this.issue.milestone;
- const date = dateInWords(new Date(dueDate), true);
+ const date = dateInWords(newDateAsLocaleTime(dueDate), true);
const remainingTime = this.milestoneRemainingTime(dueDate, startDate);
return `${date} (${remainingTime})`;
}
@@ -37,10 +39,13 @@ export default {
return this.issue.milestone.webPath || this.issue.milestone.webUrl;
},
dueDate() {
- return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true);
+ return this.issue.dueDate && dateInWords(newDateAsLocaleTime(this.issue.dueDate), true);
},
showDueDateInRed() {
- return isInPast(new Date(this.issue.dueDate)) && !this.issue.closedAt;
+ return (
+ isInPast(newDateAsLocaleTime(this.issue.dueDate)) &&
+ this.issue.state !== IssuableStatus.Closed
+ );
},
timeEstimate() {
return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate;
@@ -48,8 +53,8 @@ export default {
},
methods: {
milestoneRemainingTime(dueDate, startDate) {
- const due = new Date(dueDate);
- const start = new Date(startDate);
+ const due = newDateAsLocaleTime(dueDate);
+ const start = newDateAsLocaleTime(startDate);
if (dueDate && isInPast(due)) {
return __('Past due');
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 a532fa5b771..a43aed6c521 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -19,6 +19,7 @@ import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
+import { IssuableStatus } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
@@ -260,6 +261,9 @@ export default {
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
+ showIssuableByEmail() {
+ return this.initialEmail && this.isSignedIn;
+ },
showNewIssueDropdown() {
return !this.isProject && this.hasAnyProjects;
},
@@ -477,10 +481,10 @@ export default {
return `${this.exportCsvPath}${window.location.search}`;
},
getStatus(issue) {
- if (issue.closedAt && issue.moved) {
+ if (issue.state === IssuableStatus.Closed && issue.moved) {
return this.$options.i18n.closedMoved;
}
- if (issue.closedAt) {
+ if (issue.state === IssuableStatus.Closed) {
return this.$options.i18n.closed;
}
return undefined;
@@ -624,8 +628,9 @@ export default {
</script>
<template>
- <div v-if="hasAnyIssues">
+ <div>
<issuable-list
+ v-if="hasAnyIssues"
:namespace="fullPath"
recent-searches-storage-key="issues"
:search-input-placeholder="$options.i18n.searchPlaceholder"
@@ -768,50 +773,50 @@ export default {
</template>
</issuable-list>
- <issuable-by-email v-if="initialEmail" class="gl-text-center gl-pt-5 gl-pb-7" />
- </div>
+ <template v-else-if="isSignedIn">
+ <gl-empty-state
+ :description="$options.i18n.noIssuesSignedInDescription"
+ :title="$options.i18n.noIssuesSignedInTitle"
+ :svg-path="emptyStateSvgPath"
+ >
+ <template #actions>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ <csv-import-export-buttons
+ v-if="showCsvButtons"
+ class="gl-w-full gl-sm-w-auto gl-sm-mr-3"
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="currentTabCount"
+ />
+ <new-issue-dropdown v-if="showNewIssueDropdown" />
+ </template>
+ </gl-empty-state>
+ <hr />
+ <p class="gl-text-center gl-font-weight-bold gl-mb-0">
+ {{ $options.i18n.jiraIntegrationTitle }}
+ </p>
+ <p class="gl-text-center gl-mb-0">
+ <gl-sprintf :message="$options.i18n.jiraIntegrationMessage">
+ <template #jiraDocsLink="{ content }">
+ <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-text-center gl-text-gray-500">
+ {{ $options.i18n.jiraIntegrationSecondaryMessage }}
+ </p>
+ </template>
- <div v-else-if="isSignedIn">
<gl-empty-state
- :description="$options.i18n.noIssuesSignedInDescription"
- :title="$options.i18n.noIssuesSignedInTitle"
+ v-else
+ :description="$options.i18n.noIssuesSignedOutDescription"
+ :title="$options.i18n.noIssuesSignedOutTitle"
:svg-path="emptyStateSvgPath"
- >
- <template #actions>
- <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
- {{ $options.i18n.newIssueLabel }}
- </gl-button>
- <csv-import-export-buttons
- v-if="showCsvButtons"
- class="gl-mr-3"
- :export-csv-path="exportCsvPathWithQuery"
- :issuable-count="currentTabCount"
- />
- <new-issue-dropdown v-if="showNewIssueDropdown" />
- </template>
- </gl-empty-state>
- <hr />
- <p class="gl-text-center gl-font-weight-bold gl-mb-0">
- {{ $options.i18n.jiraIntegrationTitle }}
- </p>
- <p class="gl-text-center gl-mb-0">
- <gl-sprintf :message="$options.i18n.jiraIntegrationMessage">
- <template #jiraDocsLink="{ content }">
- <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- <p class="gl-text-center gl-text-gray-500">
- {{ $options.i18n.jiraIntegrationSecondaryMessage }}
- </p>
- </div>
+ :primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
+ :primary-button-link="signInPath"
+ />
- <gl-empty-state
- v-else
- :description="$options.i18n.noIssuesSignedOutDescription"
- :title="$options.i18n.noIssuesSignedOutTitle"
- :svg-path="emptyStateSvgPath"
- :primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
- :primary-button-link="signInPath"
- />
+ <issuable-by-email v-if="showIssuableByEmail" class="gl-text-center gl-pt-5 gl-pb-7" />
+ </div>
</template>
diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
index 529262d2162..ec24ea7c56a 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
#import "./issue.fragment.graphql"
query getIssues(
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 430d494deab..d09e4d9df2b 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -2,7 +2,6 @@ fragment IssueFragment on Issue {
__typename
id
iid
- closedAt
confidential
createdAt
downvotes
@@ -11,6 +10,7 @@ fragment IssueFragment on Issue {
humanTimeEstimate
mergeRequestsCount
moved
+ state
title
updatedAt
upvotes
diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js
index 8fb891f62f7..bc1cffef943 100644
--- a/app/assets/javascripts/issues/manual_ordering.js
+++ b/app/assets/javascripts/issues/manual_ordering.js
@@ -1,11 +1,8 @@
import Sortable from 'sortablejs';
-import {
- getBoardSortableDefaultOptions,
- sortableStart,
-} from '~/boards/mixins/sortable_default_options';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
+import { getSortableDefaultOptions, sortableStart } from '~/sortable/utils';
const updateIssue = (url, { move_before_id, move_after_id }) =>
axios
@@ -28,7 +25,7 @@ const initManualOrdering = () => {
Sortable.create(
issueList,
- getBoardSortableDefaultOptions({
+ getSortableDefaultOptions({
scroll: true,
fallbackTolerance: 1,
dataIdAttr: 'data-id',
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index 0490728c6bc..456a2029703 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -185,6 +185,11 @@ export default {
required: false,
default: false,
},
+ issueId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
const store = new Store({
@@ -322,9 +327,12 @@ export default {
});
},
+ updateFormState(state) {
+ this.store.setFormState(state);
+ },
+
updateAndShowForm(templates = {}) {
if (!this.showForm) {
- this.showForm = true;
this.store.setFormState({
title: this.state.titleText,
description: this.state.descriptionText,
@@ -333,6 +341,7 @@ export default {
updateLoading: false,
issuableTemplates: templates,
});
+ this.showForm = true;
}
},
@@ -364,6 +373,10 @@ export default {
},
updateIssuable() {
+ this.store.setFormState({
+ updateLoading: true,
+ });
+
const {
store: { formState },
issueState,
@@ -371,7 +384,9 @@ export default {
const issuablePayload = issueState.isDirty
? { ...formState, issue_type: issueState.issueType }
: formState;
+
this.clearFlash();
+
return this.service
.updateIssuable(issuablePayload)
.then((res) => res.data)
@@ -426,7 +441,7 @@ export default {
clearFlash() {
if (this.flashContainer) {
- this.flashContainer.style.display = 'none';
+ this.flashContainer.close();
this.flashContainer = null;
}
},
@@ -468,6 +483,7 @@ export default {
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
:issuable-type="issuableType"
+ @updateForm="updateFormState"
/>
</div>
<div v-else>
@@ -534,6 +550,7 @@ export default {
<component
:is="descriptionComponent"
+ :issue-id="issueId"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
@@ -545,6 +562,7 @@ export default {
@taskListUpdateStarted="taskListUpdateStarted"
@taskListUpdateSucceeded="taskListUpdateSucceeded"
@taskListUpdateFailed="taskListUpdateFailed"
+ @updateDescription="state.descriptionHtml = $event"
/>
<edited-component
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 68ed7bb4062..0b7e128c47b 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -2,13 +2,18 @@
import {
GlSafeHtmlDirective as SafeHtml,
GlModal,
+ GlToast,
+ GlTooltip,
GlModalDirective,
- GlPopover,
- GlButton,
} from '@gitlab/ui';
import $ from 'jquery';
+import Vue from 'vue';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import createFlash from '~/flash';
-import { __, sprintf } from '~/locale';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+import { __, s__, sprintf } from '~/locale';
import TaskList from '~/task_list';
import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -16,6 +21,8 @@ import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import animateMixin from '../mixins/animate';
+Vue.use(GlToast);
+
export default {
directives: {
SafeHtml,
@@ -23,9 +30,8 @@ export default {
},
components: {
GlModal,
- GlPopover,
CreateWorkItem,
- GlButton,
+ GlTooltip,
WorkItemDetailModal,
},
mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
@@ -63,15 +69,24 @@ export default {
required: false,
default: 0,
},
+ issueId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
+ const workItemId = getParameterByName('work_item_id');
+
return {
preAnimation: false,
pulseAnimation: false,
initialUpdate: true,
taskButtons: [],
activeTask: {},
- workItemId: null,
+ workItemId: isPositiveInteger(workItemId)
+ ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
+ : undefined,
};
},
computed: {
@@ -81,6 +96,9 @@ export default {
workItemsEnabled() {
return this.glFeatures.workItems;
},
+ issueGid() {
+ return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null;
+ },
},
watch: {
descriptionHtml(newDescription, oldDescription) {
@@ -92,6 +110,9 @@ export default {
this.$nextTick(() => {
this.renderGFM();
+ if (this.workItemsEnabled) {
+ this.renderTaskActions();
+ }
});
},
taskStatus() {
@@ -168,9 +189,25 @@ export default {
return;
}
+ this.taskButtons = [];
const taskListFields = this.$el.querySelectorAll('.task-list-item');
taskListFields.forEach((item, index) => {
+ const taskLink = item.querySelector('.gfm-issue');
+ if (taskLink) {
+ const { issue, referenceType } = taskLink.dataset;
+ taskLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue);
+ this.updateWorkItemIdUrlQuery(issue);
+ this.track('viewed_work_item_from_modal', {
+ category: 'workItems:show',
+ label: 'work_item_view',
+ property: `type_${referenceType}`,
+ });
+ });
+ return;
+ }
const button = document.createElement('button');
button.classList.add(
'btn',
@@ -188,59 +225,44 @@ export default {
this.taskButtons.push(button.id);
button.innerHTML = `
<svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14">
- <use href="${gon.sprite_icons}#ellipsis_v"></use>
+ <use href="${gon.sprite_icons}#doc-new"></use>
</svg>
`;
+ button.setAttribute('aria-label', s__('WorkItem|Convert to work item'));
+ button.addEventListener('click', () => this.openCreateTaskModal(button.id));
item.prepend(button);
});
},
openCreateTaskModal(id) {
- this.activeTask = { id, title: this.$el.querySelector(`#${id}`).parentElement.innerText };
+ const { parentElement } = this.$el.querySelector(`#${id}`);
+ const lineNumbers = parentElement.getAttribute('data-sourcepos').match(/\b\d+(?=:)/g);
+ this.activeTask = {
+ id,
+ title: parentElement.innerText,
+ lineNumberStart: lineNumbers[0],
+ lineNumberEnd: lineNumbers[1],
+ };
this.$refs.modal.show();
},
closeCreateTaskModal() {
this.$refs.modal.hide();
},
closeWorkItemDetailModal() {
- this.workItemId = null;
+ this.workItemId = undefined;
+ this.updateWorkItemIdUrlQuery(undefined);
},
- handleWorkItemDetailModalError(message) {
- createFlash({ message });
- },
- handleCreateTask({ id, title, type }) {
- const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement;
- const taskBadge = document.createElement('span');
- taskBadge.innerHTML = `
- <svg data-testid="issue-open-m-icon" role="img" aria-hidden="true" class="gl-icon gl-fill-green-500 s12">
- <use href="${gon.sprite_icons}#issue-open-m"></use>
- </svg>
- <span class="badge badge-info badge-pill gl-badge sm gl-mr-1">
- ${__('Task')}
- </span>
- `;
- const button = this.createWorkItemDetailButton(id, title, type);
- taskBadge.append(button);
-
- listItem.insertBefore(taskBadge, listItem.lastChild);
- listItem.removeChild(listItem.lastChild);
+ handleCreateTask(description) {
+ this.$emit('updateDescription', description);
this.closeCreateTaskModal();
},
- createWorkItemDetailButton(id, title, type) {
- const button = document.createElement('button');
- button.addEventListener('click', () => {
- this.workItemId = id;
- this.track('viewed_work_item_from_modal', {
- category: 'workItems:show',
- label: 'work_item_view',
- property: `type_${type}`,
- });
- });
- button.classList.add('btn-link');
- button.innerText = title;
- return button;
+ handleDeleteTask() {
+ this.$toast.show(s__('WorkItem|Work item deleted'));
},
- focusButton() {
- this.$refs.convertButton[0].$el.focus();
+ updateWorkItemIdUrlQuery(workItemId) {
+ updateHistory({
+ url: setUrlParams({ work_item_id: workItemId }),
+ replace: true,
+ });
},
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
@@ -266,17 +288,17 @@ export default {
}"
class="md"
></div>
- <!-- eslint-disable vue/no-mutating-props -->
+
<textarea
v-if="descriptionText"
- v-model="descriptionText"
+ :value="descriptionText"
:data-update-url="updateUrl"
class="hidden js-task-list-field"
dir="auto"
data-testid="textarea"
>
</textarea>
- <!-- eslint-enable vue/no-mutating-props -->
+
<gl-modal
ref="modal"
modal-id="create-task-modal"
@@ -285,36 +307,27 @@ export default {
body-class="gl-p-0!"
>
<create-work-item
- :is-modal="true"
+ is-modal
:initial-title="activeTask.title"
+ :issue-gid="issueGid"
+ :lock-version="lockVersion"
+ :line-number-start="activeTask.lineNumberStart"
+ :line-number-end="activeTask.lineNumberEnd"
@closeModal="closeCreateTaskModal"
@onCreate="handleCreateTask"
/>
</gl-modal>
<work-item-detail-modal
+ :can-update="canUpdate"
:visible="showWorkItemDetailModal"
:work-item-id="workItemId"
+ @workItemDeleted="handleDeleteTask"
@close="closeWorkItemDetailModal"
- @error="handleWorkItemDetailModalError"
/>
<template v-if="workItemsEnabled">
- <gl-popover
- v-for="item in taskButtons"
- :key="item"
- :target="item"
- placement="top"
- triggers="focus"
- @shown="focusButton"
- >
- <gl-button
- ref="convertButton"
- variant="link"
- data-testid="convert-to-task"
- class="gl-text-gray-900! gl-text-decoration-none! gl-outline-0!"
- @click="openCreateTaskModal(item)"
- >{{ s__('WorkItem|Convert to work item') }}</gl-button
- >
- </gl-popover>
+ <gl-tooltip v-for="item in taskButtons" :key="item" :target="item">
+ {{ s__('WorkItem|Convert to work item') }}
+ </gl-tooltip>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
index 0da1900a6d0..41cc3964055 100644
--- a/app/assets/javascripts/issues/show/components/edited.vue
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -32,7 +32,7 @@ export default {
</script>
<template>
- <small class="edited-text">
+ <small class="edited-text js-issue-widgets">
Edited
<time-ago-tooltip v-if="updatedAt" :time="updatedAt" tooltip-placement="bottom" />
<span v-if="hasUpdatedBy">
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index d5ac7b28afc..0bb5e7cb2ee 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -1,5 +1,6 @@
<script>
import markdownField from '~/vue_shared/components/markdown/field.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
import updateMixin from '../../mixins/update';
export default {
@@ -8,8 +9,8 @@ export default {
},
mixins: [updateMixin],
props: {
- formState: {
- type: Object,
+ value: {
+ type: String,
required: true,
},
markdownPreviewPath: {
@@ -31,6 +32,11 @@ export default {
default: true,
},
},
+ computed: {
+ quickActionsDocsPath() {
+ return helpPagePath('user/project/quick_actions');
+ },
+ },
mounted() {
this.$refs.textarea.focus();
},
@@ -43,26 +49,26 @@ export default {
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
- :textarea-value="formState.description"
+ :textarea-value="value"
>
<template #textarea>
- <!-- eslint-disable vue/no-mutating-props -->
<textarea
id="issue-description"
ref="textarea"
- v-model="formState.description"
+ :value="value"
class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
dir="auto"
data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
+ @input="$emit('input', $event.target.value)"
@keydown.meta.enter="updateIssuable"
@keydown.ctrl.enter="updateIssuable"
>
</textarea>
- <!-- eslint-enable vue/no-mutating-props -->
</template>
</markdown-field>
</div>
diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue
index d528641dcb6..98f92c97f77 100644
--- a/app/assets/javascripts/issues/show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue
@@ -8,8 +8,8 @@ export default {
GlIcon,
},
props: {
- formState: {
- type: Object,
+ value: {
+ type: String,
required: true,
},
issuableTemplates: {
@@ -39,10 +39,9 @@ export default {
// Create the editor for the template
const editor = document.querySelector('.detail-page-description .note-textarea') || {};
editor.setValue = (val) => {
- // eslint-disable-next-line vue/no-mutating-props
- this.formState.description = val;
+ this.$emit('input', val);
};
- editor.getValue = () => this.formState.description;
+ editor.getValue = () => this.value;
this.issuableTemplate = new IssuableTemplateSelectors({
$dropdowns: $(this.$refs.toggle),
diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue
index a73926575d0..594d1a65700 100644
--- a/app/assets/javascripts/issues/show/components/fields/title.vue
+++ b/app/assets/javascripts/issues/show/components/fields/title.vue
@@ -4,8 +4,8 @@ import updateMixin from '../../mixins/update';
export default {
mixins: [updateMixin],
props: {
- formState: {
- type: Object,
+ value: {
+ type: String,
required: true,
},
},
@@ -15,19 +15,18 @@ export default {
<template>
<fieldset>
<label class="sr-only" for="issuable-title">{{ __('Title') }}</label>
- <!-- eslint-disable vue/no-mutating-props -->
<input
id="issuable-title"
ref="input"
- v-model="formState.title"
+ :value="value"
class="form-control qa-title-input gl-border-gray-200"
dir="auto"
type="text"
:placeholder="__('Title')"
:aria-label="__('Title')"
+ @input="$emit('input', $event.target.value)"
@keydown.meta.enter="updateIssuable"
@keydown.ctrl.enter="updateIssuable"
/>
- <!-- eslint-enable vue/no-mutating-props -->
</fieldset>
</template>
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index 6447ec85b4e..e2c12edf46d 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -86,6 +86,10 @@ export default {
},
data() {
return {
+ formData: {
+ title: this.formState.title,
+ description: this.formState.description,
+ },
showOutdatedDescriptionWarning: false,
};
},
@@ -100,6 +104,14 @@ export default {
return this.issuableType === IssuableType.Issue;
},
},
+ watch: {
+ formData: {
+ handler(value) {
+ this.$emit('updateForm', value);
+ },
+ deep: true,
+ },
+ },
created() {
eventHub.$on('delete.issuable', this.resetAutosave);
eventHub.$on('update.issuable', this.resetAutosave);
@@ -191,16 +203,17 @@ export default {
>
<div class="row gl-mb-3">
<div class="col-12">
- <issuable-title-field ref="title" :form-state="formState" />
+ <issuable-title-field ref="title" v-model="formData.title" />
</div>
</div>
<div class="row">
<div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
<issuable-type-field ref="issue-type" />
</div>
+
<div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2">
<description-template-field
- :form-state="formState"
+ v-model="formData.description"
:issuable-templates="issuableTemplates"
:project-path="projectPath"
:project-id="projectId"
@@ -208,14 +221,16 @@ export default {
/>
</div>
</div>
+
<description-field
ref="description"
- :form-state="formState"
+ v-model="formData.description"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
/>
+
<edit-actions
:endpoint="endpoint"
:form-state="formState"
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 04ddc7f3501..ea0e15adfed 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -17,12 +17,13 @@ export default {
GlTab,
GlTabs,
HighlightBar,
- MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'),
TimelineTab: () =>
import('ee_component/issues/show/components/incidents/timeline_events_tab.vue'),
+ IncidentMetricTab: () =>
+ import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'),
},
mixins: [glFeatureFlagsMixin()],
- inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
+ inject: ['fullPath', 'iid'],
apollo: {
alert: {
query: getAlert,
@@ -52,7 +53,7 @@ export default {
return this.$apollo.queries.alert.loading;
},
incidentTabEnabled() {
- return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimelineEventTab;
+ return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimeline;
},
},
mounted() {
@@ -63,18 +64,37 @@ export default {
const { category, action } = trackIncidentDetailsViewsOptions;
Tracking.event(category, action);
},
+ handleTabChange(tabIndex) {
+ const parent = document.querySelector('.js-issue-details');
+
+ if (parent !== null) {
+ const itemsToHide = parent.querySelectorAll('.js-issue-widgets');
+ const lineSeparator = parent.querySelector('.js-detail-page-description');
+
+ lineSeparator.classList.toggle('gl-border-b-0', tabIndex > 0);
+
+ itemsToHide.forEach(function hide(item) {
+ item.classList.toggle('gl-display-none', tabIndex > 0);
+ });
+ }
+ },
},
};
</script>
<template>
<div>
- <gl-tabs content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs">
+ <gl-tabs
+ content-class="gl-reset-line-height"
+ class="gl-mt-n3"
+ data-testid="incident-tabs"
+ @input="handleTabChange"
+ >
<gl-tab :title="s__('Incident|Summary')">
<highlight-bar :alert="alert" />
<description-component v-bind="$attrs" />
</gl-tab>
- <metrics-tab v-if="uploadMetricsFeatureAvailable" data-testid="metrics-tab" />
+ <incident-metric-tab />
<gl-tab
v-if="alert"
class="alert-management-details"
diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue
index 4b99888ae73..12feacb027b 100644
--- a/app/assets/javascripts/issues/show/components/locked_warning.vue
+++ b/app/assets/javascripts/issues/show/components/locked_warning.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
+import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
const alertMessage = __(
@@ -11,6 +11,7 @@ export default {
components: {
GlSprintf,
GlLink,
+ GlAlert,
},
computed: {
currentPath() {
@@ -21,7 +22,7 @@ export default {
</script>
<template>
- <div class="alert alert-danger">
+ <gl-alert variant="danger" class="gl-mb-5" :dismissible="false">
<gl-sprintf :message="$options.alertMessage">
<template #link="{ content }">
<gl-link :href="currentPath" target="_blank" rel="nofollow">
@@ -29,5 +30,5 @@ export default {
</gl-link>
</template>
</gl-sprintf>
- </div>
+ </gl-alert>
</template>
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index c9af5d9b4a7..4a5ebf9615b 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -102,7 +102,7 @@ export function initIssueApp(issueData, store) {
isConfidential: this.getNoteableData?.confidential,
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
- id: this.getNoteableData?.id,
+ issueId: this.getNoteableData?.id,
},
});
},
diff --git a/app/assets/javascripts/issues/show/mixins/update.js b/app/assets/javascripts/issues/show/mixins/update.js
index 72be65b426f..31b29de580c 100644
--- a/app/assets/javascripts/issues/show/mixins/update.js
+++ b/app/assets/javascripts/issues/show/mixins/update.js
@@ -3,7 +3,6 @@ import eventHub from '../event_hub';
export default {
methods: {
updateIssuable() {
- this.formState.updateLoading = true;
eventHub.$emit('update.issuable');
},
},
diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
index 88005cccd89..9b36642feb7 100644
--- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
+++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
@@ -7,6 +7,7 @@ import {
GlAvatarLabeled,
} from '@gitlab/ui';
import { __ } from '~/locale';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { PROJECTS_PER_PAGE } from '../constants';
import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
@@ -80,6 +81,7 @@ export default {
i18n: {
selectProjectText: __('Select a project'),
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
@@ -107,7 +109,7 @@ export default {
>
<gl-avatar-labeled
class="gl-text-truncate"
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
:size="32"
:src="project.avatarUrl"
:label="project.name"
diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
index 03e8e3e986b..d9fba40688d 100644
--- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
+++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query jiraGetProjects(
$search: String!
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
index 005c3bcd0e3..1fc40e5c0d6 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
@@ -43,7 +43,9 @@ export default {
message: s__(
'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
),
- linkUrl: helpPagePath('integration/jira_development_panel.html', { anchor: 'usage' }),
+ linkUrl: helpPagePath('integration/jira_development_panel.html', {
+ anchor: 'use-the-integration',
+ }),
variant: 'success',
});
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index afdb414e82c..51db3e784aa 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -3,12 +3,15 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
+import AccessorUtilities from '~/lib/utils/accessor';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants';
import { SET_ALERT } from '../store/mutation_types';
import SignInPage from '../pages/sign_in.vue';
import SubscriptionsPage from '../pages/subscriptions.vue';
import UserLink from './user_link.vue';
import CompatibilityAlert from './compatibility_alert.vue';
+import BrowserSupportAlert from './browser_support_alert.vue';
export default {
name: 'JiraConnectApp',
@@ -18,9 +21,11 @@ export default {
GlSprintf,
UserLink,
CompatibilityAlert,
+ BrowserSupportAlert,
SignInPage,
SubscriptionsPage,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
usersPath: {
default: '',
@@ -45,6 +50,16 @@ export default {
userSignedIn() {
return Boolean(!this.usersPath || this.user);
},
+ isOauthEnabled() {
+ return this.glFeatures.jiraConnectOauth;
+ },
+ /**
+ * Returns false if the GitLab for Jira app doesn't support the user's browser.
+ * Any web API that the GitLab for Jira app depends on should be checked here.
+ */
+ isBrowserSupported() {
+ return !this.isOauthEnabled || AccessorUtilities.canUseCrypto();
+ },
},
created() {
this.setInitialAlert();
@@ -71,14 +86,15 @@ export default {
</script>
<template>
- <div>
- <compatibility-alert />
+ <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" />
+ <div v-else data-testid="jira-connect-app">
+ <compatibility-alert class="gl-mb-7" />
<gl-alert
v-if="shouldShowAlert"
- class="gl-mb-7"
:variant="alert.variant"
:title="alert.title"
+ class="gl-mb-5"
data-testid="jira-connect-persisted-alert"
@dismiss="setAlert"
>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue
new file mode 100644
index 00000000000..ea7db5be0c4
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export default {
+ name: 'BrowserSupportAlert',
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ i18n: {
+ title: s__('Integrations|Your browser is not supported'),
+ body: s__(
+ 'Integrations|You must use a %{linkStart}supported browser%{linkEnd} to use the GitLab for Jira app.',
+ ),
+ },
+ DOCS_LINK_URL: helpPagePath('install/requirements', { anchor: 'supported-web-browsers' }),
+};
+</script>
+<template>
+ <gl-alert variant="danger" :title="$options.i18n.title" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.body">
+ <template #link="{ content }">
+ <gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
index 3cfbd87ac53..c5b56535247 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
@@ -46,16 +46,13 @@ export default {
>
<gl-alert
v-if="shouldShowAlert"
- class="gl-mb-7"
variant="info"
:title="$options.i18n.title"
@dismiss="dismissAlert"
>
<gl-sprintf :message="$options.i18n.body">
<template #link="{ content }">
- <gl-link :href="$options.DOCS_LINK_URL" target="_blank" rel="noopener noreferrer">{{
- content
- }}</gl-link>
+ <gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue b/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue
index e6c172dae9e..509a32460bb 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar, GlIcon } from '@gitlab/ui';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
components: {
@@ -12,6 +13,7 @@ export default {
required: true,
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
@@ -19,7 +21,12 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<gl-icon name="folder-o" class="gl-mr-3" />
<div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3">
- <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" />
+ <gl-avatar
+ :size="32"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :entity-name="group.name"
+ :src="group.avatar_url"
+ />
</div>
<div>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
index d7ec909cb28..dfed57df7d6 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -24,7 +24,7 @@ export default {
canUseCrypto: AccessorUtilities.canUseCrypto(),
};
},
- mounted() {
+ created() {
window.addEventListener('message', this.handleWindowMessage);
},
beforeDestroy() {
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
index 33126040c16..0251728c896 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlTable } from '@gitlab/ui';
+import { GlButton, GlTableLite } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapMutations } from 'vuex';
import { removeSubscription } from '~/jira_connect/subscriptions/api';
@@ -12,7 +12,7 @@ import GroupItemName from './group_item_name.vue';
export default {
components: {
GlButton,
- GlTable,
+ GlTableLite,
GroupItemName,
TimeagoTooltip,
},
@@ -78,7 +78,7 @@ export default {
</script>
<template>
- <gl-table :items="subscriptions" :fields="$options.fields">
+ <gl-table-lite :items="subscriptions" :fields="$options.fields">
<template #cell(name)="{ item }">
<group-item-name :group="item.group" />
</template>
@@ -95,5 +95,5 @@ export default {
>{{ __('Unlink') }}</gl-button
>
</template>
- </gl-table>
+ </gl-table-lite>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js
index 320f0f8aa6c..3b584b5fe98 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/index.js
@@ -1,4 +1,4 @@
-import '../../webpack';
+import '~/webpack';
import setConfigs from '@gitlab/ui/dist/config';
import Vue from 'vue';
@@ -48,4 +48,4 @@ export function initJiraConnect() {
});
}
-document.addEventListener('DOMContentLoaded', initJiraConnect);
+initJiraConnect();
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index 1b6e365fdb2..af4a26a7352 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -12,7 +12,7 @@ import {
GlLoadingIcon,
GlSearchBoxByType,
GlSprintf,
- GlTable,
+ GlTableLite,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -45,7 +45,7 @@ export default {
GlLoadingIcon,
GlSearchBoxByType,
GlSprintf,
- GlTable,
+ GlTableLite,
},
currentUsername: gon.current_username,
dropdownLabel,
@@ -295,7 +295,7 @@ export default {
<p>{{ $options.userMappingMessage }}</p>
- <gl-table :fields="$options.tableConfig" :items="userMappings" fixed>
+ <gl-table-lite :fields="$options.tableConfig" :items="userMappings" fixed>
<template #cell(arrow)>
<gl-icon name="arrow-right" :aria-label="__('Will be mapped to')" />
</template>
@@ -326,9 +326,9 @@ export default {
</gl-dropdown-text>
</gl-dropdown>
</template>
- </gl-table>
+ </gl-table-lite>
- <gl-loading-icon v-if="isInitialLoadingState" size="sm" />
+ <gl-loading-icon v-if="isInitialLoadingState" size="md" />
<gl-button
v-if="hasMoreUsers"
@@ -343,7 +343,7 @@ export default {
<gl-button
type="submit"
category="primary"
- variant="success"
+ variant="confirm"
class="js-no-auto-disable"
:loading="isSubmitting"
data-qa-selector="jira_issues_import_button"
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 695a237bf50..c1701cd94c2 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -20,6 +20,7 @@ export default function mountJiraImportApp() {
return new Vue({
el,
+ name: 'JiraImportRoot',
apolloProvider,
render(createComponent) {
return createComponent(App, {
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index 9d451f94e8a..da72cbeb856 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -2,7 +2,7 @@
import { GlSprintf, GlLink } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import { __ } from '../../locale';
+import { __ } from '~/locale';
export default {
creatingEnvironment: 'creating',
diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue
new file mode 100644
index 00000000000..fe7b7428c6e
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlFilteredSearch } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import JobStatusToken from './tokens/job_status_token.vue';
+
+export default {
+ tokenTypes: {
+ status: 'status',
+ },
+ components: {
+ GlFilteredSearch,
+ },
+ computed: {
+ tokens() {
+ return [
+ {
+ type: this.$options.tokenTypes.status,
+ icon: 'status',
+ title: s__('Jobs|Status'),
+ unique: true,
+ token: JobStatusToken,
+ operators: OPERATOR_IS_ONLY,
+ },
+ ];
+ },
+ },
+ methods: {
+ onSubmit(filters) {
+ this.$emit('filterJobsBySearch', filters);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search
+ :placeholder="s__('Jobs|Filter jobs')"
+ :available-tokens="tokens"
+ @submit="onSubmit"
+ />
+</template>
diff --git a/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue b/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue
new file mode 100644
index 00000000000..aad86ded80a
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ statuses() {
+ return [
+ {
+ class: 'ci-status-icon-canceled',
+ icon: 'status_canceled',
+ text: s__('Job|Canceled'),
+ value: 'CANCELED',
+ },
+ {
+ class: 'ci-status-icon-created',
+ icon: 'status_created',
+ text: s__('Job|Created'),
+ value: 'CREATED',
+ },
+ {
+ class: 'ci-status-icon-failed',
+ icon: 'status_failed',
+ text: s__('Job|Failed'),
+ value: 'FAILED',
+ },
+ {
+ class: 'ci-status-icon-manual',
+ icon: 'status_manual',
+ text: s__('Job|Manual'),
+ value: 'MANUAL',
+ },
+ {
+ class: 'ci-status-icon-success',
+ icon: 'status_success',
+ text: s__('Job|Passed'),
+ value: 'SUCCESS',
+ },
+ {
+ class: 'ci-status-icon-pending',
+ icon: 'status_pending',
+ text: s__('Job|Pending'),
+ value: 'PENDING',
+ },
+ {
+ class: 'ci-status-icon-preparing',
+ icon: 'status_preparing',
+ text: s__('Job|Preparing'),
+ value: 'PREPARING',
+ },
+ {
+ class: 'ci-status-icon-running',
+ icon: 'status_running',
+ text: s__('Job|Running'),
+ value: 'RUNNING',
+ },
+ {
+ class: 'ci-status-icon-scheduled',
+ icon: 'status_scheduled',
+ text: s__('Job|Scheduled'),
+ value: 'SCHEDULED',
+ },
+ {
+ class: 'ci-status-icon-skipped',
+ icon: 'status_skipped',
+ text: s__('Job|Skipped'),
+ value: 'SKIPPED',
+ },
+ {
+ class: 'ci-status-icon-waiting-for-resource',
+ icon: 'status-waiting',
+ text: s__('Job|Waiting for resource'),
+ value: 'WAITING_FOR_RESOURCE',
+ },
+ ];
+ },
+ findActiveStatus() {
+ return this.statuses.find((status) => status.value === this.value.data);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
+ <template #view>
+ <div class="gl-display-flex gl-align-items-center">
+ <div :class="findActiveStatus.class">
+ <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" />
+ </div>
+ <span>{{ findActiveStatus.text }}</span>
+ </div>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="(status, index) in statuses"
+ :key="index"
+ :value="status.value"
+ >
+ <div class="gl-display-flex" :class="status.class">
+ <gl-icon :name="status.icon" class="gl-mr-3" />
+ <span>{{ status.text }}</span>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 753a15871ab..f16e0287d5d 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -171,6 +171,7 @@ export default {
data-testid="cancel-button"
icon="cancel"
:title="$options.CANCEL"
+ :aria-label="$options.CANCEL"
:disabled="cancelBtnDisabled"
@click="cancelJob()"
/>
@@ -182,6 +183,7 @@ export default {
v-gl-modal-directive="$options.playJobModalId"
icon="play"
:title="$options.ACTIONS_START_NOW"
+ :aria-label="$options.ACTIONS_START_NOW"
data-testid="play-scheduled"
/>
<gl-modal
@@ -196,6 +198,7 @@ export default {
<gl-button
icon="time-out"
:title="$options.ACTIONS_UNSCHEDULE"
+ :aria-label="$options.ACTIONS_UNSCHEDULE"
:disabled="unscheduleBtnDisabled"
data-testid="unschedule"
@click="unscheduleJob()"
@@ -207,6 +210,7 @@ export default {
v-if="manualJobPlayable"
icon="play"
:title="$options.ACTIONS_PLAY"
+ :aria-label="$options.ACTIONS_PLAY"
:disabled="playManualBtnDisabled"
data-testid="play"
@click="playJob()"
@@ -215,6 +219,7 @@ export default {
v-else-if="isRetryable"
icon="repeat"
:title="$options.ACTIONS_RETRY"
+ :aria-label="$options.ACTIONS_RETRY"
:method="currentJobMethod"
:disabled="retryBtnDisabled"
data-testid="retry"
@@ -226,6 +231,7 @@ export default {
v-if="shouldDisplayArtifacts"
icon="download"
:title="$options.ACTIONS_DOWNLOAD_ARTIFACTS"
+ :aria-label="$options.ACTIONS_DOWNLOAD_ARTIFACTS"
:href="artifactDownloadPath"
rel="nofollow"
download
diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
index 19594c4955d..120f01db8f0 100644
--- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { formatDate, getTimeago, durationTimeFormatted } from '~/lib/utils/datetime_utility';
export default {
iconSize: 12,
@@ -10,7 +10,6 @@ export default {
components: {
GlIcon,
},
- mixins: [timeagoMixin],
props: {
job: {
type: Object,
@@ -24,6 +23,15 @@ export default {
duration() {
return this.job?.duration;
},
+ timeFormatted() {
+ return getTimeago().format(this.finishedTime);
+ },
+ tooltipTitle() {
+ return formatDate(this.finishedTime);
+ },
+ durationFormatted() {
+ return durationTimeFormatted(this.duration);
+ },
},
};
</script>
@@ -32,18 +40,18 @@ export default {
<div>
<div v-if="duration" data-testid="job-duration">
<gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" />
- {{ durationTimeFormatted(duration) }}
+ {{ durationFormatted }}
</div>
<div v-if="finishedTime" data-testid="job-finished-time">
<gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" />
<time
v-gl-tooltip
- :title="tooltipTitle(finishedTime)"
+ :title="tooltipTitle"
:datetime="finishedTime"
data-placement="top"
data-container="body"
>
- {{ timeFormatted(finishedTime) }}
+ {{ timeFormatted }}
</time>
</div>
</div>
diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
index 951d9324813..853834ed51d 100644
--- a/app/assets/javascripts/jobs/components/table/constants.js
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -4,6 +4,9 @@ import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
/* Error constants */
export const POST_FAILURE = 'post_failure';
export const DEFAULT = 'default';
+export const RAW_TEXT_WARNING = s__(
+ 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.',
+);
/* Job Status Constants */
export const JOB_SCHEDULED = 'SCHEDULED';
diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
index b9946925c95..8bcd7ffd10f 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
+++ b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
@@ -13,16 +13,40 @@ export default {
merge(existing = {}, incoming, { args = {} }) {
let nodes;
+ const areNodesEqual = isEqual(existing.nodes, incoming.nodes);
+ const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses;
+ const { pageInfo } = incoming;
+
if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) {
- nodes = [...existing.nodes, ...incoming.nodes];
+ if (areNodesEqual) {
+ if (incoming.pageInfo.hasNextPage) {
+ nodes = [...existing.nodes, ...incoming.nodes];
+ } else {
+ nodes = [...incoming.nodes];
+ }
+ } else {
+ if (!existing.pageInfo?.hasNextPage) {
+ nodes = [...incoming.nodes];
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ count: incoming.count,
+ };
+ }
+
+ nodes = [...existing.nodes, ...incoming.nodes];
+ }
} else {
nodes = [...incoming.nodes];
}
return {
nodes,
- statuses: Array.isArray(args.statuses) ? [...args.statuses] : args.statuses,
- pageInfo: incoming.pageInfo,
+ statuses,
+ pageInfo,
+ count: incoming.count,
};
},
},
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index 151e49af87e..f3ca958b3ca 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -3,6 +3,7 @@ query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) {
id
__typename
jobs(after: $after, first: 30, statuses: $statuses) {
+ count
pageInfo {
endCursor
hasNextPage
diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js
index 1b9c7cdcfdd..88da1169e01 100644
--- a/app/assets/javascripts/jobs/components/table/index.js
+++ b/app/assets/javascripts/jobs/components/table/index.js
@@ -27,7 +27,6 @@ export default (containerId = 'js-jobs-table') => {
const {
fullPath,
- jobCounts,
jobStatuses,
pipelineEditorPath,
emptyStateSvgPath,
@@ -42,7 +41,6 @@ export default (containerId = 'js-jobs-table') => {
fullPath,
pipelineEditorPath,
jobStatuses: JSON.parse(jobStatuses),
- jobCounts: JSON.parse(jobCounts),
admin: parseBoolean(admin),
},
render(createElement) {
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
index 864e322eecd..3ea50dfb7a3 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -1,26 +1,34 @@
<script>
import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
+import createFlash from '~/flash';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
import JobsTableTabs from './jobs_table_tabs.vue';
+import { RAW_TEXT_WARNING } from './constants';
export default {
i18n: {
errorMsg: __('There was an error fetching the jobs for your project.'),
loadingAriaLabel: __('Loading'),
},
+ filterSearchBoxStyles:
+ 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-gray-100 gl-border-b',
components: {
GlAlert,
GlSkeletonLoader,
+ JobsFilteredSearch,
JobsTable,
JobsTableEmptyState,
JobsTableTabs,
GlIntersectionObserver,
GlLoadingIcon,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
fullPath: {
default: '',
@@ -35,10 +43,11 @@ export default {
};
},
update(data) {
- const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {};
+ const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data.project || {};
return {
list,
pageInfo,
+ count,
};
},
error() {
@@ -54,19 +63,52 @@ export default {
hasError: false,
isAlertDismissed: false,
scope: null,
- firstLoad: true,
+ infiniteScrollingTriggered: false,
+ filterSearchTriggered: false,
+ count: 0,
};
},
computed: {
+ loading() {
+ return this.$apollo.queries.jobs.loading;
+ },
shouldShowAlert() {
return this.hasError && !this.isAlertDismissed;
},
+ // Show when on All tab with no jobs
+ // Show only when not loading and filtered search has not been triggered
+ // So we don't show empty state when results are empty on a filtered search
showEmptyState() {
- return this.jobs.list.length === 0 && !this.scope;
+ return (
+ this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered
+ );
},
hasNextPage() {
return this.jobs?.pageInfo?.hasNextPage;
},
+ showLoadingSpinner() {
+ return this.loading && this.infiniteScrollingTriggered;
+ },
+ showSkeletonLoader() {
+ return this.loading && !this.showLoadingSpinner;
+ },
+ showFilteredSearch() {
+ return this.glFeatures?.jobsTableVueSearch && !this.scope;
+ },
+ jobsCount() {
+ return this.jobs.count;
+ },
+ },
+ watch: {
+ // this watcher ensures that the count on the all tab
+ // is not updated when switching to the finished tab
+ jobsCount(newCount, oldCount) {
+ if (this.scope) {
+ this.count = oldCount;
+ } else {
+ this.count = newCount;
+ }
+ },
},
mounted() {
eventHub.$on('jobActionPerformed', this.handleJobAction);
@@ -79,16 +121,38 @@ export default {
this.$apollo.queries.jobs.refetch({ statuses: this.scope });
},
fetchJobsByStatus(scope) {
- this.firstLoad = true;
+ this.infiniteScrollingTriggered = false;
this.scope = scope;
this.$apollo.queries.jobs.refetch({ statuses: scope });
},
+ filterJobsBySearch(filters) {
+ this.infiniteScrollingTriggered = false;
+ this.filterSearchTriggered = true;
+
+ // Eventually there will be more tokens available
+ // this code is written to scale for those tokens
+ filters.forEach((filter) => {
+ // Raw text input in filtered search does not have a type
+ // when a user enters raw text we alert them that it is
+ // not supported and we do not make an additional API call
+ if (!filter.type) {
+ createFlash({
+ message: RAW_TEXT_WARNING,
+ type: 'warning',
+ });
+ }
+
+ if (filter.type === 'status') {
+ this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
+ }
+ });
+ },
fetchMoreJobs() {
- this.firstLoad = false;
+ if (!this.loading) {
+ this.infiniteScrollingTriggered = true;
- if (!this.$apollo.queries.jobs.loading) {
this.$apollo.queries.jobs.fetchMore({
variables: {
fullPath: this.fullPath,
@@ -113,9 +177,19 @@ export default {
{{ $options.i18n.errorMsg }}
</gl-alert>
- <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" />
+ <jobs-table-tabs
+ :all-jobs-count="count"
+ :loading="loading"
+ @fetchJobsByStatus="fetchJobsByStatus"
+ />
+
+ <jobs-filtered-search
+ v-if="showFilteredSearch"
+ :class="$options.filterSearchBoxStyles"
+ @filterJobsBySearch="filterJobsBySearch"
+ />
- <div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
+ <div v-if="showSkeletonLoader" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73">
<circle cx="748.031" cy="37.7193" r="15.0307" />
<circle cx="787.241" cy="37.7193" r="15.0307" />
@@ -138,7 +212,7 @@ export default {
<gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
<gl-loading-icon
- v-if="$apollo.loading"
+ v-if="showLoadingSpinner"
size="md"
:aria-label="$options.i18n.loadingAriaLabel"
/>
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
index 26791e4284d..0a25dc5bea5 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
@@ -1,56 +1,56 @@
<script>
-import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
export default {
components: {
GlBadge,
GlTab,
GlTabs,
+ GlLoadingIcon,
},
inject: {
- jobCounts: {
- default: {},
- },
jobStatuses: {
default: {},
},
},
+ props: {
+ allJobsCount: {
+ type: Number,
+ required: true,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ },
computed: {
tabs() {
return [
{
- text: __('All'),
- count: this.jobCounts.all,
+ text: s__('Jobs|All'),
+ count: this.allJobsCount,
scope: null,
testId: 'jobs-all-tab',
+ showBadge: true,
},
{
- text: __('Pending'),
- count: this.jobCounts.pending,
- scope: this.jobStatuses.pending,
- testId: 'jobs-pending-tab',
- },
- {
- text: __('Running'),
- count: this.jobCounts.running,
- scope: this.jobStatuses.running,
- testId: 'jobs-running-tab',
- },
- {
- text: __('Finished'),
- count: this.jobCounts.finished,
+ text: s__('Jobs|Finished'),
scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled],
testId: 'jobs-finished-tab',
+ showBadge: false,
},
];
},
+ showLoadingIcon() {
+ return this.loading && !this.allJobsCount;
+ },
},
};
</script>
<template>
- <gl-tabs content-class="gl-pb-0">
+ <gl-tabs content-class="gl-py-0">
<gl-tab
v-for="tab in tabs"
:key="tab.text"
@@ -59,7 +59,11 @@ export default {
>
<template #title>
<span>{{ tab.text }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
+ <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" />
+
+ <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge">
+ {{ tab.count }}
+ </gl-badge>
</template>
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index b1ddede8fe8..1afc1c9a595 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlTable } from '@gitlab/ui';
+import { GlButton, GlTableLite } from '@gitlab/ui';
import { __ } from '~/locale';
const DEFAULT_TD_CLASSES = 'gl-w-half gl-font-sm! gl-border-gray-200!';
@@ -25,7 +25,7 @@ export default {
],
components: {
GlButton,
- GlTable,
+ GlTableLite,
},
props: {
trigger: {
@@ -84,7 +84,7 @@ export default {
>
</p>
- <gl-table :items="trigger.variables" :fields="$options.fields" small bordered fixed>
+ <gl-table-lite :items="trigger.variables" :fields="$options.fields" small bordered fixed>
<template #cell(key)="{ item }">
<span class="gl-overflow-break-word">{{ item.key }}</span>
</template>
@@ -92,7 +92,7 @@ export default {
<template #cell(value)="data">
<span class="gl-overflow-break-word">{{ getDisplayValue(data.value) }}</span>
</template>
- </gl-table>
+ </gl-table-lite>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 8bca448ee11..7dfe24afa23 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -1,4 +1,4 @@
-import { parseBoolean } from '../../lib/utils/common_utils';
+import { parseBoolean } from '~/lib/utils/common_utils';
/**
* Adds the line number property
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
new file mode 100644
index 00000000000..07388f1fdfa
--- /dev/null
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -0,0 +1,38 @@
+import { unified } from 'unified';
+import remarkParse from 'remark-parse';
+import remarkRehype from 'remark-rehype';
+import rehypeRaw from 'rehype-raw';
+
+const createParser = () => {
+ return unified().use(remarkParse).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw);
+};
+
+const compilerFactory = (renderer) =>
+ function compiler() {
+ Object.assign(this, {
+ Compiler(tree) {
+ return renderer(tree);
+ },
+ });
+ };
+
+/**
+ * Parses a Markdown string and provides the result Abstract
+ * Syntax Tree (AST) to a renderer function to convert the
+ * tree in any desired representation
+ *
+ * @param {String} params.markdown Markdown to parse
+ * @param {(tree: MDast -> any)} params.renderer A function that accepts mdast
+ * AST tree and returns an object of any type that represents the result of
+ * rendering the tree. See the references below to for more information
+ * about MDast.
+ *
+ * MDastTree documentation https://github.com/syntax-tree/mdast
+ * @returns {Promise<any>} Returns a promise with the result of rendering
+ * the MDast tree
+ */
+export const render = async ({ markdown, renderer }) => {
+ const { value } = await createParser().use(compilerFactory(renderer)).process(markdown);
+
+ return value;
+};
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index f533ba3671c..451950346b0 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -3,7 +3,7 @@ import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { createUploadLink } from 'apollo-upload-client';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
-import possibleTypes from '~/graphql_shared/possibleTypes.json';
+import possibleTypes from '~/graphql_shared/possible_types.json';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
import { objectToQuery, queryToObject } from '~/lib/utils/url_utility';
@@ -47,6 +47,9 @@ export const typePolicies = {
DesignCollection: {
merge: true,
},
+ TreeEntry: {
+ keyFields: ['webPath'],
+ },
};
export const stripWhitespaceFromQuery = (url, path) => {
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
index f3380b7b4ba..1d8eb73d3d7 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
@@ -26,6 +26,16 @@ export default {
required: false,
default: 'confirm',
},
+ secondaryText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ secondaryVariant: {
+ type: String,
+ required: false,
+ default: 'confirm',
+ },
modalHtmlMessage: {
type: String,
required: false,
@@ -39,7 +49,26 @@ export default {
},
computed: {
primaryAction() {
- return { text: this.primaryText, attributes: { variant: this.primaryVariant } };
+ return {
+ text: this.primaryText,
+ attributes: {
+ variant: this.primaryVariant,
+ 'data-qa-selector': 'confirm_ok_button',
+ },
+ };
+ },
+ secondaryAction() {
+ if (!this.secondaryText) {
+ return null;
+ }
+
+ return {
+ text: this.secondaryText,
+ attributes: {
+ variant: this.secondaryVariant,
+ category: 'secondary',
+ },
+ };
},
cancelAction() {
return this.hideCancel ? null : this.$options.cancelAction;
@@ -63,6 +92,7 @@ export default {
:title="title"
:action-primary="primaryAction"
:action-cancel="cancelAction"
+ :action-secondary="secondaryAction"
:hide-header="!shouldShowHeader"
@primary="$emit('confirmed')"
@hidden="$emit('closed')"
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
index a8a89d0644a..1adb6f9c26f 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
@@ -2,7 +2,15 @@ import Vue from 'vue';
export function confirmAction(
message,
- { primaryBtnVariant, primaryBtnText, modalHtmlMessage, title, hideCancel } = {},
+ {
+ primaryBtnVariant,
+ primaryBtnText,
+ secondaryBtnVariant,
+ secondaryBtnText,
+ modalHtmlMessage,
+ title,
+ hideCancel,
+ } = {},
) {
return new Promise((resolve) => {
let confirmed = false;
@@ -16,6 +24,8 @@ export function confirmAction(
'confirm-modal',
{
props: {
+ secondaryText: secondaryBtnText,
+ secondaryVariant: secondaryBtnVariant,
primaryVariant: primaryBtnVariant,
primaryText: primaryBtnText,
title,
diff --git a/app/assets/javascripts/lib/utils/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js
index 76ac442a470..e4f68dd1b6c 100644
--- a/app/assets/javascripts/lib/utils/css_utils.js
+++ b/app/assets/javascripts/lib/utils/css_utils.js
@@ -19,3 +19,7 @@ export function loadCSSFile(path) {
}
});
}
+
+export function getCssVariable(variable) {
+ return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
+}
diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
index 396c1703c1e..4e7086e62c5 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
@@ -1,5 +1,5 @@
import { isNumber } from 'lodash';
-import { __, n__ } from '../../../locale';
+import { __, n__ } from '~/locale';
import { getDayName, parseSeconds } from './date_format_utility';
const DAYS_IN_WEEK = 7;
@@ -189,13 +189,21 @@ export const getDateInFuture = (date, daysInFuture) =>
*/
export const isValidDate = (date) => date instanceof Date && !Number.isNaN(date.getTime());
-/*
+/**
* Appending T00:00:00 makes JS assume local time and prevents it from shifting the date
* to match the user's time zone. We want to display the date in server time for now, to
* be consistent with the "edit issue -> due date" UI.
+ *
+ * @param {String} date Date without time, e.g. `2022-03-22`
+ * @return {Date} new Date object
*/
-
export const newDateAsLocaleTime = (date) => {
+ if (!date || typeof date !== 'string') {
+ return null;
+ }
+ if (date.includes('T')) {
+ return new Date(date);
+ }
const suffix = 'T00:00:00';
return new Date(`${date}${suffix}`);
};
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 7bff2bf3e47..830f4604382 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -2,7 +2,7 @@ import dateFormat from 'dateformat';
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';
+import { s__, n__, __, sprintf } from '~/locale';
/**
* Returns i18n month names array.
@@ -386,3 +386,23 @@ export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, mont
}
return '-';
};
+
+export const durationTimeFormatted = (duration) => {
+ const date = new Date(duration * 1000);
+
+ let hh = date.getUTCHours();
+ let mm = date.getUTCMinutes();
+ let ss = date.getSeconds();
+
+ if (hh < 10) {
+ hh = `0${hh}`;
+ }
+ if (mm < 10) {
+ mm = `0${mm}`;
+ }
+ if (ss < 10) {
+ ss = `0${ss}`;
+ }
+
+ return `${hh}:${mm}:${ss}`;
+};
diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
index d68682ebed1..095a29a2eff 100644
--- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
@@ -1,5 +1,5 @@
import * as timeago from 'timeago.js';
-import { languageCode, s__, createDateTimeFormat } from '../../../locale';
+import { languageCode, s__, createDateTimeFormat } from '~/locale';
import { formatDate } from './date_format_utility';
/**
@@ -70,8 +70,41 @@ const memoizedLocale = () => {
};
};
+/**
+ * Registers timeago time duration
+ */
+const memoizedLocaleDuration = () => {
+ const cache = [];
+
+ const durations = [
+ () => [s__('Duration|%s seconds')],
+ () => [s__('Duration|%s seconds')],
+ () => [s__('Duration|1 minute')],
+ () => [s__('Duration|%s minutes')],
+ () => [s__('Duration|1 hour')],
+ () => [s__('Duration|%s hours')],
+ () => [s__('Duration|1 day')],
+ () => [s__('Duration|%s days')],
+ () => [s__('Duration|1 week')],
+ () => [s__('Duration|%s weeks')],
+ () => [s__('Duration|1 month')],
+ () => [s__('Duration|%s months')],
+ () => [s__('Duration|1 year')],
+ () => [s__('Duration|%s years')],
+ ];
+
+ return (_, index) => {
+ if (cache[index]) {
+ return cache[index];
+ }
+ cache[index] = durations[index] && durations[index]();
+ return cache[index];
+ };
+};
+
timeago.register(timeagoLanguageCode, memoizedLocale());
timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
+timeago.register(`${timeagoLanguageCode}-duration`, memoizedLocaleDuration());
let memoizedFormatter = null;
@@ -133,3 +166,16 @@ export const timeFor = (time, expiredLabel) => {
}
return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim();
};
+
+/**
+ * Returns a duration of time given an amount.
+ *
+ * @param {number} milliseconds - Duration in milliseconds.
+ * @returns {string} A formatted duration, e.g. "10 minutes".
+ */
+export const duration = (milliseconds) => {
+ const now = new Date();
+ return timeago
+ .format(now.getTime() - Math.abs(milliseconds), `${timeagoLanguageCode}-duration`)
+ .trim();
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index ac2eb34260c..52fa90c7791 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -9,7 +9,10 @@ const LINK_TAG_PATTERN = '[{text}](url)';
// a bullet point character (*+-) and an optional checkbox ([ ] [x])
// OR a number with a . after it and an optional checkbox ([ ] [x])
// followed by one or more whitespace characters
-const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/;
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/;
+
+// detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>)
+const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/;
function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
@@ -381,16 +384,20 @@ function handleContinueList(e, textArea) {
let itemToInsert;
+ // Behaviors specific to either `ol` or `ul`
if (isOl) {
const nextLine = lineAfter(textArea.value, textArea, false);
const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN);
itemToInsert = continueOlText(result, nextLineResult);
} else {
- // isUl
+ if (currentLine.match(HR_PATTERN)) return;
+
itemToInsert = `${indent}${leader}`;
}
+ itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]');
+
e.preventDefault();
updateText({
diff --git a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
index 418cc69bf5a..08c32944181 100644
--- a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
+++ b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
@@ -5,7 +5,7 @@ import { formatNumber } from '~/locale';
*
* @param {Number} number to be converted
*
- * @param {options.maxCharLength} Max output char length at the
+ * @param {options.maxLength} Max output char length at the
* expense of precision, if the output is longer than this,
* the formatter switches to using exponential notation.
*
@@ -16,10 +16,10 @@ import { formatNumber } from '~/locale';
* `formatNumber` such as `valueFactor`, `unit` and `style`.
*
*/
-const formatNumberNormalized = (value, { maxCharLength, valueFactor = 1, ...options }) => {
+const formatNumberNormalized = (value, { maxLength, valueFactor = 1, ...options }) => {
const formatted = formatNumber(value * valueFactor, options);
- if (maxCharLength !== undefined && formatted.length > maxCharLength) {
+ if (maxLength !== undefined && formatted.length > maxLength) {
// 123456 becomes 1.23e+8
return value.toExponential(2);
}
@@ -27,6 +27,25 @@ const formatNumberNormalized = (value, { maxCharLength, valueFactor = 1, ...opti
};
/**
+ * This function converts the old positional arguments into an options
+ * object.
+ *
+ * This is done so we can support legacy fractionDigits and maxLength as positional
+ * arguments, as well as the better options object.
+ *
+ * @param {Object|Number} options
+ * @returns {Object} options given to the formatter
+ */
+const getFormatterArguments = (options) => {
+ if (typeof options === 'object' && options !== null) {
+ return options;
+ }
+ return {
+ maxLength: options,
+ };
+};
+
+/**
* Formats a number as a string scaling it up according to units.
*
* While the number is scaled down, the units are scaled up.
@@ -40,7 +59,9 @@ const scaledFormatter = (units, unitFactor = 1000) => {
return new RangeError(`unitFactor cannot have the value 0.`);
}
- return (value, fractionDigits) => {
+ return (value, fractionDigits, options) => {
+ const { maxLength, unitSeparator = '' } = getFormatterArguments(options);
+
if (value === null) {
return '';
}
@@ -66,11 +87,13 @@ const scaledFormatter = (units, unitFactor = 1000) => {
}
const unit = units[scale];
+ const length = maxLength !== undefined ? maxLength - unit.length : undefined;
return `${formatNumberNormalized(num, {
+ maxLength: length,
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
- })}${unit}`;
+ })}${unitSeparator}${unit}`;
};
};
@@ -78,14 +101,16 @@ const scaledFormatter = (units, unitFactor = 1000) => {
* Returns a function that formats a number as a string.
*/
export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
- return (value, fractionDigits, maxCharLength) => {
- return `${formatNumberNormalized(value, {
- maxCharLength,
+ return (value, fractionDigits, options) => {
+ const { maxLength } = getFormatterArguments(options);
+
+ return formatNumberNormalized(value, {
+ maxLength,
valueFactor,
style,
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
- })}`;
+ });
};
};
@@ -93,15 +118,16 @@ export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
* Returns a function that formats a number as a string with a suffix.
*/
export const suffixFormatter = (unit = '', valueFactor = 1) => {
- return (value, fractionDigits, maxCharLength) => {
- const length = maxCharLength !== undefined ? maxCharLength - unit.length : undefined;
+ return (value, fractionDigits, options) => {
+ const { maxLength, unitSeparator = '' } = getFormatterArguments(options);
+ const length = maxLength !== undefined ? maxLength - unit.length : undefined;
return `${formatNumberNormalized(value, {
- maxCharLength: length,
+ maxLength: length,
valueFactor,
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
- })}${unit}`;
+ })}${unitSeparator}${unit}`;
};
};
diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js
index bc82c6aa74d..5c5210027e4 100644
--- a/app/assets/javascripts/lib/utils/unit_format/index.js
+++ b/app/assets/javascripts/lib/utils/unit_format/index.js
@@ -126,9 +126,11 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
*
* @function
* @param {Number} value - Number to format
- * @param {Number} fractionDigits - precision decimals
- * @param {Number} maxLength - Max length of formatted number
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
* if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const number = getFormatter(SUPPORTED_FORMATS.number);
@@ -137,9 +139,11 @@ export const number = getFormatter(SUPPORTED_FORMATS.number);
*
* @function
* @param {Number} value - Number to format, `1` is rendered as `100%`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
* if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const percent = getFormatter(SUPPORTED_FORMATS.percent);
@@ -148,9 +152,11 @@ export const percent = getFormatter(SUPPORTED_FORMATS.percent);
*
* @function
* @param {Number} value - Number to format, `100` is rendered as `100%`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
* if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
@@ -159,9 +165,11 @@ export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
*
* @function
* @param {Number} value - Number to format, `1` is rendered as `1s`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
* if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const seconds = getFormatter(SUPPORTED_FORMATS.seconds);
@@ -170,9 +178,11 @@ export const seconds = getFormatter(SUPPORTED_FORMATS.seconds);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1ms`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
* if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const milliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds);
@@ -182,7 +192,11 @@ export const milliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1B`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const decimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes);
@@ -192,7 +206,11 @@ export const decimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1kB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const kilobytes = getFormatter(SUPPORTED_FORMATS.kilobytes);
@@ -202,7 +220,11 @@ export const kilobytes = getFormatter(SUPPORTED_FORMATS.kilobytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1MB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const megabytes = getFormatter(SUPPORTED_FORMATS.megabytes);
@@ -212,7 +234,11 @@ export const megabytes = getFormatter(SUPPORTED_FORMATS.megabytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const gigabytes = getFormatter(SUPPORTED_FORMATS.gigabytes);
@@ -222,7 +248,11 @@ export const gigabytes = getFormatter(SUPPORTED_FORMATS.gigabytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const terabytes = getFormatter(SUPPORTED_FORMATS.terabytes);
@@ -232,7 +262,11 @@ export const terabytes = getFormatter(SUPPORTED_FORMATS.terabytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1PB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const petabytes = getFormatter(SUPPORTED_FORMATS.petabytes);
@@ -242,7 +276,11 @@ export const petabytes = getFormatter(SUPPORTED_FORMATS.petabytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1B`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const bytes = getFormatter(SUPPORTED_FORMATS.bytes);
@@ -252,7 +290,11 @@ export const bytes = getFormatter(SUPPORTED_FORMATS.bytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1kB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const kibibytes = getFormatter(SUPPORTED_FORMATS.kibibytes);
@@ -262,7 +304,11 @@ export const kibibytes = getFormatter(SUPPORTED_FORMATS.kibibytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1MB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const mebibytes = getFormatter(SUPPORTED_FORMATS.mebibytes);
@@ -272,7 +318,11 @@ export const mebibytes = getFormatter(SUPPORTED_FORMATS.mebibytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const gibibytes = getFormatter(SUPPORTED_FORMATS.gibibytes);
@@ -282,7 +332,11 @@ export const gibibytes = getFormatter(SUPPORTED_FORMATS.gibibytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const tebibytes = getFormatter(SUPPORTED_FORMATS.tebibytes);
@@ -292,7 +346,11 @@ export const tebibytes = getFormatter(SUPPORTED_FORMATS.tebibytes);
*
* @function
* @param {Number} value - Number to format, `1` is formatted as `1PB`
- * @param {Number} fractionDigits - number of precision decimals
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - number of precision decimals
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const pebibytes = getFormatter(SUPPORTED_FORMATS.pebibytes);
@@ -301,6 +359,10 @@ export const pebibytes = getFormatter(SUPPORTED_FORMATS.pebibytes);
*
* @function
* @param {Number} value - Value to format
- * @param {Number} fractionDigits - precision decimals - Defaults to 2
+ * @param {Object} options - Formatting options
+ * @param {Number} options.fractionDigits - precision decimals, defaults to 2
+ * @param {Number} options.maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ * @param {String} options.unitSeparator - Separator between value and unit
*/
export const engineering = getFormatter();
diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js
index a88f1bd82fc..38d2f3d7551 100644
--- a/app/assets/javascripts/lib/utils/webpack.js
+++ b/app/assets/javascripts/lib/utils/webpack.js
@@ -10,5 +10,5 @@ export function resetServiceWorkersPublicPath() {
// see: https://webpack.js.org/guides/public-path/
const relativeRootPath = (gon && gon.relative_url_root) || '';
const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/');
- __webpack_public_path__ = webpackAssetPath; // eslint-disable-line babel/camelcase
+ __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase
}
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index b0d31ca315e..609592edc3b 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -163,7 +163,7 @@ export default {
<gl-sprintf
:message="
s__(
- 'Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.',
+ 'Deprecations|The logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.',
)
"
>
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index b3cb93e74f2..8fc54be9c28 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -127,7 +127,8 @@ function deferredInitialisation() {
// In case the user started searching before we bootstrapped, let's pass the search along.
const initialSearchValue = searchInputBox.value;
await initHeaderSearchApp(initialSearchValue);
- searchInputBox.focus();
+ // this is new #search input element. We need to re-find it.
+ document.querySelector('#search').focus();
})
.catch(() => {});
} else {
diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
index 00973100e15..112f722c632 100644
--- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
@@ -40,7 +40,7 @@ export default {
:title="$options.title"
:aria-label="$options.title"
icon="check"
- variant="success"
+ variant="confirm"
type="submit"
/>
</gl-form>
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index ca60f876c6f..cb7b963b698 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -18,6 +18,8 @@ export default {
name: 'MembersFilteredSearchBar',
components: { FilteredSearchBar },
availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS,
+ searchButtonAttributes: { 'data-qa-selector': 'search_button' },
+ searchInputAttributes: { 'data-qa-selector': 'search_bar_input' },
inject: {
namespace: {},
sourceId: {},
@@ -127,8 +129,9 @@ export default {
:recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey"
:search-input-placeholder="filteredSearchBar.placeholder"
:initial-filter-value="initialFilterValue"
+ :search-button-attributes="$options.searchButtonAttributes"
+ :search-input-attributes="$options.searchInputAttributes"
data-testid="members-filtered-search-bar"
- data-qa-selector="members_filtered_search_bar_content"
@onFilter="handleFilter"
/>
</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index b4ba9aa36e7..0b97ce7e33e 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -5,6 +5,7 @@ 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 UserDate from '~/vue_shared/components/user_date.vue';
import {
FIELD_KEY_ACTIONS,
FIELDS,
@@ -40,6 +41,7 @@ export default {
RemoveGroupLinkModal,
RemoveMemberModal,
ExpirationDatepicker,
+ UserDate,
LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
@@ -287,6 +289,14 @@ export default {
</members-table-cell>
</template>
+ <template #cell(userCreatedAt)="{ item: member }">
+ <user-date :date="member.user.createdAt" />
+ </template>
+
+ <template #cell(lastActivityOn)="{ item: member }">
+ <user-date :date="member.user.lastActivityOn" />
+ </template>
+
<template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 49ce00a1689..c66a19c4765 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -9,6 +9,8 @@ export const FIELD_KEY_GRANTED = 'granted';
export const FIELD_KEY_INVITED = 'invited';
export const FIELD_KEY_REQUESTED = 'requested';
export const FIELD_KEY_MAX_ROLE = 'maxRole';
+export const FIELD_KEY_USER_CREATED_AT = 'userCreatedAt';
+export const FIELD_KEY_LAST_ACTIVITY_ON = 'lastActivityOn';
export const FIELD_KEY_EXPIRATION = 'expiration';
export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn';
export const FIELD_KEY_ACTIONS = 'actions';
@@ -67,6 +69,22 @@ export const FIELDS = [
tdClass: 'col-expiration',
},
{
+ key: FIELD_KEY_USER_CREATED_AT,
+ label: __('Created on'),
+ sort: {
+ asc: 'oldest_created_user',
+ desc: 'recent_created_user',
+ },
+ },
+ {
+ key: FIELD_KEY_LAST_ACTIVITY_ON,
+ label: __('Last activity'),
+ sort: {
+ asc: 'oldest_last_activity',
+ desc: 'recent_last_activity',
+ },
+ },
+ {
key: FIELD_KEY_LAST_SIGN_IN,
label: __('Last sign-in'),
sort: {
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index 05f086c8f4f..7ec083646e9 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -32,7 +32,7 @@ export const isGroup = (member) => {
};
export const isDirectMember = (member) => {
- return isGroup(member) || member.isDirectMember;
+ return member.isDirectMember;
};
export const isCurrentUser = (member, currentUserId) => {
diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js
index e42703ef0a5..cf7a7c304e3 100644
--- a/app/assets/javascripts/merge_conflicts/utils.js
+++ b/app/assets/javascripts/merge_conflicts/utils.js
@@ -9,7 +9,7 @@ import {
export const getFilePath = (file) => {
const { old_path, new_path } = file;
- // eslint-disable-next-line babel/camelcase
+ // eslint-disable-next-line camelcase
return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
};
@@ -71,7 +71,7 @@ export const getLineForParallelView = (line, id, lineType, isHead) => {
isHead: hasConflict && isHead,
isOrigin: hasConflict && !isHead,
hasMatch: lineType === 'match',
- // eslint-disable-next-line babel/camelcase
+ // eslint-disable-next-line camelcase
lineNumber: isHead ? new_line : old_line,
section: isHead ? 'head' : 'origin',
richText: rich_text,
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 244cf1e150a..829e2264152 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -72,11 +72,15 @@ MergeRequest.prototype.initMRBtnListeners = function () {
const wipEvent = getParameterValues('merge_request[wip_event]', url)[0];
const mobileDropdown = draftToggle.closest('.dropdown.show');
+ const loader = document.createElement('span');
+ loader.classList.add('gl-spinner', 'gl-mr-3');
+
if (mobileDropdown) {
$(mobileDropdown.firstElementChild).dropdown('toggle');
}
draftToggle.setAttribute('disabled', 'disabled');
+ draftToggle.prepend(loader);
axios
.put(draftToggle.href, null, { params: { format: 'json' } })
@@ -124,7 +128,7 @@ MergeRequest.prototype.submitNoteForm = function (form, $button) {
MergeRequest.decreaseCounter = function (by = 1) {
const $el = $('.js-merge-counter');
- const count = Math.max(parseInt($el.text().replace(/[^\d]/, ''), 10) - by, 0);
+ const count = Math.max(parseInt($el.first().text().replace(/[^\d]/, ''), 10) - by, 0);
$el.text(addDelimiter(count));
};
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue
index a840e696386..d7ffdfd7c5f 100644
--- a/app/assets/javascripts/milestones/components/milestone_combobox.vue
+++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue
@@ -192,10 +192,12 @@ export default {
@keydown.enter.prevent="onSearchBoxEnter"
/>
- <gl-dropdown-item @click="selectNoMilestone()">
- <span :class="{ 'gl-pl-6': true, 'selected-item': selectedMilestones.length === 0 }">
- {{ $options.translations.noMilestone }}
- </span>
+ <gl-dropdown-item
+ :is-checked="selectedMilestones.length === 0"
+ is-check-item
+ @click="selectNoMilestone()"
+ >
+ {{ $options.translations.noMilestone }}
</gl-dropdown-item>
<gl-dropdown-divider />
@@ -241,9 +243,10 @@ export default {
v-for="(item, idx) in extraLinks"
:key="idx"
:href="item.url"
+ :is-check-item="true"
data-testid="milestone-combobox-extra-links"
>
- <span class="gl-pl-6">{{ item.text }}</span>
+ {{ item.text }}
</gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/milestones/components/milestone_results_section.vue b/app/assets/javascripts/milestones/components/milestone_results_section.vue
index b866977b974..e3c691b14c7 100644
--- a/app/assets/javascripts/milestones/components/milestone_results_section.vue
+++ b/app/assets/javascripts/milestones/components/milestone_results_section.vue
@@ -77,10 +77,14 @@ export default {
</div>
</template>
<template v-else>
- <gl-dropdown-item v-for="{ title } in items" :key="title" @click="$emit('selected', title)">
- <span class="gl-pl-6" :class="{ 'selected-item': isSelectedMilestone(title) }">
- {{ title }}
- </span>
+ <gl-dropdown-item
+ v-for="{ title } in items"
+ :key="title"
+ :is-checked="isSelectedMilestone(title)"
+ is-check-item
+ @click="$emit('selected', title)"
+ >
+ {{ title }}
</gl-dropdown-item>
<gl-dropdown-divider />
</template>
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 5138c450feb..e375435436e 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -76,7 +76,7 @@ export default class SSHMirror {
// Disable button while we make request
this.$btnDetectHostKeys.disable();
- $btnLoadSpinner.removeClass('d-none');
+ $btnLoadSpinner.removeClass('gl-display-none');
// Make backOff polling to get data
backOff((next, stop) => {
@@ -101,7 +101,7 @@ export default class SSHMirror {
.catch(stop);
})
.then((res) => {
- $btnLoadSpinner.addClass('d-none');
+ $btnLoadSpinner.addClass('gl-display-none');
// Once data is received, we show verification info along with Host keys and fingerprints
this.$hostKeysInformation
.find('.js-fingerprint-verification')
diff --git a/app/assets/javascripts/monitoring/components/charts/bar.vue b/app/assets/javascripts/monitoring/components/charts/bar.vue
index 1e0f4b10297..df91bd078d1 100644
--- a/app/assets/javascripts/monitoring/components/charts/bar.vue
+++ b/app/assets/javascripts/monitoring/components/charts/bar.vue
@@ -36,12 +36,12 @@ export default {
return xLabel;
},
yAxisTitle() {
- const { y_label = '' } = this.graphData;
- return y_label; // eslint-disable-line babel/camelcase
+ const { y_label: yLabel = '' } = this.graphData;
+ return yLabel;
},
xAxisType() {
- const { x_type = 'value' } = this.graphData;
- return x_type; // eslint-disable-line babel/camelcase
+ const { x_type: xType = 'value' } = this.graphData;
+ return xType;
},
dataZoomConfig() {
const handleIcon = this.svgs['scroll-handle'];
diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
index bfaf8b2bd28..288487d25a5 100644
--- a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
+++ b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
@@ -55,7 +55,7 @@ export default {
{{ s__('Metrics|View documentation') }}
</gl-button>
<gl-button
- variant="success"
+ variant="confirm"
data-testid="create-dashboard-modal-repo-button"
:href="projectPath"
>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 6467d953500..c4392dd3748 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -409,17 +409,13 @@ export default {
<div>
<gl-alert
v-if="!isDeprecationNoticeDismissed"
- :title="__('Feature deprecation and removal')"
+ :title="__('Feature deprecation')"
class="mb-3"
- variant="danger"
+ variant="warning"
@dismiss="isDeprecationNoticeDismissed = true"
>
<gl-sprintf
- :message="
- s__(
- 'Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.',
- )
- "
+ :message="s__('Deprecations|The metrics feature was deprecated in GitLab 14.7.')"
>
<template #epic="{ content }">
<gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 1238996154d..568c66cf152 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -48,8 +48,8 @@ export default {
},
filteredDashboards() {
- return this.allDashboards.filter(({ display_name = '' }) =>
- display_name.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ return this.allDashboards.filter(({ display_name: displayName = '' }) =>
+ displayName.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
},
shouldShowNoMsgContainer() {
diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql
index 32b982ff195..32b982ff195 100644
--- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql
+++ b/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql
diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql
index a61d601cd34..a61d601cd34 100644
--- a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql
+++ b/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql
diff --git a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql b/app/assets/javascripts/monitoring/queries/get_environments.query.graphql
index 48d0a780fc7..48d0a780fc7 100644
--- a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql
+++ b/app/assets/javascripts/monitoring/queries/get_environments.query.graphql
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 215b4b7b2d7..5c99dbc0d98 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -2,13 +2,13 @@ import * as Sentry from '@sentry/browser';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
-import { s__, sprintf } from '../../locale';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { s__, sprintf } from '~/locale';
import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants';
import trackDashboardLoad from '../monitoring_tracking_helper';
-import getAnnotations from '../queries/getAnnotations.query.graphql';
-import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql';
-import getEnvironments from '../queries/getEnvironments.query.graphql';
+import getAnnotations from '../queries/get_annotations.query.graphql';
+import getDashboardValidationWarnings from '../queries/get_dashboard_validation_warnings.query.graphql';
+import getEnvironments from '../queries/get_environments.query.graphql';
import { getDashboard, getPrometheusQueryData } from '../requests';
import * as types from './mutation_types';
@@ -385,7 +385,7 @@ export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) =
dashboardPath,
},
})
- .then((resp) => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard)
+ .then((resp) => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard || undefined)
.then(({ schemaValidationWarnings } = {}) => {
const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0;
/**
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 20f7c5cdb60..7f75a501635 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -29,7 +29,7 @@ export const gqClient = createGqClient(
* @param {String} metric.id - User-defined identifier
* @returns {Object} - normalized metric with a uniqueID
*/
-// eslint-disable-next-line babel/camelcase
+// eslint-disable-next-line camelcase
export const uniqMetricsId = ({ metric_id, id }) => `${metric_id || NOT_IN_DB_PREFIX}_${id}`;
/**
@@ -45,7 +45,7 @@ export const removeLeadingSlash = (str) => (str || '').replace(/^\/+/, '');
/**
* GraphQL environments API returns only id and name.
* For the environments dropdown we need metrics_path.
- * This method parses the results and add neccessart attrs
+ * This method parses the results and add necessary attrs
*
* @param {Array} response Environments API result
* @param {String} projectPath Current project path
@@ -57,7 +57,7 @@ export const parseEnvironmentsResponse = (response = [], projectPath) =>
return {
...env,
id,
- metrics_path: `${projectPath}/environments/${id}/metrics`,
+ metrics_path: `${projectPath}/-/metrics?environment=${id}`,
};
});
@@ -169,10 +169,10 @@ export const mapPanelToViewModel = ({
id = null,
title = '',
type,
- x_axis = {},
+ x_axis = {}, // eslint-disable-line camelcase
x_label,
y_label,
- y_axis = {},
+ y_axis = {}, // eslint-disable-line camelcase
field,
metrics = [],
links = [],
@@ -184,11 +184,11 @@ export const mapPanelToViewModel = ({
}) => {
// Both `x_axis.name` and `x_label` are supported for now
// https://gitlab.com/gitlab-org/gitlab/issues/210521
- const xAxis = mapXAxisToViewModel({ name: x_label, ...x_axis }); // eslint-disable-line babel/camelcase
+ const xAxis = mapXAxisToViewModel({ name: x_label, ...x_axis }); // eslint-disable-line camelcase
// Both `y_axis.name` and `y_label` are supported for now
// https://gitlab.com/gitlab-org/gitlab/issues/208385
- const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line babel/camelcase
+ const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line camelcase
return {
id,
@@ -295,7 +295,7 @@ export const mapToDashboardViewModel = ({
dashboard = '',
templating = {},
links = [],
- panel_groups = [],
+ panel_groups = [], // eslint-disable-line camelcase
}) => {
return {
dashboard,
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 336b613b620..221f28e923b 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -294,7 +294,7 @@ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.
if (params.group || params.title || params.y_label) {
const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group);
const panel = panelGroup.panels.find(
- // eslint-disable-next-line babel/camelcase
+ // eslint-disable-next-line camelcase
({ y_label, title }) => y_label === params.y_label && title === params.title,
);
diff --git a/app/assets/javascripts/mr_notes/stores/actions.js b/app/assets/javascripts/mr_notes/stores/actions.js
index bc66d1dd68f..0200a8aefc8 100644
--- a/app/assets/javascripts/mr_notes/stores/actions.js
+++ b/app/assets/javascripts/mr_notes/stores/actions.js
@@ -10,23 +10,14 @@ export function setEndpoints({ commit }, endpoints) {
commit(types.SET_ENDPOINTS, endpoints);
}
-export function setMrMetadata({ commit }, metadata) {
- commit(types.SET_MR_METADATA, metadata);
-}
-
-export function fetchMrMetadata({ dispatch, state }) {
+export async function fetchMrMetadata({ state, commit }) {
if (state.endpoints?.metadata) {
- axios
- .get(state.endpoints.metadata)
- .then((response) => {
- dispatch('setMrMetadata', response.data);
- })
- .catch(() => {
- // https://gitlab.com/gitlab-org/gitlab/-/issues/324740
- // We can't even do a simple console warning here because
- // the pipeline will fail. However, the issue above will
- // eventually handle errors appropriately.
- // console.warn('Failed to load MR Metadata for the Overview tab.');
- });
+ commit(types.SET_FAILED_TO_LOAD_METADATA, false);
+ try {
+ const { data } = await axios.get(state.endpoints.metadata);
+ commit(types.SET_MR_METADATA, data);
+ } catch (error) {
+ commit(types.SET_FAILED_TO_LOAD_METADATA, true);
+ }
}
}
diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js
index 52e12ba664c..75b2b2f4dc6 100644
--- a/app/assets/javascripts/mr_notes/stores/modules/index.js
+++ b/app/assets/javascripts/mr_notes/stores/modules/index.js
@@ -7,6 +7,7 @@ export default () => ({
endpoints: {},
activeTab: null,
mrMetadata: {},
+ failedToLoadMetadata: false,
},
actions,
getters,
diff --git a/app/assets/javascripts/mr_notes/stores/mutation_types.js b/app/assets/javascripts/mr_notes/stores/mutation_types.js
index 88cf6e48988..91d75e77a60 100644
--- a/app/assets/javascripts/mr_notes/stores/mutation_types.js
+++ b/app/assets/javascripts/mr_notes/stores/mutation_types.js
@@ -2,4 +2,5 @@ export default {
SET_ACTIVE_TAB: 'SET_ACTIVE_TAB',
SET_ENDPOINTS: 'SET_ENDPOINTS',
SET_MR_METADATA: 'SET_MR_METADATA',
+ SET_FAILED_TO_LOAD_METADATA: 'SET_FAILED_TO_LOAD_METADATA',
};
diff --git a/app/assets/javascripts/mr_notes/stores/mutations.js b/app/assets/javascripts/mr_notes/stores/mutations.js
index 6af6adb4e18..8b17f63cfb1 100644
--- a/app/assets/javascripts/mr_notes/stores/mutations.js
+++ b/app/assets/javascripts/mr_notes/stores/mutations.js
@@ -10,4 +10,7 @@ export default {
[types.SET_MR_METADATA](state, metadata) {
Object.assign(state, { mrMetadata: metadata });
},
+ [types.SET_FAILED_TO_LOAD_METADATA](state, value) {
+ Object.assign(state, { failedToLoadMetadata: value });
+ },
};
diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue
index d99a3adb358..fef75b6d5d0 100644
--- a/app/assets/javascripts/mr_popover/components/mr_popover.vue
+++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue
@@ -1,8 +1,8 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
-import CiIcon from '../../vue_shared/components/ci_icon.vue';
-import timeagoMixin from '../../vue_shared/mixins/timeago';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
import { mrStates, humanMRStates } from '../constants';
import query from '../queries/merge_request.query.graphql';
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index 71894b4ff3e..5ae68d22667 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -1,8 +1,8 @@
/* eslint-disable func-names, consistent-return */
import $ from 'jquery';
-import axios from '../lib/utils/axios_utils';
-import { __ } from '../locale';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
import Raphael from './raphael';
export default class BranchGraph {
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 0ce1eb8191a..45d97f278dc 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -6,7 +6,7 @@ import { mapActions } from 'vuex';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import noteEditedText from './note_edited_text.vue';
import noteHeader from './note_header.vue';
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index b4f7ba5f960..e2b0c7fee32 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -9,7 +9,7 @@ import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { getDiffMode } from '~/diffs/store/utils';
import { diffViewerModes } from '~/ide/constants';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
-import { isCollapsed } from '../../diffs/utils/diff_file';
+import { isCollapsed } from '~/diffs/utils/diff_file';
const FIRST_CHAR_REGEX = /^(\+|-| )/;
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index d5a7fc36ace..15887c2738d 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
-import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
+import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
HISTORY_ONLY_FILTER_VALUE,
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index e2a2edd7344..1bd2f879e6c 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -9,7 +9,7 @@ import { __, sprintf } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { splitCamelCase } from '../../lib/utils/text_utility';
+import { splitCamelCase } from '~/lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
export default {
@@ -292,40 +292,18 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve"
/>
- <template v-if="canAwardEmoji">
- <emoji-picker
- v-if="glFeatures.improvedEmojiPicker"
- toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!"
- @click="setAwardEmoji"
- >
- <template #button-content>
- <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" />
- <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" />
- <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" />
- </template>
- </emoji-picker>
- <gl-button
- v-else
- v-gl-tooltip
- :class="{ 'js-user-authored': isAuthoredByCurrentUser }"
- class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji"
- category="tertiary"
- variant="default"
- :title="$options.i18n.addReactionLabel"
- :aria-label="$options.i18n.addReactionLabel"
- data-position="right"
- >
- <span class="reaction-control-icon reaction-control-icon-neutral">
- <gl-icon name="slight-smile" />
- </span>
- <span class="reaction-control-icon reaction-control-icon-positive">
- <gl-icon name="smiley" />
- </span>
- <span class="reaction-control-icon reaction-control-icon-super-positive">
- <gl-icon name="smile" />
- </span>
- </gl-button>
- </template>
+ <emoji-picker
+ v-if="canAwardEmoji"
+ toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!"
+ data-testid="note-emoji-button"
+ @click="setAwardEmoji"
+ >
+ <template #button-content>
+ <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" />
+ <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" />
+ <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" />
+ </template>
+ </emoji-picker>
<reply-button
v-if="showReply"
ref="replyButton"
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index f465ad23a06..fe17a061c0a 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -57,14 +57,15 @@ export default {
computed: {
...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']),
...mapGetters('diffs', ['suggestionCommitMessage']),
+ ...mapState({
+ batchSuggestionsInfo: (state) => state.notes.batchSuggestionsInfo,
+ failedToLoadMetadata: (state) => state.page.failedToLoadMetadata,
+ }),
discussion() {
if (!this.note.isDraft) return {};
return this.getDiscussion(this.note.discussion_id);
},
- ...mapState({
- batchSuggestionsInfo: (state) => state.notes.batchSuggestionsInfo,
- }),
noteBody() {
return this.note.note;
},
@@ -165,6 +166,7 @@ export default {
:line-type="lineType"
:help-page-path="helpPagePath"
:default-commit-message="commitMessage"
+ :failed-to-load-metadata="failedToLoadMetadata"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
@@ -174,7 +176,6 @@ export default {
<note-form
v-if="isEditing"
ref="noteForm"
- :is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
:line="line"
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index 7c052320c98..03cbdf45ddd 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -1,6 +1,6 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'EditedNoteText',
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index ee22c118e11..c1e763d81ee 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -41,10 +41,6 @@ export default {
required: false,
default: () => ({}),
},
- isEditing: {
- type: Boolean,
- required: true,
- },
lineCode: {
type: String,
required: false,
@@ -184,7 +180,7 @@ export default {
return this.getNotesDataByProp('markdownDocsPath');
},
quickActionsDocsPath() {
- return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined;
+ return this.getNotesDataByProp('quickActionsDocsPath');
},
currentUserId() {
return this.getUserDataByProp('id');
@@ -348,7 +344,7 @@ export default {
ref="textarea"
v-model="updatedNoteBody"
:disabled="isSubmitting"
- :data-supports-quick-actions="!isEditing"
+ data-supports-quick-actions="true"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 71d767c3b95..11b427b9346 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -6,9 +6,11 @@ import {
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { mapActions } from 'vuex';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue';
+import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
+
+import { NOTEABLE_TYPE_MAPPING } from '../constants';
export default {
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
@@ -45,6 +47,11 @@ export default {
required: false,
default: null,
},
+ noteableType: {
+ type: String,
+ required: false,
+ default: '',
+ },
includeToggle: {
type: Boolean,
required: false,
@@ -103,6 +110,15 @@ export default {
authorName() {
return this.author.name;
},
+ noteConfidentialityTooltip() {
+ if (
+ this.noteableType === NOTEABLE_TYPE_MAPPING.Issue ||
+ this.noteableType === NOTEABLE_TYPE_MAPPING.MergeRequest
+ ) {
+ return s__('Notes|This comment is confidential and only visible to project members');
+ }
+ return s__('Notes|This comment is confidential and only visible to group members');
+ },
},
mounted() {
this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : '';
@@ -226,7 +242,7 @@ export default {
data-testid="confidentialIndicator"
name="eye-slash"
:size="16"
- :title="s__('Notes|This comment is confidential and only visible to project members')"
+ :title="noteConfidentialityTooltip"
class="gl-ml-1 gl-text-orange-700 align-middle"
/>
<slot name="extra-controls"></slot>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index c4602363da1..000eb3bdff3 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -10,7 +10,7 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { s__, __ } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -307,7 +307,6 @@ export default {
v-if="isReplying"
ref="noteForm"
:discussion="discussion"
- :is-editing="false"
:line="diffLine"
save-button-title="Comment"
:autosave-key="autosaveKey"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index a271ac91f6e..a2fbb242222 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -10,8 +10,8 @@ import httpStatusCodes from '~/lib/utils/http_status';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import { __, s__, sprintf } from '../../locale';
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import { __, s__, sprintf } from '~/locale';
+import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -357,7 +357,13 @@ export default {
}) {
if (shouldConfirm && isDirty) {
const msg = __('Are you sure you want to cancel editing this comment?');
- const confirmed = await confirmAction(msg);
+ const confirmed = await confirmAction(msg, {
+ primaryBtnText: __('Cancel editing'),
+ primaryBtnVariant: 'danger',
+ secondaryBtnVariant: 'default',
+ secondaryBtnText: __('Continue editing'),
+ hideCancel: true,
+ });
if (!confirmed) return;
}
this.$refs.noteBody.resetAutoSave();
@@ -432,6 +438,7 @@ export default {
:created-at="note.created_at"
:note-id="note.id"
:is-confidential="note.confidential"
+ :noteable-type="noteableType"
>
<template #note-header-info>
<slot name="note-header-info"></slot>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index c4924cd41f5..7d8d23335e0 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -7,12 +7,12 @@ import initUserPopovers from '~/user_popovers';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import draftNote from '../../batch_comments/components/draft_note.vue';
-import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
-import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
-import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
-import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
-import systemNote from '../../vue_shared/components/notes/system_note.vue';
+import draftNote from '~/batch_comments/components/draft_note.vue';
+import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
+import placeholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
+import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
+import skeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue';
+import systemNote from '~/vue_shared/components/notes/system_note.vue';
import * as constants from '../constants';
import eventHub from '../event_hub';
import commentForm from './comment_form.vue';
diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue
index 92c39fbb9f0..bcc5d12b7c8 100644
--- a/app/assets/javascripts/notes/components/sort_discussion.vue
+++ b/app/assets/javascripts/notes/components/sort_discussion.vue
@@ -57,6 +57,7 @@ export default {
:value="sortDirection"
:storage-key="storageKey"
:persist="persistSortOrder"
+ as-string
@input="setDiscussionSortDirection({ direction: $event })"
/>
<gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile">
diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue
index 87d22e5b986..e4d89f54652 100644
--- a/app/assets/javascripts/notes/components/timeline_toggle.vue
+++ b/app/assets/javascripts/notes/components/timeline_toggle.vue
@@ -7,8 +7,8 @@ import { COMMENTS_ONLY_FILTER_VALUE, DESC } from '../constants';
import notesEventHub from '../event_hub';
import { trackToggleTimelineView } from '../utils';
-export const timelineEnabledTooltip = s__('Timeline|Turn timeline view off');
-export const timelineDisabledTooltip = s__('Timeline|Turn timeline view on');
+export const timelineEnabledTooltip = s__('Timeline|Turn recent updates view off');
+export const timelineDisabledTooltip = s__('Timeline|Turn recent updates view on');
export default {
components: {
@@ -49,7 +49,7 @@ export default {
<gl-button
v-gl-tooltip
v-track-event="trackToggleTimelineView(timelineEnabled)"
- icon="comments"
+ icon="history"
:selected="timelineEnabled"
:title="tooltip"
:aria-label="tooltip"
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index 01e3f84d00e..65b3fd6f8b3 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -57,7 +57,7 @@ export default {
:link-href="author.path"
:img-alt="author.name"
:img-src="author.avatar_url"
- :img-size="26"
+ :img-size="24"
:tooltip-text="author.name"
tooltip-placement="bottom"
/>
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
index d670d0bd4c5..61cb4ab2a10 100644
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import { s__ } from '~/locale';
-import Autosave from '../../autosave';
-import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
+import Autosave from '~/autosave';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
export default {
methods: {
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 93236b05100..754a534e055 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,6 +1,6 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils';
-import { updateHistory } from '../../lib/utils/url_utility';
+import { updateHistory } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
/**
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 50b05ea9d69..204e704e504 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -9,14 +9,14 @@ import { __, sprintf } from '~/locale';
import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
-import loadAwardsHandler from '../../awards_handler';
-import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
-import Poll from '../../lib/utils/poll';
-import { create } from '../../lib/utils/recurrence';
-import { mergeUrlParams } from '../../lib/utils/url_utility';
-import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
-import TaskList from '../../task_list';
-import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
+import loadAwardsHandler from '~/awards_handler';
+import { isInViewport, scrollToElement, isInMRPage } from '~/lib/utils/common_utils';
+import Poll from '~/lib/utils/poll';
+import { create } from '~/lib/utils/recurrence';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import sidebarTimeTrackingEventHub from '~/sidebar/event_hub';
+import TaskList from '~/task_list';
+import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
import * as constants from '../constants';
import eventHub from '../event_hub';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index ba19ecd0c04..5cc2c673391 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -1,5 +1,5 @@
import { isEqual } from 'lodash';
-import { isInMRPage } from '../../lib/utils/common_utils';
+import { isInMRPage } from '~/lib/utils/common_utils';
import * as constants from '../constants';
import * as types from './mutation_types';
import * as utils from './utils';
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue
index e4a1a1a8266..bb1dac40b92 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue
@@ -1,13 +1,13 @@
<script>
-import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { GlButton, GlLink, GlTooltip, GlSprintf } from '@gitlab/ui';
export default {
name: 'DeleteButton',
components: {
GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ GlLink,
+ GlTooltip,
+ GlSprintf,
},
props: {
title: {
@@ -18,6 +18,11 @@ export default {
type: String,
required: true,
},
+ tooltipLink: {
+ type: String,
+ default: '',
+ required: false,
+ },
disabled: {
type: Boolean,
default: false,
@@ -29,21 +34,12 @@ export default {
required: false,
},
},
- computed: {
- tooltipConfiguration() {
- return {
- disabled: this.tooltipDisabled,
- title: this.tooltipTitle,
- };
- },
- },
};
</script>
<template>
- <div v-gl-tooltip="tooltipConfiguration">
+ <div ref="deleteImageButton">
<gl-button
- v-gl-tooltip
:disabled="disabled"
:title="title"
:aria-label="title"
@@ -52,5 +48,14 @@ export default {
icon="remove"
@click="$emit('delete')"
/>
+ <gl-tooltip :target="() => $refs.deleteImageButton" :disabled="tooltipDisabled" placement="top">
+ <gl-sprintf :message="tooltipTitle">
+ <template #docLink="{ content }">
+ <gl-link v-if="tooltipLink" :href="tooltipLink" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index c1ec523574a..484903354e8 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -8,11 +8,13 @@ import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
LIST_DELETE_BUTTON_DISABLED,
+ LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION,
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
+ IMAGE_MIGRATING_STATE,
ROOT_IMAGE_TEXT,
} from '../../constants/index';
import DeleteButton from '../delete_button.vue';
@@ -32,6 +34,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['config'],
props: {
item: {
type: Object,
@@ -44,13 +47,12 @@ export default {
},
},
i18n: {
- LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
},
computed: {
disabledDelete() {
- return !this.item.canDelete || this.deleting;
+ return !this.item.canDelete || this.deleting || this.migrating;
},
id() {
return getIdFromGraphQLId(this.item.id);
@@ -58,6 +60,9 @@ export default {
deleting() {
return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS;
},
+ migrating() {
+ return this.item.migrationState === IMAGE_MIGRATING_STATE;
+ },
failedDelete() {
return this.item.status === IMAGE_FAILED_DELETED_STATUS;
},
@@ -83,6 +88,11 @@ export default {
routerLinkEvent() {
return this.deleting ? '' : 'click';
},
+ deleteButtonTooltipTitle() {
+ return this.migrating
+ ? LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION
+ : LIST_DELETE_BUTTON_DISABLED;
+ },
},
};
</script>
@@ -144,8 +154,9 @@ export default {
<delete-button
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:disabled="disabledDelete"
- :tooltip-disabled="item.canDelete"
- :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
+ :tooltip-disabled="!disabledDelete"
+ :tooltip-link="config.containerRegistryImportingHelpPagePath"
+ :tooltip-title="deleteButtonTooltipTitle"
@delete="$emit('delete', item)"
/>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
index 6d2ff9ea7b6..154e176dc6e 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLink } from '@gitlab/ui';
import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
import { n__, sprintf } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
@@ -9,6 +10,7 @@ import {
LIST_INTRO_TEXT,
EXPIRATION_POLICY_WILL_RUN_IN,
EXPIRATION_POLICY_DISABLED_TEXT,
+ SET_UP_CLEANUP,
} from '../../constants/index';
export default {
@@ -16,6 +18,7 @@ export default {
components: {
TitleArea,
MetadataItem,
+ GlLink,
},
props: {
expirationPolicy: {
@@ -43,6 +46,16 @@ export default {
required: false,
default: false,
},
+ cleanupPoliciesSettingsPath: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ showCleanupPolicyLink: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
loader: {
repeat: 10,
@@ -51,6 +64,7 @@ export default {
},
i18n: {
CONTAINER_REGISTRY_TITLE,
+ SET_UP_CLEANUP,
},
computed: {
imagesCountText() {
@@ -105,6 +119,9 @@ export default {
:text="expirationPolicyText"
size="xl"
/>
+ <gl-link v-if="showCleanupPolicyLink" class="gl-ml-2" :href="cleanupPoliciesSettingsPath">{{
+ $options.i18n.SET_UP_CLEANUP
+ }}</gl-link>
</template>
</title-area>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
index 40f9b09a982..e584da23edb 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
@@ -4,7 +4,7 @@ export const EXPIRATION_POLICY_WILL_RUN_IN = s__(
'ContainerRegistry|Expiration policy will run in %{time}',
);
export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
- 'ContainerRegistry|Expiration policy is disabled',
+ 'ContainerRegistry|Expiration policy is disabled.',
);
export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
export const DELETE_ALERT_LINK_TEXT = s__(
@@ -13,3 +13,4 @@ export const DELETE_ALERT_LINK_TEXT = s__(
export const CLEANUP_TIMED_OUT_ERROR_MESSAGE = s__(
'ContainerRegistry|Cleanup timed out before it could delete all tags',
);
+export const SET_UP_CLEANUP = s__('ContainerRegistry|Set up cleanup');
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
index 7fa950ccfd0..c7022d6070f 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
@@ -14,6 +14,9 @@ export const LIST_INTRO_TEXT = s__(
export const LIST_DELETE_BUTTON_DISABLED = s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
);
+export const LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION = s__(
+ `ContainerRegistry|Image repository temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}`,
+);
export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
@@ -45,6 +48,7 @@ export const EMPTY_RESULT_MESSAGE = s__(
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
+export const IMAGE_MIGRATING_STATE = 'importing';
export const GRAPHQL_PAGE_SIZE = 10;
export const SORT_FIELDS = [
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
index d753d33a02c..8c577cc7b17 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getContainerRepositoryTags(
$id: ID!
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
index ca5bd8d6964..a558550c91f 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
@@ -35,7 +35,7 @@ export default () => {
expirationPolicy,
isGroupPage,
isAdmin,
- showCleanupPolicyOnAlert,
+ showCleanupPolicyLink,
showUnfinishedTagCleanupCallout,
connectionError,
invalidPathError,
@@ -68,7 +68,7 @@ export default () => {
expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined,
isGroupPage: parseBoolean(isGroupPage),
isAdmin: parseBoolean(isAdmin),
- showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert),
+ showCleanupPolicyLink: parseBoolean(showCleanupPolicyLink),
showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout),
connectionError: parseBoolean(connectionError),
invalidPathError: parseBoolean(invalidPathError),
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index 5f9e614bebb..d1cab406984 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -11,7 +11,6 @@ import {
import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import createFlash from '~/flash';
-import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import Tracking from '~/tracking';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
@@ -60,7 +59,6 @@ export default {
GlSkeletonLoader,
RegistryHeader,
DeleteImage,
- CleanupPolicyEnabledAlert,
PersistedSearch,
},
directives: {
@@ -273,12 +271,6 @@ export default {
</gl-sprintf>
</gl-alert>
- <cleanup-policy-enabled-alert
- v-if="config.showCleanupPolicyOnAlert"
- :project-path="config.projectPath"
- :cleanup-policies-settings-path="config.cleanupPoliciesSettingsPath"
- />
-
<gl-empty-state
v-if="showConnectionError"
:title="$options.i18n.CONNECTION_ERROR_TITLE"
@@ -304,6 +296,8 @@ export default {
:expiration-policy="config.expirationPolicy"
:help-page-path="config.helpPagePath"
:hide-expiration-policy-data="config.isGroupPage"
+ :cleanup-policies-settings-path="config.cleanupPoliciesSettingsPath"
+ :show-cleanup-policy-link="config.showCleanupPolicyLink"
>
<template #commands>
<cli-commands
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index eb112238c11..67c2ca02d20 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -1,13 +1,18 @@
<script>
import {
GlAlert,
+ GlDropdown,
+ GlDropdownItem,
GlEmptyState,
GlFormGroup,
GlFormInputGroup,
+ GlModal,
+ GlModalDirective,
GlSkeletonLoader,
GlSprintf,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { __, s__, n__, sprintf } from '~/locale';
+import Api from '~/api';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue';
@@ -22,16 +27,22 @@ import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency
export default {
components: {
GlAlert,
+ GlDropdown,
+ GlDropdownItem,
GlEmptyState,
GlFormGroup,
GlFormInputGroup,
+ GlModal,
GlSkeletonLoader,
GlSprintf,
ClipboardButton,
TitleArea,
ManifestsList,
},
- inject: ['groupPath', 'dependencyProxyAvailable', 'noManifestsIllustration'],
+ directives: {
+ GlModalDirective,
+ },
+ inject: ['groupPath', 'groupId', 'dependencyProxyAvailable', 'noManifestsIllustration'],
i18n: {
proxyNotAvailableText: s__(
'DependencyProxy|Dependency Proxy feature is limited to public groups for now.',
@@ -41,6 +52,20 @@ export default {
blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'),
pageTitle: s__('DependencyProxy|Dependency Proxy'),
noManifestTitle: s__('DependencyProxy|There are no images in the cache'),
+ deleteCacheAlertMessageSuccess: s__(
+ 'DependencyProxy|All items in the cache are scheduled for removal.',
+ ),
+ clearCache: s__('DependencyProxy|Clear cache'),
+ },
+ confirmClearCacheModal: 'confirm-clear-cache-modal',
+ modalButtons: {
+ primary: {
+ text: s__('DependencyProxy|Clear cache'),
+ attributes: [{ variant: 'danger' }],
+ },
+ secondary: {
+ text: __('Cancel'),
+ },
},
links: {
DEPENDENCY_PROXY_DOCS_PATH,
@@ -48,6 +73,8 @@ export default {
data() {
return {
group: {},
+ showDeleteCacheAlert: false,
+ deleteCacheAlertMessage: '',
};
},
apollo: {
@@ -80,6 +107,33 @@ export default {
manifests() {
return this.group.dependencyProxyManifests.nodes;
},
+ modalTitleWithCount() {
+ return sprintf(
+ n__(
+ 'Clear %{count} image from cache?',
+ 'Clear %{count} images from cache?',
+ this.group.dependencyProxyBlobCount,
+ ),
+ {
+ count: this.group.dependencyProxyBlobCount,
+ },
+ );
+ },
+ modalConfirmationMessageWithCount() {
+ return sprintf(
+ n__(
+ 'You are about to clear %{count} image from the cache. Once you confirm, the next time a pipeline runs it must pull an image or tag from Docker Hub. Are you sure?',
+ 'You are about to clear %{count} images from the cache. Once you confirm, the next time a pipeline runs it must pull an image or tag from Docker Hub. Are you sure?',
+ this.group.dependencyProxyBlobCount,
+ ),
+ {
+ count: this.group.dependencyProxyBlobCount,
+ },
+ );
+ },
+ showDeleteDropdown() {
+ return this.group.dependencyProxyBlobCount > 0;
+ },
},
methods: {
fetchNextPage() {
@@ -103,13 +157,47 @@ export default {
},
});
},
+ async submit() {
+ try {
+ await Api.deleteDependencyProxyCacheList(this.groupId);
+
+ this.deleteCacheAlertMessage = this.$options.i18n.deleteCacheAlertMessageSuccess;
+ this.showDeleteCacheAlert = true;
+ } catch (err) {
+ this.deleteCacheAlertMessage = err;
+ this.showDeleteCacheAlert = true;
+ }
+ },
},
};
</script>
<template>
<div>
- <title-area :title="$options.i18n.pageTitle" :info-messages="infoMessages" />
+ <gl-alert
+ v-if="showDeleteCacheAlert"
+ data-testid="delete-cache-alert"
+ @dismiss="showDeleteCacheAlert = false"
+ >
+ {{ deleteCacheAlertMessage }}
+ </gl-alert>
+ <title-area :title="$options.i18n.pageTitle" :info-messages="infoMessages">
+ <template v-if="showDeleteDropdown" #right-actions>
+ <gl-dropdown
+ icon="ellipsis_v"
+ text="More actions"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item
+ v-gl-modal-directive="$options.confirmClearCacheModal"
+ variant="danger"
+ >{{ $options.i18n.clearCache }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </template>
+ </title-area>
<gl-alert
v-if="!dependencyProxyAvailable"
:dismissible="false"
@@ -159,5 +247,15 @@ export default {
:title="$options.i18n.noManifestTitle"
/>
</div>
+
+ <gl-modal
+ :modal-id="$options.confirmClearCacheModal"
+ :title="modalTitleWithCount"
+ :action-primary="$options.modalButtons.primary"
+ :action-secondary="$options.modalButtons.secondary"
+ @primary="submit"
+ >
+ {{ modalConfirmationMessageWithCount }}
+ </gl-modal>
</div>
</template>
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
index 9241dccb2d5..5c43b10a5e3 100644
--- 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
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getDependencyProxyDetails(
$fullPath: ID!
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list.vue
new file mode 100644
index 00000000000..c1b5367c96a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list.vue
@@ -0,0 +1,42 @@
+<script>
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue';
+
+export default {
+ name: 'HarborList',
+ components: {
+ RegistryList,
+ HarborListRow,
+ },
+ props: {
+ images: {
+ type: Array,
+ required: true,
+ },
+ metadataLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <registry-list
+ :items="images"
+ :hidden-delete="true"
+ :pagination="pageInfo"
+ id-property="name"
+ @prev-page="$emit('prev-page')"
+ @next-page="$emit('next-page')"
+ >
+ <template #default="{ item }">
+ <harbor-list-row :item="item" :metadata-loading="metadataLoading" />
+ </template>
+ </registry-list>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue
new file mode 100644
index 00000000000..086b9c73d75
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue
@@ -0,0 +1,67 @@
+<script>
+import { sprintf } from '~/locale';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import {
+ HARBOR_REGISTRY_TITLE,
+ LIST_INTRO_TEXT,
+ imagesCountInfoText,
+} from '~/packages_and_registries/harbor_registry/constants';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+
+export default {
+ name: 'HarborListHeader',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ props: {
+ imagesCount: {
+ type: Number,
+ default: 0,
+ required: false,
+ },
+ helpPagePath: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ metadataLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ i18n: {
+ HARBOR_REGISTRY_TITLE,
+ },
+ computed: {
+ imagesCountText() {
+ const pluralisedString = imagesCountInfoText(this.imagesCount);
+ return sprintf(pluralisedString, { count: this.imagesCount });
+ },
+ infoMessages() {
+ return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
+ },
+ },
+};
+</script>
+
+<template>
+ <title-area
+ :title="$options.i18n.HARBOR_REGISTRY_TITLE"
+ :info-messages="infoMessages"
+ :metadata-loading="metadataLoading"
+ >
+ <template #right-actions>
+ <slot name="commands"></slot>
+ </template>
+ <template #metadata-count>
+ <metadata-item
+ v-if="imagesCount"
+ data-testid="images-count"
+ icon="container-image"
+ :text="imagesCountText"
+ />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue
new file mode 100644
index 00000000000..258472fe16e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import { n__ } from '~/locale';
+
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+
+export default {
+ name: 'HarborListRow',
+ components: {
+ ClipboardButton,
+ GlSprintf,
+ GlIcon,
+ ListItem,
+ GlSkeletonLoader,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ metadataLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ id() {
+ return this.item.id;
+ },
+ artifactCountText() {
+ return n__(
+ 'HarborRegistry|%{count} Tag',
+ 'HarborRegistry|%{count} Tags',
+ this.item.artifactCount,
+ );
+ },
+ imageName() {
+ return this.item.name;
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item v-bind="$attrs">
+ <template #left-primary>
+ <router-link
+ class="gl-text-body gl-font-weight-bold"
+ data-testid="details-link"
+ data-qa-selector="registry_image_content"
+ :to="{ name: 'details', params: { id } }"
+ >
+ {{ imageName }}
+ </router-link>
+ <clipboard-button
+ v-if="item.location"
+ :text="item.location"
+ :title="item.location"
+ category="tertiary"
+ />
+ </template>
+ <template #left-secondary>
+ <template v-if="!metadataLoading">
+ <span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
+ <gl-icon name="tag" class="gl-mr-2" />
+ <gl-sprintf :message="artifactCountText">
+ <template #count>
+ {{ item.artifactCount }}
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+
+ <div v-else class="gl-w-full">
+ <gl-skeleton-loader :width="900" :height="16" preserve-aspect-ratio="xMinYMax meet">
+ <circle cx="6" cy="8" r="6" />
+ <rect x="16" y="4" width="100" height="8" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js
new file mode 100644
index 00000000000..a7891821755
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js
@@ -0,0 +1,29 @@
+import { s__, __ } from '~/locale';
+
+export const ROOT_IMAGE_TEXT = s__('HarborRegistry|Root image');
+export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') };
+
+export const ASCENDING_ORDER = 'asc';
+export const DESCENDING_ORDER = 'desc';
+
+export const NAME_SORT_FIELD_KEY = 'name';
+export const UPDATED_SORT_FIELD_KEY = 'update_time';
+export const CREATED_SORT_FIELD_KEY = 'creation_time';
+
+export const SORT_FIELD_MAPPING = {
+ NAME: NAME_SORT_FIELD_KEY,
+ UPDATED: UPDATED_SORT_FIELD_KEY,
+ CREATED: CREATED_SORT_FIELD_KEY,
+};
+
+/* eslint-disable @gitlab/require-i18n-strings */
+export const dockerBuildCommand = (repositoryUrl) => {
+ return `docker build -t ${repositoryUrl} .`;
+};
+export const dockerPushCommand = (repositoryUrl) => {
+ return `docker push ${repositoryUrl}`;
+};
+export const dockerLoginCommand = (registryHostUrlWithPort) => {
+ return `docker login ${registryHostUrlWithPort}`;
+};
+/* eslint-enable @gitlab/require-i18n-strings */
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
new file mode 100644
index 00000000000..2519f6b74a2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
@@ -0,0 +1,39 @@
+import { s__, __ } from '~/locale';
+
+export const UPDATED_AT = s__('HarborRegistry|Last updated %{time}');
+
+export const MISSING_OR_DELETED_IMAGE_TITLE = s__(
+ 'HarborRegistry|The image repository could not be found.',
+);
+
+export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__(
+ 'HarborRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.',
+);
+
+export const NO_TAGS_TITLE = s__('HarborRegistry|This image has no active tags');
+
+export const NO_TAGS_MESSAGE = s__(
+ `HarborRegistry|The last tag related to this image was recently removed.
+This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
+If you have any questions, contact your administrator.`,
+);
+
+export const NO_TAGS_MATCHING_FILTERS_TITLE = s__('HarborRegistry|The filter returned no results');
+
+export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__(
+ 'HarborRegistry|Please try different search criteria',
+);
+
+export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}');
+export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}');
+export const PUBLISHED_DETAILS_ROW_TEXT = s__(
+ 'HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}',
+);
+export const MANIFEST_DETAILS_ROW_TEST = s__('HarborRegistry|Manifest digest: %{digest}');
+export const CONFIGURATION_DETAILS_ROW_TEST = s__('HarborRegistry|Configuration digest: %{digest}');
+export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
+ 'HarborRegistry|Invalid tag: missing manifest digest',
+);
+
+export const NOT_AVAILABLE_TEXT = __('N/A');
+export const NOT_AVAILABLE_SIZE = __('0 bytes');
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/index.js
new file mode 100644
index 00000000000..22f462e0b97
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/index.js
@@ -0,0 +1,3 @@
+export * from './common';
+export * from './list';
+export * from './details';
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js
new file mode 100644
index 00000000000..a6cd59918ff
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js
@@ -0,0 +1,33 @@
+import { s__, __, n__ } from '~/locale';
+import { NAME_SORT_FIELD } from './common';
+
+// Translations strings
+
+export const HARBOR_REGISTRY_TITLE = s__('HarborRegistry|Harbor Registry');
+
+export const CONNECTION_ERROR_TITLE = s__('HarborRegistry|Harbor connection error');
+export const CONNECTION_ERROR_MESSAGE = s__(
+ `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`,
+);
+export const LIST_INTRO_TEXT = s__(
+ `HarborRegistry|With the Harbor Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`,
+);
+
+export const imagesCountInfoText = (count) => {
+ return n__(
+ 'HarborRegistry|%{count} Image repository',
+ 'HarborRegistry|%{count} Image repositories',
+ count,
+ );
+};
+
+export const EMPTY_RESULT_TITLE = s__('HarborRegistry|Sorry, your filter produced no results.');
+export const EMPTY_RESULT_MESSAGE = s__(
+ 'HarborRegistry|To widen your search, change or remove the filters above.',
+);
+
+export const SORT_FIELDS = [
+ { orderBy: 'UPDATED', label: __('Updated') },
+ { orderBy: 'CREATED', label: __('Created') },
+ NAME_SORT_FIELD,
+];
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js
new file mode 100644
index 00000000000..ecfefead61a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js
@@ -0,0 +1,78 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import PerformancePlugin from '~/performance/vue_performance_plugin';
+import Translate from '~/vue_shared/translate';
+import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue';
+import { renderBreadcrumb } from '~/packages_and_registries/shared/utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import {
+ dockerBuildCommand,
+ dockerPushCommand,
+ dockerLoginCommand,
+} from '~/packages_and_registries/harbor_registry/constants';
+import createRouter from './router';
+import HarborRegistryExplorer from './pages/index.vue';
+
+Vue.use(Translate);
+Vue.use(GlToast);
+
+Vue.use(PerformancePlugin, {
+ components: [
+ 'RegistryListPage',
+ 'ListHeader',
+ 'ImageListRow',
+ 'RegistryDetailsPage',
+ 'DetailsHeader',
+ 'TagsList',
+ ],
+});
+
+export default (id) => {
+ const el = document.getElementById(id);
+
+ if (!el) {
+ return null;
+ }
+
+ const { endpoint, connectionError, invalidPathError, isGroupPage, ...config } = el.dataset;
+
+ const breadCrumbState = Vue.observable({
+ name: '',
+ updateName(value) {
+ this.name = value;
+ },
+ });
+
+ const router = createRouter(endpoint, breadCrumbState);
+
+ const attachMainComponent = () => {
+ return new Vue({
+ el,
+ router,
+ provide() {
+ return {
+ breadCrumbState,
+ config: {
+ ...config,
+ connectionError: parseBoolean(connectionError),
+ invalidPathError: parseBoolean(invalidPathError),
+ isGroupPage: parseBoolean(isGroupPage),
+ helpPagePath: helpPagePath('user/packages/container_registry/index'),
+ },
+ dockerBuildCommand: dockerBuildCommand(config.repositoryUrl),
+ dockerPushCommand: dockerPushCommand(config.repositoryUrl),
+ dockerLoginCommand: dockerLoginCommand(config.registryHostUrlWithPort),
+ };
+ },
+ render(createElement) {
+ return createElement(HarborRegistryExplorer);
+ },
+ });
+ };
+
+ return {
+ attachBreadcrumb: renderBreadcrumb(router, null, RegistryBreadcrumb),
+ attachMainComponent,
+ };
+};
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js
new file mode 100644
index 00000000000..50c7df1483c
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js
@@ -0,0 +1,200 @@
+const mockRequestFn = (mockData) => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(mockData);
+ }, 2000);
+ });
+};
+export const harborListResponse = () => {
+ const harborListResponseData = {
+ repositories: [
+ {
+ artifactCount: 1,
+ creationTime: '2022-03-02T06:35:53.205Z',
+ id: 25,
+ name: 'shao/flinkx',
+ projectId: 21,
+ pullCount: 0,
+ updateTime: '2022-03-02T06:35:53.205Z',
+ location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ },
+ {
+ artifactCount: 1,
+ creationTime: '2022-03-02T06:35:53.205Z',
+ id: 26,
+ name: 'shao/flinkx1',
+ projectId: 21,
+ pullCount: 0,
+ updateTime: '2022-03-02T06:35:53.205Z',
+ location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ },
+ {
+ artifactCount: 1,
+ creationTime: '2022-03-02T06:35:53.205Z',
+ id: 27,
+ name: 'shao/flinkx2',
+ projectId: 21,
+ pullCount: 0,
+ updateTime: '2022-03-02T06:35:53.205Z',
+ location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ },
+ ],
+ totalCount: 3,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+ };
+
+ return mockRequestFn(harborListResponseData);
+};
+
+export const getHarborRegistryImageDetail = () => {
+ const harborRegistryImageDetailData = {
+ artifactCount: 1,
+ creationTime: '2022-03-02T06:35:53.205Z',
+ id: 25,
+ name: 'shao/flinkx',
+ projectId: 21,
+ pullCount: 0,
+ updateTime: '2022-03-02T06:35:53.205Z',
+ location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ tagsCount: 10,
+ };
+
+ return mockRequestFn(harborRegistryImageDetailData);
+};
+
+export const harborTagsResponse = () => {
+ const harborTagsResponseData = {
+ tags: [
+ {
+ digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
+ name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
+ revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255',
+ shortRevision: 'f53bde3d4',
+ createdAt: '2022-03-02T23:59:05+00:00',
+ totalSize: '6623124',
+ },
+ {
+ digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
+ name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
+ revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e',
+ shortRevision: 'e1fe52d8b',
+ createdAt: '2022-02-10T01:09:56+00:00',
+ totalSize: '920760',
+ },
+ {
+ digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
+ name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
+ revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f',
+ shortRevision: 'c72770c6e',
+ createdAt: '2021-12-22T04:48:48+00:00',
+ totalSize: '48609053',
+ },
+ {
+ digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
+ name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
+ revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a',
+ shortRevision: '1ac2a4319',
+ createdAt: '2022-03-09T11:02:27+00:00',
+ totalSize: '35141894',
+ },
+ {
+ digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
+ name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
+ revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c',
+ shortRevision: 'cf8fee086',
+ createdAt: '2022-01-21T11:31:43+00:00',
+ totalSize: '48716070',
+ },
+ {
+ digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
+ name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
+ revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15',
+ shortRevision: '1a4b48198',
+ createdAt: '2022-01-21T11:31:51+00:00',
+ totalSize: '6623127',
+ },
+ {
+ digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
+ name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
+ revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61',
+ shortRevision: '03e2e2777',
+ createdAt: '2022-03-02T23:58:20+00:00',
+ totalSize: '911377',
+ },
+ {
+ digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
+ name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
+ revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012',
+ shortRevision: '350e78d60',
+ createdAt: '2022-01-19T13:49:14+00:00',
+ totalSize: '48710241',
+ },
+ {
+ digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
+ name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
+ revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18',
+ shortRevision: '76038370b',
+ createdAt: '2022-01-24T12:56:22+00:00',
+ totalSize: '280065',
+ },
+ {
+ digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
+ name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
+ revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f',
+ shortRevision: '3d4b49a7b',
+ createdAt: '2022-02-17T17:37:52+00:00',
+ totalSize: '48655767',
+ },
+ ],
+ totalCount: 10,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: true,
+ },
+ };
+
+ return mockRequestFn(harborTagsResponseData);
+};
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue
new file mode 100644
index 00000000000..dca63e1a569
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue
@@ -0,0 +1,5 @@
+<template>
+ <div>
+ <router-view ref="router-view" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
new file mode 100644
index 00000000000..7aaef2ed57a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
@@ -0,0 +1,177 @@
+<script>
+import { GlEmptyState, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue';
+import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
+import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import {
+ SORT_FIELDS,
+ CONNECTION_ERROR_TITLE,
+ CONNECTION_ERROR_MESSAGE,
+ EMPTY_RESULT_TITLE,
+ EMPTY_RESULT_MESSAGE,
+} from '~/packages_and_registries/harbor_registry/constants';
+import Tracking from '~/tracking';
+import { harborListResponse } from '../mock_api';
+
+export default {
+ name: 'HarborListPage',
+ components: {
+ HarborListHeader,
+ HarborList,
+ GlSkeletonLoader,
+ GlEmptyState,
+ GlSprintf,
+ GlLink,
+ PersistedSearch,
+ CliCommands: () =>
+ import(
+ /* webpackChunkName: 'harbor_registry_components' */ '~/packages_and_registries/shared/components/cli_commands.vue'
+ ),
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
+ loader: {
+ repeat: 10,
+ width: 1000,
+ height: 40,
+ },
+ i18n: {
+ CONNECTION_ERROR_TITLE,
+ CONNECTION_ERROR_MESSAGE,
+ EMPTY_RESULT_TITLE,
+ EMPTY_RESULT_MESSAGE,
+ },
+ searchConfig: SORT_FIELDS,
+ data() {
+ return {
+ images: [],
+ totalCount: 0,
+ pageInfo: {},
+ filter: [],
+ isLoading: true,
+ sorting: null,
+ name: null,
+ };
+ },
+ computed: {
+ showCommands() {
+ return !this.isLoading && !this.config?.isGroupPage && this.images?.length;
+ },
+ showConnectionError() {
+ return this.config.connectionError || this.config.invalidPathError;
+ },
+ },
+ methods: {
+ fetchHarborImages() {
+ // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
+ this.isLoading = true;
+
+ harborListResponse()
+ .then((res) => {
+ this.images = res?.repositories || [];
+ this.totalCount = res?.totalCount || 0;
+ this.pageInfo = res?.pageInfo || {};
+ this.isLoading = false;
+ })
+ .catch(() => {});
+ },
+ handleSearchUpdate({ sort, filters }) {
+ this.sorting = sort;
+
+ const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM);
+ this.name = search?.value?.data;
+
+ this.fetchHarborImages();
+ },
+ fetchPrevPage() {
+ // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
+ this.fetchHarborImages();
+ },
+ fetchNextPage() {
+ // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
+ this.fetchHarborImages();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-empty-state
+ v-if="showConnectionError"
+ :title="$options.i18n.CONNECTION_ERROR_TITLE"
+ :svg-path="config.containersErrorImage"
+ >
+ <template #description>
+ <p>
+ <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE">
+ <template #docLink="{ content }">
+ <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-empty-state>
+ <template v-else>
+ <harbor-list-header
+ :metadata-loading="isLoading"
+ :images-count="totalCount"
+ :help-page-path="config.helpPagePath"
+ >
+ <template #commands>
+ <cli-commands
+ v-if="showCommands"
+ :docker-build-command="dockerBuildCommand"
+ :docker-push-command="dockerPushCommand"
+ :docker-login-command="dockerLoginCommand"
+ />
+ </template>
+ </harbor-list-header>
+ <persisted-search
+ :sortable-fields="$options.searchConfig"
+ :default-order="$options.searchConfig[0].orderBy"
+ default-sort="desc"
+ @update="handleSearchUpdate"
+ />
+
+ <div v-if="isLoading" class="gl-mt-5">
+ <gl-skeleton-loader
+ v-for="index in $options.loader.repeat"
+ :key="index"
+ :width="$options.loader.width"
+ :height="$options.loader.height"
+ preserve-aspect-ratio="xMinYMax meet"
+ >
+ <rect width="500" x="10" y="10" height="20" rx="4" />
+ <circle cx="525" cy="20" r="10" />
+ <rect x="960" y="0" width="40" height="40" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ <template v-else>
+ <template v-if="images.length > 0 || name">
+ <harbor-list
+ v-if="images.length"
+ :images="images"
+ :meta-data-loading="isLoading"
+ :page-info="pageInfo"
+ @prev-page="fetchPrevPage"
+ @next-page="fetchNextPage"
+ />
+ <gl-empty-state
+ v-else
+ :svg-path="config.noContainersImage"
+ data-testid="emptySearch"
+ :title="$options.i18n.EMPTY_RESULT_TITLE"
+ >
+ <template #description>
+ {{ $options.i18n.EMPTY_RESULT_MESSAGE }}
+ </template>
+ </gl-empty-state>
+ </template>
+ </template>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js
new file mode 100644
index 00000000000..572dd382be3
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { HARBOR_REGISTRY_TITLE } from './constants/index';
+import List from './pages/list.vue';
+import Details from './pages/details.vue';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base, breadCrumbState) {
+ const router = new VueRouter({
+ base,
+ mode: 'history',
+ routes: [
+ {
+ name: 'list',
+ path: '/',
+ component: List,
+ meta: {
+ nameGenerator: () => HARBOR_REGISTRY_TITLE,
+ root: true,
+ },
+ },
+ {
+ name: 'details',
+ path: '/:id',
+ component: Details,
+ meta: {
+ nameGenerator: () => breadCrumbState.name,
+ },
+ },
+ ],
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
index 488860e5bc2..408d34fbe93 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
@@ -26,6 +26,7 @@ export const receivePackagesListSuccess = ({ commit }, { data, headers }) => {
export const requestPackagesList = ({ dispatch, state }, params = {}) => {
dispatch('setLoading', true);
+ // eslint-disable-next-line camelcase
const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params;
const { sort, orderBy } = state.sorting;
const type = state.config.forceTerraform
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 c27083261b5..7a88e04d1f9 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
@@ -99,7 +99,6 @@ export default {
<local-storage-sync
storage-key="package_registry_list_sorting"
:value="sorting"
- as-json
@input="updateSorting"
>
<url-sync>
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
index 4b913590949..5bde5f08e56 100644
--- 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
@@ -1,5 +1,5 @@
#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getPackages(
$fullPath: ID!
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 7be3bba7cae..854c88b2ad3 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
@@ -9,7 +9,6 @@ import {
UNAVAILABLE_ADMIN_FEATURE_TEXT,
} from '~/packages_and_registries/settings/project/constants';
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
-import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
import SettingsForm from './settings_form.vue';
@@ -18,19 +17,11 @@ export default {
components: {
SettingsBlock,
SettingsForm,
- CleanupPolicyEnabledAlert,
GlAlert,
GlSprintf,
GlLink,
},
- inject: [
- 'projectPath',
- 'isAdmin',
- 'adminSettingsPath',
- 'enableHistoricEntries',
- 'helpPagePath',
- 'showCleanupPolicyOnAlert',
- ],
+ inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'],
i18n: {
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
@@ -87,7 +78,6 @@ export default {
<template>
<section data-testid="registry-settings-app">
- <cleanup-policy-enabled-alert v-if="showCleanupPolicyOnAlert" :project-path="projectPath" />
<settings-block :collapsible="false">
<template #title> {{ __('Clean up image tags') }}</template>
<template #description>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
index 2a3e2c28fa6..17c33073668 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
@@ -20,7 +20,6 @@ export default () => {
adminSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
- showCleanupPolicyOnAlert,
} = el.dataset;
return new Vue({
el,
@@ -35,7 +34,6 @@ export default () => {
adminSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
- showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert),
},
render(createElement) {
return createElement('registry-settings-app', {});
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue b/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue
deleted file mode 100644
index d51c62e0623..00000000000
--- a/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<script>
-import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-
-export default {
- components: {
- GlAlert,
- GlLink,
- GlSprintf,
- LocalStorageSync,
- },
- props: {
- projectPath: {
- type: String,
- required: true,
- },
- cleanupPoliciesSettingsPath: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- dismissed: false,
- };
- },
- computed: {
- storageKey() {
- return `cleanup_policy_enabled_for_project_${this.projectPath}`;
- },
- },
- i18n: {
- message: s__(
- 'ContainerRegistry|Cleanup policies are now available for this project. %{linkStart}Click here to get started.%{linkEnd}',
- ),
- },
-};
-</script>
-
-<template>
- <local-storage-sync v-model="dismissed" :storage-key="storageKey">
- <gl-alert v-if="!dismissed" class="gl-mt-2" dismissible @dismiss="dismissed = true">
- <gl-sprintf :message="$options.i18n.message">
- <template #link="{ content }">
- <gl-link v-if="cleanupPoliciesSettingsPath" :href="cleanupPoliciesSettingsPath">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
- </local-storage-sync>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
index 79381f82009..cc345fda7e8 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
@@ -13,7 +13,8 @@ export default {
props: {
title: {
type: String,
- required: true,
+ default: '',
+ required: false,
},
isLoading: {
type: Boolean,
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index aa2f539b6e2..e15766a7839 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -22,7 +22,10 @@ export default {
this.prepareData = prepareData;
this.successCallback = successCallback;
this.errorCallback = errorCallback;
- this.loading = $(`${container} .loading`).first();
+ this.$container = $(container);
+ this.$loading = this.$container.length
+ ? this.$container.find('.loading').first()
+ : $('.loading').first();
if (preload) {
this.offset = 0;
this.getOld();
@@ -31,7 +34,7 @@ export default {
},
getOld() {
- this.loading.show();
+ this.$loading.show();
const url = $('.content_list').data('href') || removeParams(['limit', 'offset']);
axios
@@ -49,11 +52,11 @@ export default {
if (!this.disable && !this.isScrollable()) {
this.getOld();
} else {
- this.loading.hide();
+ this.$loading.hide();
}
})
.catch((err) => this.errorCallback(err))
- .finally(() => this.loading.hide());
+ .finally(() => this.$loading.hide());
},
append(count, html) {
@@ -83,8 +86,12 @@ export default {
fireOnce: true,
ceaseFire: () => this.disable === true,
callback: () => {
- if (!this.loading.is(':visible')) {
- this.loading.show();
+ if (this.$container.length && !this.$container.is(':visible')) {
+ return;
+ }
+
+ if (!this.$loading.is(':visible')) {
+ this.$loading.show();
this.getOld();
}
},
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
index e78b3f9ec95..29e92a8abad 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { parseBoolean } from '~/lib/utils/common_utils';
-import { truncate } from '../../../lib/utils/text_utility';
+import { truncate } from '~/lib/utils/text_utility';
const MAX_MESSAGE_LENGTH = 500;
const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
index 6b7bfbf217d..e6c1d6d147b 100644
--- a/app/assets/javascripts/pages/admin/admin.js
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { refreshCurrentPage } from '../../lib/utils/url_utility';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
export default function adminInit() {
$('input#user_force_random_password').on('change', function randomPasswordClick() {
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
index 67eee2c3209..7c81cf80dc6 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
@@ -1,6 +1,6 @@
import createFlash from '~/flash';
-import axios from '../../../lib/utils/axios_utils';
-import { __ } from '../../../locale';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
export default class PayloadDownloader {
constructor(trigger) {
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index c017cf0afa2..ae08806fe4c 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -1,6 +1,6 @@
import createFlash from '~/flash';
-import axios from '../../../lib/utils/axios_utils';
-import { __ } from '../../../locale';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
export default class PayloadPreviewer {
constructor(trigger) {
diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
index 70b896f6372..a50d8de0e88 100644
--- a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
+++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
@@ -23,6 +23,7 @@ export default function initSignupRestrictions(elementSelector = '#js-signup-for
return new Vue({
el,
+ name: 'SignupRestrictions',
provide: {
...parsedDataset,
},
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
index 2a7e6a45cdd..18ba89f8856 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -2,52 +2,41 @@ import $ from 'jquery';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { textColorForBackground } from '~/lib/utils/color_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
export default () => {
- const $broadcastMessageColor = $('.js-broadcast-message-color');
+ const $broadcastMessageTheme = $('.js-broadcast-message-theme');
const $broadcastMessageType = $('.js-broadcast-message-type');
- const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview');
+ const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview [role="alert"]');
const $broadcastMessage = $('.js-broadcast-message-message');
- const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview');
+ const $jsBroadcastMessagePreview = $('#broadcast-message-preview');
const reloadPreview = function reloadPreview() {
const previewPath = $broadcastMessage.data('previewPath');
const message = $broadcastMessage.val();
const type = $broadcastMessageType.val();
-
- if (message === '') {
- $jsBroadcastMessagePreview.text(__('Your message here'));
- } else {
- axios
- .post(previewPath, {
- broadcast_message: {
- message,
- broadcast_type: type,
- },
- })
- .then(({ data }) => {
- $jsBroadcastMessagePreview.html(data.message);
- })
- .catch(() =>
- createFlash({
- message: __('An error occurred while rendering preview broadcast message'),
- }),
- );
- }
+ const theme = $broadcastMessageTheme.val();
+
+ axios
+ .post(previewPath, {
+ broadcast_message: {
+ message,
+ broadcast_type: type,
+ theme,
+ },
+ })
+ .then(({ data }) => {
+ $jsBroadcastMessagePreview.html(data);
+ })
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while rendering preview broadcast message'),
+ }),
+ );
};
- $broadcastMessageColor.on('input', function onMessageColorInput() {
- const previewColor = $(this).val();
- $broadcastBannerMessagePreview.css('background-color', previewColor);
- });
-
- $('input#broadcast_message_font').on('input', function onMessageFontInput() {
- const previewColor = $(this).val();
- $broadcastBannerMessagePreview.css('color', previewColor);
- });
+ $broadcastMessageTheme.on('change', reloadPreview);
$broadcastMessageType.on('change', () => {
const $broadcastMessageColorFormGroup = $('.js-broadcast-message-background-color-form-group');
@@ -68,37 +57,4 @@ export default () => {
reloadPreview();
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
);
-
- const updateColorPreview = () => {
- const selectedBackgroundColor = $broadcastMessageColor.val();
- const contrastTextColor = textColorForBackground(selectedBackgroundColor);
-
- // save contrastTextColor to hidden input field
- $('input.text-font-color').val(contrastTextColor);
-
- // Updates the preview color with the hex-color input
- const selectedColorStyle = {
- backgroundColor: selectedBackgroundColor,
- color: contrastTextColor,
- };
-
- $('.label-color-preview').css(selectedColorStyle);
-
- return $jsBroadcastMessagePreview.css(selectedColorStyle);
- };
-
- const setSuggestedColor = (e) => {
- const color = $(e.currentTarget).data('color');
- $broadcastMessageColor
- .val(color)
- // Notify the form, that color has changed
- .trigger('input');
- // Only banner supports colors
- if ($broadcastMessageType === 'banner') {
- updateColorPreview();
- }
- return e.preventDefault();
- };
-
- $(document).on('click', '.suggest-colors a', setSuggestedColor);
};
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
index 1630cfb8253..710d2d72f4c 100644
--- a/app/assets/javascripts/pages/admin/groups/new/index.js
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -1,6 +1,6 @@
import initFilePickers from '~/file_pickers';
-import BindInOut from '../../../../behaviors/bind_in_out';
-import Group from '../../../../group';
+import BindInOut from '~/behaviors/bind_in_out';
+import Group from '~/group';
(() => {
BindInOut.initAll();
diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js
index f0f85b82e2b..a249864fa36 100644
--- a/app/assets/javascripts/pages/admin/index.js
+++ b/app/assets/javascripts/pages/admin/index.js
@@ -1,6 +1,6 @@
import initGitlabVersionCheck from '~/gitlab_version_check';
-import initAdminStatisticsPanel from '../../admin/statistics_panel/index';
-import initVueAlerts from '../../vue_alerts';
+import initAdminStatisticsPanel from '~/admin/statistics_panel/index';
+import initVueAlerts from '~/vue_alerts';
import initAdmin from './admin';
initVueAlerts();
diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js
index a99e0dfa4f0..a1ba920b322 100644
--- a/app/assets/javascripts/pages/groups/clusters/index/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/index/index.js
@@ -1,8 +1,6 @@
import initClustersListApp from '~/clusters_list';
import PersistentUserCallout from '~/persistent_user_callout';
-document.addEventListener('DOMContentLoaded', () => {
- const callout = document.querySelector('.gcp-signup-offer');
- PersistentUserCallout.factory(callout);
- initClustersListApp();
-});
+const callout = document.querySelector('.gcp-signup-offer');
+PersistentUserCallout.factory(callout);
+initClustersListApp();
diff --git a/app/assets/javascripts/pages/groups/crm/contacts/index.js b/app/assets/javascripts/pages/groups/crm/contacts/index.js
index a595246957f..6af47621c1d 100644
--- a/app/assets/javascripts/pages/groups/crm/contacts/index.js
+++ b/app/assets/javascripts/pages/groups/crm/contacts/index.js
@@ -1,3 +1,3 @@
-import initCrmContactsApp from '~/crm/contacts_bundle';
+import initCrmContactsApp from '~/crm/contacts/bundle';
initCrmContactsApp();
diff --git a/app/assets/javascripts/pages/groups/crm/organizations/index.js b/app/assets/javascripts/pages/groups/crm/organizations/index.js
index 16479b43d52..2ad0904688e 100644
--- a/app/assets/javascripts/pages/groups/crm/organizations/index.js
+++ b/app/assets/javascripts/pages/groups/crm/organizations/index.js
@@ -1,3 +1,3 @@
-import initCrmOrganizationsApp from '~/crm/organizations_bundle';
+import initCrmOrganizationsApp from '~/crm/organizations/bundle';
initCrmOrganizationsApp();
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 96487e14e30..58ca195d7b9 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -10,21 +10,19 @@ import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import initConfirmDanger from '~/init_confirm_danger';
-document.addEventListener('DOMContentLoaded', () => {
- initFilePickers();
- initConfirmDanger();
- initSettingsPanels();
- initTransferGroupForm();
- dirtySubmitFactory(
- document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'),
- );
- mountBadgeSettings(GROUP_BADGE);
+initFilePickers();
+initConfirmDanger();
+initSettingsPanels();
+initTransferGroupForm();
+dirtySubmitFactory(
+ document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'),
+);
+mountBadgeSettings(GROUP_BADGE);
- // Initialize Subgroups selector
- groupsSelect();
+// Initialize Subgroups selector
+groupsSelect();
- projectSelect();
+projectSelect();
- initSearchSettings();
- initCascadingSettingsLockPopovers();
-});
+initSearchSettings();
+initCascadingSettingsLockPopovers();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 280b544af3c..79ac31f1659 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -12,9 +12,16 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-group-members-list-app'), {
[MEMBER_TYPES.user]: {
- tableFields: SHARED_FIELDS.concat(['source', 'granted']),
+ tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
- tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
+ tableSortableFields: [
+ 'account',
+ 'granted',
+ 'maxRole',
+ 'lastSignIn',
+ 'userCreatedAt',
+ 'lastActivityOn',
+ ],
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
show: true,
@@ -25,12 +32,25 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
},
},
[MEMBER_TYPES.group]: {
- tableFields: SHARED_FIELDS.concat('granted'),
+ tableFields: gon?.features?.groupMemberInheritedGroup
+ ? SHARED_FIELDS.concat(['source', 'granted'])
+ : SHARED_FIELDS.concat(['granted']),
tableAttrs: {
table: { 'data-qa-selector': 'groups_list' },
tr: { 'data-qa-selector': 'group_row' },
},
requestFormatter: groupLinkRequestFormatter,
+ ...(gon?.features?.groupMemberInheritedGroup
+ ? {
+ filteredSearchBar: {
+ show: true,
+ tokens: ['with_inherited_permissions'],
+ searchParam: 'search_groups',
+ placeholder: s__('Members|Filter groups'),
+ recentSearchesStorageKey: 'group_links_members',
+ },
+ }
+ : {}),
},
[MEMBER_TYPES.invite]: {
tableFields: SHARED_FIELDS.concat('invited'),
diff --git a/app/assets/javascripts/pages/groups/harbor/repositories/index.js b/app/assets/javascripts/pages/groups/harbor/repositories/index.js
new file mode 100644
index 00000000000..0ecce44be54
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/harbor/repositories/index.js
@@ -0,0 +1,8 @@
+import HarborRegistryExplorer from '~/packages_and_registries/harbor_registry/index';
+
+const explorer = HarborRegistryExplorer('js-harbor-registry-list-group');
+
+if (explorer) {
+ explorer.attachBreadcrumb();
+ explorer.attachMainComponent();
+}
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
index 0ec382983a5..9a4054eb110 100644
--- 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
@@ -111,7 +111,7 @@ export default {
},
getFullDestinationUrl(params) {
- return joinPaths(gon.relative_url_root || '', this.getDestinationUrl(params));
+ return joinPaths(gon.relative_url_root || '', '/', this.getDestinationUrl(params));
},
},
@@ -161,7 +161,7 @@ export default {
>
</template>
<template #row-details="{ item }">
- <pre>{{ item.failures }}</pre>
+ <pre><code>{{ item.failures }}</code></pre>
</template>
</gl-table>
<pagination-bar
diff --git a/app/assets/javascripts/pages/import/history/components/import_error_details.vue b/app/assets/javascripts/pages/import/history/components/import_error_details.vue
new file mode 100644
index 00000000000..33ba73317f8
--- /dev/null
+++ b/app/assets/javascripts/pages/import/history/components/import_error_details.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import API from '~/api';
+import { createAlert } from '~/flash';
+import { DEFAULT_ERROR } from '../utils/error_messages';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ },
+ props: {
+ id: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ error: null,
+ };
+ },
+ async mounted() {
+ try {
+ const {
+ data: { import_error: importError },
+ } = await API.project(this.id);
+ this.error = importError;
+ } catch (e) {
+ createAlert({ message: DEFAULT_ERROR });
+ this.error = null;
+ } finally {
+ this.loading = false;
+ }
+ },
+};
+</script>
+<template>
+ <gl-loading-icon v-if="loading" size="md" />
+ <pre
+ v-else
+ ><code>{{ error || s__('BulkImport|No additional information provided.') }}</code></pre>
+</template>
diff --git a/app/assets/javascripts/pages/import/history/components/import_history_app.vue b/app/assets/javascripts/pages/import/history/components/import_history_app.vue
new file mode 100644
index 00000000000..557e25f66e2
--- /dev/null
+++ b/app/assets/javascripts/pages/import/history/components/import_history_app.vue
@@ -0,0 +1,199 @@
+<script>
+import { GlButton, GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import createFlash from '~/flash';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { getProjects } from '~/rest_api';
+import ImportStatus from '~/import_entities/components/import_status.vue';
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { DEFAULT_ERROR } from '../utils/error_messages';
+import ImportErrorDetails from './import_error_details.vue';
+
+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,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlTable,
+ PaginationBar,
+ ImportStatus,
+ ImportErrorDetails,
+ TimeAgo,
+ },
+
+ inject: ['assets'],
+
+ data() {
+ return {
+ loading: true,
+ historyItems: [],
+ paginationConfig: {
+ page: 1,
+ perPage: DEFAULT_PER_PAGE,
+ },
+ pageInfo: {},
+ };
+ },
+
+ fields: [
+ tableCell({
+ key: 'source',
+ label: s__('BulkImport|Source'),
+ thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`,
+ }),
+ tableCell({
+ key: 'destination',
+ label: s__('BulkImport|Destination'),
+ 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 getProjects(undefined, {
+ imported: true,
+ simple: false,
+ 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;
+ }
+ },
+
+ hasHttpProtocol(url) {
+ try {
+ const parsedUrl = new URL(url);
+ return ['http:', 'https:'].includes(parsedUrl.protocol);
+ } catch (e) {
+ return false;
+ }
+ },
+
+ setPageSize(size) {
+ this.paginationConfig.perPage = size;
+ this.paginationConfig.page = 1;
+ },
+ },
+};
+</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="assets.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
+ {{ s__('BulkImport|Project 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 projects 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(source)="{ item }">
+ <template v-if="item.import_url">
+ <gl-link
+ v-if="hasHttpProtocol(item.import_url)"
+ :href="item.import_url"
+ target="_blank"
+ >
+ {{ item.import_url }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ <span v-else>{{ item.import_url }}</span>
+ </template>
+ <span v-else>{{
+ s__('BulkImport|Template / File-based import / GitLab Migration')
+ }}</span>
+ </template>
+ <template #cell(destination)="{ item }">
+ <gl-link :href="item.http_url_to_repo">
+ {{ item.path_with_namespace }}
+ </gl-link>
+ </template>
+ <template #cell(created_at)="{ value }">
+ <time-ago :time="value" />
+ </template>
+ <template #cell(status)="{ item, toggleDetails, detailsShowing }">
+ <import-status :status="item.import_status" class="gl-display-inline-block gl-w-13" />
+ <gl-button
+ v-if="item.import_status === 'failed'"
+ class="gl-ml-3"
+ :selected="detailsShowing"
+ @click="toggleDetails"
+ >{{ __('Details') }}</gl-button
+ >
+ </template>
+ <template #row-details="{ item }">
+ <import-error-details :id="item.id" />
+ </template>
+ </gl-table>
+ <pagination-bar
+ :page-info="pageInfo"
+ class="gl-m-0 gl-mt-3"
+ @set-page="paginationConfig.page = $event"
+ @set-page-size="setPageSize"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/import/history/index.js b/app/assets/javascripts/pages/import/history/index.js
new file mode 100644
index 00000000000..d540272c266
--- /dev/null
+++ b/app/assets/javascripts/pages/import/history/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import ImportHistoryApp from './components/import_history_app.vue';
+
+function mountImportHistoryApp(mountElement) {
+ if (!mountElement) return undefined;
+
+ return new Vue({
+ el: mountElement,
+ name: 'ImportHistoryRoot',
+ provide: {
+ assets: {
+ gitlabLogo: mountElement.dataset.logo,
+ },
+ },
+ render(createElement) {
+ return createElement(ImportHistoryApp);
+ },
+ });
+}
+
+mountImportHistoryApp(document.querySelector('#import-history-mount-element'));
diff --git a/app/assets/javascripts/pages/import/history/utils/error_messages.js b/app/assets/javascripts/pages/import/history/utils/error_messages.js
new file mode 100644
index 00000000000..24669e22ade
--- /dev/null
+++ b/app/assets/javascripts/pages/import/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/preferences/show/index.js b/app/assets/javascripts/pages/profiles/preferences/show/index.js
index d489ed80f46..76939434680 100644
--- a/app/assets/javascripts/pages/profiles/preferences/show/index.js
+++ b/app/assets/javascripts/pages/profiles/preferences/show/index.js
@@ -1,3 +1,5 @@
import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle';
+import initProfilePreferencesDiffsColors from '~/profile/preferences/profile_preferences_diffs_colors';
-document.addEventListener('DOMContentLoaded', initProfilePreferences);
+initProfilePreferences();
+initProfilePreferencesDiffsColors();
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index c6a76df7bde..eca3cf7ab13 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -1,5 +1,6 @@
/* eslint-disable no-new */
import $ from 'jquery';
+import Vue from 'vue';
import loadAwardsHandler from '~/awards_handler';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import Diff from '~/diff';
@@ -14,6 +15,7 @@ import { initCommitBoxInfo } from '~/projects/commit_box/info';
import syntaxHighlight from '~/syntax_highlight';
import ZenMode from '~/zen_mode';
import '~/sourcegraph/load';
+import DiffStats from '~/diffs/components/diff_stats.vue';
const hasPerfBar = document.querySelector('.with-performance-bar');
const performanceHeight = hasPerfBar ? 35 : 0;
@@ -25,6 +27,33 @@ initCommitBoxInfo();
initDeprecatedNotes();
+const loadDiffStats = () => {
+ const diffStatsElements = document.querySelectorAll('#js-diff-stats');
+
+ if (diffStatsElements.length) {
+ diffStatsElements.forEach((diffStatsEl) => {
+ const { addedLines, removedLines, oldSize, newSize, viewerName } = diffStatsEl.dataset;
+
+ new Vue({
+ el: diffStatsEl,
+ render(createElement) {
+ return createElement(DiffStats, {
+ props: {
+ diffFile: {
+ old_size: oldSize,
+ new_size: newSize,
+ viewer: { name: viewerName },
+ },
+ addedLines: Number(addedLines),
+ removedLines: Number(removedLines),
+ },
+ });
+ },
+ });
+ });
+ }
+};
+
const filesContainer = $('.js-diffs-batch');
if (filesContainer.length) {
@@ -37,12 +66,15 @@ if (filesContainer.length) {
syntaxHighlight(filesContainer);
handleLocationHash();
new Diff();
+ loadDiffStats();
})
.catch(() => {
createFlash({ message: __('An error occurred while retrieving diff files') });
});
} else {
new Diff();
+ loadDiffStats();
}
+
loadAwardsHandler();
initCommitActions();
diff --git a/app/assets/javascripts/pages/projects/harbor/repositories/index.js b/app/assets/javascripts/pages/projects/harbor/repositories/index.js
new file mode 100644
index 00000000000..efbe24ac346
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/harbor/repositories/index.js
@@ -0,0 +1,8 @@
+import HarborRegistryExplorer from '~/packages_and_registries/harbor_registry/index';
+
+const explorer = HarborRegistryExplorer('js-harbor-registry-list-project');
+
+if (explorer) {
+ explorer.attachBreadcrumb();
+ explorer.attachMainComponent();
+}
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index 8ec6e5e66b3..7380055cbbf 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,5 +1,5 @@
-import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
-import initTerraformNotification from '../../projects/terraform_notification';
+import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
+import initTerraformNotification from '~/projects/terraform_notification';
import { initSidebarTracking } from '../shared/nav/sidebar_tracking';
import Project from './project';
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
index 67962d69fa5..db9ef4df8af 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
@@ -127,8 +127,12 @@ export default {
</p>
<gl-progress-bar :value="progressValue" :max="$options.maxValue" />
</div>
- <div class="row row-cols-1 row-cols-md-3 gl-mt-5">
- <div v-for="section in $options.actionSections" :key="section" class="col gl-mb-6">
+ <div class="row">
+ <div
+ v-for="section in $options.actionSections"
+ :key="section"
+ class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4"
+ >
<learn-gitlab-section-card
:section="section"
:svg="svgFor(section)"
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
index 6a196687a76..e8f0e6c47ee 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
@@ -34,17 +34,23 @@ export default {
};
</script>
<template>
- <gl-card class="gl-pt-0 learn-gitlab-section-card">
- <div class="learn-gitlab-section-card-header">
+ <gl-card
+ class="gl-pt-0 h-100"
+ header-class="gl-bg-white gl-border-0 gl-pb-0"
+ body-class="gl-pt-0"
+ >
+ <template #header>
<img :src="svg" />
<h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2>
<p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p>
- </div>
- <learn-gitlab-section-link
- v-for="[action, value] in sortedActions"
- :key="action"
- :action="action"
- :value="value"
- />
+ </template>
+ <template #default>
+ <learn-gitlab-section-link
+ v-for="[action, value] in sortedActions"
+ :key="action"
+ :action="action"
+ :value="value"
+ />
+ </template>
</gl-card>
</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index 573f996a254..1667f2c3576 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
@@ -1,16 +1,25 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
+import { GlLink, GlIcon, GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import { isExperimentVariant } from '~/experimentation/utils';
import eventHub from '~/invite_members/event_hub';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import { ACTION_LABELS } from '../constants';
export default {
name: 'LearnGitlabSectionLink',
- components: { GlLink, GlIcon },
+ components: {
+ GlLink,
+ GlIcon,
+ GlButton,
+ GitlabExperiment,
+ },
+ directives: {
+ GlTooltip,
+ },
i18n: {
- ACTION_LABELS,
trialOnly: s__('LearnGitlab|Trial only'),
+ watchHow: __('Watch how'),
},
props: {
action: {
@@ -23,6 +32,9 @@ export default {
},
},
computed: {
+ linkTitle() {
+ return ACTION_LABELS[this.action].title;
+ },
trialOnly() {
return ACTION_LABELS[this.action].trialRequired;
},
@@ -34,6 +46,9 @@ export default {
openInNewTab() {
return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true;
},
+ linkToVideoTutorial() {
+ return ACTION_LABELS[this.action].videoTutorial;
+ },
},
methods: {
openModal() {
@@ -44,32 +59,55 @@ export default {
</script>
<template>
<div class="gl-mb-4">
- <span v-if="value.completed" class="gl-text-green-500">
- <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
- {{ $options.i18n.ACTION_LABELS[action].title }}
- </span>
- <gl-link
- v-else-if="showInviteModalLink"
- data-track-action="click_link"
- :data-track-label="$options.i18n.ACTION_LABELS[action].title"
- data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
- data-testid="invite-for-help-continuous-onboarding-experiment-link"
- @click="openModal"
- >
- {{ $options.i18n.ACTION_LABELS[action].title }}
- </gl-link>
- <gl-link
- v-else
- :target="openInNewTab ? '_blank' : '_self'"
- :href="value.url"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- :data-track-label="$options.i18n.ACTION_LABELS[action].title"
- >
- {{ $options.i18n.ACTION_LABELS[action].title }}
- </gl-link>
- <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
- - {{ $options.i18n.trialOnly }}
- </span>
+ <div v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
+ {{ $options.i18n.trialOnly }}
+ </div>
+ <div class="flex align-items-center">
+ <span v-if="value.completed" class="gl-text-green-500">
+ <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
+ {{ linkTitle }}
+ </span>
+ <gl-link
+ v-else-if="showInviteModalLink"
+ data-track-action="click_link"
+ :data-track-label="linkTitle"
+ data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
+ data-testid="invite-for-help-continuous-onboarding-experiment-link"
+ @click="openModal"
+ >
+ {{ linkTitle }}
+ </gl-link>
+ <gl-link
+ v-else
+ :target="openInNewTab ? '_blank' : '_self'"
+ :href="value.url"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ :data-track-label="linkTitle"
+ >
+ {{ linkTitle }}
+ </gl-link>
+ <gitlab-experiment name="video_tutorials_continuous_onboarding">
+ <template #control></template>
+ <template #candidate>
+ <gl-button
+ v-if="linkToVideoTutorial"
+ v-gl-tooltip
+ category="tertiary"
+ icon="live-preview"
+ :title="$options.i18n.watchHow"
+ :aria-label="$options.i18n.watchHow"
+ :href="linkToVideoTutorial"
+ target="_blank"
+ class="ml-auto"
+ data-testid="video-tutorial-link"
+ data-track-action="click_video_link"
+ :data-track-label="linkTitle"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ data-track-experiment="video_tutorials_continuous_onboarding"
+ />
+ </template>
+ </gitlab-experiment>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
index 1887c48dd1b..9ba5e17237a 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -40,6 +40,7 @@ export const ACTION_LABELS = {
trialRequired: true,
section: 'workspace',
position: 4,
+ videoTutorial: 'https://vimeo.com/670896787',
},
requiredMrApprovalsEnabled: {
title: s__('LearnGitLab|Add merge request approval'),
@@ -48,6 +49,7 @@ export const ACTION_LABELS = {
trialRequired: true,
section: 'workspace',
position: 5,
+ videoTutorial: 'https://vimeo.com/670904904',
},
mergeRequestCreated: {
title: s__('LearnGitLab|Submit a merge request'),
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
index 63357ea9c72..af4a6f8a0c9 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
+import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import LearnGitlab from '../components/learn_gitlab.vue';
@@ -24,5 +25,7 @@ function initLearnGitlab() {
});
}
-initLearnGitlab();
initInviteMembersModal();
+initInviteMembersTrigger();
+
+initLearnGitlab();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index c548ea9bb80..0e0c1475eda 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import loadAwardsHandler from '~/awards_handler';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { initPipelineCountListener } from '~/commit/pipelines/utils';
import { initIssuableSidebar } from '~/issuable';
@@ -8,23 +7,16 @@ import StatusBox from '~/issuable/components/status_box.vue';
import createDefaultClient from '~/lib/graphql';
import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
+import initAwardsApp from '~/emoji/awards_app';
import getStateQuery from './queries/get_state.query.graphql';
export default function initMergeRequestShow() {
- const awardEmojiEl = document.getElementById('js-vue-awards-block');
-
new ZenMode(); // eslint-disable-line no-new
initPipelineCountListener(document.querySelector('#commit-pipeline-table-view'));
new ShortcutsIssuable(true); // eslint-disable-line no-new
initSourcegraph();
initIssuableSidebar();
- if (awardEmojiEl) {
- import('~/emoji/awards_app')
- .then((m) => m.default(awardEmojiEl))
- .catch(() => {});
- } else {
- loadAwardsHandler();
- }
+ initAwardsApp(document.getElementById('js-vue-awards-block'));
const el = document.querySelector('.js-mr-status-box');
const apolloProvider = new VueApollo({
diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js
index 5f2014f1631..b88127384dc 100644
--- a/app/assets/javascripts/pages/projects/network/network.js
+++ b/app/assets/javascripts/pages/projects/network/network.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import BranchGraph from '../../../network/branch_graph';
+import BranchGraph from '~/network/branch_graph';
const vph = $(window).height() - 250;
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index ee70ff858be..37e8a316ee4 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -2,8 +2,7 @@
import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
-
-import Translate from '../../../../../vue_shared/translate';
+import Translate from '~/vue_shared/translate';
Vue.use(Translate);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index 9c039a6be81..5dae812bbcb 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -3,9 +3,9 @@ import Vue from 'vue';
import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
-import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list';
-import GlFieldErrors from '../../../../gl_field_errors';
-import Translate from '../../../../vue_shared/translate';
+import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
+import GlFieldErrors from '~/gl_field_errors';
+import Translate from '~/vue_shared/translate';
import intervalPatternInput from './components/interval_pattern_input.vue';
import TimezoneDropdown from './components/timezone_dropdown';
@@ -33,13 +33,7 @@ function initIntervalPatternInput() {
}
function getEnabledRefTypes() {
- const refTypes = [REF_TYPE_BRANCHES];
-
- if (gon.features.pipelineSchedulesWithTags) {
- refTypes.push(REF_TYPE_TAGS);
- }
-
- return refTypes;
+ return [REF_TYPE_BRANCHES, REF_TYPE_TAGS];
}
function initTargetRefDropdown() {
@@ -61,9 +55,7 @@ function initTargetRefDropdown() {
value: $refField.value,
useSymbolicRefNames: true,
translations: {
- dropdownHeader: gon.features.pipelineSchedulesWithTags
- ? __('Select target branch or tag')
- : __('Select target branch'),
+ dropdownHeader: __('Select target branch or tag'),
},
},
class: 'gl-w-full',
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 0c17bf2f344..4f57e1308df 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -9,7 +9,7 @@ import axios from '~/lib/utils/axios_utils';
import { serializeForm } from '~/lib/utils/forms';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import projectSelect from '../../project_select';
+import projectSelect from '~/project_select';
export default class Project {
constructor() {
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 2c0394dc12c..bf4fb5f3b7e 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -18,9 +18,16 @@ initInviteGroupTrigger();
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
- tableFields: SHARED_FIELDS.concat(['source', 'granted']),
+ tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
- tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
+ tableSortableFields: [
+ 'account',
+ 'granted',
+ 'maxRole',
+ 'lastSignIn',
+ 'userCreatedAt',
+ 'lastActivityOn',
+ ],
requestFormatter: projectMemberRequestFormatter,
filteredSearchBar: {
show: true,
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index e88dbf20e1b..43ab829f5f9 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -10,36 +10,34 @@ import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_to
import initSettingsPanels from '~/settings_panels';
import { initTokenAccess } from '~/token_access';
-document.addEventListener('DOMContentLoaded', () => {
- // Initialize expandable settings panels
- initSettingsPanels();
+// Initialize expandable settings panels
+initSettingsPanels();
- const runnerToken = document.querySelector('.js-secret-runner-token');
- if (runnerToken) {
- const runnerTokenSecretValue = new SecretValues({
- container: runnerToken,
- });
- runnerTokenSecretValue.init();
- }
-
- initVariableList();
-
- // hide extra auto devops settings based checkbox state
- const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');
- const instanceDefaultBadge = document.querySelector('.js-instance-default-badge');
- document.querySelector('.js-toggle-extra-settings').addEventListener('click', (event) => {
- const { target } = event;
- if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none';
- autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked);
+const runnerToken = document.querySelector('.js-secret-runner-token');
+if (runnerToken) {
+ const runnerTokenSecretValue = new SecretValues({
+ container: runnerToken,
});
+ runnerTokenSecretValue.init();
+}
- registrySettingsApp();
- initDeployFreeze();
+initVariableList();
- initSettingsPipelinesTriggers();
- initArtifactsSettings();
- initSharedRunnersToggle();
- initInstallRunner();
- initRunnerAwsDeployments();
- initTokenAccess();
+// hide extra auto devops settings based checkbox state
+const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');
+const instanceDefaultBadge = document.querySelector('.js-instance-default-badge');
+document.querySelector('.js-toggle-extra-settings').addEventListener('click', (event) => {
+ const { target } = event;
+ if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none';
+ autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked);
});
+
+registrySettingsApp();
+initDeployFreeze();
+
+initSettingsPipelinesTriggers();
+initArtifactsSettings();
+initSharedRunnersToggle();
+initInstallRunner();
+initRunnerAwsDeployments();
+initTokenAccess();
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index e90954c14c5..d45052d76f4 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,9 +1,7 @@
import MirrorRepos from '~/mirrors/mirror_repos';
import initForm from '../form';
-document.addEventListener('DOMContentLoaded', () => {
- initForm();
+initForm();
- const mirrorReposContainer = document.querySelector('.js-mirror-settings');
- if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init();
-});
+const mirrorReposContainer = document.querySelector('.js-mirror-settings');
+if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init();
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
index 9fb8be3fdb9..b2d32c2c943 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
@@ -44,6 +44,15 @@ export default {
},
},
computed: {
+ internalValue: {
+ get() {
+ return this.value;
+ },
+ set(value) {
+ this.$emit('change', value);
+ },
+ },
+
featureEnabled() {
return this.value !== 0;
},
@@ -68,10 +77,6 @@ export default {
this.$emit('change', firstOptionValue);
}
},
-
- selectOption(e) {
- this.$emit('change', Number(e.target.value));
- },
},
};
</script>
@@ -93,15 +98,14 @@ export default {
/>
<div class="select-wrapper gl-flex-grow-1">
<select
+ v-model="internalValue"
:disabled="displaySelectInput"
class="form-control project-repo-select select-control"
- @change="selectOption"
>
<option
v-for="[optionValue, optionName] in displayOptions"
:key="optionValue"
:value="optionValue"
- :selected="optionValue === value"
>
{{ optionName }}
</option>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 184bda4410f..03bab0fa773 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -9,7 +9,6 @@ import {
featureAccessLevelMembers,
featureAccessLevelEveryone,
featureAccessLevel,
- featureAccessLevelNone,
CVE_ID_REQUEST_BUTTON_I18N,
featureAccessLevelDescriptions,
} from '../constants';
@@ -225,8 +224,6 @@ export default {
},
operationsFeatureAccessLevelOptions() {
- if (!this.operationsEnabled) return [featureAccessLevelNone];
-
return this.featureAccessLevelOptions.filter(
([value]) => value <= this.operationsAccessLevel,
);
@@ -251,10 +248,6 @@ export default {
return options;
},
- metricsOptionsDropdownDisabled() {
- return this.operationsFeatureAccessLevelOptions.length < 2 || !this.operationsEnabled;
- },
-
operationsEnabled() {
return this.operationsAccessLevel > featureAccessLevel.NOT_ENABLED;
},
@@ -392,6 +385,15 @@ export default {
else if (oldValue === featureAccessLevel.NOT_ENABLED)
toggleHiddenClassBySelector('.merge-requests-feature', false);
},
+
+ operationsAccessLevel(value, oldValue) {
+ if (value < oldValue) {
+ // sub-features cannot have more permissive access level
+ this.metricsDashboardAccessLevel = Math.min(this.metricsDashboardAccessLevel, value);
+ } else if (oldValue === 0) {
+ this.metricsDashboardAccessLevel = value;
+ }
+ },
},
methods: {
@@ -590,7 +592,9 @@ export default {
:help-path="packagesHelpPath"
:label="$options.i18n.packagesLabel"
:help-text="
- s__('ProjectSettings|Every project can have its own space to store its packages.')
+ s__(
+ 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.',
+ )
"
>
<gl-toggle
diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js
index c719601ee0b..77baa6d77a5 100644
--- a/app/assets/javascripts/pages/projects/snippets/show/index.js
+++ b/app/assets/javascripts/pages/projects/snippets/show/index.js
@@ -1,9 +1,4 @@
import '~/snippet/snippet_show';
+import initAwardsApp from '~/emoji/awards_app';
-const awardEmojiEl = document.getElementById('js-vue-awards-block');
-
-if (awardEmojiEl) {
- import('~/emoji/awards_app')
- .then((m) => m.default(awardEmojiEl))
- .catch(() => {});
-}
+initAwardsApp(document.getElementById('js-vue-awards-block'));
diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js
index b071e7a45fc..9ef1017f9f2 100644
--- a/app/assets/javascripts/pages/projects/tags/new/index.js
+++ b/app/assets/javascripts/pages/projects/tags/new/index.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
-import GLForm from '../../../../gl_form';
-import RefSelectDropdown from '../../../../ref_select_dropdown';
-import ZenMode from '../../../../zen_mode';
+import GLForm from '~/gl_form';
+import RefSelectDropdown from '~/ref_select_dropdown';
+import ZenMode from '~/zen_mode';
new ZenMode(); // eslint-disable-line no-new
new GLForm($('.tag-form')); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
index 4bb461aadad..cf7162f477d 100644
--- a/app/assets/javascripts/pages/projects/tree/show/index.js
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import initTree from 'ee_else_ce/repository';
import initBlob from '~/blob_edit/blob_bundle';
-import ShortcutsNavigation from '../../../../behaviors/shortcuts/shortcuts_navigation';
-import NewCommitForm from '../../../../new_commit_form';
+import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
+import NewCommitForm from '~/new_commit_form';
new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new
initBlob();
diff --git a/app/assets/javascripts/pages/projects/wikis/show/index.js b/app/assets/javascripts/pages/projects/wikis/show/index.js
index c08a10122b6..7ca5f6964cd 100644
--- a/app/assets/javascripts/pages/projects/wikis/show/index.js
+++ b/app/assets/javascripts/pages/projects/wikis/show/index.js
@@ -1,3 +1,5 @@
+import { mountApplications } from '~/pages/shared/wikis/show';
import { mountApplications as mountEditApplications } from '~/pages/shared/wikis/async_edit';
+mountApplications();
mountEditApplications();
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index 8c2fd624a83..b62417cf595 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import initVueAlerts from '~/vue_alerts';
-import NoEmojiValidator from '../../../emoji/no_emoji_validator';
+import NoEmojiValidator from '~/emoji/no_emoji_validator';
import LengthValidator from './length_validator';
import OAuthRememberMe from './oauth_remember_me';
import preserveUrlFragment from './preserve_url_fragment';
diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/pages/sessions/new/length_validator.js
index 17acad10bc1..b2074fb1e39 100644
--- a/app/assets/javascripts/pages/sessions/new/length_validator.js
+++ b/app/assets/javascripts/pages/sessions/new/length_validator.js
@@ -1,4 +1,4 @@
-import InputValidator from '../../../validators/input_validator';
+import InputValidator from '~/validators/input_validator';
const errorMessageClass = 'gl-field-error';
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
new file mode 100644
index 00000000000..7c23f60954a
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
@@ -0,0 +1,92 @@
+<script>
+import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import { renderGFM } from '../render_gfm_facade';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ GlAlert,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ getWikiContentUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoadingContent: false,
+ loadingContentFailed: false,
+ content: null,
+ };
+ },
+ mounted() {
+ this.loadWikiContent();
+ },
+ methods: {
+ async loadWikiContent() {
+ this.loadingContentFailed = false;
+ this.isLoadingContent = true;
+
+ try {
+ const {
+ data: { content },
+ } = await axios.get(this.getWikiContentUrl, { params: { render_html: true } });
+ this.content = content;
+
+ this.$nextTick()
+ .then(() => {
+ renderGFM(this.$refs.content);
+ })
+ .catch(() =>
+ createFlash({
+ message: this.$options.i18n.renderingContentFailed,
+ }),
+ );
+ } catch (e) {
+ this.loadingContentFailed = true;
+ } finally {
+ this.isLoadingContent = false;
+ }
+ },
+ },
+ i18n: {
+ loadingContentFailed: __(
+ 'The content for this wiki page failed to load. To fix this error, reload the page.',
+ ),
+ retryLoadingContent: __('Retry'),
+ renderingContentFailed: __('The content for this wiki page failed to render.'),
+ },
+};
+</script>
+<template>
+ <gl-skeleton-loader v-if="isLoadingContent" :width="830" :height="113">
+ <rect width="540" height="16" rx="4" />
+ <rect y="49" width="701" height="16" rx="4" />
+ <rect y="24" width="830" height="16" rx="4" />
+ <rect y="73" width="540" height="16" rx="4" />
+ </gl-skeleton-loader>
+ <gl-alert
+ v-else-if="loadingContentFailed"
+ :dismissible="false"
+ variant="danger"
+ :primary-button-text="$options.i18n.retryLoadingContent"
+ @primaryAction="loadWikiContent"
+ >
+ {{ $options.i18n.loadingContentFailed }}
+ </gl-alert>
+ <div
+ v-else-if="!loadingContentFailed && !isLoadingContent"
+ ref="content"
+ data-qa-selector="wiki_page_content"
+ data-testid="wiki_page_content"
+ class="js-wiki-page-content md"
+ v-html="content /* eslint-disable-line vue/no-v-html */"
+ ></div>
+</template>
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 8ef31b9b983..024b3bc9595 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -1,21 +1,11 @@
<script>
-import {
- GlForm,
- GlIcon,
- GlLink,
- GlButton,
- GlSprintf,
- GlAlert,
- GlModal,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlForm, GlIcon, GlLink, GlButton, GlSprintf, GlAlert } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
import { setUrlFragment } from '~/lib/utils/url_utility';
-import { __, s__, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
CONTENT_EDITOR_LOADED_ACTION,
SAVED_USING_CONTENT_EDITOR_ACTION,
@@ -64,31 +54,6 @@ export default {
),
primaryAction: s__('WikiPage|Retry'),
},
- useNewEditor: {
- primaryLabel: s__('WikiPage|Use the new editor'),
- secondaryLabel: s__('WikiPage|Try this later'),
- title: s__('WikiPage|Get a richer editing experience'),
- text: s__(
- "WikiPage|Try the new visual Markdown editor. Read the %{linkStart}documentation%{linkEnd} to learn what's currently supported.",
- ),
- },
- switchToOldEditor: {
- label: s__('WikiPage|Switch me back to the classic editor.'),
- helpText: s__(
- "WikiPage|This editor is in beta and may not display the page's contents properly. Switching back to the classic editor will discard changes you've made in the new editor.",
- ),
- modal: {
- title: s__('WikiPage|Are you sure you want to switch back to the classic editor?'),
- primary: s__('WikiPage|Switch to classic editor'),
- cancel: s__('WikiPage|Keep editing'),
- text: s__(
- "WikiPage|Switching to the classic editor will discard any changes you've made in the new editor.",
- ),
- },
- },
- feedbackTip: __(
- 'Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}.',
- ),
},
linksHelpText: s__(
'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.',
@@ -108,7 +73,6 @@ export default {
editSourceButtonText: s__('WikiPage|Edit source'),
editRichTextButtonText: s__('WikiPage|Edit rich text'),
},
- contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629',
components: {
GlAlert,
GlForm,
@@ -116,24 +80,19 @@ export default {
GlIcon,
GlLink,
GlButton,
- GlModal,
MarkdownField,
ContentEditor: () =>
import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
),
},
- directives: {
- GlModalDirective,
- },
- mixins: [trackingMixin, glFeatureFlagMixin()],
+ mixins: [trackingMixin],
inject: ['formatOptions', 'pageInfo'],
data() {
return {
title: this.pageInfo.title?.trim() || '',
format: this.pageInfo.format || 'markdown',
content: this.pageInfo.content || '',
- isContentEditorAlertDismissed: false,
useContentEditor: false,
commitMessage: '',
isDirty: false,
@@ -194,25 +153,9 @@ export default {
isMarkdownFormat() {
return this.format === 'markdown';
},
- showContentEditorAlert() {
- return (
- !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown &&
- this.isMarkdownFormat &&
- !this.useContentEditor &&
- !this.isContentEditorAlertDismissed
- );
- },
- showSwitchEditingModeButton() {
- return this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isMarkdownFormat;
- },
displayWikiSpecificMarkdownHelp() {
return !this.isContentEditorActive;
},
- displaySwitchBackToClassicEditorMessage() {
- return (
- !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isContentEditorActive
- );
- },
disableSubmitButton() {
return this.noContent || !this.title || this.contentEditorRenderFailed;
},
@@ -312,23 +255,6 @@ export default {
this.commitMessage = newCommitMessage;
},
- initContentEditor() {
- this.useContentEditor = true;
- },
-
- switchToOldEditor() {
- this.useContentEditor = false;
- },
-
- confirmSwitchToOldEditor() {
- if (this.contentEditorRenderFailed) {
- this.contentEditorRenderFailed = false;
- this.switchToOldEditor();
- } else {
- this.$refs.confirmSwitchToOldEditorModal.show();
- }
- },
-
trackContentEditorLoaded() {
this.track(CONTENT_EDITOR_LOADED_ACTION);
},
@@ -349,10 +275,6 @@ export default {
},
});
},
-
- dismissContentEditorAlert() {
- this.isContentEditorAlertDismissed = true;
- },
},
};
</script>
@@ -438,10 +360,7 @@ export default {
}}</label>
</div>
<div class="col-sm-10">
- <div
- v-if="showSwitchEditingModeButton"
- class="gl-display-flex gl-justify-content-end gl-mb-3"
- >
+ <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3">
<gl-button
data-testid="toggle-editing-mode-button"
data-qa-selector="editing_mode_button"
@@ -451,42 +370,6 @@ export default {
>{{ toggleEditingModeButtonText }}</gl-button
>
</div>
- <gl-alert
- v-if="showContentEditorAlert"
- class="gl-mb-6"
- variant="info"
- data-qa-selector="try_new_editor_container"
- :primary-button-text="$options.i18n.contentEditor.useNewEditor.primaryLabel"
- :secondary-button-text="$options.i18n.contentEditor.useNewEditor.secondaryLabel"
- :dismiss-label="$options.i18n.contentEditor.useNewEditor.secondaryLabel"
- :title="$options.i18n.contentEditor.useNewEditor.title"
- @primaryAction="initContentEditor"
- @secondaryAction="dismissContentEditorAlert"
- @dismiss="dismissContentEditorAlert"
- >
- <gl-sprintf :message="$options.i18n.contentEditor.useNewEditor.text">
- <template
- #link="// eslint-disable-next-line vue/no-template-shadow
- { content }"
- ><gl-link
- :href="contentEditorHelpPath"
- target="_blank"
- data-testid="content-editor-help-link"
- >{{ content }}</gl-link
- ></template
- >
- </gl-sprintf>
- </gl-alert>
- <gl-modal
- ref="confirmSwitchToOldEditorModal"
- modal-id="confirm-switch-to-old-editor"
- :title="$options.i18n.contentEditor.switchToOldEditor.modal.title"
- :action-primary="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.primary }"
- :action-cancel="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.cancel }"
- @primary="switchToOldEditor"
- >
- {{ $options.i18n.contentEditor.switchToOldEditor.modal.text }}
- </gl-modal>
<markdown-field
v-if="!isContentEditorActive"
:markdown-preview-path="pageInfo.markdownPreviewPath"
@@ -516,22 +399,7 @@ export default {
</textarea>
</template>
</markdown-field>
-
<div v-if="isContentEditorActive">
- <gl-alert class="gl-mb-6" variant="tip" :dismissible="false">
- <gl-sprintf :message="$options.i18n.contentEditor.feedbackTip">
- <template
- #link="// eslint-disable-next-line vue/no-template-shadow
- { content }"
- ><gl-link
- :href="$options.contentEditorFeedbackIssue"
- target="_blank"
- data-testid="wiki-markdown-help-link"
- >{{ content }}</gl-link
- ></template
- >
- </gl-sprintf>
- </gl-alert>
<content-editor
:render-markdown="renderMarkdown"
:uploads-path="pageInfo.uploadsPath"
@@ -560,12 +428,6 @@ export default {
></template
>
</gl-sprintf>
- <span v-if="displaySwitchBackToClassicEditorMessage">
- {{ $options.i18n.contentEditor.switchToOldEditor.helpText }}
- <gl-button variant="link" @click="confirmSwitchToOldEditor">{{
- $options.i18n.contentEditor.switchToOldEditor.label
- }}</gl-button>
- </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/pages/shared/wikis/edit.js b/app/assets/javascripts/pages/shared/wikis/edit.js
index beeabfde1a6..02878633916 100644
--- a/app/assets/javascripts/pages/shared/wikis/edit.js
+++ b/app/assets/javascripts/pages/shared/wikis/edit.js
@@ -3,8 +3,8 @@ import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
import Translate from '~/vue_shared/translate';
-import GLForm from '../../../gl_form';
-import ZenMode from '../../../zen_mode';
+import GLForm from '~/gl_form';
+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';
diff --git a/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js b/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js
new file mode 100644
index 00000000000..90cc2983153
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js
@@ -0,0 +1,5 @@
+import $ from 'jquery';
+
+export const renderGFM = (el) => {
+ return $(el).renderGFM();
+};
diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js
new file mode 100644
index 00000000000..9906cb595f8
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/show.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import Wikis from './wikis';
+import WikiContent from './components/wiki_content.vue';
+
+const mountWikiContentApp = () => {
+ const el = document.querySelector('.js-async-wiki-page-content');
+
+ if (el) {
+ const { getWikiContentUrl } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(WikiContent, {
+ props: { getWikiContentUrl },
+ });
+ },
+ });
+ }
+};
+
+export const mountApplications = () => {
+ // eslint-disable-next-line no-new
+ new Wikis();
+ mountWikiContentApp();
+};
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
index a614342c858..4c0293f5b78 100644
--- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -1,5 +1,5 @@
import { parseBoolean } from '~/lib/utils/common_utils';
-import axios from '../../lib/utils/axios_utils';
+import axios from '~/lib/utils/axios_utils';
export default class PerformanceBarService {
static interceptor = null;
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index f6de21ec0c5..dee832c01d5 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -12,6 +12,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-security-newsletter-callout',
'.js-approaching-seats-count-threshold',
'.js-storage-enforcement-banner',
+ '.js-user-over-limit-free-plan-alert',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
index a8ad56ab6a5..897bd2dcccf 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
@@ -1,5 +1,5 @@
<script>
-import { GlCard, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
@@ -22,7 +22,6 @@ export default {
),
},
components: {
- GlCard,
GlLink,
GlSprintf,
},
@@ -30,22 +29,20 @@ export default {
};
</script>
<template>
- <gl-card>
- <template #default>
- <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4>
- <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
- <ol class="gl-mb-3">
- <li v-for="(item, i) in $options.i18n.listItems" :key="`li-${i}`">{{ item }}</li>
- </ol>
- <p class="gl-mb-0">
- <gl-sprintf :message="$options.i18n.note">
- <template #link="{ content }">
- <gl-link :href="runnerHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </template>
- </gl-card>
+ <div>
+ <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <ol class="gl-mb-3">
+ <li v-for="(item, i) in $options.i18n.listItems" :key="`li-${i}`">{{ item }}</li>
+ </ol>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.note">
+ <template #link="{ content }">
+ <gl-link :href="runnerHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue
index 3da535f5f94..d2682cf6326 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue
@@ -1,5 +1,5 @@
<script>
-import { GlCard, GlSprintf } from '@gitlab/ui';
+import { GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
@@ -13,23 +13,20 @@ export default {
),
},
components: {
- GlCard,
GlSprintf,
},
};
</script>
<template>
- <gl-card>
- <template #default>
- <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4>
- <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
- <p class="gl-mb-0">
- <gl-sprintf :message="$options.i18n.secondParagraph">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
- </template>
- </gl-card>
+ <div>
+ <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.secondParagraph">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue
index f714f6411f1..04140434af2 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue
@@ -1,5 +1,5 @@
<script>
-import { GlCard, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
@@ -20,7 +20,6 @@ export default {
),
},
components: {
- GlCard,
GlLink,
GlSprintf,
},
@@ -28,48 +27,46 @@ export default {
};
</script>
<template>
- <gl-card>
- <template #default>
- <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4>
- <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
- <ul>
- <li>
- <gl-sprintf :message="$options.i18n.browseExamples">
- <template #link="{ content }">
- <gl-link :href="ciExamplesHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="$options.i18n.viewSyntaxRef">
- <template #link="{ content }">
- <gl-link :href="ymlHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="$options.i18n.learnMore">
- <template #link="{ content }">
- <gl-link :href="ciHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="$options.i18n.needs">
- <template #link="{ content }">
- <gl-link :href="needsHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </li>
- </ul>
- </template>
- </gl-card>
+ <div>
+ <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <ul>
+ <li>
+ <gl-sprintf :message="$options.i18n.browseExamples">
+ <template #link="{ content }">
+ <gl-link :href="ciExamplesHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.viewSyntaxRef">
+ <template #link="{ content }">
+ <gl-link :href="ymlHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.learnMore">
+ <template #link="{ content }">
+ <gl-link :href="ciHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.needs">
+ <template #link="{ content }">
+ <gl-link :href="needsHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ </div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue
index 512414f0246..aeeb52319d2 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue
@@ -1,5 +1,4 @@
<script>
-import { GlCard } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
@@ -9,16 +8,11 @@ export default {
'PipelineEditorTutorial|Use the Visualize and Lint tabs in the Pipeline Editor to visualize your pipeline and check for any errors or warnings before committing your changes.',
),
},
- components: {
- GlCard,
- },
};
</script>
<template>
- <gl-card>
- <template #default>
- <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4>
- <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p>
- </template>
- </gl-card>
+ <div>
+ <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3>
+ <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p>
+ </div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
index 9cb070a5517..375db7f3054 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -1,101 +1,61 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlDrawer } from '@gitlab/ui';
import { __ } from '~/locale';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { DRAWER_EXPANDED_KEY } from '../../constants';
import FirstPipelineCard from './cards/first_pipeline_card.vue';
import GettingStartedCard from './cards/getting_started_card.vue';
import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue';
import VisualizeAndLintCard from './cards/visualize_and_lint_card.vue';
+const DRAWER_CARD_STYLES = ['gl-border-bottom-0', 'gl-pt-6!', 'gl-pb-0!', 'gl-line-height-20'];
+
export default {
- width: {
- expanded: '482px',
- collapsed: '58px',
- },
+ DRAWER_CARD_STYLES,
i18n: {
- toggleTxt: __('Collapse'),
+ title: __('Help'),
},
- localDrawerKey: DRAWER_EXPANDED_KEY,
components: {
FirstPipelineCard,
GettingStartedCard,
- GlButton,
- GlIcon,
- LocalStorageSync,
+ GlDrawer,
PipelineConfigReferenceCard,
VisualizeAndLintCard,
},
- data() {
- return {
- isExpanded: false,
- topPosition: 0,
- };
+ props: {
+ isVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- buttonIconName() {
- return this.isExpanded ? 'chevron-double-lg-right' : 'chevron-double-lg-left';
- },
- buttonClass() {
- return this.isExpanded ? 'gl-justify-content-end!' : '';
+ drawerCardStyles() {
+ return '';
},
- rootStyle() {
- const { expanded, collapsed } = this.$options.width;
- const top = this.topPosition;
- const style = { top: `${top}px` };
-
- return this.isExpanded ? { ...style, width: expanded } : { ...style, width: collapsed };
+ drawerHeightOffset() {
+ const wrapperEl = document.querySelector('.content-wrapper');
+ return wrapperEl ? `${wrapperEl.offsetTop}px` : '';
},
},
- mounted() {
- this.setTopPosition();
- },
methods: {
- setTopPosition() {
- const navbarEl = document.querySelector('.js-navbar');
-
- if (navbarEl) {
- this.topPosition = navbarEl.getBoundingClientRect().bottom;
- }
- },
- toggleDrawer() {
- this.isExpanded = !this.isExpanded;
+ closeDrawer() {
+ this.$emit('close-drawer');
},
},
};
</script>
<template>
- <local-storage-sync v-model="isExpanded" :storage-key="$options.localDrawerKey" as-json>
- <aside
- aria-live="polite"
- class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-200 gl-overflow-y-auto"
- :style="rootStyle"
- >
- <gl-button
- category="tertiary"
- class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex"
- :class="buttonClass"
- :title="__('Toggle sidebar')"
- data-qa-selector="toggle_sidebar_collapse_button"
- @click="toggleDrawer"
- >
- <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text">
- {{ __('Collapse') }}
- </span>
- <gl-icon data-testid="toggle-icon" :name="buttonIconName" />
- </gl-button>
- <div
- v-if="isExpanded"
- class="gl-h-full gl-p-5"
- data-testid="drawer-content"
- data-qa-selector="drawer_content"
- >
- <getting-started-card class="gl-mb-4" />
- <first-pipeline-card class="gl-mb-4" />
- <visualize-and-lint-card class="gl-mb-4" />
- <pipeline-config-reference-card />
- <div class="gl-h-13"></div>
- </div>
- </aside>
- </local-storage-sync>
+ <gl-drawer
+ :header-height="drawerHeightOffset"
+ :open="isVisible"
+ :z-index="200"
+ @close="closeDrawer"
+ >
+ <template #title>
+ <h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.title }}</h2>
+ </template>
+ <getting-started-card :class="$options.DRAWER_CARD_STYLES" />
+ <first-pipeline-card :class="$options.DRAWER_CARD_STYLES" />
+ <visualize-and-lint-card :class="$options.DRAWER_CARD_STYLES" />
+ <pipeline-config-reference-card :class="$options.DRAWER_CARD_STYLES" />
+ </gl-drawer>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
index b4e9ab81d38..9765d669fc1 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
@@ -7,13 +7,23 @@ import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../co
export default {
i18n: {
browseTemplates: __('Browse templates'),
+ help: __('Help'),
},
TEMPLATE_REPOSITORY_URL,
components: {
GlButton,
},
mixins: [Tracking.mixin()],
+ props: {
+ showDrawer: {
+ type: Boolean,
+ required: true,
+ },
+ },
methods: {
+ toggleDrawer() {
+ this.$emit(this.showDrawer ? 'close-drawer' : 'open-drawer');
+ },
trackTemplateBrowsing() {
const { label, actions } = pipelineEditorTrackingOptions;
@@ -30,9 +40,20 @@ export default {
size="small"
icon="external-link"
target="_blank"
+ data-testid="template-repo-link"
+ data-qa-selector="template_repo_link"
@click="trackTemplateBrowsing"
>
{{ $options.i18n.browseTemplates }}
</gl-button>
+ <gl-button
+ icon="information-o"
+ size="small"
+ data-testid="drawer-toggle"
+ data-qa-selector="drawer_toggle"
+ @click="toggleDrawer"
+ >
+ {{ $options.i18n.help }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index 5cff93c884f..d50e6f9a623 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -86,6 +86,10 @@ export default {
type: Boolean,
required: true,
},
+ showDrawer: {
+ type: Boolean,
+ required: true,
+ },
},
apollo: {
appStatus: {
@@ -157,7 +161,7 @@ export default {
@click="setCurrentTab($options.tabConstants.CREATE_TAB)"
>
<walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
- <ci-editor-header />
+ <ci-editor-header :show-drawer="showDrawer" v-on="$listeners" />
<text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
</editor-tab>
<editor-tab
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 aee71999373..3e87088e77e 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
@@ -14,7 +14,7 @@ export default {
body: __(
'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.',
),
- btnText: __('Create new CI/CD pipeline'),
+ btnText: __('Configure pipeline'),
},
inject: {
emptyStateIllustrationPath: {
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 2ebc4306405..9b4732b26d2 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -45,8 +45,6 @@ export const TAB_QUERY_PARAM = 'tab';
export const COMMIT_ACTION_CREATE = 'CREATE';
export const COMMIT_ACTION_UPDATE = 'UPDATE';
-export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded';
-
export const BRANCH_PAGINATION_LIMIT = 20;
export const BRANCH_SEARCH_DEBOUNCE = '500';
export const SOURCE_EDITOR_DEBOUNCE = 500;
@@ -102,7 +100,5 @@ export const I18N = {
subtitle: s__(
"Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
),
- description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
- cta: s__('Pipelines|Use template'),
},
};
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index a5436ca63cb..4e6a4ffa6d2 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -388,7 +388,7 @@ export default {
@createEmptyConfigFile="setNewEmptyCiConfigFile"
@refetchContent="refetchContent"
/>
- <div v-else class="gl-pr-10">
+ <div v-else>
<pipeline-editor-messages
:failure-type="failureType"
:failure-reasons="failureReasons"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index 631dd8a2c00..23e3ce10d5a 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -60,6 +60,7 @@ export default {
currentTab: CREATE_TAB,
scrollToCommitForm: false,
shouldLoadNewBranch: false,
+ showDrawer: false,
showSwitchBranchModal: false,
};
},
@@ -72,9 +73,15 @@ export default {
closeBranchModal() {
this.showSwitchBranchModal = false;
},
+ closeDrawer() {
+ this.showDrawer = false;
+ },
handleConfirmSwitchBranch() {
this.showSwitchBranchModal = true;
},
+ openDrawer() {
+ this.showDrawer = true;
+ },
switchBranch() {
this.showSwitchBranchModal = false;
this.shouldLoadNewBranch = true;
@@ -122,7 +129,10 @@ export default {
:ci-file-content="ciFileContent"
:commit-sha="commitSha"
:is-new-ci-config-file="isNewCiConfigFile"
+ :show-drawer="showDrawer"
v-on="$listeners"
+ @open-drawer="openDrawer"
+ @close-drawer="closeDrawer"
@set-current-tab="setCurrentTab"
@walkthrough-popover-cta-clicked="setScrollToCommitForm"
/>
@@ -137,6 +147,10 @@ export default {
@scrolled-to-commit-form="setScrollToCommitForm(false)"
v-on="$listeners"
/>
- <pipeline-editor-drawer />
+ <pipeline-editor-drawer
+ :is-visible="showDrawer"
+ v-on="$listeners"
+ @close-drawer="closeDrawer"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index d74b6e8edf6..32e1e18b684 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -396,6 +396,7 @@ export default {
:key="variable.uniqueId"
class="gl-mb-3 gl-ml-n3 gl-pb-2"
data-testid="ci-variable-row"
+ data-qa-selector="ci_variable_row_container"
>
<div
class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row"
@@ -411,6 +412,7 @@ export default {
:placeholder="s__('CiVariables|Input variable key')"
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-key"
+ data-qa-selector="ci_variable_key_field"
@change="addEmptyVariable(refFullName)"
/>
<gl-form-textarea
@@ -420,6 +422,7 @@ export default {
:style="$options.textAreaStyle"
:no-resize="false"
data-testid="pipeline-form-ci-variable-value"
+ data-qa-selector="ci_variable_value_field"
/>
<template v-if="variables.length > 1">
diff --git a/app/assets/javascripts/pipeline_wizard/components/input.vue b/app/assets/javascripts/pipeline_wizard/components/input.vue
index 9a0c8026648..5efae2471e5 100644
--- a/app/assets/javascripts/pipeline_wizard/components/input.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/input.vue
@@ -92,6 +92,7 @@ export default {
ref="widget"
:validate="validate"
v-bind="$attrs"
+ :data-input-target="target"
@input="onModelChange"
@update:valid="onValidationStateChange"
/>
diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
index b7207576ddc..f50cd175510 100644
--- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
@@ -1,6 +1,7 @@
<script>
import { GlProgressBar } from '@gitlab/ui';
import { Document } from 'yaml';
+import { uniqueId } from 'lodash';
import { merge } from '~/lib/utils/yaml';
import { __ } from '~/locale';
import { isValidStepSeq } from '~/pipeline_wizard/validators';
@@ -57,15 +58,6 @@ export default {
};
},
computed: {
- currentStepConfig() {
- return this.steps.get(this.currentStepIndex);
- },
- currentStepInputs() {
- return this.currentStepConfig.get('inputs').toJSON();
- },
- currentStepTemplate() {
- return this.currentStepConfig.get('template', true);
- },
currentStep() {
return this.currentStepIndex + 1;
},
@@ -78,6 +70,13 @@ export default {
isLastStep() {
return this.currentStep === this.stepCount;
},
+ stepList() {
+ return this.steps.items.map((_, i) => ({
+ id: uniqueId(),
+ inputs: this.steps.get(i).get('inputs').toJSON(),
+ template: this.steps.get(i).get('template', true),
+ }));
+ },
},
watch: {
isLastStep(value) {
@@ -85,6 +84,9 @@ export default {
},
},
methods: {
+ getStep(index) {
+ return this.steps.get(index);
+ },
resetHighlight() {
this.highlightPath = null;
},
@@ -119,8 +121,8 @@ export default {
</header>
<section class="gl-mb-4">
<commit-step
- v-if="isLastStep"
- ref="step"
+ v-show="isLastStep"
+ data-testid="step"
:default-branch="defaultBranch"
:file-content="pipelineBlob"
:filename="filename"
@@ -128,15 +130,16 @@ export default {
@back="currentStepIndex--"
/>
<wizard-step
- v-else
- :key="currentStepIndex"
- ref="step"
+ v-for="(step, i) in stepList"
+ v-show="i === currentStepIndex"
+ :key="step.id"
+ data-testid="step"
:compiled.sync="compiled"
- :has-next-step="currentStepIndex < steps.items.length"
- :has-previous-step="currentStepIndex > 0"
+ :has-next-step="i < steps.items.length"
+ :has-previous-step="i > 0"
:highlight.sync="highlightPath"
- :inputs="currentStepInputs"
- :template="currentStepTemplate"
+ :inputs="step.inputs"
+ :template="step.template"
@back="currentStepIndex--"
@next="currentStepIndex++"
@update:compiled="onUpdate"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index e995d400907..534ad25a35d 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -273,6 +273,7 @@ export default {
<local-storage-sync
:storage-key="$options.viewTypeKey"
:value="currentViewType"
+ as-string
@input="updateViewType"
>
<graph-view-selector
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 795b95421c7..f69b25dfa7c 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -234,8 +234,9 @@ export default {
:title="tooltipText"
:class="jobClasses"
:href="detailsPath"
- class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full"
+ class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full"
:data-testid="testId"
+ data-qa-selector="job_link"
@click="jobItemClick"
@mouseout="hideTooltips"
>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index c59f56fc68f..d59802196af 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -133,7 +133,7 @@ export default {
class="gl-h-full gl-display-flex! gl-border-solid gl-border-gray-100 gl-border-1"
:class="flexDirection"
:title="tooltipText"
- data-qa-selector="child_pipeline"
+ data-qa-selector="linked_pipeline_container"
@mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave"
>
@@ -171,7 +171,7 @@ export default {
:icon="expandedIcon"
:aria-label="__('Expand pipeline')"
data-testid="expand-pipeline-button"
- data-qa-selector="expand_pipeline_button"
+ data-qa-selector="expand_linked_pipeline_button"
@click="onClickLinkedPipeline"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index b0f375c9aeb..6ab4eb58977 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -141,7 +141,9 @@ export default {
class="gl-display-flex gl-justify-content-space-between gl-relative"
:class="$options.titleClasses"
>
- <div>{{ formattedTitle }}</div>
+ <span :title="formattedTitle" class="gl-text-truncate gl-pr-3 gl-w-85p">
+ {{ formattedTitle }}
+ </span>
<action-component
v-if="hasAction && canUpdatePipeline"
:action-icon="action.icon"
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index ac97c9d2743..04b78b8aa23 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -249,8 +249,7 @@ export default {
:title="$options.BUTTON_TOOLTIP_RETRY"
:loading="isRetrying"
:disabled="isRetrying"
- category="secondary"
- variant="info"
+ variant="confirm"
data-testid="retryPipeline"
class="js-retry-button"
@click="retryPipeline()"
@@ -262,7 +261,6 @@ export default {
v-if="canCancelPipeline"
:loading="isCanceling"
:disabled="isCanceling"
- class="gl-ml-3"
variant="danger"
data-testid="cancelPipeline"
@click="cancelPipeline()"
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
index fffd8e1818a..70d1a5c08cc 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
@@ -1,5 +1,5 @@
<script>
-import ciIcon from '../../../vue_shared/components/ci_icon.vue';
+import ciIcon from '~/vue_shared/components/ci_icon.vue';
/**
* Component that renders both the CI icon status and the job name.
diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
new file mode 100644
index 00000000000..62c785d7ad2
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlTabs, GlTab } from '@gitlab/ui';
+import { __ } from '~/locale';
+import PipelineGraphWrapper from './graph/graph_component_wrapper.vue';
+import Dag from './dag/dag.vue';
+import JobsApp from './jobs/jobs_app.vue';
+import TestReports from './test_reports/test_reports.vue';
+
+export default {
+ i18n: {
+ tabs: {
+ failedJobsTitle: __('Failed Jobs'),
+ jobsTitle: __('Jobs'),
+ needsTitle: __('Needs'),
+ pipelineTitle: __('Pipeline'),
+ testsTitle: __('Tests'),
+ },
+ },
+ components: {
+ Dag,
+ GlTab,
+ GlTabs,
+ JobsApp,
+ FailedJobsApp: JobsApp,
+ PipelineGraphWrapper,
+ TestReports,
+ },
+};
+</script>
+
+<template>
+ <gl-tabs>
+ <gl-tab :title="$options.i18n.tabs.pipelineTitle" data-testid="pipeline-tab">
+ <pipeline-graph-wrapper />
+ </gl-tab>
+ <gl-tab :title="$options.i18n.tabs.needsTitle" data-testid="dag-tab">
+ <dag />
+ </gl-tab>
+ <gl-tab :title="$options.i18n.tabs.jobsTitle" data-testid="jobs-tab">
+ <jobs-app />
+ </gl-tab>
+ <gl-tab :title="$options.i18n.tabs.failedJobsTitle" data-testid="failed-jobs-tab">
+ <failed-jobs-app />
+ </gl-tab>
+ <gl-tab :title="$options.i18n.tabs.testsTitle" data-testid="tests-tab">
+ <test-reports />
+ </gl-tab>
+ <slot></slot>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index 0380ba646cc..5a9c85a0f10 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,7 +1,7 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
import { s__ } from '~/locale';
-import PipelinesCiTemplates from './pipelines_ci_templates.vue';
+import PipelinesCiTemplates from './empty_state/pipelines_ci_templates.vue';
export default {
i18n: {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue
new file mode 100644
index 00000000000..3b312e78d11
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlAvatar, GlButton } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
+import Tracking from '~/tracking';
+
+export default {
+ components: {
+ GlAvatar,
+ GlButton,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['pipelineEditorPath', 'suggestedCiTemplates'],
+ data() {
+ const templates = this.suggestedCiTemplates.map(({ name, logo }) => {
+ return {
+ name,
+ logo,
+ link: mergeUrlParams({ template: name }, this.pipelineEditorPath),
+ description: sprintf(this.$options.i18n.description, { name }),
+ };
+ });
+
+ return {
+ templates,
+ };
+ },
+ methods: {
+ trackEvent(template) {
+ this.track('template_clicked', {
+ label: template,
+ });
+ },
+ },
+ i18n: {
+ description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
+ cta: s__('Pipelines|Use template'),
+ },
+ AVATAR_SHAPE_OPTION_RECT,
+};
+</script>
+<template>
+ <ul class="gl-list-style-none gl-pl-0">
+ <li v-for="template in templates" :key="template.name">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
+ >
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
+ <gl-avatar
+ :src="template.logo"
+ :size="48"
+ class="gl-mr-5 gl-bg-white dark-mode-override"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :alt="template.name"
+ data-testid="template-logo"
+ />
+ <div class="gl-flex-direction-row">
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800" data-testid="template-name">
+ {{ template.name }}
+ </strong>
+ </div>
+ <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
+ {{ template.description }}
+ </p>
+ </div>
+ </div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="template.link"
+ data-testid="template-link"
+ @click="trackEvent(template.name)"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
index d50229e47c4..be46a7f5cec 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
@@ -1,7 +1,6 @@
<script>
-import { GlAvatar, GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui';
+import { GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { sprintf } from '~/locale';
import {
STARTER_TEMPLATE_NAME,
RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
@@ -10,21 +9,22 @@ import {
RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
I18N,
} from '~/pipeline_editor/constants';
+import Tracking from '~/tracking';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { isExperimentVariant } from '~/experimentation/utils';
import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import ExperimentTracking from '~/experimentation/experiment_tracking';
-import { isExperimentVariant } from '~/experimentation/utils';
-import Tracking from '~/tracking';
+import CiTemplates from './ci_templates.vue';
export default {
components: {
- GlAvatar,
GlButton,
GlCard,
GlSprintf,
GlIcon,
GlLink,
GitlabExperiment,
+ CiTemplates,
},
mixins: [Tracking.mixin()],
STARTER_TEMPLATE_NAME,
@@ -33,7 +33,7 @@ export default {
RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
I18N,
- inject: ['pipelineEditorPath', 'suggestedCiTemplates'],
+ inject: ['pipelineEditorPath'],
props: {
ciRunnerSettingsPath: {
type: String,
@@ -47,17 +47,7 @@ export default {
},
},
data() {
- const templates = this.suggestedCiTemplates.map(({ name, logo }) => {
- return {
- name,
- logo,
- link: mergeUrlParams({ template: name }, this.pipelineEditorPath),
- description: sprintf(this.$options.I18N.templates.description, { name }),
- };
- });
-
return {
- templates,
gettingStartedTemplateUrl: mergeUrlParams(
{ template: STARTER_TEMPLATE_NAME },
this.pipelineEditorPath,
@@ -177,43 +167,7 @@ export default {
<h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
<p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
- <ul class="gl-list-style-none gl-pl-0">
- <li v-for="template in templates" :key="template.name">
- <div
- class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
- >
- <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
- <gl-avatar
- :src="template.logo"
- :size="48"
- class="gl-mr-5 gl-bg-white dark-mode-override"
- shape="rect"
- :alt="template.name"
- data-testid="template-logo"
- />
- <div class="gl-flex-direction-row">
- <div class="gl-mb-3">
- <strong class="gl-text-gray-800" data-testid="template-name">
- {{ template.name }}
- </strong>
- </div>
- <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
- {{ template.description }}
- </p>
- </div>
- </div>
- <gl-button
- category="primary"
- variant="confirm"
- :href="template.link"
- data-testid="template-link"
- @click="trackEvent(template.name)"
- >
- {{ $options.I18N.templates.cta }}
- </gl-button>
- </div>
- </li>
- </ul>
+ <ci-templates />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index b29c8426301..2a73795db0a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -1,10 +1,14 @@
<script>
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { GlAvatarLink, GlAvatar, GlTooltipDirective } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
- UserAvatarLink,
+ GlAvatarLink,
+ GlAvatar,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
props: {
@@ -22,15 +26,11 @@ export default {
</script>
<template>
<div class="pipeline-triggerer" data-testid="pipeline-triggerer">
- <user-avatar-link
- v-if="user"
- :link-href="user.path"
- :img-src="user.avatar_url"
- :img-size="32"
- :tooltip-text="user.name"
- class="gl-ml-3 js-pipeline-url-user"
- />
- <span v-else class="gl-ml-3 js-pipeline-url-api api">
+ <gl-avatar-link v-if="user" v-gl-tooltip :href="user.path" :title="user.name" class="gl-ml-3">
+ <gl-avatar :size="32" :src="user.avatar_url" />
+ </gl-avatar-link>
+
+ <span v-else class="gl-ml-3">
{{ s__('Pipelines|API') }}
</span>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 1dcbd77a92d..63c492c8bcd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -2,6 +2,7 @@
import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { ICONS } from '../../constants';
import PipelineLabels from './pipeline_labels.vue';
@@ -11,6 +12,7 @@ export default {
GlLink,
PipelineLabels,
TooltipOnTruncate,
+ UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -169,6 +171,15 @@ export default {
<gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{
commitShortSha
}}</gl-link>
+ <user-avatar-link
+ v-if="commitAuthor"
+ :link-href="commitAuthor.path"
+ :img-src="commitAuthor.avatar_url"
+ :img-size="16"
+ :img-alt="commitAuthor.name"
+ :tooltip-text="commitAuthor.name"
+ class="gl-ml-1"
+ />
<!--End of commit row-->
</div>
<pipeline-labels :pipeline-schedule-url="pipelineScheduleUrl" :pipeline="pipeline" />
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index 6f0e67e1ae0..77b9c2b5203 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -127,6 +127,10 @@ export default {
eventHub.$emit('refreshPipelinesTable');
},
},
+ TBODY_TR_ATTR: {
+ 'data-testid': 'pipeline-table-row',
+ 'data-qa-selector': 'pipeline_row_container',
+ },
};
</script>
<template>
@@ -135,7 +139,7 @@ export default {
:fields="$options.tableFields"
:items="pipelines"
tbody-tr-class="commit"
- :tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }"
+ :tbody-tr-attr="$options.TBODY_TR_ATTR"
stacked="lg"
fixed
>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index cde963e4051..387438fb726 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,13 +1,12 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { formatDate, getTimeago, durationTimeFormatted } from '~/lib/utils/datetime_utility';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
- mixins: [timeagoMixin],
props: {
pipeline: {
type: Object,
@@ -28,24 +27,7 @@ export default {
return this.pipeline.flags.stuck;
},
durationFormatted() {
- const date = new Date(this.duration * 1000);
-
- let hh = date.getUTCHours();
- let mm = date.getUTCMinutes();
- let ss = date.getSeconds();
-
- // left pad
- if (hh < 10) {
- hh = `0${hh}`;
- }
- if (mm < 10) {
- mm = `0${mm}`;
- }
- if (ss < 10) {
- ss = `0${ss}`;
- }
-
- return `${hh}:${mm}:${ss}`;
+ return durationTimeFormatted(this.duration);
},
showInProgress() {
return !this.duration && !this.finishedTime && !this.skipped;
@@ -53,6 +35,12 @@ export default {
showSkipped() {
return !this.duration && !this.finishedTime && this.skipped;
},
+ timeFormatted() {
+ return getTimeago().format(this.finishedTime);
+ },
+ tooltipTitle() {
+ return formatDate(this.finishedTime);
+ },
},
};
</script>
@@ -85,12 +73,12 @@ export default {
<time
v-gl-tooltip
- :title="tooltipTitle(finishedTime)"
+ :title="tooltipTitle"
:datetime="finishedTime"
data-placement="top"
data-container="body"
>
- {{ timeFormatted(finishedTime) }}
+ {{ timeFormatted }}
</time>
</p>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
index 33115d72b9c..746cf238646 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
@@ -83,13 +83,7 @@ export default {
@input="searchAuthors"
>
<template #view="{ inputValue }">
- <gl-avatar
- v-if="activeUser"
- :size="16"
- :src="activeUser.avatar_url"
- shape="circle"
- class="gl-mr-2"
- />
+ <gl-avatar v-if="activeUser" :size="16" :src="activeUser.avatar_url" class="gl-mr-2" />
<span>{{ activeUser ? activeUser.name : inputValue }}</span>
</template>
<template #suggestions>
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
index 5fe47e09d9c..641ec7a3cf6 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) {
project(fullPath: $fullPath) {
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 801f71cb364..338de65e795 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -13,6 +13,7 @@ const SELECTORS = {
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
PIPELINE_NOTIFICATION: '#js-pipeline-notification',
+ PIPELINE_TABS: '#js-pipeline-tabs',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
PIPELINE_JOBS: '#js-pipeline-jobs-vue',
};
@@ -29,22 +30,6 @@ export default async function initPipelineDetailsBundle() {
}
try {
- createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
- } catch {
- createFlash({
- message: __('An error occurred while loading the pipeline.'),
- });
- }
-
- try {
- createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag);
- } catch {
- createFlash({
- message: __('An error occurred while loading a section of this page.'),
- });
- }
-
- try {
createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
} catch {
createFlash({
@@ -52,27 +37,47 @@ export default async function initPipelineDetailsBundle() {
});
}
- try {
- createDagApp(apolloProvider);
- } catch {
- createFlash({
- message: __('An error occurred while loading the Needs tab.'),
- });
- }
+ if (gon.features?.pipelineTabsVue) {
+ const { createPipelineTabs } = await import('./pipeline_tabs');
- try {
- createTestDetails(SELECTORS.PIPELINE_TESTS);
- } catch {
- createFlash({
- message: __('An error occurred while loading the Test Reports tab.'),
- });
- }
+ try {
+ createPipelineTabs(SELECTORS.PIPELINE_TABS, apolloProvider);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading a section of this page.'),
+ });
+ }
+ } else {
+ try {
+ createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading the pipeline.'),
+ });
+ }
- try {
- createPipelineJobsApp(SELECTORS.PIPELINE_JOBS);
- } catch {
- createFlash({
- message: __('An error occurred while loading the Jobs tab.'),
- });
+ try {
+ createDagApp(apolloProvider);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading the Needs tab.'),
+ });
+ }
+
+ try {
+ createTestDetails(SELECTORS.PIPELINE_TESTS);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading the Test Reports tab.'),
+ });
+ }
+
+ try {
+ createPipelineJobsApp(SELECTORS.PIPELINE_JOBS);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading the Jobs tab.'),
+ });
+ }
}
}
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
new file mode 100644
index 00000000000..ff88c6215e5
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue';
+import { reportToSentry } from './utils';
+
+Vue.use(VueApollo);
+
+const createPipelineTabs = (selector, apolloProvider) => {
+ const el = document.querySelector(selector);
+
+ if (!el) return;
+
+ const { dataset } = document.querySelector(selector);
+ const {
+ canGenerateCodequalityReports,
+ codequalityReportDownloadPath,
+ downloadablePathForReportType,
+ exposeSecurityDashboard,
+ exposeLicenseScanningData,
+ } = dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: selector,
+ components: {
+ PipelineTabs,
+ },
+ apolloProvider,
+ provide: {
+ canGenerateCodequalityReports: JSON.parse(canGenerateCodequalityReports),
+ codequalityReportDownloadPath,
+ downloadablePathForReportType,
+ exposeSecurityDashboard: JSON.parse(exposeSecurityDashboard),
+ exposeLicenseScanningData: JSON.parse(exposeLicenseScanningData),
+ },
+ errorCaptured(err, _vm, info) {
+ reportToSentry('pipeline_tabs', `error: ${err}, info: ${info}`);
+ },
+ render(createElement) {
+ return createElement(PipelineTabs);
+ },
+ });
+};
+
+export { createPipelineTabs };
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index 523ca13b6c3..3ec563c95bb 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import axios from '../../lib/utils/axios_utils';
+import axios from '~/lib/utils/axios_utils';
import { validateParams } from '../utils';
export default class PipelinesService {
diff --git a/app/assets/javascripts/pipelines/stores/pipelines_store.js b/app/assets/javascripts/pipelines/stores/pipelines_store.js
index a4bbada89c8..765441560d8 100644
--- a/app/assets/javascripts/pipelines/stores/pipelines_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipelines_store.js
@@ -1,4 +1,4 @@
-import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default class PipelinesStore {
constructor() {
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
index 7b28d48b5b6..b7f590a7b3c 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
@@ -30,6 +30,7 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => {
dispatch('toggleLoading');
+ // eslint-disable-next-line camelcase
const { build_ids = [] } = state.testReports?.test_suites?.[index] || {};
// Replacing `/:suite_name.json` with the name of the suite. Including the extra characters
// to ensure that we replace exactly the template part of the URL string
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
index 63a58798958..6b616601bc5 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
@@ -1,4 +1,4 @@
-import { __, sprintf } from '../../../locale';
+import { __, sprintf } from '~/locale';
import { TestStatus } from '../../constants';
/**
diff --git a/app/assets/javascripts/profile/preferences/components/diffs_colors.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors.vue
new file mode 100644
index 00000000000..1992819ab82
--- /dev/null
+++ b/app/assets/javascripts/profile/preferences/components/diffs_colors.vue
@@ -0,0 +1,107 @@
+<script>
+import { validateHexColor, hexToRgb } from '~/lib/utils/color_utils';
+import { s__ } from '~/locale';
+import { getCssVariable } from '~/lib/utils/css_utils';
+import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue';
+import DiffsColorsPreview from './diffs_colors_preview.vue';
+
+export default {
+ components: {
+ ColorPicker,
+ DiffsColorsPreview,
+ },
+ inject: ['deletion', 'addition'],
+ data() {
+ return {
+ deletionColor: this.deletion || '',
+ additionColor: this.addition || '',
+ defaultDeletionColor: getCssVariable('--default-diff-color-deletion'),
+ defaultAdditionColor: getCssVariable('--default-diff-color-addition'),
+ };
+ },
+ computed: {
+ suggestedColors() {
+ const colors = {
+ '#d99530': s__('SuggestedColors|Orange'),
+ '#1f75cb': s__('SuggestedColors|Blue'),
+ };
+ if (this.isValidColor(this.deletion)) {
+ colors[this.deletion] = s__('SuggestedColors|Current removal color');
+ }
+ if (this.isValidColor(this.addition)) {
+ colors[this.addition] = s__('SuggestedColors|Current addition color');
+ }
+ if (this.isValidColor(this.defaultDeletionColor)) {
+ colors[this.defaultDeletionColor] = s__('SuggestedColors|Default removal color');
+ }
+ if (this.isValidColor(this.defaultAdditionColor)) {
+ colors[this.defaultAdditionColor] = s__('SuggestedColors|Default addition color');
+ }
+ return colors;
+ },
+ previewClasses() {
+ return {
+ 'diff-custom-addition-color': this.isValidColor(this.additionColor),
+ 'diff-custom-deletion-color': this.isValidColor(this.deletionColor),
+ };
+ },
+ previewStyle() {
+ let style = {};
+ if (this.isValidColor(this.deletionColor)) {
+ const colorRgb = hexToRgb(this.deletionColor).join();
+ style = {
+ ...style,
+ '--diff-deletion-color': `rgba(${colorRgb},0.2)`,
+ };
+ }
+ if (this.isValidColor(this.additionColor)) {
+ const colorRgb = hexToRgb(this.additionColor).join();
+ style = {
+ ...style,
+ '--diff-addition-color': `rgba(${colorRgb},0.2)`,
+ };
+ }
+ return style;
+ },
+ },
+ methods: {
+ isValidColor(color) {
+ return validateHexColor(color);
+ },
+ },
+ i18n: {
+ colorDeletionInputLabel: s__('Preferences|Color for removed lines'),
+ colorAdditionInputLabel: s__('Preferences|Color for added lines'),
+ previewLabel: s__('Preferences|Preview'),
+ },
+};
+</script>
+<template>
+ <div :style="previewStyle" :class="previewClasses">
+ <diffs-colors-preview />
+ <color-picker
+ v-model="deletionColor"
+ :label="$options.i18n.colorDeletionInputLabel"
+ :state="isValidColor(deletionColor)"
+ :suggested-colors="suggestedColors"
+ />
+ <input
+ id="user_diffs_deletion_color"
+ v-model="deletionColor"
+ name="user[diffs_deletion_color]"
+ type="hidden"
+ />
+ <color-picker
+ v-model="additionColor"
+ :label="$options.i18n.colorAdditionInputLabel"
+ :state="isValidColor(additionColor)"
+ :suggested-colors="suggestedColors"
+ />
+ <input
+ id="user_diffs_addition_color"
+ v-model="additionColor"
+ name="user[diffs_addition_color]"
+ type="hidden"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue
new file mode 100644
index 00000000000..74dd2d5628a
--- /dev/null
+++ b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue
@@ -0,0 +1,231 @@
+<script>
+import { s__ } from '~/locale';
+
+export default {
+ computed: {
+ themeClass() {
+ return window.gon?.user_color_scheme;
+ },
+ },
+ i18n: {
+ previewLabel: s__('Preferences|Preview'),
+ },
+};
+</script>
+<template>
+ <div class="form-group">
+ <label>{{ $options.i18n.previewLabel }}</label>
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <table :class="themeClass" class="code">
+ <tbody>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="1"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span
+ ><span class="c1"># <span class="idiff deletion">Removed</span> content</span></span
+ >
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="1"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span
+ ><span class="c1"># <span class="idiff addition">Added</span> content</span></span
+ >
+ </td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="2"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span><span class="n">v</span> <span class="o">=</span> <span class="mi">1</span></span>
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="2"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span><span class="n">v</span> <span class="o">=</span> <span class="mi">1</span></span>
+ </td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="3"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span
+ ><span class="n">s</span> <span class="o">=</span>
+ <span class="s">"string"</span></span
+ >
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="3"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span
+ ><span class="n">s</span> <span class="o">=</span>
+ <span class="s">"string"</span></span
+ >
+ </td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="4"></a>
+ </td>
+ <td class="line_content parallel left-side old"><span></span></td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="4"></a>
+ </td>
+ <td class="line_content parallel right-side new"><span></span></td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="5"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span
+ ><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span>
+ <span class="nb">range</span><span class="p">(</span><span class="o">-</span
+ ><span class="mi">10</span><span class="p">,</span> <span class="mi">10</span
+ ><span class="p">):</span></span
+ >
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="5"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span
+ ><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span>
+ <span class="nb">range</span><span class="p">(</span><span class="o">-</span
+ ><span class="mi">10</span><span class="p">,</span> <span class="mi">10</span
+ ><span class="p">):</span></span
+ >
+ </td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="6"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span>
+ <span>{{ ' ' }}</span>
+ <span class="k">print</span><span class="p">(</span><span class="n">i</span>
+ <span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span
+ >
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="6"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span>
+ <span>{{ ' ' }}</span>
+ <span class="k">print</span><span class="p">(</span><span class="n">i</span>
+ <span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span
+ >
+ </td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="7"></a>
+ </td>
+ <td class="line_content parallel left-side old"><span></span></td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="7"></a>
+ </td>
+ <td class="line_content parallel right-side new"><span></span></td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="8"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span
+ ><span class="k">class</span> <span class="nc">LinkedList</span
+ ><span class="p">(</span><span class="nb">object</span><span class="p">):</span></span
+ >
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="8"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span
+ ><span class="k">class</span> <span class="nc">LinkedList</span
+ ><span class="p">(</span><span class="nb">object</span><span class="p">):</span></span
+ >
+ </td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="9"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span>
+ <span>{{ ' ' }}</span>
+ <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span
+ ><span class="bp">self</span><span class="p">,</span> <span class="n">x</span
+ ><span class="p">):</span></span
+ >
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="9"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span>
+ <span>{{ ' ' }}</span>
+ <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span
+ ><span class="bp">self</span><span class="p">,</span> <span class="n">x</span
+ ><span class="p">):</span></span
+ >
+ </td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="10"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span>
+ <span>{{ ' ' }}</span>
+ <span class="bp">self</span><span class="p">.</span><span class="n">val</span>
+ <span class="o">=</span> <span class="n">x</span></span
+ >
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="10"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span>
+ <span>{{ ' ' }}</span>
+ <span class="bp">self</span><span class="p">.</span><span class="n">val</span>
+ <span class="o">=</span> <span class="n">x</span></span
+ >
+ </td>
+ </tr>
+ <tr class="line_holder parallel">
+ <td class="old_line diff-line-num old">
+ <a data-linenumber="11"></a>
+ </td>
+ <td class="line_content parallel left-side old">
+ <span>
+ <span>{{ ' ' }}</span>
+ <span class="bp">self</span><span class="p">.</span><span class="nb">next</span>
+ <span class="o">=</span> <span class="bp">None</span></span
+ >
+ </td>
+ <td class="new_line diff-line-num new">
+ <a data-linenumber="11"></a>
+ </td>
+ <td class="line_content parallel right-side new">
+ <span>
+ <span>{{ ' ' }}</span>
+ <span class="bp">self</span><span class="p">.</span><span class="nb">next</span>
+ <span class="o">=</span> <span class="bp">None</span></span
+ >
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/preferences/components/integration_view.vue b/app/assets/javascripts/profile/preferences/components/integration_view.vue
index c2952629a5d..9924f248b89 100644
--- a/app/assets/javascripts/profile/preferences/components/integration_view.vue
+++ b/app/assets/javascripts/profile/preferences/components/integration_view.vue
@@ -1,13 +1,14 @@
<script>
-import { GlFormText, GlIcon, GlLink } from '@gitlab/ui';
+import { GlIcon, GlLink, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
export default {
name: 'IntegrationView',
components: {
- GlFormText,
GlIcon,
GlLink,
+ GlFormGroup,
+ GlFormCheckbox,
IntegrationHelpText,
},
inject: ['userFields'],
@@ -31,7 +32,7 @@ export default {
},
data() {
return {
- isEnabled: this.userFields[this.config.formName],
+ isEnabled: this.userFields[this.config.formName] ? '1' : '0',
};
},
computed: {
@@ -46,36 +47,25 @@ export default {
</script>
<template>
- <div>
- <label class="label-bold">
+ <gl-form-group>
+ <template #label>
{{ config.title }}
- </label>
- <gl-link class="has-tooltip" title="More information" :href="helpLink">
- <gl-icon name="question-o" class="vertical-align-middle" />
- </gl-link>
- <div class="form-group form-check" data-testid="profile-preferences-integration-form-group">
- <!-- Necessary for Rails to receive the value when not checked -->
- <input
- :name="formName"
- type="hidden"
- value="0"
- data-testid="profile-preferences-integration-hidden-field"
- />
- <input
- :id="formId"
- v-model="isEnabled"
- type="checkbox"
- class="form-check-input"
- :name="formName"
- value="1"
- data-testid="profile-preferences-integration-checkbox"
- />
- <label class="form-check-label" :for="formId">
- {{ config.label }}
- </label>
- <gl-form-text tag="div">
+ <gl-link class="has-tooltip" title="More information" :href="helpLink">
+ <gl-icon name="question-o" class="vertical-align-middle" />
+ </gl-link>
+ </template>
+ <!-- Necessary for Rails to receive the value when not checked -->
+ <input
+ :name="formName"
+ type="hidden"
+ value="0"
+ data-testid="profile-preferences-integration-hidden-field"
+ />
+ <gl-form-checkbox :id="formId" :checked="isEnabled" :name="formName" value="1"
+ >{{ config.label }}
+ <template #help>
<integration-help-text :message="message" :message-url="messageUrl" />
- </gl-form-text>
- </div>
- </div>
+ </template>
+ </gl-form-checkbox>
+ </gl-form-group>
</template>
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index 757a66ef148..7542f81a361 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -45,7 +45,7 @@ export default {
return {
isSubmitEnabled: true,
darkModeOnCreate: null,
- darkModeOnSubmit: null,
+ schemeOnCreate: null,
};
},
computed: {
@@ -61,6 +61,7 @@ export default {
this.formEl.addEventListener('ajax:success', this.handleSuccess);
this.formEl.addEventListener('ajax:error', this.handleError);
this.darkModeOnCreate = this.darkModeSelected();
+ this.schemeOnCreate = this.getSelectedScheme();
},
beforeDestroy() {
this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading);
@@ -76,15 +77,19 @@ export default {
const themeId = new FormData(this.formEl).get('user[theme_id]');
return this.applicationThemes[themeId] ?? null;
},
+ getSelectedScheme() {
+ return new FormData(this.formEl).get('user[color_scheme_id]');
+ },
handleLoading() {
this.isSubmitEnabled = false;
- this.darkModeOnSubmit = this.darkModeSelected();
},
handleSuccess(customEvent) {
// Reload the page if the theme has changed from light to dark mode or vice versa
- // to correctly load all required styles.
- const modeChanged = this.darkModeOnCreate ? !this.darkModeOnSubmit : this.darkModeOnSubmit;
- if (modeChanged) {
+ // or if color scheme has changed to correctly load all required styles.
+ if (
+ this.darkModeOnCreate !== this.darkModeSelected() ||
+ this.schemeOnCreate !== this.getSelectedScheme()
+ ) {
window.location.reload();
return;
}
diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_diffs_colors.js b/app/assets/javascripts/profile/preferences/profile_preferences_diffs_colors.js
new file mode 100644
index 00000000000..1b200187610
--- /dev/null
+++ b/app/assets/javascripts/profile/preferences/profile_preferences_diffs_colors.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import DiffsColors from './components/diffs_colors.vue';
+
+export default () => {
+ const el = document.querySelector('#js-profile-preferences-diffs-colors-app');
+
+ if (!el) return false;
+
+ const { deletion, addition } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ deletion,
+ addition,
+ },
+ render(createElement) {
+ return createElement(DiffsColors);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index da14b1e8470..8511f9bdb0f 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -3,11 +3,20 @@ import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+import {
+ getQueryHeaders,
+ toggleQueryPollingByVisibility,
+} from '~/pipelines/components/graph/utils';
+import { formatStages } from '../utils';
import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql';
+import getPipelineStagesQuery from '../graphql/queries/get_pipeline_stages.query.graphql';
+import { COMMIT_BOX_POLL_INTERVAL } from '../constants';
export default {
i18n: {
linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'),
+ stageConversionError: __('There was a problem handling the pipeline data.'),
+ stagesFetchError: __('There was a problem fetching the pipeline stages.'),
},
components: {
GlLoadingIcon,
@@ -22,6 +31,9 @@ export default {
iid: {
default: '',
},
+ graphqlResourceEtag: {
+ default: '',
+ },
},
props: {
stages: {
@@ -48,10 +60,31 @@ export default {
createFlash({ message: this.$options.i18n.linkedPipelinesFetchError });
},
},
+ pipelineStages: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
+ query: getPipelineStagesQuery,
+ pollInterval: COMMIT_BOX_POLL_INTERVAL,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update({ project }) {
+ return project?.pipeline?.stages?.nodes || [];
+ },
+ error() {
+ createFlash({ message: this.$options.i18n.stagesFetchError });
+ },
+ },
},
data() {
return {
+ formattedStages: [],
pipeline: null,
+ pipelineStages: [],
};
},
computed: {
@@ -65,6 +98,25 @@ export default {
return this.pipeline?.upstream;
},
},
+ watch: {
+ pipelineStages() {
+ // pipelineStages are from GraphQL
+ // stages are from REST
+ // we do this to use dropdown_path for fetching jobs on stage click
+ try {
+ this.formattedStages = formatStages(this.pipelineStages, this.stages);
+ } catch (error) {
+ createFlash({
+ message: this.$options.i18n.stageConversionError,
+ captureError: true,
+ error,
+ });
+ }
+ },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages);
+ },
};
</script>
@@ -79,7 +131,7 @@ export default {
/>
<pipeline-mini-graph
- :stages="stages"
+ :stages="formattedStages"
class="gl-display-inline"
data-testid="commit-box-mini-graph"
/>
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue
new file mode 100644
index 00000000000..5a9d3129809
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlLoadingIcon, GlLink } from '@gitlab/ui';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import createFlash from '~/flash';
+import {
+ getQueryHeaders,
+ toggleQueryPollingByVisibility,
+} from '~/pipelines/components/graph/utils';
+import getLatestPipelineStatusQuery from '../graphql/queries/get_latest_pipeline_status.query.graphql';
+import { COMMIT_BOX_POLL_INTERVAL, PIPELINE_STATUS_FETCH_ERROR } from '../constants';
+
+export default {
+ PIPELINE_STATUS_FETCH_ERROR,
+ components: {
+ CiIcon,
+ GlLoadingIcon,
+ GlLink,
+ },
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ iid: {
+ default: '',
+ },
+ graphqlResourceEtag: {
+ default: '',
+ },
+ },
+ apollo: {
+ pipelineStatus: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
+ query: getLatestPipelineStatusQuery,
+ pollInterval: COMMIT_BOX_POLL_INTERVAL,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update({ project }) {
+ return project?.pipeline?.detailedStatus || {};
+ },
+ error() {
+ createFlash({ message: this.$options.PIPELINE_STATUS_FETCH_ERROR });
+ },
+ },
+ },
+ data() {
+ return {
+ pipelineStatus: {},
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.pipelineStatus.loading;
+ },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStatus);
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-inline-block gl-vertical-align-middle gl-mr-2">
+ <gl-loading-icon v-if="loading" />
+ <gl-link v-else :href="pipelineStatus.detailsPath">
+ <ci-icon :status="pipelineStatus" :size="24" />
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/commit_box/info/constants.js b/app/assets/javascripts/projects/commit_box/info/constants.js
new file mode 100644
index 00000000000..be0bf715314
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/constants.js
@@ -0,0 +1,7 @@
+import { __ } from '~/locale';
+
+export const COMMIT_BOX_POLL_INTERVAL = 10000;
+
+export const PIPELINE_STATUS_FETCH_ERROR = __(
+ 'There was a problem fetching the latest pipeline status.',
+);
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql
new file mode 100644
index 00000000000..cec96f82336
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql
@@ -0,0 +1,14 @@
+query getLatestPipelineStatus($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ detailedStatus {
+ id
+ detailsPath
+ icon
+ group
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql
new file mode 100644
index 00000000000..69a29947b16
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql
@@ -0,0 +1,19 @@
+query getPipelineStages($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ stages {
+ nodes {
+ id
+ name
+ detailedStatus {
+ id
+ icon
+ group
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 69fe2d30489..7500c152b6a 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -2,6 +2,7 @@ import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph';
import { initDetailsButton } from './init_details_button';
import { loadBranches } from './load_branches';
+import initCommitPipelineStatus from './init_commit_pipeline_status';
export const initCommitBoxInfo = () => {
// Display commit related branches
@@ -14,4 +15,6 @@ export const initCommitBoxInfo = () => {
initCommitPipelineMiniGraph();
initDetailsButton();
+
+ initCommitPipelineStatus();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
index 1d4ec4c110b..c206e648561 100644
--- a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
+++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
@@ -5,7 +5,7 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { useGet: true }),
});
export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => {
@@ -15,7 +15,7 @@ export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipelin
return;
}
- const { stages, fullPath, iid } = el.dataset;
+ const { stages, fullPath, iid, graphqlResourceEtag } = el.dataset;
// Some commits have no pipeline, code splitting to load the pipeline optionally
const { default: CommitBoxPipelineMiniGraph } = await import(
@@ -30,6 +30,7 @@ export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipelin
fullPath,
iid,
dataMethod: 'graphql',
+ graphqlResourceEtag,
},
render(createElement) {
return createElement(CommitBoxPipelineMiniGraph, {
diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js
new file mode 100644
index 00000000000..d5e62531283
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import CommitBoxPipelineStatus from './components/commit_box_pipeline_status.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient({}, { useGet: true }),
+});
+
+export default (selector = '.js-commit-pipeline-status') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return;
+ }
+
+ const { fullPath, iid, graphqlResourceEtag } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ fullPath,
+ iid,
+ graphqlResourceEtag,
+ },
+ render(createElement) {
+ return createElement(CommitBoxPipelineStatus);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commit_box/info/utils.js b/app/assets/javascripts/projects/commit_box/info/utils.js
new file mode 100644
index 00000000000..ea7eb35cbaf
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/utils.js
@@ -0,0 +1,14 @@
+export const formatStages = (graphQLStages = [], restStages = []) => {
+ if (graphQLStages.length !== restStages.length) {
+ throw new Error('Rest stages and graphQl stages must be the same length');
+ }
+
+ return graphQLStages.map((stage, index) => {
+ return {
+ name: stage.name,
+ status: stage.detailedStatus,
+ dropdown_path: restStages[index]?.dropdown_path || '',
+ title: restStages[index].title || '',
+ };
+ });
+};
diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue
index fd71a246a26..277af2f281e 100644
--- a/app/assets/javascripts/projects/components/shared/delete_button.vue
+++ b/app/assets/javascripts/projects/components/shared/delete_button.vue
@@ -104,7 +104,6 @@ export default {
<gl-modal
ref="removeModal"
:modal-id="modalId"
- size="sm"
ok-variant="danger"
footer-class="gl-bg-gray-10 gl-p-5"
title-class="gl-text-red-500"
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index 0393d82ca36..6708b7bd9e2 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -57,9 +57,9 @@ export default {
text: s__('ProjectTemplates|Pages/Hexo'),
icon: '.template-option .icon-hexo',
},
- sse_middleman: {
- text: s__('ProjectTemplates|Static Site Editor/Middleman'),
- icon: '.template-option .icon-sse_middleman',
+ middleman: {
+ text: s__('ProjectTemplates|Pages/Middleman'),
+ icon: '.template-option .icon-middleman',
},
gitpod_spring_petclinic: {
text: s__('ProjectTemplates|Gitpod/Spring Petclinic'),
diff --git a/app/assets/javascripts/projects/new/components/deployment_target_select.vue b/app/assets/javascripts/projects/new/components/deployment_target_select.vue
index f3b7e39f148..0003134f15c 100644
--- a/app/assets/javascripts/projects/new/components/deployment_target_select.vue
+++ b/app/assets/javascripts/projects/new/components/deployment_target_select.vue
@@ -1,12 +1,15 @@
<script>
-import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { GlFormGroup, GlFormSelect, GlFormText, GlSprintf, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import {
DEPLOYMENT_TARGET_SELECTIONS,
DEPLOYMENT_TARGET_LABEL,
DEPLOYMENT_TARGET_EVENT,
+ VISIT_DOCS_EVENT,
NEW_PROJECT_FORM,
+ K8S_OPTION,
} from '../constants';
const trackingMixin = Tracking.mixin({ label: DEPLOYMENT_TARGET_LABEL });
@@ -15,12 +18,21 @@ export default {
i18n: {
deploymentTargetLabel: s__('Deployment Target|Project deployment target (optional)'),
defaultOption: s__('Deployment Target|Select the deployment target'),
+ k8sEducationText: s__(
+ 'Deployment Target|%{linkStart}How to provision or deploy to Kubernetes clusters from GitLab?%{linkEnd}',
+ ),
},
deploymentTargets: DEPLOYMENT_TARGET_SELECTIONS,
+ VISIT_DOCS_EVENT,
+ DEPLOYMENT_TARGET_LABEL,
selectId: 'deployment-target-select',
+ helpPageUrl: helpPagePath('user/clusters/agent/index'),
components: {
GlFormGroup,
GlFormSelect,
+ GlFormText,
+ GlSprintf,
+ GlLink,
},
mixins: [trackingMixin],
data() {
@@ -29,6 +41,11 @@ export default {
formSubmitted: false,
};
},
+ computed: {
+ isK8sOptionSelected() {
+ return this.selectedTarget === K8S_OPTION;
+ },
+ },
mounted() {
const form = document.getElementById(NEW_PROJECT_FORM);
form.addEventListener('submit', () => {
@@ -52,10 +69,24 @@ export default {
:id="$options.selectId"
v-model="selectedTarget"
:options="$options.deploymentTargets"
+ class="input-lg"
>
<template #first>
<option :value="null" disabled>{{ $options.i18n.defaultOption }}</option>
</template>
</gl-form-select>
+
+ <gl-form-text v-if="isK8sOptionSelected">
+ <gl-sprintf :message="$options.i18n.k8sEducationText">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.helpPageUrl"
+ :data-track-action="$options.VISIT_DOCS_EVENT"
+ :data-track-label="$options.DEPLOYMENT_TARGET_LABEL"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </gl-form-text>
</gl-form-group>
</template>
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
index f4a21c6057c..506f1ec5ffd 100644
--- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue
+++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
@@ -13,6 +13,7 @@ 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 { s__ } from '~/locale';
import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
import eventHub from '../event_hub';
@@ -43,14 +44,7 @@ export default {
debounce: DEBOUNCE_DELAY,
},
},
- inject: [
- 'namespaceFullPath',
- 'namespaceId',
- 'rootUrl',
- 'trackLabel',
- 'userNamespaceFullPath',
- 'userNamespaceId',
- ],
+ inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel', 'userNamespaceId'],
data() {
return {
currentUser: {},
@@ -62,10 +56,11 @@ export default {
fullPath: this.namespaceFullPath,
}
: {
- id: this.userNamespaceId,
- fullPath: this.userNamespaceFullPath,
+ id: undefined,
+ fullPath: s__('ProjectsNew|Pick a group or namespace'),
},
shouldSkipQuery: true,
+ userNamespaceId: this.userNamespaceId,
};
},
computed: {
@@ -92,6 +87,9 @@ export default {
hasNoMatches() {
return !this.hasGroupMatches && !this.hasNamespaceMatches;
},
+ dropdownPlaceholderClass() {
+ return this.selectedNamespace.id ? '' : 'gl-text-gray-500!';
+ },
},
created() {
eventHub.$on('select-template', this.handleSelectTemplate);
@@ -130,11 +128,18 @@ export default {
</script>
<template>
- <gl-button-group class="input-lg">
- <gl-button class="gl-text-truncate" label :title="rootUrl">{{ rootUrl }}</gl-button>
+ <gl-button-group class="gl-w-full">
+ <gl-button
+ class="js-group-namespace-button gl-text-truncate gl-flex-grow-0!"
+ 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"
+ class="js-group-namespace-dropdown gl-flex-grow-1"
+ :toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`"
data-qa-selector="select_namespace_dropdown"
@show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
@shown="handleDropdownShown"
@@ -166,11 +171,13 @@ export default {
</template>
</gl-dropdown>
+ <input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" />
+
<input
id="project_namespace_id"
type="hidden"
name="project[namespace_id]"
- :value="selectedNamespace.id"
+ :value="selectedNamespace.id || userNamespaceId"
/>
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/projects/new/constants.js b/app/assets/javascripts/projects/new/constants.js
index c5e6722981b..e52a84dc07e 100644
--- a/app/assets/javascripts/projects/new/constants.js
+++ b/app/assets/javascripts/projects/new/constants.js
@@ -1,7 +1,9 @@
import { s__ } from '~/locale';
+export const K8S_OPTION = s__('DeploymentTarget|Kubernetes (GKE, EKS, OpenShift, and so on)');
+
export const DEPLOYMENT_TARGET_SELECTIONS = [
- s__('DeploymentTarget|Kubernetes (GKE, EKS, OpenShift, and so on)'),
+ K8S_OPTION,
s__('DeploymentTarget|Managed container runtime (Fargate, Cloud Run, DigitalOcean App)'),
s__('DeploymentTarget|Self-managed container runtime (Podman, Docker Swarm, Docker Compose)'),
s__('DeploymentTarget|Heroku'),
@@ -18,3 +20,4 @@ export const DEPLOYMENT_TARGET_SELECTIONS = [
export const NEW_PROJECT_FORM = 'new_project';
export const DEPLOYMENT_TARGET_LABEL = 'new_project_deployment_target';
export const DEPLOYMENT_TARGET_EVENT = 'select_deployment_target';
+export const VISIT_DOCS_EVENT = 'visit_docs';
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
index 4de9b8a6f47..a72172a4f5e 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -58,7 +58,6 @@ export function initNewProjectUrlSelect() {
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/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index f1b7e3df7d6..3e1c471f015 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -3,6 +3,7 @@ import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants';
+import { ENTER_KEY } from '../lib/utils/keys';
import axios from '../lib/utils/axios_utils';
import {
convertToTitleCase,
@@ -14,6 +15,7 @@ import {
let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
const invalidInputClass = 'gl-field-error-outline';
+const invalidDropdownClass = 'gl-inset-border-1-red-400!';
const cancelSource = axios.CancelToken.source();
const endpoint = `${gon.relative_url_root}/import/url/validate`;
@@ -50,6 +52,25 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr
}
};
+const selectedNamespaceId = () => document.querySelector('[name="project[selected_namespace_id]"]');
+const dropdownButton = () => document.querySelector('.js-group-namespace-dropdown > button');
+const namespaceButton = () => document.querySelector('.js-group-namespace-button');
+const namespaceError = () => document.querySelector('.js-group-namespace-error');
+
+const validateGroupNamespaceDropdown = (e) => {
+ if (selectedNamespaceId() && !selectedNamespaceId().attributes.value) {
+ document.querySelector('input[data-qa-selector="project_name"]').reportValidity();
+ e.preventDefault();
+ dropdownButton().classList.add(invalidDropdownClass);
+ namespaceButton().classList.add(invalidDropdownClass);
+ namespaceError().classList.remove('gl-display-none');
+ } else {
+ dropdownButton().classList.remove(invalidDropdownClass);
+ namespaceButton().classList.remove(invalidDropdownClass);
+ namespaceError().classList.add('gl-display-none');
+ }
+};
+
const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
const specialRepo = document.querySelector('.js-user-readme-repo');
@@ -70,6 +91,10 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
$projectPathInput.val() !== $projectPathInput.data('username'),
);
});
+
+ document.querySelector('.js-create-project-button').addEventListener('click', (e) => {
+ validateGroupNamespaceDropdown(e);
+ });
};
const deriveProjectPathFromUrl = ($projectImportUrl) => {
@@ -158,7 +183,11 @@ const bindEvents = () => {
$projectTemplateButtons.addClass('hidden');
$projectFieldsForm.addClass('selected');
$selectedIcon.empty();
- const value = $(this).val();
+
+ const $selectedTemplate = $(this);
+ $selectedTemplate.prop('checked', true);
+
+ const value = $selectedTemplate.val();
const selectedTemplate = DEFAULT_PROJECT_TEMPLATES[value];
$selectedTemplateText.text(selectedTemplate.text);
@@ -170,7 +199,21 @@ const bindEvents = () => {
setProjectNamePathHandlers($activeTabProjectName, $activeTabProjectPath);
}
- $useTemplateBtn.on('change', chooseTemplate);
+ function toggleActiveClassOnLabel(event) {
+ const $label = $(event.target).parent();
+ $label.toggleClass('active');
+ }
+
+ function chooseTemplateOnEnter(event) {
+ if (event.code === ENTER_KEY) {
+ chooseTemplate.call(this);
+ }
+ }
+
+ $useTemplateBtn.on('click', chooseTemplate);
+
+ $useTemplateBtn.on('focus focusout', toggleActiveClassOnLabel);
+ $useTemplateBtn.on('keypress', chooseTemplateOnEnter);
$changeTemplateBtn.on('click', () => {
$projectTemplateButtons.removeClass('hidden');
diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
index e8b0e95b142..d4c97cbf038 100644
--- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
+++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
@@ -1,6 +1,7 @@
<script>
import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import searchProjectTopics from '../queries/project_topics_search.query.graphql';
export default {
@@ -65,6 +66,7 @@ export default {
this.$emit('update', tokens);
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
@@ -85,7 +87,7 @@ export default {
:entity-name="dropdownItem.name"
:label="dropdownItem.name"
:size="32"
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
</template>
</gl-token-selector>
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 174049b15fe..9ed895e90fb 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -1,8 +1,8 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Sortable from 'sortablejs';
-import sortableConfig from '~/sortable/sortable_config';
import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
+import { defaultSortableOptions } from '~/sortable/constants';
export default {
name: 'RelatedIssuesList',
@@ -53,7 +53,7 @@ export default {
mounted() {
if (this.canReorder) {
this.sortable = Sortable.create(this.$refs.list, {
- ...sortableConfig,
+ ...defaultSortableOptions,
onStart: this.addDraggingCursor,
onEnd: this.reordered,
});
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 39140216bc5..8365e6a5ab0 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -185,7 +185,7 @@ export default {
<gl-button
class="mr-auto js-no-auto-disable"
category="primary"
- variant="success"
+ variant="confirm"
type="submit"
:disabled="isFormSubmissionDisabled"
data-testid="submit-button"
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index e53bfea7389..59fa2fca736 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,67 +1,237 @@
<script>
-import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { getParameterByName } from '~/lib/utils/url_utility';
+import { GlButton } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { historyPushState } from '~/lib/utils/common_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
+import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
+import { convertAllReleasesGraphQLResponse } from '~/releases/util';
+import allReleasesQuery from '../graphql/queries/all_releases.query.graphql';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
+import ReleasesEmptyState from './releases_empty_state.vue';
import ReleasesPagination from './releases_pagination.vue';
import ReleasesSort from './releases_sort.vue';
export default {
- name: 'ReleasesApp',
+ name: 'ReleasesIndexApp',
components: {
- GlEmptyState,
- GlLink,
GlButton,
ReleaseBlock,
- ReleasesPagination,
ReleaseSkeletonLoader,
+ ReleasesEmptyState,
+ ReleasesPagination,
ReleasesSort,
},
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ newReleasePath: {
+ default: '',
+ },
+ },
+ apollo: {
+ /**
+ * The same query as `fullGraphqlResponse`, except that it limits its
+ * results to a single item. This causes this request to complete much more
+ * quickly than `fullGraphqlResponse`, which allows the page to show
+ * meaningful content to the user much earlier.
+ */
+ singleGraphqlResponse: {
+ query: allReleasesQuery,
+ // This trick only works when paginating _forward_.
+ // When paginating backwards, limiting the query to a single item loads
+ // the _last_ item in the page, which is not useful for our purposes.
+ skip() {
+ return !this.includeSingleQuery;
+ },
+ variables() {
+ return {
+ ...this.queryVariables,
+ first: 1,
+ };
+ },
+ update(data) {
+ return { data };
+ },
+ error() {
+ this.singleRequestError = true;
+ },
+ },
+ fullGraphqlResponse: {
+ query: allReleasesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return { data };
+ },
+ error(error) {
+ this.fullRequestError = true;
+
+ createFlash({
+ message: this.$options.i18n.errorMessage,
+ captureError: true,
+ error,
+ });
+ },
+ },
+ },
+ data() {
+ return {
+ singleRequestError: false,
+ fullRequestError: false,
+ cursors: {
+ before: getParameterByName('before'),
+ after: getParameterByName('after'),
+ },
+ sort: DEFAULT_SORT,
+ };
+ },
computed: {
- ...mapState('index', [
- 'documentationPath',
- 'illustrationPath',
- 'newReleasePath',
- 'isLoading',
- 'releases',
- 'hasError',
- ]),
- shouldRenderEmptyState() {
- return !this.releases.length && !this.hasError && !this.isLoading;
+ queryVariables() {
+ let paginationParams = { first: PAGE_SIZE };
+ if (this.cursors.after) {
+ paginationParams = {
+ after: this.cursors.after,
+ first: PAGE_SIZE,
+ };
+ } else if (this.cursors.before) {
+ paginationParams = {
+ before: this.cursors.before,
+ last: PAGE_SIZE,
+ };
+ }
+
+ return {
+ fullPath: this.projectPath,
+ ...paginationParams,
+ sort: this.sort,
+ };
+ },
+ /**
+ * @returns {Boolean} Whether or not to request/include
+ * the results of the single-item query
+ */
+ includeSingleQuery() {
+ return Boolean(!this.cursors.before || this.cursors.after);
+ },
+ isSingleRequestLoading() {
+ return this.$apollo.queries.singleGraphqlResponse.loading;
},
- shouldRenderSuccessState() {
- return this.releases.length && !this.isLoading && !this.hasError;
+ isFullRequestLoading() {
+ return this.$apollo.queries.fullGraphqlResponse.loading;
+ },
+ /**
+ * @returns {Boolean} `true` if the `singleGraphqlResponse`
+ * query has finished loading without errors
+ */
+ isSingleRequestLoaded() {
+ return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
+ },
+ /**
+ * @returns {Boolean} `true` if the `fullGraphqlResponse`
+ * query has finished loading without errors
+ */
+ isFullRequestLoaded() {
+ return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
+ },
+ releases() {
+ if (this.isFullRequestLoaded) {
+ return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
+ }
+
+ if (this.isSingleRequestLoaded && this.includeSingleQuery) {
+ return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
+ }
+
+ return [];
},
- emptyStateText() {
- return __(
- "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.",
+ pageInfo() {
+ if (!this.isFullRequestLoaded) {
+ return {
+ hasPreviousPage: false,
+ hasNextPage: false,
+ };
+ }
+
+ return this.fullGraphqlResponse.data.project.releases.pageInfo;
+ },
+ shouldRenderEmptyState() {
+ return this.isFullRequestLoaded && this.releases.length === 0;
+ },
+ shouldRenderLoadingIndicator() {
+ return (
+ (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
+ (this.isFullRequestLoading && !this.fullRequestError)
);
},
+ shouldRenderPagination() {
+ return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
+ },
},
created() {
- this.fetchReleases();
+ this.updateQueryParamsFromUrl();
- window.addEventListener('popstate', this.fetchReleases);
+ window.addEventListener('popstate', this.updateQueryParamsFromUrl);
+ },
+ destroyed() {
+ window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
},
methods: {
- ...mapActions('index', {
- fetchReleasesStoreAction: 'fetchReleases',
- }),
- fetchReleases() {
- this.fetchReleasesStoreAction({
- before: getParameterByName('before'),
- after: getParameterByName('after'),
- });
+ getReleaseKey(release, index) {
+ return [release.tagName, release.name, index].join('|');
+ },
+ updateQueryParamsFromUrl() {
+ this.cursors.before = getParameterByName('before');
+ this.cursors.after = getParameterByName('after');
+ },
+ onPaginationButtonPress() {
+ this.updateQueryParamsFromUrl();
+
+ // In some cases, Apollo Client is able to pull its results from the cache instead of making
+ // a new network request. In these cases, the page's content gets swapped out immediately without
+ // changing the page's scroll, leaving the user looking at the bottom of the new page.
+ // To make the experience consistent, regardless of how the data is sourced, we manually
+ // scroll to the top of the page every time a pagination button is pressed.
+ scrollUp();
+ },
+ onSortChanged(newSort) {
+ if (this.sort === newSort) {
+ return;
+ }
+
+ // Remove the "before" and "after" query parameters from the URL,
+ // effectively placing the user back on page 1 of the results.
+ // This prevents the frontend from requesting the results sorted
+ // by one field (e.g. `released_at`) while using a pagination cursor
+ // intended for a different field (e.g.) `created_at`).
+ // For more details, see the MR that introduced this change:
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434
+ historyPushState(
+ setUrlParams({
+ before: null,
+ after: null,
+ }),
+ );
+
+ this.updateQueryParamsFromUrl();
+
+ this.sort = newSort;
},
},
+ i18n: {
+ newRelease: __('New release'),
+ errorMessage: __('An error occurred while fetching the releases. Please try again.'),
+ },
};
</script>
<template>
<div class="flex flex-column mt-2">
<div class="gl-align-self-end gl-mb-3">
- <releases-sort class="gl-mr-2" @sort:changed="fetchReleases" />
+ <releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" />
<gl-button
v-if="newReleasePath"
@@ -69,44 +239,27 @@ export default {
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
variant="confirm"
- data-testid="new-release-button"
+ >{{ $options.i18n.newRelease }}</gl-button
>
- {{ __('New release') }}
- </gl-button>
</div>
- <release-skeleton-loader v-if="isLoading" />
-
- <gl-empty-state
- v-else-if="shouldRenderEmptyState"
- data-testid="empty-state"
- :title="__('Getting started with releases')"
- :svg-path="illustrationPath"
- >
- <template #description>
- <span id="releases-description">
- {{ emptyStateText }}
- <gl-link
- :href="documentationPath"
- :aria-label="__('Releases documentation')"
- target="_blank"
- >
- {{ __('More information') }}
- </gl-link>
- </span>
- </template>
- </gl-empty-state>
-
- <div v-else-if="shouldRenderSuccessState" data-testid="success-state">
- <release-block
- v-for="(release, index) in releases"
- :key="index"
- :release="release"
- :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
- />
- </div>
+ <releases-empty-state v-if="shouldRenderEmptyState" />
+
+ <release-block
+ v-for="(release, index) in releases"
+ :key="getReleaseKey(release, index)"
+ :release="release"
+ :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
+ />
+
+ <release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
- <releases-pagination v-if="!isLoading" />
+ <releases-pagination
+ v-if="shouldRenderPagination"
+ :page-info="pageInfo"
+ @prev="onPaginationButtonPress"
+ @next="onPaginationButtonPress"
+ />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue
deleted file mode 100644
index f49c44a399f..00000000000
--- a/app/assets/javascripts/releases/components/app_index_apollo_client.vue
+++ /dev/null
@@ -1,275 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql';
-import createFlash from '~/flash';
-import { historyPushState } from '~/lib/utils/common_utils';
-import { scrollUp } from '~/lib/utils/scroll_utils';
-import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
-import { convertAllReleasesGraphQLResponse } from '~/releases/util';
-import ReleaseBlock from './release_block.vue';
-import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
-import ReleasesEmptyState from './releases_empty_state.vue';
-import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue';
-import ReleasesSortApolloClient from './releases_sort_apollo_client.vue';
-
-export default {
- name: 'ReleasesIndexApolloClientApp',
- components: {
- GlButton,
- ReleaseBlock,
- ReleaseSkeletonLoader,
- ReleasesEmptyState,
- ReleasesPaginationApolloClient,
- ReleasesSortApolloClient,
- },
- inject: {
- projectPath: {
- default: '',
- },
- newReleasePath: {
- default: '',
- },
- },
- apollo: {
- /**
- * The same query as `fullGraphqlResponse`, except that it limits its
- * results to a single item. This causes this request to complete much more
- * quickly than `fullGraphqlResponse`, which allows the page to show
- * meaningful content to the user much earlier.
- */
- singleGraphqlResponse: {
- query: allReleasesQuery,
- // This trick only works when paginating _forward_.
- // When paginating backwards, limiting the query to a single item loads
- // the _last_ item in the page, which is not useful for our purposes.
- skip() {
- return !this.includeSingleQuery;
- },
- variables() {
- return {
- ...this.queryVariables,
- first: 1,
- };
- },
- update(data) {
- return { data };
- },
- error() {
- this.singleRequestError = true;
- },
- },
- fullGraphqlResponse: {
- query: allReleasesQuery,
- variables() {
- return this.queryVariables;
- },
- update(data) {
- return { data };
- },
- error(error) {
- this.fullRequestError = true;
-
- createFlash({
- message: this.$options.i18n.errorMessage,
- captureError: true,
- error,
- });
- },
- },
- },
- data() {
- return {
- singleRequestError: false,
- fullRequestError: false,
- cursors: {
- before: getParameterByName('before'),
- after: getParameterByName('after'),
- },
- sort: DEFAULT_SORT,
- };
- },
- computed: {
- queryVariables() {
- let paginationParams = { first: PAGE_SIZE };
- if (this.cursors.after) {
- paginationParams = {
- after: this.cursors.after,
- first: PAGE_SIZE,
- };
- } else if (this.cursors.before) {
- paginationParams = {
- before: this.cursors.before,
- last: PAGE_SIZE,
- };
- }
-
- return {
- fullPath: this.projectPath,
- ...paginationParams,
- sort: this.sort,
- };
- },
- /**
- * @returns {Boolean} Whether or not to request/include
- * the results of the single-item query
- */
- includeSingleQuery() {
- return Boolean(!this.cursors.before || this.cursors.after);
- },
- isSingleRequestLoading() {
- return this.$apollo.queries.singleGraphqlResponse.loading;
- },
- isFullRequestLoading() {
- return this.$apollo.queries.fullGraphqlResponse.loading;
- },
- /**
- * @returns {Boolean} `true` if the `singleGraphqlResponse`
- * query has finished loading without errors
- */
- isSingleRequestLoaded() {
- return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
- },
- /**
- * @returns {Boolean} `true` if the `fullGraphqlResponse`
- * query has finished loading without errors
- */
- isFullRequestLoaded() {
- return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
- },
- releases() {
- if (this.isFullRequestLoaded) {
- return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
- }
-
- if (this.isSingleRequestLoaded && this.includeSingleQuery) {
- return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
- }
-
- return [];
- },
- pageInfo() {
- if (!this.isFullRequestLoaded) {
- return {
- hasPreviousPage: false,
- hasNextPage: false,
- };
- }
-
- return this.fullGraphqlResponse.data.project.releases.pageInfo;
- },
- shouldRenderEmptyState() {
- return this.isFullRequestLoaded && this.releases.length === 0;
- },
- shouldRenderLoadingIndicator() {
- return (
- (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
- (this.isFullRequestLoading && !this.fullRequestError)
- );
- },
- shouldRenderPagination() {
- return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
- },
- },
- created() {
- this.updateQueryParamsFromUrl();
-
- window.addEventListener('popstate', this.updateQueryParamsFromUrl);
- },
- destroyed() {
- window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
- },
- methods: {
- getReleaseKey(release, index) {
- return [release.tagName, release.name, index].join('|');
- },
- updateQueryParamsFromUrl() {
- this.cursors.before = getParameterByName('before');
- this.cursors.after = getParameterByName('after');
- },
- onPaginationButtonPress() {
- this.updateQueryParamsFromUrl();
-
- // In some cases, Apollo Client is able to pull its results from the cache instead of making
- // a new network request. In these cases, the page's content gets swapped out immediately without
- // changing the page's scroll, leaving the user looking at the bottom of the new page.
- // To make the experience consistent, regardless of how the data is sourced, we manually
- // scroll to the top of the page every time a pagination button is pressed.
- scrollUp();
- },
- onSortChanged(newSort) {
- if (this.sort === newSort) {
- return;
- }
-
- // Remove the "before" and "after" query parameters from the URL,
- // effectively placing the user back on page 1 of the results.
- // This prevents the frontend from requesting the results sorted
- // by one field (e.g. `released_at`) while using a pagination cursor
- // intended for a different field (e.g.) `created_at`).
- // For more details, see the MR that introduced this change:
- // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434
- historyPushState(
- setUrlParams({
- before: null,
- after: null,
- }),
- );
-
- this.updateQueryParamsFromUrl();
-
- this.sort = newSort;
- },
- },
- i18n: {
- newRelease: __('New release'),
- errorMessage: __('An error occurred while fetching the releases. Please try again.'),
- },
-};
-</script>
-<template>
- <div class="flex flex-column mt-2">
- <div class="gl-align-self-end gl-mb-3">
- <releases-sort-apollo-client :value="sort" class="gl-mr-2" @input="onSortChanged" />
-
- <gl-button
- v-if="newReleasePath"
- :href="newReleasePath"
- :aria-describedby="shouldRenderEmptyState && 'releases-description'"
- category="primary"
- variant="success"
- >{{ $options.i18n.newRelease }}</gl-button
- >
- </div>
-
- <releases-empty-state v-if="shouldRenderEmptyState" />
-
- <release-block
- v-for="(release, index) in releases"
- :key="getReleaseKey(release, index)"
- :release="release"
- :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
- />
-
- <release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
-
- <releases-pagination-apollo-client
- v-if="shouldRenderPagination"
- :page-info="pageInfo"
- @prev="onPaginationButtonPress"
- @next="onPaginationButtonPress"
- />
- </div>
-</template>
-<style>
-.linked-card::after {
- width: 1px;
- content: ' ';
- border: 1px solid #e5e5e5;
- height: 17px;
- top: 100%;
- position: absolute;
- left: 32px;
-}
-</style>
diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue
index cb795b3cba7..91d6d0911a4 100644
--- a/app/assets/javascripts/releases/components/release_block_footer.vue
+++ b/app/assets/javascripts/releases/components/release_block_footer.vue
@@ -104,9 +104,11 @@ export default {
<div v-if="author" class="d-flex">
<span class="text-secondary">{{ __('by') }}&nbsp;</span>
<user-avatar-link
+ class="gl-my-n1"
:link-href="author.webUrl"
:img-src="author.avatarUrl"
:img-alt="userImageAltDescription"
+ :img-size="24"
:tooltip-text="author.username"
tooltip-placement="bottom"
/>
diff --git a/app/assets/javascripts/releases/components/releases_pagination.vue b/app/assets/javascripts/releases/components/releases_pagination.vue
index fddf85ead1e..52ad991d61a 100644
--- a/app/assets/javascripts/releases/components/releases_pagination.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination.vue
@@ -1,26 +1,24 @@
<script>
import { GlKeysetPagination } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { isBoolean } from 'lodash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default {
- name: 'ReleasesPaginationGraphql',
+ name: 'ReleasesPagination',
components: { GlKeysetPagination },
- computed: {
- ...mapState('index', ['pageInfo']),
- showPagination() {
- return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
+ props: {
+ pageInfo: {
+ type: Object,
+ required: true,
+ validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
},
},
methods: {
- ...mapActions('index', ['fetchReleases']),
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
- this.fetchReleases({ before });
},
onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
- this.fetchReleases({ after });
},
},
};
@@ -28,8 +26,10 @@ export default {
<template>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
- v-if="showPagination"
v-bind="pageInfo"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ v-on="$listeners"
@prev="onPrev($event)"
@next="onNext($event)"
/>
diff --git a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue
deleted file mode 100644
index 73339677a4b..00000000000
--- a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<script>
-import { GlKeysetPagination } from '@gitlab/ui';
-import { isBoolean } from 'lodash';
-import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
-
-export default {
- name: 'ReleasesPaginationApolloClient',
- components: { GlKeysetPagination },
- props: {
- pageInfo: {
- type: Object,
- required: true,
- validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
- },
- },
- methods: {
- onPrev(before) {
- historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
- },
- onNext(after) {
- historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
- },
- },
-};
-</script>
-<template>
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-bind="pageInfo"
- :prev-text="__('Prev')"
- :next-text="__('Next')"
- v-on="$listeners"
- @prev="onPrev($event)"
- @next="onNext($event)"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue
index d4210dad19c..0f14b579da0 100644
--- a/app/assets/javascripts/releases/components/releases_sort.vue
+++ b/app/assets/javascripts/releases/components/releases_sort.vue
@@ -1,7 +1,17 @@
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
+import {
+ ASCENDING_ORDER,
+ DESCENDING_ORDER,
+ SORT_OPTIONS,
+ RELEASED_AT,
+ CREATED_AT,
+ RELEASED_AT_ASC,
+ RELEASED_AT_DESC,
+ CREATED_ASC,
+ ALL_SORTS,
+ SORT_MAP,
+} from '../constants';
export default {
name: 'ReleasesSort',
@@ -9,35 +19,54 @@ export default {
GlSorting,
GlSortingItem,
},
+ props: {
+ value: {
+ type: String,
+ required: true,
+ validator: (sort) => ALL_SORTS.includes(sort),
+ },
+ },
computed: {
- ...mapState('index', {
- orderBy: (state) => state.sorting.orderBy,
- sort: (state) => state.sorting.sort,
- }),
+ orderBy() {
+ if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) {
+ return RELEASED_AT;
+ }
+
+ return CREATED_AT;
+ },
+ direction() {
+ if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) {
+ return ASCENDING_ORDER;
+ }
+
+ return DESCENDING_ORDER;
+ },
sortOptions() {
return SORT_OPTIONS;
},
sortText() {
- const option = this.sortOptions.find((s) => s.orderBy === this.orderBy);
- return option.label;
+ return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
},
- isSortAscending() {
- return this.sort === ASCENDING_ORDER;
+ isDirectionAscending() {
+ return this.direction === ASCENDING_ORDER;
},
},
methods: {
- ...mapActions('index', ['setSorting']),
onDirectionChange() {
- const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
- this.setSorting({ sort });
- this.$emit('sort:changed');
+ const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
+ this.emitInputEventIfChanged(this.orderBy, direction);
},
onSortItemClick(item) {
- this.setSorting({ orderBy: item });
- this.$emit('sort:changed');
+ this.emitInputEventIfChanged(item.orderBy, this.direction);
},
isActiveSortItem(item) {
- return this.orderBy === item;
+ return this.orderBy === item.orderBy;
+ },
+ emitInputEventIfChanged(orderBy, direction) {
+ const newSort = SORT_MAP[orderBy][direction];
+ if (newSort !== this.value) {
+ this.$emit('input', SORT_MAP[orderBy][direction]);
+ }
},
},
};
@@ -46,15 +75,15 @@ export default {
<template>
<gl-sorting
:text="sortText"
- :is-ascending="isSortAscending"
+ :is-ascending="isDirectionAscending"
data-testid="releases-sort"
@sortDirectionChange="onDirectionChange"
>
<gl-sorting-item
- v-for="item in sortOptions"
+ v-for="item of sortOptions"
:key="item.orderBy"
- :active="isActiveSortItem(item.orderBy)"
- @click="onSortItemClick(item.orderBy)"
+ :active="isActiveSortItem(item)"
+ @click="onSortItemClick(item)"
>
{{ item.label }}
</gl-sorting-item>
diff --git a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue
deleted file mode 100644
index 7257b34bbf6..00000000000
--- a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue
+++ /dev/null
@@ -1,91 +0,0 @@
-<script>
-import { GlSorting, GlSortingItem } from '@gitlab/ui';
-import {
- ASCENDING_ORDER,
- DESCENDING_ORDER,
- SORT_OPTIONS,
- RELEASED_AT,
- CREATED_AT,
- RELEASED_AT_ASC,
- RELEASED_AT_DESC,
- CREATED_ASC,
- ALL_SORTS,
- SORT_MAP,
-} from '../constants';
-
-export default {
- name: 'ReleasesSortApolloclient',
- components: {
- GlSorting,
- GlSortingItem,
- },
- props: {
- value: {
- type: String,
- required: true,
- validator: (sort) => ALL_SORTS.includes(sort),
- },
- },
- computed: {
- orderBy() {
- if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) {
- return RELEASED_AT;
- }
-
- return CREATED_AT;
- },
- direction() {
- if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) {
- return ASCENDING_ORDER;
- }
-
- return DESCENDING_ORDER;
- },
- sortOptions() {
- return SORT_OPTIONS;
- },
- sortText() {
- return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
- },
- isDirectionAscending() {
- return this.direction === ASCENDING_ORDER;
- },
- },
- methods: {
- onDirectionChange() {
- const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
- this.emitInputEventIfChanged(this.orderBy, direction);
- },
- onSortItemClick(item) {
- this.emitInputEventIfChanged(item.orderBy, this.direction);
- },
- isActiveSortItem(item) {
- return this.orderBy === item.orderBy;
- },
- emitInputEventIfChanged(orderBy, direction) {
- const newSort = SORT_MAP[orderBy][direction];
- if (newSort !== this.value) {
- this.$emit('input', SORT_MAP[orderBy][direction]);
- }
- },
- },
-};
-</script>
-
-<template>
- <gl-sorting
- :text="sortText"
- :is-ascending="isDirectionAscending"
- data-testid="releases-sort"
- @sortDirectionChange="onDirectionChange"
- >
- <gl-sorting-item
- v-for="item of sortOptions"
- :key="item.orderBy"
- :active="isActiveSortItem(item)"
- @click="onSortItemClick(item)"
- >
- {{ item.label }}
- </gl-sorting-item>
- </gl-sorting>
-</template>
diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
index 7f67f7d11a3..bda7ac52a47 100644
--- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -1,12 +1,4 @@
-#import "../fragments/release.fragment.graphql"
-
-# This query is identical to
-# `app/graphql/queries/releases/all_releases.query.graphql`.
-# These two queries should be kept in sync.
-# When the `releases_index_apollo_client` feature flag is
-# removed, this query should be removed entirely.
-
-query allReleasesDeprecated(
+query allReleases(
$fullPath: ID!
$first: Int
$last: Int
@@ -20,7 +12,87 @@ query allReleasesDeprecated(
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
__typename
nodes {
- ...Release
+ __typename
+ name
+ tagName
+ tagPath
+ descriptionHtml
+ releasedAt
+ createdAt
+ upcomingRelease
+ assets {
+ __typename
+ count
+ sources {
+ __typename
+ nodes {
+ __typename
+ format
+ url
+ }
+ }
+ links {
+ __typename
+ nodes {
+ __typename
+ id
+ name
+ url
+ directAssetUrl
+ linkType
+ external
+ }
+ }
+ }
+ evidences {
+ __typename
+ nodes {
+ __typename
+ id
+ filepath
+ collectedAt
+ sha
+ }
+ }
+ links {
+ __typename
+ editUrl
+ selfUrl
+ openedIssuesUrl
+ closedIssuesUrl
+ openedMergeRequestsUrl
+ mergedMergeRequestsUrl
+ closedMergeRequestsUrl
+ }
+ commit {
+ __typename
+ id
+ sha
+ webUrl
+ title
+ }
+ author {
+ __typename
+ id
+ webUrl
+ avatarUrl
+ username
+ }
+ milestones {
+ __typename
+ nodes {
+ __typename
+ id
+ title
+ description
+ webPath
+ stats {
+ __typename
+ totalIssuesCount
+ closedIssuesCount
+ }
+ }
+ }
}
pageInfo {
__typename
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index 86fa72d1496..afb8ab461cd 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -1,50 +1,32 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import Vuex from 'vuex';
import createDefaultClient from '~/lib/graphql';
import ReleaseIndexApp from './components/app_index.vue';
-import ReleaseIndexApollopClientApp from './components/app_index_apollo_client.vue';
-import createStore from './stores';
-import createIndexModule from './stores/modules/index';
export default () => {
const el = document.getElementById('js-releases-page');
- if (window.gon?.features?.releasesIndexApolloClient) {
- Vue.use(VueApollo);
+ Vue.use(VueApollo);
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- // This page attempts to decrease the perceived loading time
- // by sending two requests: one request for the first item only (which
- // completes relatively quickly), and one for all the items (which is slower).
- // By default, Apollo Client batches these requests together, which defeats
- // the purpose of making separate requests. So we explicitly
- // disable batching on this page.
- batchMax: 1,
- },
- ),
- });
-
- return new Vue({
- el,
- apolloProvider,
- provide: { ...el.dataset },
- render: (h) => h(ReleaseIndexApollopClientApp),
- });
- }
-
- Vue.use(Vuex);
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ // This page attempts to decrease the perceived loading time
+ // by sending two requests: one request for the first item only (which
+ // completes relatively quickly), and one for all the items (which is slower).
+ // By default, Apollo Client batches these requests together, which defeats
+ // the purpose of making separate requests. So we explicitly
+ // disable batching on this page.
+ batchMax: 1,
+ },
+ ),
+ });
return new Vue({
el,
- store: createStore({
- modules: {
- index: createIndexModule(el.dataset),
- },
- }),
+ apolloProvider,
+ provide: { ...el.dataset },
render: (h) => h(ReleaseIndexApp),
});
};
diff --git a/app/assets/javascripts/releases/stores/modules/index/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js
deleted file mode 100644
index d3bb11cab30..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/actions.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-import { PAGE_SIZE } from '~/releases/constants';
-import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
-import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util';
-import * as types from './mutation_types';
-
-/**
- * Gets a paginated list of releases from the GraphQL endpoint
- *
- * @param {Object} vuexParams
- * @param {Object} actionParams
- * @param {String} [actionParams.before] A GraphQL cursor. If provided,
- * the items returned will proceed the provided cursor.
- * @param {String} [actionParams.after] A GraphQL cursor. If provided,
- * the items returned will follow the provided cursor.
- */
-export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => {
- commit(types.REQUEST_RELEASES);
-
- const { sort, orderBy } = state.sorting;
- const orderByParam = orderBy === 'created_at' ? 'created' : orderBy;
- const sortParams = `${orderByParam}_${sort}`.toUpperCase();
-
- let paginationParams;
- if (!before && !after) {
- paginationParams = { first: PAGE_SIZE };
- } else if (before && !after) {
- paginationParams = { last: PAGE_SIZE, before };
- } else if (!before && after) {
- paginationParams = { first: PAGE_SIZE, after };
- } else {
- throw new Error(
- 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.',
- );
- }
-
- gqClient
- .query({
- query: allReleasesQuery,
- variables: {
- fullPath: state.projectPath,
- sort: sortParams,
- ...paginationParams,
- },
- })
- .then((response) => {
- const { data, paginationInfo: pageInfo } = convertAllReleasesGraphQLResponse(response);
-
- commit(types.RECEIVE_RELEASES_SUCCESS, {
- data,
- pageInfo,
- });
- })
- .catch(() => dispatch('receiveReleasesError'));
-};
-
-export const receiveReleasesError = ({ commit }) => {
- commit(types.RECEIVE_RELEASES_ERROR);
- createFlash({
- message: __('An error occurred while fetching the releases. Please try again.'),
- });
-};
-
-export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data);
diff --git a/app/assets/javascripts/releases/stores/modules/index/index.js b/app/assets/javascripts/releases/stores/modules/index/index.js
deleted file mode 100644
index d5ca191153a..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/index.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as actions from './actions';
-import mutations from './mutations';
-import createState from './state';
-
-export default (initialState) => ({
- namespaced: true,
- actions,
- mutations,
- state: createState(initialState),
-});
diff --git a/app/assets/javascripts/releases/stores/modules/index/mutation_types.js b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js
deleted file mode 100644
index 669168efb88..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/mutation_types.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const REQUEST_RELEASES = 'REQUEST_RELEASES';
-export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS';
-export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR';
-export const SET_SORTING = 'SET_SORTING';
diff --git a/app/assets/javascripts/releases/stores/modules/index/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js
deleted file mode 100644
index 55a8a488be8..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/mutations.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- /**
- * Sets isLoading to true while the request is being made.
- * @param {Object} state
- */
- [types.REQUEST_RELEASES](state) {
- state.isLoading = true;
- },
-
- /**
- * Sets isLoading to false.
- * Sets hasError to false.
- * Sets the received data
- * Sets the received pagination information
- * @param {Object} state
- * @param {Object} resp
- */
- [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) {
- state.hasError = false;
- state.isLoading = false;
- state.releases = data;
- state.pageInfo = pageInfo;
- },
-
- /**
- * Sets isLoading to false.
- * Sets hasError to true.
- * Resets the data
- * @param {Object} state
- * @param {Object} data
- */
- [types.RECEIVE_RELEASES_ERROR](state) {
- state.isLoading = false;
- state.releases = [];
- state.hasError = true;
- state.pageInfo = {};
- },
-
- [types.SET_SORTING](state, sorting) {
- state.sorting = { ...state.sorting, ...sorting };
- },
-};
diff --git a/app/assets/javascripts/releases/stores/modules/index/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js
deleted file mode 100644
index 5e1aaab7b58..00000000000
--- a/app/assets/javascripts/releases/stores/modules/index/state.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { DESCENDING_ORDER, RELEASED_AT } from '../../../constants';
-
-export default ({
- projectId,
- projectPath,
- documentationPath,
- illustrationPath,
- newReleasePath = '',
-}) => ({
- projectId,
- projectPath,
- documentationPath,
- illustrationPath,
- newReleasePath,
-
- isLoading: false,
- hasError: false,
- releases: [],
- pageInfo: {},
- sorting: {
- sort: DESCENDING_ORDER,
- orderBy: RELEASED_AT,
- },
-});
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 59bd54eab60..fb2ef850e4f 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
@@ -34,7 +34,7 @@ export default {
return `${this.severityLabel} - ${this.issue.name}`;
},
issueSeverity() {
- return this.issue.severity.toLowerCase();
+ return this.issue.severity?.toLowerCase();
},
isStatusSuccess() {
return this.status === STATUS_SUCCESS;
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 7a490210f0b..0714d88b392 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -3,7 +3,7 @@ import { GlButton } from '@gitlab/ui';
import api from '~/api';
import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
-import Popover from '~/vue_shared/components/help_popover.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants';
import IssuesList from './issues_list.vue';
@@ -13,7 +13,7 @@ export default {
components: {
GlButton,
IssuesList,
- Popover,
+ HelpPopover,
StatusIcon,
},
mixins: [glFeatureFlagsMixin()],
@@ -172,7 +172,7 @@ export default {
},
methods: {
toggleCollapsed() {
- if (this.trackAction && this.glFeatures.usersExpandingWidgetsUsageData) {
+ if (this.trackAction) {
api.trackRedisHllUserEvent(this.trackAction);
}
@@ -193,7 +193,7 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<p class="gl-line-height-normal gl-m-0">{{ headerText }}</p>
<slot :name="slotName"></slot>
- <popover
+ <help-popover
v-if="hasPopover"
:options="popoverOptions"
class="gl-ml-2 gl-display-inline-flex"
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 6b7d81c4878..7419b5b59d6 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import Popover from '~/vue_shared/components/help_popover.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import { ICON_WARNING } from '../constants';
/**
@@ -16,7 +16,7 @@ export default {
name: 'ReportSummaryRow',
components: {
CiIcon,
- Popover,
+ HelpPopover,
GlLoadingIcon,
},
props: {
@@ -79,7 +79,7 @@ export default {
<div class="report-block-list-issue-description-text" data-testid="summary-row-description">
<slot name="summary">{{ summary }}</slot
><span v-if="popoverOptions" class="text-nowrap"
- >&nbsp;<popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
+ >&nbsp;<help-popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
</span>
</div>
</div>
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/actions.js b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
index e3db57ad846..73f8df016b6 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/actions.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
@@ -1,7 +1,7 @@
import Visibility from 'visibilityjs';
-import axios from '../../../lib/utils/axios_utils';
-import httpStatusCodes from '../../../lib/utils/http_status';
-import Poll from '../../../lib/utils/poll';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+import Poll from '~/lib/utils/poll';
import * as types from './mutation_types';
export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 85652301f4d..c9e4aab1db1 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -12,6 +12,7 @@ import { redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import CodeIntelligence from '~/code_navigation/components/app.vue';
+import LineHighlighter from '~/blob/line_highlighter';
import getRefMixin from '../mixins/get_ref';
import blobInfoQuery from '../queries/blob_info.query.graphql';
import userInfoQuery from '../queries/user_info.query.graphql';
@@ -192,6 +193,7 @@ export default {
window.requestIdleCallback(() => {
this.isRenderingLegacyTextViewer = false;
+ new LineHighlighter(); // eslint-disable-line no-new
});
} else {
this.legacyRichViewer = html;
@@ -301,6 +303,7 @@ export default {
:code-navigation-path="blobInfo.codeNavigationPath"
:blob-path="blobInfo.path"
:path-prefix="blobInfo.projectBlobPathRoot"
+ :wrap-text-nodes="glFeatures.highlightJs"
/>
</div>
</div>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index cbe18ea396e..81d2168e2ce 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -8,6 +8,7 @@ const viewers = {
pdf: () => import('./pdf_viewer.vue'),
lfs: () => import('./lfs_viewer.vue'),
audio: () => import('./audio_viewer.vue'),
+ svg: () => import('./image_viewer.vue'),
};
export const loadViewer = (type, isUsingLfs) => {
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 08faf19d12a..84c9f9d0bbe 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -9,8 +9,7 @@ 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 { __ } 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';
@@ -58,7 +57,7 @@ export default {
directives: {
GlModal: GlModalDirective,
},
- mixins: [getRefMixin, glFeatureFlagsMixin()],
+ mixins: [getRefMixin],
props: {
currentPath: {
type: String,
@@ -176,11 +175,7 @@ export default {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
showNewDirectoryModal() {
- return (
- this.glFeatures.newDirModal &&
- this.canEditTree &&
- !this.$apollo.queries.userPermissions.loading
- );
+ return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
dropdownItems() {
const items = [];
@@ -209,24 +204,13 @@ export default {
},
);
- 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'),
- });
- }
+ items.push({
+ attrs: {
+ href: '#modal-create-new-dir',
+ },
+ text: __('New directory'),
+ modalId: NEW_DIRECTORY_MODAL_ID,
+ });
} else if (this.canCreateMrFromFork) {
items.push(
{
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index c3d121505b6..2810db33e64 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -10,10 +10,10 @@ import {
import defaultAvatarUrl from 'images/no_avatar.png';
import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
import { sprintf, s__ } from '~/locale';
-import CiIcon from '../../vue_shared/components/ci_icon.vue';
-import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
-import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
@@ -171,7 +171,7 @@ export default {
<div class="commit-actions flex-row">
<div
v-if="commit.signatureHtml"
- v-safe-html:[$options.safeHtmlConfig]="commit.signatureHtml"
+ v-html="commit.signatureHtml /* eslint-disable-line vue/no-v-html */"
></div>
<div v-if="commit.pipeline" class="ci-status-link">
<gl-link
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 0a2ed753e38..c2323d6b286 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -1,7 +1,7 @@
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { sprintf, __ } from '../../../locale';
+import { sprintf, __ } from '~/locale';
import getRefMixin from '../../mixins/get_ref';
import projectPathQuery from '../../queries/project_path.query.graphql';
import TableHeader from './header.vue';
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 130ebf77361..2200e999c75 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -2,7 +2,7 @@
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import createFlash from '~/flash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { __ } from '../../locale';
+import { __ } from '~/locale';
import {
TREE_PAGE_SIZE,
TREE_INITIAL_FETCH_COUNT,
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 8aba91eedf7..accc9926a57 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -1,12 +1,14 @@
<script>
import { GlBadge, GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber } from '~/locale';
+import { fetchPolicies } from '~/lib/graphql';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
+import RunnerBulkDelete from '../components/runner_bulk_delete.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
@@ -14,6 +16,7 @@ import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
+import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
import {
@@ -37,7 +40,7 @@ import { captureException } from '../sentry_utils';
const runnersCountSmartQuery = {
query: runnersAdminCountQuery,
- fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
update(data) {
return data?.runners?.count;
},
@@ -53,6 +56,7 @@ export default {
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
+ RunnerBulkDelete,
RunnerList,
RunnerName,
RunnerStats,
@@ -60,6 +64,8 @@ export default {
RunnerTypeTabs,
RunnerActionsCell,
},
+ mixins: [glFeatureFlagMixin()],
+ inject: ['localMutations'],
props: {
registrationToken: {
type: String,
@@ -78,10 +84,7 @@ export default {
apollo: {
runners: {
query: runnersAdminQuery,
- // Runners can be updated by users directly in this list.
- // A "cache and network" policy prevents outdated filtered
- // results.
- fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return this.variables;
},
@@ -176,6 +179,7 @@ export default {
},
searchTokens() {
return [
+ pausedTokenConfig,
statusTokenConfig,
{
...tagTokenConfig,
@@ -183,6 +187,11 @@ export default {
},
];
},
+ isBulkDeleteEnabled() {
+ // Feature flag: admin_runners_bulk_delete
+ // Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981
+ return this.glFeatures.adminRunnersBulkDelete;
+ },
},
watch: {
search: {
@@ -224,13 +233,29 @@ export default {
}
return '';
},
+ refetchFilteredCounts() {
+ this.$apollo.queries.allRunnersCount.refetch();
+ this.$apollo.queries.instanceRunnersCount.refetch();
+ this.$apollo.queries.groupRunnersCount.refetch();
+ this.$apollo.queries.projectRunnersCount.refetch();
+ },
+ onToggledPaused() {
+ // When a runner is Paused, the tab count can
+ // become stale, refetch outdated counts.
+ this.refetchFilteredCounts();
+ },
onDeleted({ message }) {
this.$root.$toast?.show(message);
- this.$apollo.queries.runners.refetch();
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
+ onChecked({ runner, isChecked }) {
+ this.localMutations.setRunnerChecked({
+ runner,
+ isChecked,
+ });
+ },
},
filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
@@ -279,7 +304,13 @@ export default {
{{ __('No runners found') }}
</div>
<template v-else>
- <runner-list :runners="runners.items" :loading="runnersLoading">
+ <runner-bulk-delete v-if="isBulkDeleteEnabled" />
+ <runner-list
+ :runners="runners.items"
+ :loading="runnersLoading"
+ :checkable="isBulkDeleteEnabled"
+ @checked="onChecked"
+ >
<template #runner-name="{ runner }">
<gl-link :href="runner.adminUrl">
<runner-name :runner="runner" />
@@ -289,6 +320,7 @@ export default {
<runner-actions-cell
:runner="runner"
:edit-url="runner.editAdminUrl"
+ @toggledPaused="onToggledPaused"
@deleted="onDeleted"
/>
</template>
diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js
index 3b8a8fe9cd1..12e2cb2ee9f 100644
--- a/app/assets/javascripts/runner/admin_runners/index.js
+++ b/app/assets/javascripts/runner/admin_runners/index.js
@@ -1,9 +1,10 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import { visitUrl } from '~/lib/utils/url_utility';
import { updateOutdatedUrl } from '~/runner/runner_search_utils';
+import createDefaultClient from '~/lib/graphql';
+import { createLocalState } from '../graphql/list/local_state';
import AdminRunnersApp from './admin_runners_app.vue';
Vue.use(GlToast);
@@ -25,10 +26,17 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
return null;
}
- const { runnerInstallHelpPage, registrationToken } = el.dataset;
+ const {
+ runnerInstallHelpPage,
+ registrationToken,
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+ } = el.dataset;
+
+ const { cacheConfig, typeDefs, localMutations } = createLocalState();
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }),
});
return new Vue({
@@ -36,6 +44,9 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
apolloProvider,
provide: {
runnerInstallHelpPage,
+ localMutations,
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
},
render(h) {
return h(AdminRunnersApp, {
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 c69321de001..7a4760f81ee 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -23,7 +23,7 @@ export default {
required: false,
},
},
- emits: ['deleted'],
+ emits: ['toggledPaused', 'deleted'],
computed: {
canUpdate() {
return this.runner.userPermissions?.updateRunner;
@@ -33,6 +33,9 @@ export default {
},
},
methods: {
+ onToggledPaused() {
+ this.$emit('toggledPaused');
+ },
onDeleted(value) {
this.$emit('deleted', value);
},
@@ -43,7 +46,17 @@ export default {
<template>
<gl-button-group>
<runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" />
- <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
- <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" />
+ <runner-pause-button
+ v-if="canUpdate"
+ :runner="runner"
+ :compact="true"
+ @toggledPaused="onToggledPaused"
+ />
+ <runner-delete-button
+ :disabled="!canDelete"
+ :runner="runner"
+ :compact="true"
+ @deleted="onDeleted"
+ />
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
index 937ec631633..1eb383a1904 100644
--- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
@@ -33,6 +33,9 @@ export default {
description() {
return this.runner.description;
},
+ ipAddress() {
+ return this.runner.ipAddress;
+ },
},
i18n: {
I18N_LOCKED_RUNNER_DESCRIPTION,
@@ -53,10 +56,12 @@ export default {
:title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
name="lock"
/>
- <tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child">
- <div class="gl-text-truncate">
- {{ description }}
- </div>
+ <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description">
+ {{ description }}
+ </tooltip-on-truncate>
+ <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress">
+ <span class="gl-md-display-none gl-lg-display-inline">{{ __('IP Address') }}</span>
+ <strong>{{ ipAddress }}</strong>
</tooltip-on-truncate>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/registration/registration_token.vue b/app/assets/javascripts/runner/components/registration/registration_token.vue
index d54a66ff0e4..68c6429a056 100644
--- a/app/assets/javascripts/runner/components/registration/registration_token.vue
+++ b/app/assets/javascripts/runner/components/registration/registration_token.vue
@@ -1,16 +1,10 @@
<script>
-import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { s__, __ } from '~/locale';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import { s__ } from '~/locale';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
export default {
components: {
- GlButtonGroup,
- GlButton,
- ModalCopyButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ InputCopyToggleVisibility,
},
props: {
value: {
@@ -19,65 +13,21 @@ export default {
default: '',
},
},
- data() {
- return {
- isMasked: true,
- };
- },
- computed: {
- maskLabel() {
- if (this.isMasked) {
- return __('Click to reveal');
- }
- return __('Click to hide');
- },
- maskIcon() {
- if (this.isMasked) {
- return 'eye';
- }
- return 'eye-slash';
- },
- displayedValue() {
- if (this.isMasked && this.value?.length) {
- return '*'.repeat(this.value.length);
- }
- return this.value;
- },
- },
methods: {
- onToggleMasked() {
- this.isMasked = !this.isMasked;
- },
- onCopied() {
+ onCopy() {
// value already in the clipboard, simply notify the user
this.$toast?.show(s__('Runners|Registration token copied!'));
},
},
- i18n: {
- copyLabel: s__('Runners|Copy registration token'),
- },
+ I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'),
};
</script>
<template>
- <gl-button-group>
- <gl-button class="gl-font-monospace" data-testid="token-value" label>
- {{ displayedValue }}
- </gl-button>
- <gl-button
- v-gl-tooltip
- :aria-label="maskLabel"
- :title="maskLabel"
- :icon="maskIcon"
- class="gl-w-auto! gl-flex-shrink-0!"
- data-testid="toggle-masked"
- @click.stop="onToggleMasked"
- />
- <modal-copy-button
- class="gl-w-auto! gl-flex-shrink-0!"
- :aria-label="$options.i18n.copyLabel"
- :title="$options.i18n.copyLabel"
- :text="value"
- @success="onCopied"
- />
- </gl-button-group>
+ <input-copy-toggle-visibility
+ class="gl-m-0"
+ :value="value"
+ data-testid="token-value"
+ :copy-button-title="$options.I18N_COPY_BUTTON_TITLE"
+ @copy="onCopy"
+ />
</template>
diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue
index ea8074199a6..38bdfecb7df 100644
--- a/app/assets/javascripts/runner/components/runner_assigned_item.vue
+++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar, GlLink } from '@gitlab/ui';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
components: {
@@ -25,13 +26,20 @@ export default {
default: null,
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
<div class="gl-display-flex gl-align-items-center gl-py-5">
<gl-link :href="href" data-testid="item-avatar" class="gl-text-decoration-none! gl-mr-3">
- <gl-avatar shape="rect" :entity-name="name" :alt="name" :src="avatarUrl" :size="48" />
+ <gl-avatar
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :entity-name="name"
+ :alt="name"
+ :src="avatarUrl"
+ :size="48"
+ />
</gl-link>
<gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue
new file mode 100644
index 00000000000..50791de0bda
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { n__, sprintf } from '~/locale';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: ['localMutations'],
+ data() {
+ return {
+ checkedRunnerIds: [],
+ };
+ },
+ apollo: {
+ checkedRunnerIds: {
+ query: checkedRunnerIdsQuery,
+ },
+ },
+ computed: {
+ checkedCount() {
+ return this.checkedRunnerIds.length || 0;
+ },
+ bannerMessage() {
+ return sprintf(
+ n__(
+ 'Runners|%{strongStart}%{count}%{strongEnd} runner selected',
+ 'Runners|%{strongStart}%{count}%{strongEnd} runners selected',
+ this.checkedCount,
+ ),
+ {
+ count: this.checkedCount,
+ },
+ );
+ },
+ modalTitle() {
+ return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount);
+ },
+ modalHtmlMessage() {
+ return sprintf(
+ n__(
+ 'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
+ 'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
+ this.checkedCount,
+ ),
+ {
+ strongStart: '<strong>',
+ strongEnd: '</strong>',
+ count: this.checkedCount,
+ },
+ false,
+ );
+ },
+ primaryBtnText() {
+ return n__(
+ 'Runners|Permanently delete %d runner',
+ 'Runners|Permanently delete %d runners',
+ this.checkedCount,
+ );
+ },
+ },
+ methods: {
+ onClearChecked() {
+ this.localMutations.clearChecked();
+ },
+ onClickDelete: ignoreWhilePending(async function onClickDelete() {
+ const confirmed = await confirmAction(null, {
+ title: this.modalTitle,
+ modalHtmlMessage: this.modalHtmlMessage,
+ primaryBtnVariant: 'danger',
+ primaryBtnText: this.primaryBtnText,
+ });
+
+ if (confirmed) {
+ // TODO Call $apollo.mutate with list of runner
+ // ids in `this.checkedRunnerIds`.
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
+ }
+ }),
+ },
+};
+</script>
+
+<template>
+ <div v-if="checkedCount" class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100">
+ <div class="gl-display-flex gl-align-items-center">
+ <div>
+ <gl-sprintf :message="bannerMessage">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-ml-auto">
+ <gl-button data-testid="clear-btn" variant="default" @click="onClearChecked">{{
+ s__('Runners|Clear selection')
+ }}</gl-button>
+ <gl-button data-testid="delete-btn" variant="danger" @click="onClickDelete">{{
+ s__('Runners|Delete selected')
+ }}</gl-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue
index 854c983f4da..b58665ecbc9 100644
--- a/app/assets/javascripts/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/runner/components/runner_delete_button.vue
@@ -5,7 +5,12 @@ import { createAlert } from '~/flash';
import { sprintf } from '~/locale';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants';
+import {
+ I18N_DELETE_DISABLED_MANY_PROJECTS,
+ I18N_DELETE_DISABLED_UNKNOWN_REASON,
+ I18N_DELETE_RUNNER,
+ I18N_DELETED_TOAST,
+} from '../constants';
import RunnerDeleteModal from './runner_delete_modal.vue';
export default {
@@ -26,6 +31,11 @@ export default {
return runner?.id && runner?.shortSha;
},
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
compact: {
type: Boolean,
required: false,
@@ -75,7 +85,14 @@ export default {
return null;
},
tooltip() {
- // Only show tooltip when compact.
+ if (this.disabled && this.runner.projectCount > 1) {
+ return I18N_DELETE_DISABLED_MANY_PROJECTS;
+ }
+ if (this.disabled) {
+ return I18N_DELETE_DISABLED_UNKNOWN_REASON;
+ }
+
+ // Only show basic "delete" tooltip when compact.
// Also prevent a "sticky" tooltip: If this button is
// disabled, mouseout listeners don't run leaving the tooltip stuck
if (this.compact && !this.deleting) {
@@ -83,6 +100,14 @@ export default {
}
return '';
},
+ wrapperTabindex() {
+ if (this.disabled) {
+ // Trigger tooltip on keyboard-focusable wrapper
+ // See https://bootstrap-vue.org/docs/directives/tooltip
+ return '0';
+ }
+ return null;
+ },
},
methods: {
async onDelete() {
@@ -90,31 +115,37 @@ export default {
// should only change back if the operation fails.
this.deleting = true;
try {
- const {
- data: {
- runnerDelete: { errors },
- },
- } = await this.$apollo.mutate({
+ await this.$apollo.mutate({
mutation: runnerDeleteMutation,
variables: {
input: {
id: this.runner.id,
},
},
+ update: (cache, { data }) => {
+ const { errors } = data.runnerDelete;
+
+ if (errors?.length) {
+ this.onError(new Error(errors.join(' ')));
+ return;
+ }
+
+ this.$emit('deleted', {
+ message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }),
+ });
+
+ // Remove deleted runner from the cache
+ const cacheId = cache.identify(this.runner);
+ cache.evict({ id: cacheId });
+ cache.gc();
+ },
});
- if (errors && errors.length) {
- throw new Error(errors.join(' '));
- } else {
- this.$emit('deleted', {
- message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }),
- });
- }
} catch (e) {
- this.deleting = false;
this.onError(e);
}
},
onError(error) {
+ this.deleting = false;
const { message } = error;
createAlert({ message });
@@ -125,20 +156,22 @@ export default {
</script>
<template>
- <gl-button
- v-gl-tooltip.hover.viewport="tooltip"
- v-gl-modal="runnerDeleteModalId"
- :aria-label="ariaLabel"
- :icon="icon"
- :class="buttonClass"
- :loading="deleting"
- variant="danger"
- >
- {{ buttonContent }}
+ <div v-gl-tooltip="tooltip" class="btn-group" :tabindex="wrapperTabindex">
+ <gl-button
+ v-gl-modal="runnerDeleteModalId"
+ :aria-label="ariaLabel"
+ :icon="icon"
+ :class="buttonClass"
+ :loading="deleting"
+ :disabled="disabled"
+ variant="danger"
+ >
+ {{ buttonContent }}
+ </gl-button>
<runner-delete-modal
:modal-id="runnerDeleteModalId"
:runner-name="runnerName"
@primary="onDelete"
/>
- </gl-button>
+ </div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
index eb77babcc57..b25d92d049e 100644
--- a/app/assets/javascripts/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { createAlert } from '~/flash';
import runnerJobsQuery from '../graphql/details/runner_jobs.query.graphql';
import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 51749b0255f..dcfd4b84dd2 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -4,17 +4,30 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/toolt
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import { formatJobCount, tableField } from '../utils';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
+import RunnerStatusPopover from './runner_status_popover.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
+const defaultFields = [
+ tableField({ key: 'status', label: s__('Runners|Status') }),
+ tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
+ tableField({ key: 'version', label: __('Version') }),
+ tableField({ key: 'jobCount', label: __('Jobs') }),
+ tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
+ tableField({ key: 'contactedAt', label: __('Last contact') }),
+ tableField({ key: 'actions', label: '' }),
+];
+
export default {
components: {
GlTableLite,
GlSkeletonLoader,
TooltipOnTruncate,
TimeAgo,
+ RunnerStatusPopover,
RunnerSummaryCell,
RunnerTags,
RunnerStatusCell,
@@ -22,7 +35,20 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ apollo: {
+ checkedRunnerIds: {
+ query: checkedRunnerIdsQuery,
+ skip() {
+ return !this.checkable;
+ },
+ },
+ },
props: {
+ checkable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
loading: {
type: Boolean,
required: false,
@@ -33,6 +59,10 @@ export default {
required: true,
},
},
+ emits: ['checked'],
+ data() {
+ return { checkedRunnerIds: [] };
+ },
computed: {
tableClass() {
// <gl-table-lite> does not provide a busy state, add
@@ -42,6 +72,18 @@ export default {
'gl-opacity-6': this.loading,
};
},
+ fields() {
+ if (this.checkable) {
+ const checkboxField = tableField({
+ key: 'checkbox',
+ label: s__('Runners|Checkbox'),
+ thClasses: ['gl-w-9'],
+ tdClass: ['gl-text-center'],
+ });
+ return [checkboxField, ...defaultFields];
+ }
+ return defaultFields;
+ },
},
methods: {
formatJobCount(jobCount) {
@@ -55,17 +97,16 @@ export default {
}
return {};
},
+ onCheckboxChange(runner, isChecked) {
+ this.$emit('checked', {
+ runner,
+ isChecked,
+ });
+ },
+ isChecked(runner) {
+ return this.checkedRunnerIds.includes(runner.id);
+ },
},
- fields: [
- tableField({ key: 'status', label: s__('Runners|Status') }),
- tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
- tableField({ key: 'version', label: __('Version') }),
- tableField({ key: 'ipAddress', label: __('IP') }),
- tableField({ key: 'jobCount', label: __('Jobs') }),
- tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
- tableField({ key: 'contactedAt', label: __('Last contact') }),
- tableField({ key: 'actions', label: '' }),
- ],
};
</script>
<template>
@@ -74,13 +115,34 @@ export default {
:aria-busy="loading"
:class="tableClass"
:items="runners"
- :fields="$options.fields"
+ :fields="fields"
:tbody-tr-attr="runnerTrAttr"
data-testid="runner-list"
stacked="md"
primary-key="id"
fixed
>
+ <template #head(checkbox)>
+ <!--
+ Checkbox to select all to be added here
+ See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
+ -->
+ <span></span>
+ </template>
+
+ <template #cell(checkbox)="{ item }">
+ <input
+ type="checkbox"
+ :checked="isChecked(item)"
+ @change="onCheckboxChange(item, $event.target.checked)"
+ />
+ </template>
+
+ <template #head(status)="{ label }">
+ {{ label }}
+ <runner-status-popover />
+ </template>
+
<template #cell(status)="{ item }">
<runner-status-cell :runner="item" />
</template>
@@ -99,12 +161,6 @@ export default {
</tooltip-on-truncate>
</template>
- <template #cell(ipAddress)="{ item: { ipAddress } }">
- <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress">
- {{ ipAddress }}
- </tooltip-on-truncate>
- </template>
-
<template #cell(jobCount)="{ item: { jobCount } }">
{{ formatJobCount(jobCount) }}
</template>
diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue
index c88634bfbd9..334e5f6023a 100644
--- a/app/assets/javascripts/runner/components/runner_pause_button.vue
+++ b/app/assets/javascripts/runner/components/runner_pause_button.vue
@@ -24,6 +24,7 @@ export default {
default: false,
},
},
+ emits: ['toggledPaused'],
data() {
return {
updating: false,
@@ -83,6 +84,7 @@ export default {
if (errors && errors.length) {
throw new Error(errors.join(' '));
}
+ this.$emit('toggledPaused');
} catch (e) {
this.onError(e);
} finally {
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
index f8ec29b8a24..d080d34fdd3 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
import { createAlert } from '~/flash';
import runnerProjectsQuery from '../graphql/details/runner_projects.query.graphql';
diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue
index 6d0445ecb7a..073d0a49f59 100644
--- a/app/assets/javascripts/runner/components/runner_status_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_status_badge.vue
@@ -3,10 +3,11 @@ import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import {
- I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION,
- I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION,
- I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION,
- I18N_STALE_RUNNER_DESCRIPTION,
+ I18N_ONLINE_TIMEAGO_TOOLTIP,
+ I18N_NEVER_CONTACTED_TOOLTIP,
+ I18N_OFFLINE_TIMEAGO_TOOLTIP,
+ I18N_STALE_TIMEAGO_TOOLTIP,
+ I18N_STALE_NEVER_CONTACTED_TOOLTIP,
STATUS_ONLINE,
STATUS_NEVER_CONTACTED,
STATUS_OFFLINE,
@@ -32,7 +33,7 @@ export default {
return getTimeago().format(this.runner.contactedAt);
}
// Prevent "just now" from being rendered, in case data is missing.
- return __('n/a');
+ return __('never');
},
badge() {
switch (this.runner?.status) {
@@ -40,35 +41,39 @@ export default {
return {
variant: 'success',
label: s__('Runners|online'),
- tooltip: sprintf(I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, {
- timeAgo: this.contactedAtTimeAgo,
- }),
+ tooltip: this.timeAgoTooltip(I18N_ONLINE_TIMEAGO_TOOLTIP),
};
case STATUS_NEVER_CONTACTED:
return {
variant: 'muted',
label: s__('Runners|never contacted'),
- tooltip: I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION,
+ tooltip: I18N_NEVER_CONTACTED_TOOLTIP,
};
case STATUS_OFFLINE:
return {
variant: 'muted',
label: s__('Runners|offline'),
- tooltip: sprintf(I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, {
- timeAgo: this.contactedAtTimeAgo,
- }),
+ tooltip: this.timeAgoTooltip(I18N_OFFLINE_TIMEAGO_TOOLTIP),
};
case STATUS_STALE:
return {
variant: 'warning',
label: s__('Runners|stale'),
- tooltip: I18N_STALE_RUNNER_DESCRIPTION,
+ // runner may have contacted (or not) and be stale: consider both cases.
+ tooltip: this.runner.contactedAt
+ ? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP)
+ : I18N_STALE_NEVER_CONTACTED_TOOLTIP,
};
default:
return null;
}
},
},
+ methods: {
+ timeAgoTooltip(text) {
+ return sprintf(text, { timeAgo: this.contactedAtTimeAgo });
+ },
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/runner/components/runner_status_popover.vue b/app/assets/javascripts/runner/components/runner_status_popover.vue
new file mode 100644
index 00000000000..5b22f7828a1
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_status_popover.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { duration } from '~/lib/utils/datetime/timeago_utility';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import {
+ I18N_STATUS_POPOVER_TITLE,
+ I18N_STATUS_POPOVER_NEVER_CONTACTED,
+ I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION,
+ I18N_STATUS_POPOVER_ONLINE,
+ I18N_STATUS_POPOVER_ONLINE_DESCRIPTION,
+ I18N_STATUS_POPOVER_OFFLINE,
+ I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION,
+ I18N_STATUS_POPOVER_STALE,
+ I18N_STATUS_POPOVER_STALE_DESCRIPTION,
+} from '~/runner/constants';
+
+export default {
+ name: 'RunnerStatusPopover',
+ components: {
+ GlSprintf,
+ HelpPopover,
+ },
+ inject: ['onlineContactTimeoutSecs', 'staleTimeoutSecs'],
+ computed: {
+ onlineContactTimeoutDuration() {
+ return duration(this.onlineContactTimeoutSecs * 1000);
+ },
+ staleTimeoutDuration() {
+ return duration(this.staleTimeoutSecs * 1000);
+ },
+ },
+ I18N_STATUS_POPOVER_TITLE,
+ I18N_STATUS_POPOVER_NEVER_CONTACTED,
+ I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION,
+ I18N_STATUS_POPOVER_ONLINE,
+ I18N_STATUS_POPOVER_ONLINE_DESCRIPTION,
+ I18N_STATUS_POPOVER_OFFLINE,
+ I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION,
+ I18N_STATUS_POPOVER_STALE,
+ I18N_STATUS_POPOVER_STALE_DESCRIPTION,
+};
+</script>
+
+<template>
+ <help-popover>
+ <template #title>{{ $options.I18N_STATUS_POPOVER_TITLE }}</template>
+
+ <p class="gl-mb-0">
+ <strong>{{ $options.I18N_STATUS_POPOVER_NEVER_CONTACTED }}</strong>
+ <gl-sprintf :message="$options.I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-mb-0">
+ <strong>{{ $options.I18N_STATUS_POPOVER_ONLINE }}</strong>
+ <gl-sprintf :message="$options.I18N_STATUS_POPOVER_ONLINE_DESCRIPTION">
+ <template #elapsedTime>{{ onlineContactTimeoutDuration }}</template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-mb-0">
+ <strong>{{ $options.I18N_STATUS_POPOVER_OFFLINE }}</strong>
+ <gl-sprintf :message="$options.I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION">
+ <template #elapsedTime>{{ onlineContactTimeoutDuration }}</template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-mb-0">
+ <strong>{{ $options.I18N_STATUS_POPOVER_STALE }}</strong>
+ <gl-sprintf :message="$options.I18N_STATUS_POPOVER_STALE_DESCRIPTION">
+ <template #elapsedTime>{{ staleTimeoutDuration }}</template>
+ </gl-sprintf>
+ </p>
+ </help-popover>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue
index e44450a2a8d..119e5236f85 100644
--- a/app/assets/javascripts/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/runner/components/runner_update_form.vue
@@ -138,7 +138,11 @@ export default {
>
{{ __('Lock to current projects') }}
<template #help>
- {{ s__('Runners|Use the runner for the currently assigned projects only.') }}
+ {{
+ s__(
+ 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.',
+ )
+ }}
</template>
</gl-form-checkbox>
diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
new file mode 100644
index 00000000000..1bab875a8a1
--- /dev/null
+++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
@@ -0,0 +1,28 @@
+import { __, s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import { PARAM_KEY_PAUSED } from '../../constants';
+
+const options = [
+ { value: 'true', title: __('Yes') },
+ { value: 'false', title: __('No') },
+];
+
+export const pausedTokenConfig = {
+ icon: 'pause',
+ title: s__('Runners|Paused'),
+ type: PARAM_KEY_PAUSED,
+ token: BaseToken,
+ unique: true,
+ options: options.map(({ value, title }) => ({
+ value,
+ // Replace whitespace with a special character to avoid
+ // splitting this value.
+ // Replacing in each option, as translations may also
+ // contain spaces!
+ // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142
+ // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
+ title: title.replace(' ', '\u00a0'),
+ })),
+ operators: OPERATOR_IS_ONLY,
+};
diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
index 79038eb8228..f28bd491ea5 100644
--- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
@@ -2,8 +2,6 @@ import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
- STATUS_ACTIVE,
- STATUS_PAUSED,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NEVER_CONTACTED,
@@ -12,8 +10,6 @@ import {
} from '../../constants';
const options = [
- { value: STATUS_ACTIVE, title: s__('Runners|Active') },
- { value: STATUS_PAUSED, title: s__('Runners|Paused') },
{ value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') },
{ value: STATUS_NEVER_CONTACTED, title: s__('Runners|Never contacted') },
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index bd5be2175ad..b9621c26b59 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -21,18 +21,39 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
);
export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
-// Status
-export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__(
+// Status help popover
+export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses');
+
+export const I18N_STATUS_POPOVER_NEVER_CONTACTED = s__('Runners|Never contacted:');
+export const I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION = s__(
+ 'Runners|Runner has never contacted GitLab (when you register a runner, use %{codeStart}gitlab-runner run%{codeEnd} to bring it online)',
+);
+export const I18N_STATUS_POPOVER_ONLINE = s__('Runners|Online:');
+export const I18N_STATUS_POPOVER_ONLINE_DESCRIPTION = s__(
+ 'Runners|Runner has contacted GitLab within the last %{elapsedTime}',
+);
+export const I18N_STATUS_POPOVER_OFFLINE = s__('Runners|Offline:');
+export const I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION = s__(
+ 'Runners|Runner has not contacted GitLab in more than %{elapsedTime}',
+);
+export const I18N_STATUS_POPOVER_STALE = s__('Runners|Stale:');
+export const I18N_STATUS_POPOVER_STALE_DESCRIPTION = s__(
+ 'Runners|Runner has not contacted GitLab in more than %{elapsedTime}',
+);
+
+// Status tooltips
+export const I18N_ONLINE_TIMEAGO_TOOLTIP = s__(
'Runners|Runner is online; last contact was %{timeAgo}',
);
-export const I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION = s__(
- 'Runners|This runner has never contacted this instance',
+export const I18N_NEVER_CONTACTED_TOOLTIP = s__('Runners|Runner has never contacted this instance');
+export const I18N_OFFLINE_TIMEAGO_TOOLTIP = s__(
+ 'Runners|Runner is offline; last contact was %{timeAgo}',
);
-export const I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION = s__(
- 'Runners|No recent contact from this runner; last contact was %{timeAgo}',
+export const I18N_STALE_TIMEAGO_TOOLTIP = s__(
+ 'Runners|Runner is stale; last contact was %{timeAgo}',
);
-export const I18N_STALE_RUNNER_DESCRIPTION = s__(
- 'Runners|No contact from this runner in over 3 months',
+export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__(
+ 'Runners|Runner is stale; it has never contacted this instance',
);
// Actions
@@ -46,15 +67,23 @@ export const I18N_RESUME = __('Resume');
export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs');
export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
+export const I18N_DELETE_DISABLED_MANY_PROJECTS = s__(
+ 'Runners|Multi-project runners cannot be deleted',
+);
+export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__(
+ 'Runners|Runner cannot be deleted, please contact your administrator',
+);
export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
-export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
+export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
+ 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.',
+);
// Runner details
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
export const I18N_NONE = __('None');
-export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.');
+export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.');
// Styles
@@ -66,6 +95,7 @@ export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
// - GlFilteredSearch tokens type
export const PARAM_KEY_STATUS = 'status';
+export const PARAM_KEY_PAUSED = 'paused';
export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
export const PARAM_KEY_TAG = 'tag';
export const PARAM_KEY_SEARCH = 'search';
@@ -83,9 +113,6 @@ export const PROJECT_TYPE = 'PROJECT_TYPE';
// CiRunnerStatus
-export const STATUS_ACTIVE = 'ACTIVE';
-export const STATUS_PAUSED = 'PAUSED';
-
export const STATUS_ONLINE = 'ONLINE';
export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED';
export const STATUS_OFFLINE = 'OFFLINE';
diff --git a/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql
index 2b1decd3ddd..14585e62bf2 100644
--- a/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) {
runner(id: $id) {
diff --git a/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql
index f97237b8267..cb27de7c200 100644
--- a/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql
@@ -1,4 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getRunnerProjects(
$id: CiRunnerID!
diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
index 8df4c2fc65c..5d0450e7418 100644
--- a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
@@ -1,11 +1,12 @@
#import "~/runner/graphql/list/list_item.fragment.graphql"
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getRunners(
$before: String
$after: String
$first: Int
$last: Int
+ $paused: Boolean
$status: CiRunnerStatus
$type: CiRunnerType
$tagList: [String!]
@@ -17,6 +18,7 @@ query getRunners(
after: $after
first: $first
last: $last
+ paused: $paused
status: $status
type: $type
tagList: $tagList
diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
index 181a4495cae..1dd258a3524 100644
--- a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
@@ -1,10 +1,11 @@
query getRunnersCount(
+ $paused: Boolean
$status: CiRunnerStatus
$type: CiRunnerType
$tagList: [String!]
$search: String
) {
- runners(status: $status, type: $type, tagList: $tagList, search: $search) {
+ runners(paused: $paused, status: $status, type: $type, tagList: $tagList, search: $search) {
count
}
}
diff --git a/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql b/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql
new file mode 100644
index 00000000000..c01f1edb451
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql
@@ -0,0 +1,3 @@
+query getCheckedRunnerIds {
+ checkedRunnerIds @client
+}
diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
index b517f5e89a8..b4f2b5cd8c8 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
@@ -1,5 +1,5 @@
#import "~/runner/graphql/list/list_item.fragment.graphql"
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getGroupRunners(
$groupFullPath: ID!
@@ -7,6 +7,7 @@ query getGroupRunners(
$after: String
$first: Int
$last: Int
+ $paused: Boolean
$status: CiRunnerStatus
$type: CiRunnerType
$search: String
@@ -20,6 +21,7 @@ query getGroupRunners(
after: $after
first: $first
last: $last
+ paused: $paused
status: $status
type: $type
search: $search
@@ -30,6 +32,7 @@ query getGroupRunners(
editUrl
node {
...ListItem
+ projectCount # Used to determine why some project runners can't be deleted
}
}
pageInfo {
diff --git a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql
index 554eb09e372..958b4ea0dd3 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql
@@ -1,5 +1,6 @@
query getGroupRunnersCount(
$groupFullPath: ID!
+ $paused: Boolean
$status: CiRunnerStatus
$type: CiRunnerType
$tagList: [String!]
@@ -9,6 +10,7 @@ query getGroupRunnersCount(
id # Apollo required
runners(
membership: DESCENDANTS
+ paused: $paused
status: $status
type: $type
tagList: $tagList
diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js
new file mode 100644
index 00000000000..e87bc72c86a
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/local_state.js
@@ -0,0 +1,63 @@
+import { makeVar } from '@apollo/client/core';
+import typeDefs from './typedefs.graphql';
+
+/**
+ * Local state for checkable runner items.
+ *
+ * Usage:
+ *
+ * ```
+ * import { createLocalState } from '~/runner/graphql/list/local_state';
+ *
+ * // initialize local state
+ * const { cacheConfig, typeDefs, localMutations } = createLocalState();
+ *
+ * // configure the client
+ * apolloClient = createApolloClient({}, { cacheConfig, typeDefs });
+ *
+ * // modify local state
+ * localMutations.setRunnerChecked( ... )
+ * ```
+ *
+ * Note: Currently only in use behind a feature flag:
+ * admin_runners_bulk_delete for the admin list, rollout issue:
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/353981
+ *
+ * @returns {Object} An object to configure an Apollo client:
+ * contains cacheConfig, typeDefs, localMutations.
+ */
+export const createLocalState = () => {
+ const checkedRunnerIdsVar = makeVar({});
+
+ const cacheConfig = {
+ typePolicies: {
+ Query: {
+ fields: {
+ checkedRunnerIds() {
+ return Object.entries(checkedRunnerIdsVar())
+ .filter(([, isChecked]) => isChecked)
+ .map(([key]) => key);
+ },
+ },
+ },
+ },
+ };
+
+ const localMutations = {
+ setRunnerChecked({ runner, isChecked }) {
+ checkedRunnerIdsVar({
+ ...checkedRunnerIdsVar(),
+ [runner.id]: isChecked,
+ });
+ },
+ clearChecked() {
+ checkedRunnerIdsVar({});
+ },
+ };
+
+ return {
+ cacheConfig,
+ typeDefs,
+ localMutations,
+ };
+};
diff --git a/app/assets/javascripts/runner/graphql/list/typedefs.graphql b/app/assets/javascripts/runner/graphql/list/typedefs.graphql
new file mode 100644
index 00000000000..24e9e20cc8c
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/typedefs.graphql
@@ -0,0 +1,3 @@
+extend type Query {
+ checkedRunnerIds: [ID!]!
+}
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 35fd7fff6d3..b299d7c40fe 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,9 +1,9 @@
<script>
import { GlBadge, GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber } from '~/locale';
+import { fetchPolicies } from '~/lib/graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -14,6 +14,7 @@ import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
+import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import {
GROUP_FILTERED_SEARCH_NAMESPACE,
@@ -35,7 +36,7 @@ import { captureException } from '../sentry_utils';
const runnersCountSmartQuery = {
query: groupRunnersCountQuery,
- fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
update(data) {
return data?.group?.runners?.count;
},
@@ -85,10 +86,7 @@ export default {
apollo: {
runners: {
query: groupRunnersQuery,
- // Runners can be updated by users directly in this list.
- // A "cache and network" policy prevents outdated filtered
- // results.
- fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return this.variables;
},
@@ -192,7 +190,7 @@ export default {
return !this.runnersLoading && !this.runners.items.length;
},
searchTokens() {
- return [statusTokenConfig];
+ return [pausedTokenConfig, statusTokenConfig];
},
filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
@@ -241,9 +239,18 @@ export default {
editUrl(runner) {
return this.runners.urlsById[runner.id]?.edit;
},
+ refetchFilteredCounts() {
+ this.$apollo.queries.allRunnersCount.refetch();
+ this.$apollo.queries.groupRunnersCount.refetch();
+ this.$apollo.queries.projectRunnersCount.refetch();
+ },
+ onToggledPaused() {
+ // When a runner is Paused, the tab count can
+ // become stale, refetch outdated counts.
+ this.refetchFilteredCounts();
+ },
onDeleted({ message }) {
this.$root.$toast?.show(message);
- this.$apollo.queries.runners.refetch();
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
@@ -302,7 +309,12 @@ export default {
</gl-link>
</template>
<template #runner-actions-cell="{ runner }">
- <runner-actions-cell :runner="runner" :edit-url="editUrl(runner)" @deleted="onDeleted" />
+ <runner-actions-cell
+ :runner="runner"
+ :edit-url="editUrl(runner)"
+ @toggledPaused="onToggledPaused"
+ @deleted="onDeleted"
+ />
</template>
</runner-list>
<runner-pagination
diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js
index 60b7a7ab541..0dade30f820 100644
--- a/app/assets/javascripts/runner/group_runners/index.js
+++ b/app/assets/javascripts/runner/group_runners/index.js
@@ -20,6 +20,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
groupId,
groupFullPath,
groupRunnersLimitedCount,
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
} = el.dataset;
const apolloProvider = new VueApollo({
@@ -32,6 +34,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
provide: {
runnerInstallHelpPage,
groupId,
+ onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10),
+ staleTimeoutSecs: parseInt(staleTimeoutSecs, 10),
},
render(h) {
return h(GroupRunnersApp, {
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js
index fe141332be3..5e3c412ddb6 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_search_utils.js
@@ -5,7 +5,9 @@ import {
urlQueryToFilter,
prepareTokens,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import { parseBoolean } from '~/lib/utils/common_utils';
import {
+ PARAM_KEY_PAUSED,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
@@ -83,6 +85,19 @@ const getPaginationFromParams = (params) => {
// Outdated URL parameters
const STATUS_NOT_CONNECTED = 'NOT_CONNECTED';
+const STATUS_ACTIVE = 'ACTIVE';
+const STATUS_PAUSED = 'PAUSED';
+
+/**
+ * Replaces params into a URL
+ *
+ * @param {String} url - Original URL
+ * @param {Object} params - Query parameters to update
+ * @returns Updated URL
+ */
+const updateUrlParams = (url, params = {}) => {
+ return setUrlParams(params, url, false, true, true);
+};
/**
* Returns an updated URL for old (or deprecated) admin runner URLs.
@@ -98,14 +113,26 @@ export const updateOutdatedUrl = (url = window.location.href) => {
const params = queryToObject(query, { gatherArrays: true });
- const runnerType = params[PARAM_KEY_STATUS]?.[0] || null;
- if (runnerType === STATUS_NOT_CONNECTED) {
- const updatedParams = {
- [PARAM_KEY_STATUS]: [STATUS_NEVER_CONTACTED],
- };
- return setUrlParams(updatedParams, url, false, true, true);
+ const status = params[PARAM_KEY_STATUS]?.[0] || null;
+
+ switch (status) {
+ case STATUS_NOT_CONNECTED:
+ return updateUrlParams(url, {
+ [PARAM_KEY_STATUS]: [STATUS_NEVER_CONTACTED],
+ });
+ case STATUS_ACTIVE:
+ return updateUrlParams(url, {
+ [PARAM_KEY_PAUSED]: ['false'],
+ [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
+ });
+ case STATUS_PAUSED:
+ return updateUrlParams(url, {
+ [PARAM_KEY_PAUSED]: ['true'],
+ [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
+ });
+ default:
+ return null;
}
- return null;
};
/**
@@ -121,7 +148,7 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
runnerType,
filters: prepareTokens(
urlQueryToFilter(query, {
- filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_TAG],
+ filterNamesAllowList: [PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG],
filteredSearchTermKey: PARAM_KEY_SEARCH,
}),
),
@@ -195,6 +222,12 @@ export const fromSearchToVariables = ({
filterVariables.search = queryObj[PARAM_KEY_SEARCH];
filterVariables.tagList = queryObj[PARAM_KEY_TAG];
+ if (queryObj[PARAM_KEY_PAUSED]) {
+ filterVariables.paused = parseBoolean(queryObj[PARAM_KEY_PAUSED]);
+ } else {
+ filterVariables.paused = undefined;
+ }
+
if (runnerType) {
filterVariables.type = runnerType;
}
diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js
index 6e4c8c45e7b..1f7794720de 100644
--- a/app/assets/javascripts/runner/utils.js
+++ b/app/assets/javascripts/runner/utils.js
@@ -24,7 +24,7 @@ export const formatJobCount = (jobCount) => {
* @param {Object} options
* @returns Field object to add to GlTable fields
*/
-export const tableField = ({ key, label = '', thClasses = [] }) => {
+export const tableField = ({ key, label = '', thClasses = [], ...options }) => {
return {
key,
label,
@@ -32,6 +32,7 @@ export const tableField = ({ key, label = '', thClasses = [] }) => {
tdAttr: {
'data-testid': `td-${key}`,
},
+ ...options,
};
};
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index a6af5644681..40513a7f363 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -18,31 +18,35 @@ export const fetchGroups = ({ commit }, search) => {
});
};
-export const fetchProjects = ({ commit, state }, search) => {
+export const fetchProjects = ({ commit, state }, search, emptyCallback = () => {}) => {
commit(types.REQUEST_PROJECTS);
const groupId = state.query?.group_id;
- const callback = (data) => {
- if (data) {
- commit(types.RECEIVE_PROJECTS_SUCCESS, data);
- } else {
- createFlash({ message: __('There was an error fetching projects') });
- commit(types.RECEIVE_PROJECTS_ERROR);
- }
+
+ const handleCatch = () => {
+ createFlash({ message: __('There was an error fetching projects') });
+ commit(types.RECEIVE_PROJECTS_ERROR);
+ };
+ const handleSuccess = ({ data }) => {
+ commit(types.RECEIVE_PROJECTS_SUCCESS, data);
};
if (groupId) {
- // TODO (https://gitlab.com/gitlab-org/gitlab/-/issues/323331): For errors `createFlash` is called twice; in `callback` and in `Api.groupProjects`
Api.groupProjects(
groupId,
search,
- { order_by: 'similarity', with_shared: false, include_subgroups: true },
- callback,
- );
+ {
+ order_by: 'similarity',
+ with_shared: false,
+ include_subgroups: true,
+ },
+ emptyCallback,
+ true,
+ )
+ .then(handleSuccess)
+ .catch(handleCatch);
} else {
// The .catch() is due to the API method not handling a rejection properly
- Api.projects(search, { order_by: 'similarity' }, callback).catch(() => {
- callback();
- });
+ Api.projects(search, { order_by: 'similarity' }).then(handleSuccess).catch(handleCatch);
}
};
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index 6b56ff0b5e5..f8198017bf8 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -1,4 +1,4 @@
-import AccessorUtilities from '../../lib/utils/accessor';
+import AccessorUtilities from '~/lib/utils/accessor';
import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY, SIDEBAR_PARAMS } from './constants';
function extractKeys(object, keyList) {
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
index 42d6444e690..a4254a355a2 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
@@ -2,6 +2,7 @@
import { GlDropdownItem, GlAvatar, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
name: 'SearchableDropdownItem',
@@ -46,6 +47,7 @@ export default {
return highlight(this.item[this.name], this.searchText);
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
@@ -61,7 +63,7 @@ export default {
:src="item.avatar_url"
:entity-id="item.id"
:entity-name="item[name]"
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
:size="32"
/>
<div class="gl-display-flex gl-flex-direction-column">
diff --git a/app/assets/javascripts/search_settings/components/search_settings.vue b/app/assets/javascripts/search_settings/components/search_settings.vue
index 3e23b8a3435..f6439c6f4c4 100644
--- a/app/assets/javascripts/search_settings/components/search_settings.vue
+++ b/app/assets/javascripts/search_settings/components/search_settings.vue
@@ -1,6 +1,6 @@
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
-import { uniq, escapeRegExp } from 'lodash';
+import { escapeRegExp } from 'lodash';
import {
EXCLUDED_NODES,
HIDE_CLASS,
@@ -60,41 +60,42 @@ const hideSectionsExcept = (sectionSelector, visibleSections) => {
});
};
-const transformMatchElement = (element, searchTerm) => {
- const textStr = element.textContent;
+const highlightTextNode = (textNode, searchTerm) => {
const escapedSearchTerm = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
+ const textList = textNode.data.split(escapedSearchTerm);
+
+ return textList.reduce((documentFragment, text) => {
+ let addElement;
- 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;
+ } else {
+ addElement = document.createTextNode(text);
}
- replaceFragment.appendChild(addElement);
- });
- return replaceFragment;
+ documentFragment.appendChild(addElement);
+ return documentFragment;
+ }, document.createDocumentFragment());
};
-const highlightElements = (elements = [], searchTerm) => {
- elements.forEach((element) => {
- const replaceFragment = transformMatchElement(element, searchTerm);
- element.innerHTML = '';
- element.appendChild(replaceFragment);
+const highlightText = (textNodes = [], searchTerm) => {
+ textNodes.forEach((textNode) => {
+ const fragmentWithHighlights = highlightTextNode(textNode, searchTerm);
+ textNode.parentElement.replaceChild(fragmentWithHighlights, textNode);
});
};
-const displayResults = ({ sectionSelector, expandSection, searchTerm }, matches) => {
- const elements = matches.map((match) => match.parentElement);
- const sections = uniq(elements.map((element) => findSettingsSection(sectionSelector, element)));
+const displayResults = ({ sectionSelector, expandSection, searchTerm }, matchingTextNodes) => {
+ const sections = Array.from(
+ new Set(matchingTextNodes.map((node) => findSettingsSection(sectionSelector, node))),
+ );
hideSectionsExcept(sectionSelector, sections);
sections.forEach(expandSection);
- highlightElements(elements, searchTerm);
+ highlightText(matchingTextNodes, searchTerm);
};
const clearResults = (params) => {
@@ -114,13 +115,13 @@ const search = (root, searchTerm) => {
: NodeFilter.FILTER_REJECT;
},
});
- const results = [];
+ const textNodes = [];
for (let currentNode = iterator.nextNode(); currentNode; currentNode = iterator.nextNode()) {
- results.push(currentNode);
+ textNodes.push(currentNode);
}
- return results;
+ return textNodes;
};
export default {
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index c48c9067250..ba0120a0a70 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -30,6 +30,7 @@ export const i18n = {
securityTrainingDescription: s__(
'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.',
),
+ securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'),
};
export default {
@@ -50,7 +51,7 @@ export default {
TrainingProviderList,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['projectFullPath'],
+ inject: ['projectFullPath', 'vulnerabilityTrainingDocsPath'],
props: {
augmentedSecurityFeatures: {
type: Array,
@@ -143,7 +144,6 @@ export default {
<local-storage-sync
v-model="autoDevopsEnabledAlertDismissedProjects"
:storage-key="$options.autoDevopsEnabledAlertStorageKey"
- as-json
/>
<user-callout-dismisser
@@ -262,6 +262,11 @@ export default {
<p>
{{ $options.i18n.securityTrainingDescription }}
</p>
+ <p>
+ <gl-link :href="vulnerabilityTrainingDocsPath">{{
+ $options.i18n.securityTrainingDoc
+ }}</gl-link>
+ </p>
</template>
<template #features>
<training-provider-list />
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 39a2939f52a..6db28ef0fad 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -50,18 +50,24 @@ export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath(
export const DAST_NAME = __('Dynamic Application Security Testing (DAST)');
export const DAST_SHORT_NAME = s__('ciReport|DAST');
-export const DAST_DESCRIPTION = __('Analyze a review version of your web application.');
+export const DAST_DESCRIPTION = s__(
+ 'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.',
+);
export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index');
export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', {
anchor: 'enable-dast',
});
+export const DAST_BADGE_TEXT = __('Available on-demand');
+export const DAST_BADGE_TOOLTIP = __(
+ 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects',
+);
-export const DAST_PROFILES_NAME = __('DAST Scans');
+export const DAST_PROFILES_NAME = __('DAST profiles');
export const DAST_PROFILES_DESCRIPTION = s__(
'SecurityConfiguration|Manage profiles for use by DAST scans.',
);
export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index');
-export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage scans');
+export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles');
export const SECRET_DETECTION_NAME = __('Secret Detection');
export const SECRET_DETECTION_DESCRIPTION = __(
@@ -171,18 +177,23 @@ export const securityFeatures = [
type: REPORT_TYPE_SAST_IAC,
},
{
- name: DAST_NAME,
- shortName: DAST_SHORT_NAME,
- description: DAST_DESCRIPTION,
- helpPath: DAST_HELP_PATH,
- configurationHelpPath: DAST_CONFIG_HELP_PATH,
- type: REPORT_TYPE_DAST,
+ badge: {
+ text: DAST_BADGE_TEXT,
+ tooltipText: DAST_BADGE_TOOLTIP,
+ variant: 'info',
+ },
secondary: {
type: REPORT_TYPE_DAST_PROFILES,
name: DAST_PROFILES_NAME,
description: DAST_PROFILES_DESCRIPTION,
configurationText: DAST_PROFILES_CONFIG_TEXT,
},
+ name: DAST_NAME,
+ shortName: DAST_SHORT_NAME,
+ description: DAST_DESCRIPTION,
+ helpPath: DAST_HELP_PATH,
+ configurationHelpPath: DAST_CONFIG_HELP_PATH,
+ type: REPORT_TYPE_DAST,
},
{
name: DEPENDENCY_SCANNING_NAME,
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index cd5ad86e1a8..309e5f21445 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -1,7 +1,9 @@
<script>
import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
+import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
+import FeatureCardBadge from './feature_card_badge.vue';
export default {
components: {
@@ -9,6 +11,7 @@ export default {
GlCard,
GlIcon,
GlLink,
+ FeatureCardBadge,
ManageViaMr,
},
props: {
@@ -36,11 +39,14 @@ export default {
text: this.$options.i18n.enableFeature,
};
- button.category = 'secondary';
+ button.category = this.feature.category || 'secondary';
button.text = sprintf(button.text, { feature: this.shortName });
return button;
},
+ manageViaMrButtonCategory() {
+ return this.feature.category || 'secondary';
+ },
showManageViaMr() {
return ManageViaMr.canRender(this.feature);
},
@@ -48,19 +54,32 @@ export default {
return { 'gl-bg-gray-10': !this.available };
},
statusClasses() {
- const { enabled } = this;
+ const { enabled, hasBadge } = this;
return {
'gl-ml-auto': true,
'gl-flex-shrink-0': true,
'gl-text-gray-500': !enabled,
'gl-text-green-500': enabled,
+ 'gl-w-full': hasBadge,
+ 'gl-justify-content-space-between': hasBadge,
+ 'gl-display-flex': hasBadge,
+ 'gl-mb-4': hasBadge,
};
},
hasSecondary() {
const { name, description, configurationText } = this.feature.secondary ?? {};
return Boolean(name && description && configurationText);
},
+ // This condition is a temporary hack to not display any wrong information
+ // until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307.
+ // More Information: https://gitlab.com/gitlab-org/gitlab/-/issues/350307#note_825447417
+ isNotSastIACTemporaryHack() {
+ return this.feature.type !== REPORT_TYPE_SAST_IAC;
+ },
+ hasBadge() {
+ return Boolean(this.available && this.feature.badge?.text);
+ },
},
methods: {
onError(message) {
@@ -81,21 +100,31 @@ export default {
<template>
<gl-card :class="cardClasses">
- <div class="gl-display-flex gl-align-items-baseline">
+ <div
+ class="gl-display-flex gl-align-items-baseline"
+ :class="{ 'gl-flex-direction-column-reverse': hasBadge }"
+ >
<h3 class="gl-font-lg gl-m-0 gl-mr-3">{{ feature.name }}</h3>
<div
+ v-if="isNotSastIACTemporaryHack"
:class="statusClasses"
data-testid="feature-status"
:data-qa-selector="`${feature.type}_status`"
>
+ <feature-card-badge
+ v-if="hasBadge"
+ :badge="feature.badge"
+ :badge-href="feature.badge.badgeHref"
+ />
+
<template v-if="enabled">
<gl-icon name="check-circle-filled" />
<span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
</template>
<template v-else-if="available">
- {{ $options.i18n.notEnabled }}
+ <span>{{ $options.i18n.notEnabled }}</span>
</template>
<template v-else>
@@ -109,7 +138,7 @@ export default {
<gl-link :href="feature.helpPath">{{ $options.i18n.learnMore }}</gl-link>
</p>
- <template v-if="available">
+ <template v-if="available && isNotSastIACTemporaryHack">
<gl-button
v-if="feature.configurationPath"
:href="feature.configurationPath"
@@ -125,7 +154,7 @@ export default {
v-else-if="showManageViaMr"
:feature="feature"
variant="confirm"
- category="secondary"
+ :category="manageViaMrButtonCategory"
class="gl-mt-5"
:data-qa-selector="`${feature.type}_mr_button`"
@error="onError"
diff --git a/app/assets/javascripts/security_configuration/components/feature_card_badge.vue b/app/assets/javascripts/security_configuration/components/feature_card_badge.vue
new file mode 100644
index 00000000000..0907e33c8e2
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/components/feature_card_badge.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlBadge, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlBadge,
+ GlTooltip,
+ },
+ props: {
+ badge: {
+ type: Object,
+ required: true,
+ },
+ badgeHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-tooltip
+ v-if="badge.tooltipText"
+ placement="top"
+ boundary="window"
+ title="Tooltip title"
+ :target="() => $refs.badge"
+ >
+ {{ badge.tooltipText }}
+ </gl-tooltip>
+ <span ref="badge">
+ <gl-badge size="sm" :href="badgeHref" :variant="badge.variant">
+ {{ badge.text }}
+ </gl-badge>
+ </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
index bb540303cfd..ef50d085ae8 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -3,6 +3,7 @@ import {
GlAlert,
GlTooltipDirective,
GlCard,
+ GlFormRadio,
GlToggle,
GlLink,
GlSkeletonLoader,
@@ -44,6 +45,7 @@ export default {
components: {
GlAlert,
GlCard,
+ GlFormRadio,
GlToggle,
GlLink,
GlSkeletonLoader,
@@ -79,6 +81,9 @@ export default {
};
},
computed: {
+ primaryProviderId() {
+ return this.securityTrainingProviders.find(({ isPrimary }) => isPrimary)?.id;
+ },
enabledProviders() {
return this.securityTrainingProviders.filter(({ isEnabled }) => isEnabled);
},
@@ -256,31 +261,19 @@ export default {
{{ __('Learn more.') }}
</gl-link>
</p>
- <!-- Note: The following `div` and it's content will be replaced by 'GlFormRadio' once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1720#note_857342988 is resolved -->
- <div
- class="gl-form-radio custom-control custom-radio"
- data-testid="primary-provider-radio"
+ <gl-form-radio
+ :checked="primaryProviderId"
+ :disabled="!provider.isEnabled"
+ :value="provider.id"
+ @change="setPrimaryProvider(provider)"
>
- <input
- :id="`security-training-provider-${provider.id}`"
- type="radio"
- :checked="provider.isPrimary"
- class="custom-control-input"
- :disabled="!provider.isEnabled"
- @change="setPrimaryProvider(provider)"
- />
- <label
- class="custom-control-label"
- :for="`security-training-provider-${provider.id}`"
- >
- {{ $options.i18n.primaryTraining }}
- </label>
+ {{ $options.i18n.primaryTraining }}
<gl-icon
v-gl-tooltip="$options.i18n.primaryTrainingDescription"
name="information-o"
class="gl-ml-2 gl-cursor-help"
/>
- </div>
+ </gl-form-radio>
</div>
</div>
</gl-card>
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index 8416692dd27..65cf1ec27a3 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -25,6 +25,7 @@ export const initSecurityConfiguration = (el) => {
gitlabCiHistoryPath,
autoDevopsHelpPagePath,
autoDevopsPath,
+ vulnerabilityTrainingDocsPath,
} = el.dataset;
const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures(
@@ -41,6 +42,7 @@ export const initSecurityConfiguration = (el) => {
upgradePath,
autoDevopsHelpPagePath,
autoDevopsPath,
+ vulnerabilityTrainingDocsPath,
},
render(createElement) {
return createElement(SecurityConfigurationApp, {
diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js
index 173560f8370..df23698ba7e 100644
--- a/app/assets/javascripts/security_configuration/utils.js
+++ b/app/assets/javascripts/security_configuration/utils.js
@@ -30,6 +30,10 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features =
augmented.secondary = { ...augmented.secondary, ...featuresByType[feature.secondary.type] };
}
+ if (augmented.badge && augmented.metaInfoPath) {
+ augmented.badge.badgeHref = augmented.metaInfoPath;
+ }
+
return augmented;
};
diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue
index 0023c64e3e4..d9e6bb5009e 100644
--- a/app/assets/javascripts/serverless/components/missing_prometheus.vue
+++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlLink } from '@gitlab/ui';
import { mapState } from 'vuex';
-import { s__ } from '../../locale';
+import { s__ } from '~/locale';
export default {
components: {
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
index 79a1f39c7dd..b105f49e475 100644
--- a/app/assets/javascripts/serverless/components/url.vue
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -1,5 +1,5 @@
<script>
-import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
components: {
diff --git a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js b/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js
deleted file mode 100644
index 3a8631a196f..00000000000
--- a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { AwardsHandler } from '~/awards_handler';
-
-class EmojiMenuInModal extends AwardsHandler {
- constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback, targetContainerEl) {
- super(emoji);
-
- this.selectEmojiCallback = selectEmojiCallback;
- this.toggleButtonSelector = toggleButtonSelector;
- this.menuClass = menuClass;
- this.targetContainerEl = targetContainerEl;
-
- this.bindEvents();
- }
-
- postEmoji($emojiButton, awardUrl, selectedEmoji) {
- this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji));
- }
-}
-
-export default EmojiMenuInModal;
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index a746642c191..eb0931c6fe2 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -19,10 +19,8 @@ import { __, s__, sprintf } from '~/locale';
import { updateUserStatus } from '~/rest_api';
import { timeRanges } from '~/vue_shared/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import EmojiMenuInModal from './emoji_menu_in_modal';
import { isUserBusy } from './utils';
-const emojiMenuClass = 'js-modal-status-emoji-menu';
export const AVAILABILITY_STATUS = {
BUSY: 'busy',
NOT_SET: 'not_set',
@@ -83,7 +81,6 @@ export default {
emoji: this.currentEmoji,
emojiMenu: null,
emojiTag: '',
- isEmojiMenuVisible: false,
message: this.currentMessage,
modalId: 'set-user-status-modal',
noEmoji: true,
@@ -105,17 +102,11 @@ export default {
mounted() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
- beforeDestroy() {
- if (this.emojiMenu) {
- this.emojiMenu.destroy();
- }
- },
methods: {
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
setupEmojiListAndAutocomplete() {
- const toggleEmojiMenuButtonSelector = '#set-user-status-modal .js-toggle-emoji-menu';
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true });
@@ -127,16 +118,6 @@ export default {
this.noEmoji = this.emoji === '';
this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
- if (!this.glFeatures.improvedEmojiPicker) {
- this.emojiMenu = new EmojiMenuInModal(
- Emoji,
- toggleEmojiMenuButtonSelector,
- emojiMenuClass,
- this.setEmoji,
- this.$refs.userStatusForm,
- );
- }
-
this.setDefaultEmoji();
})
.catch(() =>
@@ -145,19 +126,6 @@ export default {
}),
);
},
- showEmojiMenu(e) {
- e.stopPropagation();
- this.isEmojiMenuVisible = true;
- this.emojiMenu.showEmojiMenu($(this.$refs.toggleEmojiMenuButton));
- },
- hideEmojiMenu() {
- if (!this.isEmojiMenuVisible) {
- return;
- }
-
- this.isEmojiMenuVisible = false;
- this.emojiMenu.hideMenuElement($(`.${emojiMenuClass}`));
- },
setDefaultEmoji() {
const { emojiTag } = this;
const hasStatusMessage = Boolean(this.message.length);
@@ -173,16 +141,12 @@ export default {
this.clearEmoji();
}
},
- setEmoji(emoji, emojiTag) {
+ setEmoji(emoji) {
this.emoji = emoji;
this.noEmoji = false;
this.clearEmoji();
- if (this.glFeatures.improvedEmojiPicker) {
- this.emojiTag = Emoji.glEmojiTag(this.emoji);
- } else {
- this.emojiTag = emojiTag;
- }
+ this.emojiTag = Emoji.glEmojiTag(this.emoji);
},
clearEmoji() {
if (this.emojiTag) {
@@ -194,7 +158,6 @@ export default {
this.message = '';
this.noEmoji = true;
this.clearEmoji();
- this.hideEmojiMenu();
},
removeStatus() {
this.availability = false;
@@ -249,7 +212,6 @@ export default {
:action-secondary="$options.actionSecondary"
modal-class="set-user-status-modal"
@shown="setupEmojiListAndAutocomplete"
- @hide="hideEmojiMenu"
@primary="setStatus"
@secondary="removeStatus"
>
@@ -264,7 +226,6 @@ export default {
<div class="input-group gl-mb-5">
<span class="input-group-prepend">
<emoji-picker
- v-if="glFeatures.improvedEmojiPicker"
dropdown-class="gl-h-full"
toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
boundary="viewport"
@@ -283,27 +244,6 @@ export default {
</span>
</template>
</emoji-picker>
- <button
- v-else
- ref="toggleEmojiMenuButton"
- v-gl-tooltip.bottom.hover
- :title="s__('SetStatusModal|Add status emoji')"
- :aria-label="s__('SetStatusModal|Add status emoji')"
- name="button"
- type="button"
- class="js-toggle-emoji-menu emoji-menu-toggle-button btn"
- @click="showEmojiMenu"
- >
- <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
- <span
- v-show="noEmoji"
- class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
- >
- <gl-icon name="slight-smile" class="award-control-icon-neutral" />
- <gl-icon name="smiley" class="award-control-icon-positive" />
- <gl-icon name="smile" class="award-control-icon-super-positive" />
- </span>
- </button>
</span>
<input
ref="statusMessageField"
@@ -314,7 +254,6 @@ export default {
name="user[status][message]"
@keyup="setDefaultEmoji"
@keyup.enter.prevent
- @click="hideEmojiMenu"
/>
<span v-show="isDirty" class="input-group-append">
<button
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index 2387fe64b8f..78d12ac113b 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -1,6 +1,5 @@
<script>
-import produce from 'immer';
-import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants';
import { assigneesQueries } from '~/sidebar/constants';
@@ -17,10 +16,6 @@ export default {
type: String,
required: true,
},
- issuableId: {
- type: Number,
- required: true,
- },
queryVariables: {
type: Object,
required: true,
@@ -30,6 +25,9 @@ export default {
issuableClass() {
return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
},
+ issuableId() {
+ return this.issuable?.id;
+ },
},
apollo: {
issuable: {
@@ -48,29 +46,36 @@ export default {
},
variables() {
return {
- issuableId: convertToGraphQLId(this.issuableClass, this.issuableId),
+ issuableId: this.issuableId,
};
},
- updateQuery(prev, { subscriptionData }) {
- if (prev && subscriptionData?.data?.issuableAssigneesUpdated) {
- const data = produce(prev, (draftData) => {
- draftData.workspace.issuable.assignees.nodes =
- subscriptionData.data.issuableAssigneesUpdated.assignees.nodes;
- });
+ skip() {
+ return !this.issuableId;
+ },
+ updateQuery(
+ _,
+ {
+ subscriptionData: {
+ data: { issuableAssigneesUpdated },
+ },
+ },
+ ) {
+ if (issuableAssigneesUpdated) {
+ const {
+ id,
+ assignees: { nodes },
+ } = issuableAssigneesUpdated;
if (this.mediator) {
- this.handleFetchResult(data);
+ this.handleFetchResult(nodes);
}
- return data;
+ this.$emit('assigneesUpdated', { id, assignees: nodes });
}
- return prev;
},
},
},
},
methods: {
- handleFetchResult(data) {
- const { nodes } = data.workspace.issuable.assignees;
-
+ handleFetchResult(nodes) {
const assignees = nodes.map((n) => ({
...n,
avatar_url: n.avatarUrl,
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 7743004a293..14f6c9d3a15 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -232,6 +232,7 @@ export default {
:issuable-type="issuableType"
:issuable-id="issuableId"
:query-variables="queryVariables"
+ @assigneesUpdated="$emit('assignees-updated', $event)"
/>
<sidebar-editable-item
ref="toggle"
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 558fe8ca2aa..8717d205dcb 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -109,20 +109,18 @@ export default {
:key="user.id"
:class="{
'user-item': !showVerticalList,
+ 'gl-display-inline-block': !showVerticalList,
+ 'gl-display-grid gl-align-items-center': showVerticalList,
'gl-mb-3': index !== users.length - 1 && showVerticalList,
}"
- class="gl-display-inline-block"
+ class="assignee-grid"
>
- <attention-requested-toggle
- v-if="showVerticalList"
- :user="user"
- type="assignee"
- @toggle-attention-requested="toggleAttentionRequested"
- />
<assignee-avatar-link
:user="user"
:issuable-type="issuableType"
:tooltip-has-name="!showVerticalList"
+ class="gl-word-break-word"
+ data-css-area="user"
>
<div
v-if="showVerticalList"
@@ -133,6 +131,14 @@ export default {
<span>@{{ user.username }}</span>
</div>
</assignee-avatar-link>
+ <attention-requested-toggle
+ v-if="showVerticalList"
+ :user="user"
+ type="assignee"
+ class="gl-mr-2"
+ data-css-area="attention"
+ @toggle-attention-requested="toggleAttentionRequested"
+ />
</div>
</div>
<div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800">
diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
index 6ba88939373..cdc1c65a516 100644
--- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
+++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
@@ -70,19 +70,21 @@ export default {
</script>
<template>
- <span
- v-gl-tooltip.left.viewport="tooltipTitle"
- class="gl-display-inline-block js-attention-request-toggle"
- >
- <gl-button
- :loading="loading"
- :variant="user.attention_requested ? 'warning' : 'default'"
- :icon="user.attention_requested ? 'attention-solid' : 'attention'"
- :aria-label="tooltipTitle"
- :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }"
- size="small"
- category="tertiary"
- @click="toggleAttentionRequired"
- />
- </span>
+ <div>
+ <span
+ v-gl-tooltip.left.viewport="tooltipTitle"
+ class="gl-display-inline-block js-attention-request-toggle"
+ >
+ <gl-button
+ :loading="loading"
+ :variant="user.attention_requested ? 'warning' : 'default'"
+ :icon="user.attention_requested ? 'attention-solid' : 'attention'"
+ :aria-label="tooltipTitle"
+ :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }"
+ size="small"
+ category="tertiary"
+ @click="toggleAttentionRequired"
+ />
+ </span>
+ </div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
index 0d8cb8cb2b6..8528ad56ddb 100644
--- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
+++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
@@ -1,5 +1,5 @@
<script>
-import CopyableField from '../../vue_shared/components/sidebar/copyable_field.vue';
+import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue';
export default {
components: {
diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
index 2c32cf89387..aeaac76cff4 100644
--- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
+++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
@@ -21,6 +21,11 @@ export default {
return [...STATUS_LIST, null].includes(value);
},
},
+ preventDropdownClose: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
currentStatusLabel() {
@@ -35,6 +40,11 @@ export default {
this.$refs.dropdown.hide();
},
getStatusLabel,
+ hideDropdown(event) {
+ if (this.preventDropdownClose) {
+ event.preventDefault();
+ }
+ },
},
};
</script>
@@ -45,6 +55,7 @@ export default {
block
:text="currentStatusLabel"
toggle-class="dropdown-menu-toggle gl-mb-2"
+ @hide="hideDropdown"
>
<slot name="header"> </slot>
<gl-dropdown-item
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index 42d2e456a07..2ab46a7a655 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -3,7 +3,7 @@ import { GlButton } from '@gitlab/ui';
import $ from 'jquery';
import { mapActions } from 'vuex';
import createFlash from '~/flash';
-import { __, sprintf } from '../../../locale';
+import { __, sprintf } from '~/locale';
import eventHub from '../../event_hub';
export default {
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index 4a255a3b916..3fd35de2132 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
@@ -8,9 +8,10 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- userAvatarImage,
+ GlButton,
GlIcon,
GlLoadingIcon,
+ userAvatarImage,
},
props: {
loading: {
@@ -124,9 +125,13 @@ export default {
</div>
</div>
<div v-if="hasMoreParticipants" class="participants-more hide-collapsed">
- <button type="button" class="btn-transparent btn-link" @click="toggleMoreParticipants">
- {{ toggleLabel }}
- </button>
+ <gl-button
+ variant="link"
+ button-text-classes="gl-text-secondary"
+ data-testid="more-participants"
+ @click="toggleMoreParticipants"
+ >{{ toggleLabel }}</gl-button
+ >
</div>
</div>
</template>
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 361a082def6..a11468c8761 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -73,10 +73,10 @@ export default {
v-gl-tooltip="tooltipOption"
:href="reviewerUrl"
:title="tooltipTitle"
- class="d-inline-block"
+ class="gl-display-inline-block"
>
<!-- use d-flex so that slot can be appropriately styled -->
- <span class="gl-display-flex gl-align-items-center">
+ <span class="gl-display-flex">
<reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
<slot :user="user"></slot>
</span>
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 9485802d3da..3e6be3487b1 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -94,28 +94,40 @@ export default {
<div
v-for="(user, index) in users"
:key="user.id"
- :class="{ 'gl-mb-3': index !== users.length - 1 }"
+ :class="{
+ 'gl-mb-3': index !== users.length - 1,
+ 'attention-requests': glFeatures.mrAttentionRequests,
+ }"
+ class="gl-display-grid gl-align-items-center reviewer-grid"
data-testid="reviewer"
>
- <attention-requested-toggle
- v-if="glFeatures.mrAttentionRequests"
+ <reviewer-avatar-link
:user="user"
- type="reviewer"
- @toggle-attention-requested="toggleAttentionRequested"
- />
- <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType">
+ :root-path="rootPath"
+ :issuable-type="issuableType"
+ class="gl-word-break-word gl-mr-2"
+ data-css-area="user"
+ >
<div class="gl-ml-3 gl-line-height-normal gl-display-grid">
<span>{{ user.name }}</span>
<span>@{{ user.username }}</span>
</div>
</reviewer-avatar-link>
+ <attention-requested-toggle
+ v-if="glFeatures.mrAttentionRequests"
+ :user="user"
+ type="reviewer"
+ class="gl-mr-2"
+ data-css-area="attention"
+ @toggle-attention-requested="toggleAttentionRequested"
+ />
<gl-icon
v-if="user.approved"
v-gl-tooltip.left
:size="16"
:title="approvedByTooltipTitle(user)"
name="status-success"
- class="float-right gl-my-2 gl-ml-2 gl-text-green-500"
+ class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0"
data-testid="re-approved"
/>
<gl-icon
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index bb90ef8e444..91c15061fb9 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
-import { sprintf, s__ } from '../../../locale';
+import { sprintf, s__ } from '~/locale';
export default {
name: 'TimeTrackingHelpState',
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index d222a2af382..fdbcef22bba 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -207,7 +207,7 @@ export default {
class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center"
>
{{ __('Time tracking') }}
- <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline />
+ <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline />
<gl-button
:data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'"
category="tertiary"
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index fc757922f09..034bdc71122 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -1,11 +1,9 @@
import produce from 'immer';
import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
-import { resolvers as workItemResolvers } from '~/work_items/graphql/resolvers';
import createDefaultClient from '~/lib/graphql';
const resolvers = {
- ...workItemResolvers,
Mutation: {
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
const sourceData = cache.readQuery({ query: getIssueStateQuery });
@@ -14,7 +12,6 @@ const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
- ...workItemResolvers.Mutation,
},
};
diff --git a/app/assets/javascripts/sidebar/queries/issuable_labels.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_labels.subscription.graphql
new file mode 100644
index 00000000000..edd713baddf
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issuable_labels.subscription.graphql
@@ -0,0 +1,22 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+subscription issuableLabelsUpdated($issuableId: IssuableID!) {
+ issuableLabelsUpdated(issuableId: $issuableId) {
+ ... on Issue {
+ id
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ ... on MergeRequest {
+ id
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebar_details.query.graphql
index 90d1a7794ea..90d1a7794ea 100644
--- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/sidebar_details.query.graphql
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql b/app/assets/javascripts/sidebar/queries/sidebar_details_mr.query.graphql
index 0505f88773d..0505f88773d 100644
--- a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/sidebar_details_mr.query.graphql
diff --git a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_status.mutation.graphql
index 2c6f379744e..2c6f379744e 100644
--- a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_status.mutation.graphql
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index d8ab8f1c65b..90d8f2098bb 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -1,10 +1,10 @@
-import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql';
+import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebar_details.query.graphql';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql';
-import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql';
+import sidebarDetailsMRQuery from '../queries/sidebar_details_mr.query.graphql';
import toggleAttentionRequestedMutation from '../queries/toggle_attention_requested.mutation.graphql';
const queries = {
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index e3aa29d5f89..e4a97f08c8d 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -15,8 +15,8 @@ import TitleField from '~/vue_shared/components/form/title.vue';
import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants';
import { getSnippetMixin } from '../mixins/snippets';
-import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
-import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
+import CreateSnippetMutation from '../mutations/create_snippet.mutation.graphql';
+import UpdateSnippetMutation from '../mutations/update_snippet.mutation.graphql';
import { markBlobPerformance } from '../utils/blob';
import { getErrorMessage } from '../utils/error';
@@ -238,9 +238,9 @@ export default {
>
</template>
<template #append>
- <gl-button type="cancel" data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{
- __('Cancel')
- }}</gl-button>
+ <gl-button type="cancel" data-testid="snippet-cancel-btn" :href="cancelButtonHref">
+ {{ __('Cancel') }}
+ </gl-button>
</template>
</form-footer-actions>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 9b24c8afe37..dd8f2897018 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -21,7 +21,7 @@ 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';
+import DeleteSnippetMutation from '../mutations/delete_snippet.mutation.graphql';
export const i18n = {
snippetSpamSuccess: sprintf(
@@ -294,9 +294,9 @@ 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>
diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql
index d75b4011d1c..d75b4011d1c 100644
--- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
+++ b/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql
diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/create_snippet.mutation.graphql
index 8640c4725f4..8640c4725f4 100644
--- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/create_snippet.mutation.graphql
diff --git a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql
index f43d53661f4..f43d53661f4 100644
--- a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql
diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/update_snippet.mutation.graphql
index 99242c5d500..99242c5d500 100644
--- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/update_snippet.mutation.graphql
diff --git a/app/assets/javascripts/sortable/constants.js b/app/assets/javascripts/sortable/constants.js
new file mode 100644
index 00000000000..7fddac00ab2
--- /dev/null
+++ b/app/assets/javascripts/sortable/constants.js
@@ -0,0 +1,19 @@
+/**
+ * Default config options for sortablejs.
+ * @type {object}
+ *
+ * @example
+ * import Sortable from 'sortablejs';
+ *
+ * const sortable = Sortable.create(el, {
+ * ...defaultSortableOptions,
+ * });
+ */
+export const defaultSortableOptions = {
+ animation: 200,
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ fallbackOnBody: true,
+ ghostClass: 'is-ghost',
+ fallbackTolerance: 1,
+};
diff --git a/app/assets/javascripts/sortable/sortable_config.js b/app/assets/javascripts/sortable/sortable_config.js
deleted file mode 100644
index a4c4cb7f101..00000000000
--- a/app/assets/javascripts/sortable/sortable_config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export default {
- animation: 200,
- forceFallback: true,
- fallbackClass: 'is-dragging',
- fallbackOnBody: true,
- ghostClass: 'is-ghost',
- fallbackTolerance: 1,
-};
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/sortable/utils.js
index 1bb0ee5b7e3..c2c8fb03b58 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/sortable/utils.js
@@ -1,6 +1,6 @@
/* global DocumentTouch */
-import sortableConfig from '~/sortable/sortable_config';
+import { defaultSortableOptions } from './constants';
export function sortableStart() {
document.body.classList.add('is-dragging');
@@ -10,12 +10,12 @@ export function sortableEnd() {
document.body.classList.remove('is-dragging');
}
-export function getBoardSortableDefaultOptions(obj) {
+export function getSortableDefaultOptions(options) {
const touchEnabled =
'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch);
const defaultSortOptions = {
- ...sortableConfig,
+ ...defaultSortableOptions,
filter: '.no-drag',
delay: touchEnabled ? 100 : 0,
scrollSensitivity: touchEnabled ? 60 : 100,
@@ -24,8 +24,8 @@ export function getBoardSortableDefaultOptions(obj) {
onEnd: sortableEnd,
};
- Object.keys(obj).forEach((key) => {
- defaultSortOptions[key] = obj[key];
- });
- return defaultSortOptions;
+ return {
+ ...defaultSortOptions,
+ ...options,
+ };
}
diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
index 8c3ee7b9609..e69a6b8cd69 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
@@ -112,7 +112,6 @@ export default {
v-model="mergeRequestMeta"
:storage-key="$options.storageKey"
:clear="clearStorage"
- as-json
/>
<edit-meta-controls
ref="editMetaControls"
diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue
index fd9177bef3f..6dae55bac50 100644
--- a/app/assets/javascripts/terraform/components/empty_state.vue
+++ b/app/assets/javascripts/terraform/components/empty_state.vue
@@ -1,11 +1,16 @@
<script>
-import { GlEmptyState, GlIcon, GlLink } from '@gitlab/ui';
+import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export default {
+ i18n: {
+ title: s__("Terraform|Your project doesn't have any Terraform state files"),
+ description: s__('Terraform|How to use GitLab-managed Terraform state?'),
+ },
+ docsUrl: helpPagePath('user/infrastructure/iac/terraform_state'),
components: {
GlEmptyState,
- GlIcon,
GlLink,
},
props: {
@@ -14,23 +19,13 @@ export default {
required: true,
},
},
- computed: {
- docsUrl() {
- return helpPagePath('user/infrastructure/iac/terraform_state');
- },
- },
};
</script>
<template>
- <gl-empty-state :svg-path="image" :title="s__('Terraform|Get started with Terraform')">
+ <gl-empty-state :svg-path="image" :title="$options.i18n.title">
<template #description>
- <p>
- <gl-link :href="docsUrl" target="_blank"
- >{{ s__('Terraform|How to use GitLab-managed Terraform State?') }}
- <gl-icon name="external-link"
- /></gl-link>
- </p>
+ <gl-link :href="$options.docsUrl">{{ $options.i18n.description }}</gl-link>
</template>
</gl-empty-state>
</template>
diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue
index 817c421823c..1970d6d7949 100644
--- a/app/assets/javascripts/terraform/components/states_table_actions.vue
+++ b/app/assets/javascripts/terraform/components/states_table_actions.vue
@@ -11,6 +11,7 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
+import getStatesQuery from '../graphql/queries/get_states.query.graphql';
import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql';
import lockState from '../graphql/mutations/lock_state.mutation.graphql';
import removeState from '../graphql/mutations/remove_state.mutation.graphql';
@@ -148,7 +149,7 @@ export default {
variables: {
stateID: this.state.id,
},
- refetchQueries: () => ['getStates'],
+ refetchQueries: () => [{ query: getStatesQuery }],
awaitRefetchQueries: true,
notifyOnNetworkStatusChange: true,
})
diff --git a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
index 4d26ea88ddf..2ae7b7d905e 100644
--- a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
+++ b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
@@ -1,5 +1,5 @@
#import "../fragments/state.fragment.graphql"
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getStates($projectPath: ID!, $first: Int, $last: Int, $before: String, $after: String) {
project(fullPath: $projectPath) {
diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js
index 173eef0646b..f299c57b33f 100644
--- a/app/assets/javascripts/tracking/tracking.js
+++ b/app/assets/javascripts/tracking/tracking.js
@@ -13,8 +13,12 @@ import {
const ALLOWED_URL_HASHES = ['#diff', '#note'];
export default class Tracking {
- static queuedEvents = [];
+ static nonInitializedQueue = [];
static initialized = false;
+ static definitionsLoaded = false;
+ static definitionsManifest = {};
+ static definitionsEventsQueue = [];
+ static definitions = [];
/**
* (Legacy) Determines if tracking is enabled at the user level.
@@ -54,7 +58,7 @@ export default class Tracking {
}
if (!this.initialized) {
- this.queuedEvents.push(eventData);
+ this.nonInitializedQueue.push(eventData);
return false;
}
@@ -62,6 +66,64 @@ export default class Tracking {
}
/**
+ * Preloads event definitions.
+ *
+ * @returns {undefined}
+ */
+ static loadDefinitions() {
+ // TODO: fetch definitions from the server and flush the queue
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/358256
+ this.definitionsLoaded = true;
+
+ while (this.definitionsEventsQueue.length) {
+ this.dispatchFromDefinition(...this.definitionsEventsQueue.shift());
+ }
+ }
+
+ /**
+ * Dispatches a structured event with data from its event definition.
+ *
+ * @param {String} basename
+ * @param {Object} eventData
+ * @returns {undefined|Boolean}
+ */
+ static definition(basename, eventData = {}) {
+ if (!this.enabled()) {
+ return false;
+ }
+
+ if (!(basename in this.definitionsManifest)) {
+ throw new Error(`Missing Snowplow event definition "${basename}"`);
+ }
+
+ return this.dispatchFromDefinition(basename, eventData);
+ }
+
+ /**
+ * Builds an event with data from a valid definition and sends it to
+ * Snowplow. If the definitions are not loaded, it pushes the data to a queue.
+ *
+ * @param {String} basename
+ * @param {Object} eventData
+ * @returns {undefined|Boolean}
+ */
+ static dispatchFromDefinition(basename, eventData) {
+ if (!this.definitionsLoaded) {
+ this.definitionsEventsQueue.push([basename, eventData]);
+
+ return false;
+ }
+
+ const eventDefinition = this.definitions.find((definition) => definition.key === basename);
+
+ return this.event(
+ eventData.category ?? eventDefinition.category,
+ eventData.action ?? eventDefinition.action,
+ eventData,
+ );
+ }
+
+ /**
* Dispatches any event emitted before initialization.
*
* @returns {undefined}
@@ -69,8 +131,8 @@ export default class Tracking {
static flushPendingEvents() {
this.initialized = true;
- while (this.queuedEvents.length) {
- dispatchSnowplowEvent(...this.queuedEvents.shift());
+ while (this.nonInitializedQueue.length) {
+ dispatchSnowplowEvent(...this.nonInitializedQueue.shift());
}
}
diff --git a/app/assets/javascripts/user_lists/components/user_list_form.vue b/app/assets/javascripts/user_lists/components/user_list_form.vue
index b53aaf46ace..44aa2d9a5b4 100644
--- a/app/assets/javascripts/user_lists/components/user_list_form.vue
+++ b/app/assets/javascripts/user_lists/components/user_list_form.vue
@@ -84,7 +84,7 @@ export default {
<gl-form-input id="user-list-name" v-model="name" data-testid="user-list-name" required />
</gl-form-group>
<div :class="$options.classes.actionContainer">
- <gl-button variant="success" data-testid="save-user-list" @click="submit">
+ <gl-button variant="confirm" data-testid="save-user-list" @click="submit">
{{ saveButtonLabel }}
</gl-button>
<gl-button :href="cancelPath" data-testid="user-list-cancel">
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 656c851aa3d..f7a5589af90 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */
+/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */
/* global Issuable */
/* global emitSidebarEvent */
@@ -11,10 +11,10 @@ import {
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { isUserBusy } from '~/set_status_modal/utils';
import { fixTitle, dispose } from '~/tooltips';
-import axios from '../lib/utils/axios_utils';
-import { parseBoolean, spriteIcon } from '../lib/utils/common_utils';
-import { loadCSSFile } from '../lib/utils/css_utils';
-import { s__, __, sprintf } from '../locale';
+import axios from '~/lib/utils/axios_utils';
+import { parseBoolean, spriteIcon } from '~/lib/utils/common_utils';
+import { loadCSSFile } from '~/lib/utils/css_utils';
+import { s__, __, sprintf } from '~/locale';
import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils';
// TODO: remove eventHub hack after code splitting refactor
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 25dbb614c1d..0e31f97b9db 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
@@ -102,7 +102,11 @@ export default {
<template v-if="hasApprovers">
<span v-if="approvalLeftMessage">{{ message }}</span>
<strong v-else>{{ message }}</strong>
- <user-avatar-list class="d-inline-block align-middle" :items="approvers" />
+ <user-avatar-list
+ class="gl-display-inline-block gl-vertical-align-middle"
+ :img-size="24"
+ :items="approvers"
+ />
</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 684386883c8..f1b89c42fb5 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
@@ -66,7 +66,15 @@ export default {
return this.loadingState === LOADING_STATES.expandedLoading;
},
isCollapsible() {
- return !this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError;
+ if (!this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError) {
+ if (this.shouldCollapse) {
+ return this.shouldCollapse();
+ }
+
+ return true;
+ }
+
+ return false;
},
hasFullData() {
return this.fullData.length > 0;
@@ -86,7 +94,7 @@ export default {
);
},
statusIconName() {
- if (this.hasFetchError) return EXTENSION_ICONS.error;
+ if (this.hasFetchError) return EXTENSION_ICONS.failed;
if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData);
@@ -128,7 +136,7 @@ export default {
}
}),
toggleCollapsed(e) {
- if (!e?.target?.closest('.btn:not(.btn-icon),a')) {
+ if (this.isCollapsible && !e?.target?.closest('.btn:not(.btn-icon),a')) {
this.isCollapsed = !this.isCollapsed;
this.triggerRedisTracking();
@@ -214,7 +222,7 @@ export default {
// To allow for text to be selected we check if the the user is clicking
// or selecting, if they are selecting the time difference should be
// more than 200ms
- if (up - this.down < 200) {
+ if (up - this.down < 200 && !e?.target?.closest('.btn-icon')) {
this.toggleCollapsed(e);
}
},
@@ -226,7 +234,12 @@ export default {
<template>
<section class="media-section" data-testid="widget-extension">
- <div class="media gl-p-5 gl-cursor-pointer" @mousedown="onRowMouseDown" @mouseup="onRowMouseUp">
+ <div
+ :class="{ 'gl-cursor-pointer': isCollapsible }"
+ class="media gl-p-5"
+ @mousedown="onRowMouseDown"
+ @mouseup="onRowMouseUp"
+ >
<status-icon
:name="$options.label || $options.name"
:is-loading="isLoadingSummary"
@@ -264,7 +277,7 @@ export default {
category="tertiary"
data-testid="toggle-button"
size="small"
- @click.self="toggleCollapsed"
+ @click="toggleCollapsed"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
index 5f42c6c7acb..5cfee21dd5e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
@@ -55,19 +55,21 @@ export default {
<div class="gl-display-flex">
<status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" />
<div class="gl-w-full">
- <div class="gl-flex-wrap gl-display-flex gl-w-full">
- <div class="gl-mr-4 gl-display-flex gl-align-items-center">
- <p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
+ <div class="gl-display-flex gl-flex-nowrap">
+ <div class="gl-flex-wrap gl-display-flex gl-w-full">
+ <div class="gl-mr-4 gl-display-flex gl-align-items-center">
+ <p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
+ </div>
+ <div v-if="data.link">
+ <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
+ </div>
+ <div v-if="data.supportingText">
+ <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
+ </div>
+ <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
+ {{ data.badge.text }}
+ </gl-badge>
</div>
- <div v-if="data.link">
- <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
- </div>
- <div v-if="data.supportingText">
- <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
- </div>
- <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
- {{ data.badge.text }}
- </gl-badge>
<actions :widget="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto" />
</div>
<p
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js
index 8ba13cf8252..5fba070f79c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js
@@ -32,7 +32,7 @@ const textStyleTags = {
[getStartTag('critical')]: '<span class="gl-font-weight-bold gl-text-red-800">',
[getStartTag('same')]: '<span class="gl-font-weight-bold gl-text-gray-700">',
[getStartTag('strong')]: '<span class="gl-font-weight-bold">',
- [getStartTag('small')]: '<span class="gl-font-sm">',
+ [getStartTag('small')]: '<span class="gl-font-sm gl-text-gray-700">',
};
export const generateText = (text) => {
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 b062833cdf8..e906b8c3b59 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
@@ -79,7 +79,7 @@ export default {
},
data() {
return {
- resolveConflictsFromCli: helpPagePath('ee/user/project/merge_requests/conflicts.html', {
+ resolveConflictsFromCli: helpPagePath('user/project/merge_requests/conflicts', {
anchor: 'resolve-conflicts-from-the-command-line',
}),
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 2e3a02b1712..9499603163b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import ciIcon from '../../vue_shared/components/ci_icon.vue';
+import ciIcon from '~/vue_shared/components/ci_icon.vue';
export default {
components: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
index caafd6b995e..e86724d133a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '../../locale';
+import { __ } from '~/locale';
export default {
i18n: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index e0c4679b983..887d1aab524 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -1,7 +1,7 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import simplePoll from '~/lib/utils/simple_poll';
-import MergeRequest from '../../../merge_request';
+import MergeRequest from '~/merge_request';
import eventHub from '../../event_hub';
import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants';
import statusIcon from '../mr_widget_status_icon.vue';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index ebdc8309cd5..3511fffcfbb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -3,7 +3,7 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import simplePoll from '../../../lib/utils/simple_poll';
+import simplePoll from '~/lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import rebaseQuery from '../../queries/states/rebase.query.graphql';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
index e43319d42ca..4902c9b45e8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -28,7 +28,7 @@ export default {
api.trackRedisHllUserEvent('i_code_review_widget_nothing_merge_click_new_file');
},
},
- ciHelpPage: helpPagePath('/ci/quick_start/index.html'),
+ ciHelpPage: helpPagePath('ci/quick_start/index.html'),
safeHtmlConfig: { ADD_TAGS: ['use'] },
};
</script>
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 e52f2c2c666..6ca0ea9c4e7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -48,6 +48,9 @@ export default {
{ text: 'Full report', href: this.conflictsDocsPath, target: '_blank' },
];
},
+ shouldCollapse() {
+ return true;
+ },
},
methods: {
// Fetches the collapsed data
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js
new file mode 100644
index 00000000000..cd5cfb6837c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js
@@ -0,0 +1,39 @@
+import { __, n__, s__, sprintf } from '~/locale';
+
+const digitText = (bold = false) => (bold ? '%{strong_start}%d%{strong_end}' : '%d');
+const noText = (bold = false) => (bold ? '%{strong_start}no%{strong_end}' : 'no');
+
+export const TESTS_FAILED_STATUS = 'failed';
+export const ERROR_STATUS = 'error';
+
+export const i18n = {
+ label: s__('Reports|Test summary'),
+ loading: s__('Reports|Test summary results are loading'),
+ error: s__('Reports|Test summary failed to load results'),
+ fullReport: s__('Reports|Full report'),
+
+ noChanges: (bold) => s__(`Reports|${noText(bold)} changed test results`),
+ resultsString: (combinedString, resolvedString) =>
+ sprintf(s__('Reports|%{combinedString} and %{resolvedString}'), {
+ combinedString,
+ resolvedString,
+ }),
+
+ summaryText: (name, resultsString) =>
+ sprintf(__('%{name}: %{resultsString}'), { name, resultsString }),
+
+ failedClause: (failed, bold) =>
+ n__(`${digitText(bold)} failed`, `${digitText(bold)} failed`, failed),
+ erroredClause: (errored, bold) =>
+ n__(`${digitText(bold)} error`, `${digitText(bold)} errors`, errored),
+ resolvedClause: (resolved, bold) =>
+ n__(`${digitText(bold)} fixed test result`, `${digitText(bold)} fixed test results`, resolved),
+ totalClause: (total, bold) =>
+ n__(`${digitText(bold)} total test`, `${digitText(bold)} total tests`, total),
+
+ reportError: s__('Reports|An error occurred while loading report'),
+ reportErrorWithName: (name) =>
+ sprintf(s__('Reports|An error occurred while loading %{name} results'), { name }),
+ headReportParsingError: s__('Reports|Head report parsing error:'),
+ baseReportParsingError: s__('Reports|Base report parsing error:'),
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
new file mode 100644
index 00000000000..65d9257903f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
@@ -0,0 +1,82 @@
+import { uniqueId } from 'lodash';
+import axios from '~/lib/utils/axios_utils';
+import { EXTENSION_ICONS } from '../../constants';
+import { summaryTextBuilder, reportTextBuilder, reportSubTextBuilder } from './utils';
+import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants';
+
+export default {
+ name: 'WidgetTestSummary',
+ enablePolling: true,
+ i18n,
+ expandEvent: 'i_testing_summary_widget_total',
+ props: ['testResultsPath', 'headBlobPath', 'pipeline'],
+ computed: {
+ summary(data) {
+ if (data.parsingInProgress) {
+ return this.$options.i18n.loading;
+ }
+ if (data.hasSuiteError) {
+ return this.$options.i18n.error;
+ }
+ return summaryTextBuilder(this.$options.i18n.label, data.summary);
+ },
+ statusIcon(data) {
+ if (data.parsingInProgress) {
+ return null;
+ }
+ if (data.status === TESTS_FAILED_STATUS) {
+ return EXTENSION_ICONS.warning;
+ }
+ if (data.hasSuiteError) {
+ return EXTENSION_ICONS.failed;
+ }
+ return EXTENSION_ICONS.success;
+ },
+ tertiaryButtons() {
+ return [
+ {
+ text: this.$options.i18n.fullReport,
+ href: `${this.pipeline.path}/test_report`,
+ target: '_blank',
+ },
+ ];
+ },
+ },
+ methods: {
+ fetchCollapsedData() {
+ return axios.get(this.testResultsPath).then(({ data = {}, status }) => {
+ return {
+ data: {
+ hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS),
+ parsingInProgress: status === 204,
+ ...data,
+ },
+ };
+ });
+ },
+ fetchFullData() {
+ return Promise.resolve(this.prepareReports());
+ },
+ suiteIcon(suite) {
+ if (suite.status === ERROR_STATUS) {
+ return EXTENSION_ICONS.error;
+ }
+ if (suite.status === TESTS_FAILED_STATUS) {
+ return EXTENSION_ICONS.failed;
+ }
+ return EXTENSION_ICONS.success;
+ },
+ prepareReports() {
+ return this.collapsedData.suites.map((suite) => {
+ return {
+ id: uniqueId('suite-'),
+ text: reportTextBuilder(suite),
+ subtext: reportSubTextBuilder(suite),
+ icon: {
+ name: this.suiteIcon(suite),
+ },
+ };
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
new file mode 100644
index 00000000000..a74ed20362f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
@@ -0,0 +1,55 @@
+import { i18n } from './constants';
+
+const textBuilder = (results, boldNumbers = false) => {
+ const { failed, errored, resolved, total } = results;
+
+ const failedOrErrored = (failed || 0) + (errored || 0);
+ const failedString = failed ? i18n.failedClause(failed, boldNumbers) : null;
+ const erroredString = errored ? i18n.erroredClause(errored, boldNumbers) : null;
+ const combinedString =
+ failed && errored ? `${failedString}, ${erroredString}` : failedString || erroredString;
+ const resolvedString = resolved ? i18n.resolvedClause(resolved, boldNumbers) : null;
+ const totalString = total ? i18n.totalClause(total, boldNumbers) : null;
+
+ let resultsString = i18n.noChanges(boldNumbers);
+
+ if (failedOrErrored) {
+ if (resolved) {
+ resultsString = i18n.resultsString(combinedString, resolvedString);
+ } else {
+ resultsString = combinedString;
+ }
+ } else if (resolved) {
+ resultsString = resolvedString;
+ }
+
+ return `${resultsString}, ${totalString}`;
+};
+
+export const summaryTextBuilder = (name = '', results = {}) => {
+ const resultsString = textBuilder(results, true);
+ return i18n.summaryText(name, resultsString);
+};
+
+export const reportTextBuilder = ({ name = '', summary = {}, status }) => {
+ if (!name) {
+ return i18n.reportError;
+ }
+ if (status === 'error') {
+ return i18n.reportErrorWithName(name);
+ }
+
+ const resultsString = textBuilder(summary);
+ return i18n.summaryText(name, resultsString);
+};
+
+export const reportSubTextBuilder = ({ suite_errors }) => {
+ const errors = [];
+ if (suite_errors?.head) {
+ errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`);
+ }
+ if (suite_errors?.base) {
+ errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`);
+ }
+ return errors.join('<br />');
+};
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 965746e79fb..4b3ad288768 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
@@ -47,6 +47,7 @@ import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
import codeQualityExtension from './extensions/code_quality';
+import testReportExtension from './extensions/test_report';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@@ -191,6 +192,9 @@ export default {
shouldRenderTerraformPlans() {
return Boolean(this.mr?.terraformReportsPath);
},
+ shouldRenderTestReport() {
+ return Boolean(this.mr?.testResultsPath);
+ },
mergeError() {
let { mergeError } = this.mr;
@@ -252,6 +256,11 @@ export default {
this.registerAccessibilityExtension();
}
},
+ shouldRenderTestReport(newVal) {
+ if (newVal) {
+ this.registerTestReportExtension();
+ }
+ },
},
mounted() {
MRWidgetService.fetchInitialData()
@@ -502,6 +511,11 @@ export default {
registerExtension(codeQualityExtension);
}
},
+ registerTestReportExtension() {
+ if (this.shouldRenderTestReport && this.shouldShowExtension) {
+ registerExtension(testReportExtension);
+ }
+ },
},
};
</script>
@@ -574,7 +588,7 @@ export default {
/>
<grouped-test-reports-app
- v-if="mr.testResultsPath"
+ v-if="mr.testResultsPath && !shouldShowExtension"
class="js-reports-container"
:endpoint="mr.testResultsPath"
:head-blob-path="mr.headBlobPath"
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index 7b803b0fcbb..6515d76c17e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -1,5 +1,5 @@
import { normalizeHeaders } from '~/lib/utils/common_utils';
-import axios from '../../lib/utils/axios_utils';
+import axios from '~/lib/utils/axios_utils';
export default class MRWidgetService {
constructor(endpoints) {
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
index d595c49f9aa..948d2505966 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -21,12 +21,12 @@ import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue';
import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants';
import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql';
import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql';
import alertQuery from '../graphql/queries/alert_sidebar_details.query.graphql';
import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql';
-import AlertMetrics from './alert_metrics.vue';
import AlertSidebar from './alert_sidebar.vue';
import AlertSummaryRow from './alert_summary_row.vue';
import SystemNote from './system_notes/system_note.vue';
@@ -74,7 +74,7 @@ export default {
TimeAgoTooltip,
AlertSidebar,
SystemNote,
- AlertMetrics,
+ MetricImagesTab,
},
inject: {
projectPath: {
@@ -372,13 +372,12 @@ export default {
</alert-summary-row>
<alert-details-table :alert="alert" :loading="loading" :statuses="statuses" />
</gl-tab>
- <gl-tab
+
+ <metric-images-tab
v-if="!isThreatMonitoringPage"
:data-testid="$options.tabsConfig[1].id"
:title="$options.tabsConfig[1].title"
- >
- <alert-metrics :dashboard-url="alert.metricsDashboardUrl" />
- </gl-tab>
+ />
<gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title">
<div v-if="alert.notes.nodes.length > 0" class="issuable-discussion">
<ul class="notes main-notes-list timeline">
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index d0155c18b9c..614748fa80d 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -3,6 +3,9 @@ import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import createStore from '~/vue_shared/components/metric_images/store';
+import service from './service';
import AlertDetails from './components/alert_details.vue';
import { PAGE_CONFIG } from './constants';
import sidebarStatusQuery from './graphql/queries/alert_sidebar_status.query.graphql';
@@ -12,7 +15,8 @@ Vue.use(VueApollo);
export default (selector) => {
const domEl = document.querySelector(selector);
- const { alertId, projectPath, projectIssuesPath, projectId, page } = domEl.dataset;
+ const { alertId, projectPath, projectIssuesPath, projectId, page, canUpdate } = domEl.dataset;
+ const iid = alertId;
const router = createRouter();
const resolvers = {
@@ -54,15 +58,20 @@ export default (selector) => {
page,
projectIssuesPath,
projectId,
+ iid,
statuses: PAGE_CONFIG[page].STATUSES,
+ canUpdate: parseBoolean(canUpdate),
};
+ const opsProperties = {};
+
if (page === PAGE_CONFIG.OPERATIONS.TITLE) {
const { TRACK_ALERTS_DETAILS_VIEWS_OPTIONS, TRACK_ALERT_STATUS_UPDATE_OPTIONS } = PAGE_CONFIG[
page
];
provide.trackAlertsDetailsViewsOptions = TRACK_ALERTS_DETAILS_VIEWS_OPTIONS;
provide.trackAlertStatusUpdateOptions = TRACK_ALERT_STATUS_UPDATE_OPTIONS;
+ opsProperties.store = createStore({}, service);
} else if (page === PAGE_CONFIG.THREAT_MONITORING.TITLE) {
provide.isThreatMonitoringPage = true;
}
@@ -74,6 +83,7 @@ export default (selector) => {
components: {
AlertDetails,
},
+ ...opsProperties,
provide,
apolloProvider,
router,
diff --git a/app/assets/javascripts/vue_shared/alert_details/service.js b/app/assets/javascripts/vue_shared/alert_details/service.js
new file mode 100644
index 00000000000..90f4961103b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/service.js
@@ -0,0 +1,43 @@
+import {
+ fetchAlertMetricImages,
+ uploadAlertMetricImage,
+ updateAlertMetricImage,
+ deleteAlertMetricImage,
+} from '~/rest_api';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+function replaceModelIId(payload = {}) {
+ delete Object.assign(payload, { alertIid: payload.modelIid }).modelIid;
+ return payload;
+}
+
+export const getMetricImages = async (payload) => {
+ const apiPayload = replaceModelIId(payload);
+ const response = await fetchAlertMetricImages(apiPayload);
+ return convertObjectPropsToCamelCase(response.data, { deep: true });
+};
+
+export const uploadMetricImage = async (payload) => {
+ const apiPayload = replaceModelIId(payload);
+ const response = await uploadAlertMetricImage(apiPayload);
+ return convertObjectPropsToCamelCase(response.data);
+};
+
+export const updateMetricImage = async (payload) => {
+ const apiPayload = replaceModelIId(payload);
+ const response = await updateAlertMetricImage(apiPayload);
+ return convertObjectPropsToCamelCase(response.data);
+};
+
+export const deleteMetricImage = async (payload) => {
+ const apiPayload = replaceModelIId(payload);
+ const response = await deleteAlertMetricImage(apiPayload);
+ return convertObjectPropsToCamelCase(response.data);
+};
+
+export default {
+ getMetricImages,
+ uploadMetricImage,
+ updateMetricImage,
+ deleteMetricImage,
+};
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 96970f4ce2f..f5d8811e83c 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -4,7 +4,7 @@ import { groupBy } from 'lodash';
import EmojiPicker from '~/emoji/components/picker.vue';
import { __, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { glEmojiTag } from '../../emoji';
+import { glEmojiTag } from '~/emoji';
// Internal constant, specific to this component, used when no `currentUserId` is given
const NO_USER_ID = -1;
@@ -93,12 +93,14 @@ export default {
return awardList.some((award) => award.user.id === this.currentUserId);
},
createAwardList(name, list) {
+ const url = list.length ? list[0].url : null;
+
return {
name,
list,
title: this.getAwardListTitle(list, name),
classes: this.getAwardClassBindings(list),
- html: glEmojiTag(name),
+ html: glEmojiTag(name, { url }),
};
},
getAwardListTitle(awardsList, name) {
@@ -198,10 +200,10 @@ export default {
</gl-button>
<div v-if="canAwardEmoji" class="award-menu-holder gl-my-2">
<emoji-picker
- v-if="glFeatures.improvedEmojiPicker"
v-gl-tooltip.viewport
:title="__('Add reaction')"
:toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
+ data-testid="emoji-picker"
@click="handleAward"
@shown="setIsMenuOpen(true)"
@hidden="setIsMenuOpen(false)"
@@ -219,24 +221,6 @@ export default {
</span>
</template>
</emoji-picker>
- <gl-button
- v-else
- v-gl-tooltip.viewport
- :class="addButtonClass"
- class="add-reaction-button js-add-award"
- title="Add reaction"
- :aria-label="__('Add reaction')"
- >
- <span class="reaction-control-icon reaction-control-icon-neutral">
- <gl-icon name="slight-smile" />
- </span>
- <span class="reaction-control-icon reaction-control-icon-positive">
- <gl-icon name="smiley" />
- </span>
- <span class="reaction-control-icon reaction-control-icon-super-positive">
- <gl-icon name="smile" />
- </span>
- </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index 3aaa7d915ea..0117c06c3d5 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
@@ -1,7 +1,5 @@
<script>
import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import LineHighlighter from '~/blob/line_highlighter';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
@@ -13,7 +11,7 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [ViewerMixin, glFeatureFlagsMixin()],
+ mixins: [ViewerMixin],
inject: ['blobHash'],
data() {
return {
@@ -21,21 +19,14 @@ export default {
};
},
computed: {
- refactorBlobViewerEnabled() {
- return this.glFeatures.refactorBlobViewer;
- },
-
lineNumbers() {
return this.content.split('\n').length;
},
},
mounted() {
- if (this.refactorBlobViewerEnabled) {
- // This line will be removed once we start using highlight.js on the frontend (https://gitlab.com/groups/gitlab-org/-/epics/7146)
- new LineHighlighter(); // eslint-disable-line no-new
- } else {
- const { hash } = window.location;
- if (hash) this.scrollToLine(hash, true);
+ const { hash } = window.location;
+ if (hash) {
+ this.scrollToLine(hash, true);
}
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
index af85a2fda06..f28a2801bc0 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import { numberToHumanSize } from '../../../../lib/utils/number_utils';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
export default {
components: {
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue
deleted file mode 100644
index 733accdff44..00000000000
--- a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<script>
-import Identicon from '../identicon.vue';
-import ProjectAvatarImage from './image.vue';
-
-export default {
- name: 'DeprecatedProjectAvatar',
- components: {
- Identicon,
- ProjectAvatarImage,
- },
- props: {
- project: {
- type: Object,
- required: true,
- },
- size: {
- type: Number,
- default: 40,
- required: false,
- },
- },
- computed: {
- sizeClass() {
- return `s${this.size}`;
- },
- },
-};
-</script>
-
-<template>
- <span :class="sizeClass" class="avatar-container rect-avatar project-avatar">
- <project-avatar-image
- v-if="project.avatar_url"
- :link-href="project.path"
- :img-src="project.avatar_url"
- :img-alt="project.name"
- :img-size="size"
- />
- <identicon
- v-else
- :entity-id="project.id"
- :entity-name="project.name"
- :size-class="sizeClass"
- class="rect-avatar"
- />
- </span>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue
deleted file mode 100644
index 269736c799c..00000000000
--- a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-/* This is a re-usable vue component for rendering a project avatar that
- does not need to link to the project's profile. The image and an optional
- tooltip can be configured by props passed to this component.
-
- Sample configuration:
-
- <project-avatar-image
- :lazy="true"
- :img-src="projectAvatarSrc"
- :img-alt="tooltipText"
- :tooltip-text="tooltipText"
- tooltip-placement="top"
- />
-
- */
-import defaultAvatarUrl from 'images/no_avatar.png';
-import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
-
-export default {
- name: 'ProjectAvatarImage',
- props: {
- lazy: {
- type: Boolean,
- required: false,
- default: false,
- },
- imgSrc: {
- type: String,
- required: false,
- default: defaultAvatarUrl,
- },
- cssClasses: {
- type: String,
- required: false,
- default: '',
- },
- imgAlt: {
- type: String,
- required: false,
- default: __('project avatar'),
- },
- size: {
- type: Number,
- required: false,
- default: 20,
- },
- },
- computed: {
- // API response sends null when gravatar is disabled and
- // we provide an empty string when we use it inside project avatar link.
- // In both cases we should render the defaultAvatarUrl
- sanitizedSource() {
- return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- },
- resultantSrcAttribute() {
- return this.lazy ? placeholderImage : this.sanitizedSource;
- },
- avatarSizeClass() {
- return `s${this.size}`;
- },
- },
-};
-</script>
-
-<template>
- <img
- :class="{
- lazy: lazy,
- [avatarSizeClass]: true,
- [cssClasses]: true,
- }"
- :src="resultantSrcAttribute"
- :width="size"
- :height="size"
- :alt="imgAlt"
- :data-src="sanitizedSource"
- class="avatar"
- />
-</template>
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 014276c7e36..d14d8c9b92e 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -37,7 +37,7 @@ export default {
<template>
<div v-show="showAlert">
- <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
+ <local-storage-sync v-model="isDismissed" :storage-key="storageKey" />
<gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert">
<slot></slot>
</gl-alert>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 5cdf7b6a3b2..6638a5de62f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -79,6 +79,16 @@ export default {
required: false,
default: '',
},
+ searchButtonAttributes: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ searchInputAttributes: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending;
@@ -163,33 +173,6 @@ export default {
return undefined;
},
},
- watch: {
- /**
- * GlFilteredSearch currently doesn't emit any event when
- * tokens are manually removed from search field so we'd
- * never know when user actually clears all the tokens.
- * This watcher listens for updates to `filterValue` on
- * such instances. :(
- */
- filterValue(newValue, oldValue) {
- const [firstVal] = newValue;
- if (
- !this.initialRender &&
- newValue.length === 1 &&
- firstVal.type === 'filtered-search-term' &&
- !firstVal.value.data
- ) {
- const filtersCleared =
- oldValue[0].type !== 'filtered-search-term' || oldValue[0].value.data !== '';
- this.$emit('onFilter', [], filtersCleared);
- }
-
- // Set initial render flag to false
- // as we don't want to emit event
- // on initial load when value is empty already.
- this.initialRender = false;
- },
- },
created() {
if (this.recentSearchesStorageKey) this.setupRecentSearch();
},
@@ -322,6 +305,10 @@ export default {
return tokenOption.title;
},
+ onClear() {
+ const cleared = true;
+ this.$emit('onFilter', [], cleared);
+ },
},
};
</script>
@@ -343,8 +330,11 @@ export default {
:available-tokens="tokens"
:history-items="filteredRecentSearches"
:suggestions-list-class="suggestionsListClass"
+ :search-button-attributes="searchButtonAttributes"
+ :search-input-attributes="searchInputAttributes"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
+ @clear="onClear"
@clear-history="handleClearHistory"
@submit="handleFilterSubmit"
>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index b70317b2ec4..696456be990 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -95,7 +95,6 @@ export default {
v-if="activeTokenValue"
:size="16"
:src="getAvatarUrl(activeTokenValue)"
- shape="circle"
class="gl-mr-2"
/>
{{ activeTokenValue ? activeTokenValue.name : inputValue }}
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 06949b59823..69548f0e7a8 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -110,7 +110,7 @@ export default {
v-gl-tooltip.hover="toggleVisibilityLabel"
:aria-label="toggleVisibilityLabel"
:icon="toggleVisibilityIcon"
- @click="handleToggleVisibilityButtonClick"
+ @click.stop="handleToggleVisibilityButtonClick"
/>
<clipboard-button
v-if="showCopyButton"
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 9bff469b670..f2abade8036 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -8,8 +8,8 @@ import {
GlTooltip,
} from '@gitlab/ui';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { glEmojiTag } from '../../emoji';
-import { __, sprintf } from '../../locale';
+import { glEmojiTag } from '~/emoji';
+import { __, sprintf } from '~/locale';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
@@ -117,7 +117,7 @@ export default {
<template>
<header
- class="page-content-header gl-display-flex gl-min-h-7"
+ class="page-content-header gl-md-display-flex gl-min-h-7"
data-qa-selector="pipeline_header"
data-testid="ci-header-content"
>
@@ -163,11 +163,7 @@ export default {
</template>
</section>
- <section
- v-if="$slots.default"
- data-testid="ci-header-action-buttons"
- class="gl-display-flex gl-mr-3"
- >
+ <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex">
<slot></slot>
</section>
<gl-button
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index f3b871c91b6..c3f184446a8 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -21,12 +21,17 @@ export default {
default: () => ({}),
},
},
+ methods: {
+ targetFn() {
+ return this.$refs.popoverTrigger?.$el;
+ },
+ },
};
</script>
<template>
<span>
- <gl-button ref="popoverTrigger" variant="link" icon="question" :aria-label="__('Help')" />
- <gl-popover :target="() => $refs.popoverTrigger.$el" v-bind="options">
+ <gl-button ref="popoverTrigger" variant="link" icon="question-o" :aria-label="__('Help')" />
+ <gl-popover :target="targetFn" v-bind="options">
<template v-if="options.title" #title>
<span v-safe-html="options.title"></span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
deleted file mode 100644
index 87a995464fa..00000000000
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
-
-export default {
- props: {
- entityId: {
- type: [Number, String],
- required: true,
- },
- entityName: {
- type: String,
- required: true,
- },
- sizeClass: {
- type: String,
- required: false,
- default: 's40',
- },
- },
- computed: {
- identiconBackgroundClass() {
- return getIdenticonBackgroundClass(this.entityId);
- },
- identiconTitle() {
- return getIdenticonTitle(this.entityName);
- },
- },
-};
-</script>
-
-<template>
- <div ref="identicon" :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon">
- {{ identiconTitle }}
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue
deleted file mode 100644
index 11caf3be00a..00000000000
--- a/app/assets/javascripts/vue_shared/components/line_numbers.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<script>
-import { GlIcon, GlLink } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- GlLink,
- },
- props: {
- lines: {
- type: Number,
- required: true,
- },
- },
-};
-</script>
-<template>
- <div class="line-numbers">
- <gl-link
- v-for="line in lines"
- :id="`L${line}`"
- :key="line"
- class="diff-line-num gl-shadow-none!"
- :to="`#LC${line}`"
- :data-line-number="line"
- >
- <gl-icon :size="12" name="link" />
- {{ line }}
- </gl-link>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
index 33e77b6510c..4ece87310c7 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -1,6 +1,18 @@
<script>
-import { isEqual } from 'lodash';
+import { isEqual, isString } from 'lodash';
+/**
+ * This component will save and restore a value to and from localStorage.
+ * The value will be saved only when the value changes; the initial value won't be saved.
+ *
+ * By default, the value will be saved using JSON.stringify(), and retrieved back using JSON.parse().
+ *
+ * If you would like to save the raw string instead, you may set the 'asString' prop to true, though be aware that this is a
+ * legacy prop to maintain backwards compatibility.
+ *
+ * For new components saving data for the first time, it's recommended to not use 'asString' even if you're saving a string; it will still be
+ * saved and restored properly using JSON.stringify()/JSON.parse().
+ */
export default {
props: {
storageKey: {
@@ -12,7 +24,7 @@ export default {
required: false,
default: '',
},
- asJson: {
+ asString: {
type: Boolean,
required: false,
default: false,
@@ -30,6 +42,8 @@ export default {
},
watch: {
value(newVal) {
+ if (!this.persist) return;
+
this.saveValue(this.serialize(newVal));
},
clear(newVal) {
@@ -67,15 +81,22 @@ export default {
}
},
saveValue(val) {
- if (!this.persist) return;
-
localStorage.setItem(this.storageKey, val);
},
serialize(val) {
- return this.asJson ? JSON.stringify(val) : val;
+ if (!isString(val) && this.asString) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[gitlab] LocalStorageSync is saving`,
+ val,
+ `to the key "${this.storageKey}", but it is not a string and the 'asString' prop is true. This will save and restore the stringified value rather than the original value. If this is not intended, please remove or set the 'asString' prop to false.`,
+ );
+ }
+
+ return this.asString ? val : JSON.stringify(val);
},
deserialize(val) {
- return this.asJson ? JSON.parse(val) : val;
+ return this.asString ? val : JSON.parse(val);
},
},
render() {
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 709d3592828..926034efd10 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -1,9 +1,9 @@
<script>
-import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui';
+import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui';
import { __, n__ } from '~/locale';
export default {
- components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton },
+ components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert },
props: {
disabled: {
type: Boolean,
@@ -19,6 +19,11 @@ export default {
required: false,
default: 0,
},
+ errorMessage: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -55,6 +60,9 @@ export default {
>
<gl-dropdown-form class="gl-px-4! gl-m-0!">
<label for="commit-message">{{ __('Commit message') }}</label>
+ <gl-alert v-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-4">
+ {{ errorMessage }}
+ </gl-alert>
<gl-form-textarea
id="commit-message"
ref="commitMessage"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index e1020ce656b..722df3cc58b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { debounce, unescape } from 'lodash';
@@ -24,6 +24,9 @@ export default {
GlIcon,
Suggestions,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
mixins: [glFeatureFlagsMixin()],
props: {
/**
@@ -308,6 +311,9 @@ export default {
);
},
},
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
};
</script>
@@ -369,18 +375,19 @@ export default {
<div
v-show="previewMarkdown"
ref="markdown-preview"
+ v-safe-html:[$options.safeHtmlConfig]="markdownPreview"
class="js-vue-md-preview md md-preview-holder"
- v-html="markdownPreview /* eslint-disable-line vue/no-v-html */"
></div>
</template>
<div
v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading"
+ v-safe-html:[$options.safeHtmlConfig]="referencedCommands"
class="referenced-commands"
- v-html="referencedCommands /* eslint-disable-line vue/no-v-html */"
+ data-testid="referenced-commands"
></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
<gl-icon name="warning-solid" />
- <span v-html="addMultipleToDiscussionWarning /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="addMultipleToDiscussionWarning"></span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 13189670e17..d0bd5046bf0 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -10,7 +10,7 @@ import {
} from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
-import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
+import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
export default {
@@ -187,7 +187,7 @@ export default {
<template #tabs-end>
<div
data-testid="md-header-toolbar"
- :class="{ 'gl-display-none': previewMarkdown }"
+ :class="{ 'gl-display-none!': previewMarkdown }"
class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center"
>
<toolbar-button
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 7d8d8c0b90e..4d10c3f0a51 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -36,6 +36,11 @@ export default {
required: false,
default: 0,
},
+ failedToLoadMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
batchSuggestionsCount() {
@@ -80,6 +85,7 @@ export default {
:help-page-path="helpPagePath"
:default-commit-message="defaultCommitMessage"
:inapplicable-reason="suggestion.inapplicable_reason"
+ :failed-to-load-metadata="failedToLoadMetadata"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
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 648e9c9462f..8a1b8363f19 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
@@ -4,6 +4,10 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import ApplySuggestion from './apply_suggestion.vue';
+const APPLY_SUGGESTION_ERROR_MESSAGE = __(
+ 'Unable to fully load the default commit message. You can still apply this suggestion and the commit message will be correct.',
+);
+
export default {
components: { GlBadge, GlIcon, GlButton, GlLoadingIcon, ApplySuggestion },
directives: { 'gl-tooltip': GlTooltipDirective },
@@ -52,6 +56,11 @@ export default {
required: false,
default: 0,
},
+ failedToLoadMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -94,6 +103,9 @@ export default {
return true;
},
+ applySuggestionErrorMessage() {
+ return this.failedToLoadMetadata ? APPLY_SUGGESTION_ERROR_MESSAGE : null;
+ },
},
methods: {
apply(message) {
@@ -171,6 +183,7 @@ export default {
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
:batch-suggestions-count="batchSuggestionsCount"
+ :error-message="applySuggestionErrorMessage"
class="gl-ml-3"
@apply="apply"
/>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 2f6776f835e..de3eda6b04f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -47,6 +47,11 @@ export default {
required: false,
default: 0,
},
+ failedToLoadMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -60,6 +65,9 @@ export default {
noteHtml() {
this.reset();
},
+ failedToLoadMetadata() {
+ this.reset();
+ },
},
mounted() {
this.renderSuggestions();
@@ -105,6 +113,7 @@ export default {
helpPagePath,
defaultCommitMessage,
suggestionsCount,
+ failedToLoadMetadata,
} = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
@@ -117,6 +126,7 @@ export default {
helpPagePath,
defaultCommitMessage,
suggestionsCount,
+ failedToLoadMetadata,
},
});
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue
new file mode 100644
index 00000000000..3e796a73f72
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal, GlTab } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { __, s__ } from '~/locale';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlLoadingIcon,
+ GlModal,
+ GlTab,
+ MetricImagesTable,
+ UploadDropzone,
+ },
+ inject: ['canUpdate', 'projectId', 'iid'],
+ data() {
+ return {
+ currentFiles: [],
+ modalVisible: false,
+ modalUrl: '',
+ modalUrlText: '',
+ };
+ },
+ computed: {
+ ...mapState(['metricImages', 'isLoadingMetricImages', 'isUploadingImage']),
+ actionPrimaryProps() {
+ return {
+ text: this.$options.i18n.modalUpload,
+ attributes: {
+ loading: this.isUploadingImage,
+ disabled: this.isUploadingImage,
+ category: 'primary',
+ variant: 'confirm',
+ },
+ };
+ },
+ },
+ mounted() {
+ this.setInitialData({ modelIid: this.iid, projectId: this.projectId });
+ this.fetchImages();
+ },
+ methods: {
+ ...mapActions(['fetchImages', 'uploadImage', 'setInitialData']),
+ clearInputs() {
+ this.modalVisible = false;
+ this.modalUrl = '';
+ this.modalUrlText = '';
+ this.currentFile = false;
+ },
+ openMetricDialog(files) {
+ this.modalVisible = true;
+ this.currentFiles = files;
+ },
+ async onUpload() {
+ try {
+ await this.uploadImage({
+ files: this.currentFiles,
+ url: this.modalUrl,
+ urlText: this.modalUrlText,
+ });
+ // Error case handled within action
+ } finally {
+ this.clearInputs();
+ }
+ },
+ },
+ i18n: {
+ modalUpload: __('Upload'),
+ modalCancel: __('Cancel'),
+ modalTitle: s__('Incidents|Add image details'),
+ modalDescription: s__(
+ "Incidents|Add text or a link to display with your image. If you don't add either, the file name displays instead.",
+ ),
+ dropDescription: s__(
+ 'Incidents|Drop or %{linkStart}upload%{linkEnd} a metric screenshot to attach it to the incident',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-tab :title="s__('Incident|Metrics')" data-testid="metrics-tab">
+ <div v-if="isLoadingMetricImages">
+ <gl-loading-icon class="gl-p-5" size="sm" />
+ </div>
+ <gl-modal
+ modal-id="upload-metric-modal"
+ size="sm"
+ :action-primary="actionPrimaryProps"
+ :action-cancel="{ text: $options.i18n.modalCancel }"
+ :title="$options.i18n.modalTitle"
+ :visible="modalVisible"
+ @hidden="clearInputs"
+ @primary.prevent="onUpload"
+ >
+ <p>{{ $options.i18n.modalDescription }}</p>
+ <gl-form-group :label="__('Text (optional)')" label-for="upload-text-input">
+ <gl-form-input id="upload-text-input" v-model="modalUrlText" />
+ </gl-form-group>
+
+ <gl-form-group
+ :label="__('Link (optional)')"
+ label-for="upload-url-input"
+ :description="s__('Incidents|Must start with http or https')"
+ >
+ <gl-form-input id="upload-url-input" v-model="modalUrl" />
+ </gl-form-group>
+ </gl-modal>
+ <metric-images-table v-for="metric in metricImages" :key="metric.id" v-bind="metric" />
+ <upload-dropzone
+ v-if="canUpdate"
+ :drop-description-message="$options.i18n.dropDescription"
+ @change="openMetricDialog"
+ />
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue
new file mode 100644
index 00000000000..8eb8e52728d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue
@@ -0,0 +1,266 @@
+<script>
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlCard,
+ GlIcon,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { __, s__ } from '~/locale';
+
+export default {
+ i18n: {
+ modalDelete: __('Delete'),
+ modalDescription: s__('Incident|Are you sure you wish to delete this image?'),
+ modalCancel: __('Cancel'),
+ modalTitle: s__('Incident|Deleting %{filename}'),
+ editModalUpdate: __('Update'),
+ editModalTitle: s__('Incident|Editing %{filename}'),
+ editIconTitle: s__('Incident|Edit image text or link'),
+ deleteIconTitle: s__('Incident|Delete image'),
+ },
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlCard,
+ GlIcon,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['canUpdate'],
+ props: {
+ id: {
+ type: Number,
+ required: true,
+ },
+ filePath: {
+ type: String,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ urlText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ isCollapsed: false,
+ isDeleting: false,
+ isUpdating: false,
+ modalVisible: false,
+ editModalVisible: false,
+ modalUrl: this.url,
+ modalUrlText: this.urlText,
+ };
+ },
+ computed: {
+ deleteActionPrimaryProps() {
+ return {
+ text: this.$options.i18n.modalDelete,
+ attributes: {
+ loading: this.isDeleting,
+ disabled: this.isDeleting,
+ category: 'primary',
+ variant: 'danger',
+ },
+ };
+ },
+ updateActionPrimaryProps() {
+ return {
+ text: this.$options.i18n.editModalUpdate,
+ attributes: {
+ loading: this.isUpdating,
+ disabled: this.isUpdating,
+ category: 'primary',
+ variant: 'confirm',
+ },
+ };
+ },
+ arrowIconName() {
+ return this.isCollapsed ? 'chevron-right' : 'chevron-down';
+ },
+ bodyClass() {
+ return [
+ 'gl-border-1',
+ 'gl-border-t-solid',
+ 'gl-border-gray-100',
+ { 'gl-display-none': this.isCollapsed },
+ ];
+ },
+ },
+ methods: {
+ ...mapActions(['deleteImage', 'updateImage']),
+ toggleCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+ },
+ resetEditFields() {
+ this.modalUrl = this.url;
+ this.modalUrlText = this.urlText;
+ this.editModalVisible = false;
+ this.modalVisible = false;
+ },
+ async onDelete() {
+ try {
+ this.isDeleting = true;
+ await this.deleteImage(this.id);
+ } finally {
+ this.isDeleting = false;
+ this.modalVisible = false;
+ }
+ },
+ async onUpdate() {
+ try {
+ this.isUpdating = true;
+ await this.updateImage({
+ imageId: this.id,
+ url: this.modalUrl,
+ urlText: this.modalUrlText,
+ });
+ } finally {
+ this.isUpdating = false;
+ this.modalUrl = '';
+ this.modalUrlText = '';
+ this.editModalVisible = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-card
+ class="collapsible-card border gl-p-0 gl-mb-5"
+ header-class="gl-display-flex gl-align-items-center gl-border-b-0 gl-py-3"
+ :body-class="bodyClass"
+ >
+ <gl-modal
+ body-class="gl-pb-0! gl-min-h-6!"
+ modal-id="delete-metric-modal"
+ size="sm"
+ :visible="modalVisible"
+ :action-primary="deleteActionPrimaryProps"
+ :action-cancel="{ text: $options.i18n.modalCancel }"
+ @primary.prevent="onDelete"
+ @hidden="resetEditFields"
+ >
+ <template #modal-title>
+ <gl-sprintf :message="$options.i18n.modalTitle">
+ <template #filename>
+ {{ filename }}
+ </template>
+ </gl-sprintf>
+ </template>
+ <p>{{ $options.i18n.modalDescription }}</p>
+ </gl-modal>
+
+ <gl-modal
+ modal-id="edit-metric-modal"
+ size="sm"
+ :action-primary="updateActionPrimaryProps"
+ :action-cancel="{ text: $options.i18n.modalCancel }"
+ :visible="editModalVisible"
+ data-testid="metric-image-edit-modal"
+ @hidden="resetEditFields"
+ @primary.prevent="onUpdate"
+ >
+ <template #modal-title>
+ <gl-sprintf :message="$options.i18n.editModalTitle">
+ <template #filename>
+ {{ filename }}
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <gl-form-group :label="__('Text (optional)')" label-for="upload-text-input">
+ <gl-form-input
+ id="upload-text-input"
+ v-model="modalUrlText"
+ data-testid="metric-image-text-field"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ :label="__('Link (optional)')"
+ label-for="upload-url-input"
+ :description="s__('Incidents|Must start with http or https')"
+ >
+ <gl-form-input
+ id="upload-url-input"
+ v-model="modalUrl"
+ data-testid="metric-image-url-field"
+ />
+ </gl-form-group>
+ </gl-modal>
+
+ <template #header>
+ <div class="gl-w-full gl-display-flex gl-flex-direction-row gl-justify-content-space-between">
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center gl-w-full">
+ <gl-button
+ class="collapsible-card-btn gl-display-flex gl-text-decoration-none gl-reset-color! gl-hover-text-blue-800! gl-shadow-none!"
+ :aria-label="filename"
+ variant="link"
+ category="tertiary"
+ data-testid="collapse-button"
+ @click="toggleCollapsed"
+ >
+ <gl-icon class="gl-mr-2" :name="arrowIconName" />
+ </gl-button>
+ <gl-link v-if="url" :href="url" target="_blank" data-testid="metric-image-label-span">
+ {{ urlText == null || urlText == '' ? filename : urlText }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ <span v-else data-testid="metric-image-label-span">{{
+ urlText == null || urlText == '' ? filename : urlText
+ }}</span>
+ <div class="gl-ml-auto btn-group">
+ <gl-button
+ v-if="canUpdate"
+ v-gl-tooltip.bottom
+ icon="pencil"
+ :aria-label="__('Edit')"
+ :title="$options.i18n.editIconTitle"
+ data-testid="edit-button"
+ @click="editModalVisible = true"
+ />
+ <gl-button
+ v-if="canUpdate"
+ v-gl-tooltip.bottom
+ icon="remove"
+ :aria-label="__('Delete')"
+ :title="$options.i18n.deleteIconTitle"
+ data-testid="delete-button"
+ @click="modalVisible = true"
+ />
+ </div>
+ </div>
+ </div>
+ </template>
+ <div
+ v-show="!isCollapsed"
+ class="gl-display-flex gl-flex-direction-column"
+ data-testid="metric-image-body"
+ >
+ <img class="gl-max-w-full gl-align-self-center" :src="filePath" />
+ </div>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
new file mode 100644
index 00000000000..832fb891838
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
@@ -0,0 +1,85 @@
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import * as types from './mutation_types';
+
+export const fetchImagesFactory = (service) => async ({ state, commit }) => {
+ commit(types.REQUEST_METRIC_IMAGES);
+ const { modelIid, projectId } = state;
+
+ try {
+ const response = await service.getMetricImages({ id: projectId, modelIid });
+ commit(types.RECEIVE_METRIC_IMAGES_SUCCESS, response);
+ } catch (error) {
+ commit(types.RECEIVE_METRIC_IMAGES_ERROR);
+ createFlash({ message: s__('MetricImages|There was an issue loading metric images.') });
+ }
+};
+
+export const uploadImageFactory = (service) => async (
+ { state, commit },
+ { files, url, urlText },
+) => {
+ commit(types.REQUEST_METRIC_UPLOAD);
+
+ const { modelIid, projectId } = state;
+
+ try {
+ const response = await service.uploadMetricImage({
+ file: files.item(0),
+ id: projectId,
+ modelIid,
+ url,
+ urlText,
+ });
+ commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response);
+ } catch (error) {
+ commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
+ createFlash({ message: s__('MetricImages|There was an issue uploading your image.') });
+ }
+};
+
+export const updateImageFactory = (service) => async (
+ { state, commit },
+ { imageId, url, urlText },
+) => {
+ commit(types.REQUEST_METRIC_UPLOAD);
+
+ const { modelIid, projectId } = state;
+
+ try {
+ const response = await service.updateMetricImage({
+ modelIid,
+ id: projectId,
+ imageId,
+ url,
+ urlText,
+ });
+ commit(types.RECEIVE_METRIC_UPDATE_SUCCESS, response);
+ } catch (error) {
+ commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
+ createFlash({ message: s__('MetricImages|There was an issue updating your image.') });
+ }
+};
+
+export const deleteImageFactory = (service) => async ({ state, commit }, imageId) => {
+ const { modelIid, projectId } = state;
+
+ try {
+ await service.deleteMetricImage({ imageId, id: projectId, modelIid });
+ commit(types.RECEIVE_METRIC_DELETE_SUCCESS, imageId);
+ } catch (error) {
+ createFlash({ message: s__('MetricImages|There was an issue deleting the image.') });
+ }
+};
+
+export const setInitialData = ({ commit }, data) => {
+ commit(types.SET_INITIAL_DATA, data);
+};
+
+export default (service) => ({
+ fetchImages: fetchImagesFactory(service),
+ uploadImage: uploadImageFactory(service),
+ updateImage: updateImageFactory(service),
+ deleteImage: deleteImageFactory(service),
+ setInitialData,
+});
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/index.js b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js
new file mode 100644
index 00000000000..f13dde9a2bc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import actionsFactory from './actions';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export default (initialState, service) =>
+ new Vuex.Store({
+ actions: actionsFactory(service),
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js
new file mode 100644
index 00000000000..8f1b31217a2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js
@@ -0,0 +1,13 @@
+export const REQUEST_METRIC_IMAGES = 'REQUEST_METRIC_IMAGES';
+export const RECEIVE_METRIC_IMAGES_SUCCESS = 'RECEIVE_METRIC_IMAGES_SUCCESS';
+export const RECEIVE_METRIC_IMAGES_ERROR = 'RECEIVE_METRIC_IMAGES_ERROR';
+
+export const REQUEST_METRIC_UPLOAD = 'REQUEST_METRIC_UPLOAD';
+export const RECEIVE_METRIC_UPLOAD_SUCCESS = 'RECEIVE_METRIC_UPLOAD_SUCCESS';
+export const RECEIVE_METRIC_UPLOAD_ERROR = 'RECEIVE_METRIC_UPLOAD_ERROR';
+
+export const RECEIVE_METRIC_UPDATE_SUCCESS = 'RECEIVE_METRIC_UPDATE_SUCCESS';
+
+export const RECEIVE_METRIC_DELETE_SUCCESS = 'RECEIVE_METRIC_DELETE_SUCCESS';
+
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js b/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js
new file mode 100644
index 00000000000..b42234b2829
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js
@@ -0,0 +1,39 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_METRIC_IMAGES](state) {
+ state.isLoadingMetricImages = true;
+ },
+ [types.RECEIVE_METRIC_IMAGES_SUCCESS](state, images) {
+ state.metricImages = images || [];
+ state.isLoadingMetricImages = false;
+ },
+ [types.RECEIVE_METRIC_IMAGES_ERROR](state) {
+ state.isLoadingMetricImages = false;
+ },
+ [types.REQUEST_METRIC_UPLOAD](state) {
+ state.isUploadingImage = true;
+ },
+ [types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, image) {
+ state.metricImages.push(image);
+ state.isUploadingImage = false;
+ },
+ [types.RECEIVE_METRIC_UPLOAD_ERROR](state) {
+ state.isUploadingImage = false;
+ },
+ [types.RECEIVE_METRIC_UPDATE_SUCCESS](state, image) {
+ state.isUploadingImage = false;
+ const metricIndex = state.metricImages.findIndex((img) => img.id === image.id);
+ if (metricIndex >= 0) {
+ state.metricImages.splice(metricIndex, 1, image);
+ }
+ },
+ [types.RECEIVE_METRIC_DELETE_SUCCESS](state, imageId) {
+ const metricIndex = state.metricImages.findIndex((image) => image.id === imageId);
+ state.metricImages.splice(metricIndex, 1);
+ },
+ [types.SET_INITIAL_DATA](state, { modelIid, projectId }) {
+ state.modelIid = modelIid;
+ state.projectId = projectId;
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/state.js b/app/assets/javascripts/vue_shared/components/metric_images/store/state.js
new file mode 100644
index 00000000000..b734e5c87a6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/state.js
@@ -0,0 +1,10 @@
+export default ({ modelIid, projectId } = {}) => ({
+ // Initial state
+ modelIid,
+ projectId,
+
+ // View state
+ metricImages: [],
+ isLoadingMetricImages: false,
+ isUploadingImage: false,
+});
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 1963d1aa7fe..dd7a851b1be 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -31,7 +31,7 @@ import { __ } from '~/locale';
import initMRPopovers from '~/mr_popover/';
import noteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { spriteIcon } from '../../../lib/utils/common_utils';
+import { spriteIcon } from '~/lib/utils/common_utils';
import TimelineEntryItem from './timeline_entry_item.vue';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue
index f16187022a5..402e75962d2 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar } from '@gitlab/ui';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
components: {
@@ -31,12 +32,13 @@ export default {
return this.alt ?? this.projectName;
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
<gl-avatar
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
:entity-name="projectName"
:src="projectAvatarUrl"
:alt="avatarAlt"
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 0bd57c84018..19ffbe37ce7 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
@@ -3,7 +3,7 @@ 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';
-import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
export default {
name: 'ProjectListItem',
@@ -22,6 +22,9 @@ export default {
matcher: { type: String, required: false, default: '' },
},
computed: {
+ projectAvatarUrl() {
+ return this.project.avatar_url || this.project.avatarUrl;
+ },
projectNameWithNamespace() {
return this.project.nameWithNamespace || this.project.name_with_namespace;
},
@@ -49,7 +52,11 @@ export default {
class="gl-display-flex gl-align-items-center gl-flex-wrap project-namespace-name-container"
>
<gl-icon v-if="selected" class="js-selected-icon" name="mobile-issue-close" />
- <project-avatar class="gl-flex-shrink-0 js-project-avatar" :project="project" :size="32" />
+ <project-avatar
+ :project-avatar-url="projectAvatarUrl"
+ :project-name="projectNameWithNamespace"
+ class="gl-mr-3"
+ />
<div
v-if="truncatedNamespace"
:title="projectNameWithNamespace"
diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
index 36b1a9c49f4..43a8e241d77 100644
--- a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
@@ -43,7 +43,7 @@ export default {
</script>
<template>
- <local-storage-sync :storage-key="storageKey" :value="selected" @input="setSelected">
+ <local-storage-sync :storage-key="storageKey" :value="selected" as-string @input="setSelected">
<gl-dropdown :text="dropdownText" lazy>
<gl-dropdown-item
v-for="option in parsedOptions"
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index d108d8d689d..fc0976b0792 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -1,6 +1,7 @@
<script>
import { GlAvatar, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { isEqual } from 'lodash';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
name: 'TitleArea',
@@ -53,6 +54,7 @@ export default {
}
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
@@ -64,7 +66,7 @@ export default {
<gl-avatar
v-if="avatar"
:src="avatar"
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
class="gl-align-self-center gl-mr-4"
/>
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 f53b75df4eb..522fbc07f5e 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,8 +1,11 @@
<script>
import { debounce } from 'lodash';
+import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issues/constants';
+
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { issuableLabelsQueries } from '~/sidebar/constants';
@@ -21,6 +24,7 @@ export default {
DropdownContents,
SidebarEditableItem,
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
allowLabelEdit: {
default: false,
@@ -106,7 +110,7 @@ export default {
data() {
return {
contentIsOnViewport: true,
- issuableLabels: [],
+ issuable: null,
labelsSelectInProgress: false,
oldIid: null,
sidebarExpandedOnClick: false,
@@ -114,14 +118,23 @@ export default {
},
computed: {
isLoading() {
- return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading;
+ return this.labelsSelectInProgress || this.$apollo.queries.issuable.loading;
},
issuableLabelIds() {
return this.issuableLabels.map((label) => label.id);
},
+ issuableLabels() {
+ return this.issuable?.labels.nodes || [];
+ },
+ issuableId() {
+ return this.issuable?.id;
+ },
+ isRealtimeEnabled() {
+ return this.glFeatures.realtimeLabels;
+ },
},
apollo: {
- issuableLabels: {
+ issuable: {
query() {
return issuableLabelsQueries[this.issuableType].issuableQuery;
},
@@ -135,11 +148,40 @@ export default {
};
},
update(data) {
- return data.workspace?.issuable?.labels.nodes || [];
+ return data.workspace?.issuable;
},
error() {
createFlash({ message: __('Error fetching labels.') });
},
+ subscribeToMore: {
+ document() {
+ return issuableLabelsSubscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return !this.issuableId || !this.isDropdownVariantSidebar || !this.isRealtimeEnabled;
+ },
+ updateQuery(
+ _,
+ {
+ subscriptionData: {
+ data: { issuableLabelsUpdated },
+ },
+ },
+ ) {
+ if (issuableLabelsUpdated) {
+ const {
+ id,
+ labels: { nodes },
+ } = issuableLabelsUpdated;
+ this.$emit('updateSelectedLabels', { id, labels: nodes });
+ }
+ },
+ },
},
},
watch: {
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
new file mode 100644
index 00000000000..6babbca58c3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui';
+import ChunkLine from './chunk_line.vue';
+
+/*
+ * We only highlight the chunk that is currently visible to the user.
+ * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
+ *
+ * Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
+ * so by making text transparent and rendering raw (non-highlighted) text,
+ * the browser spends less resources on painting content that is not immediately relevant.
+ *
+ * Why use transparent text as opposed to hiding content entirely?
+ * 1. If content is hidden entirely, native find text (⌘ + F) won't work.
+ * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
+ */
+export default {
+ components: {
+ ChunkLine,
+ GlIntersectionObserver,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ chunkIndex: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ isHighlighted: {
+ type: Boolean,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ startingFrom: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ totalLines: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ language: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ lines() {
+ return this.content.split('\n');
+ },
+ },
+ methods: {
+ handleChunkAppear() {
+ if (!this.isHighlighted) {
+ this.$emit('appear', this.chunkIndex);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-intersection-observer @appear="handleChunkAppear">
+ <div v-if="isHighlighted">
+ <chunk-line
+ v-for="(line, index) in lines"
+ :key="index"
+ :number="startingFrom + index + 1"
+ :content="line"
+ :language="language"
+ />
+ </div>
+ <div v-else class="gl-display-flex">
+ <div class="gl-display-flex gl-flex-direction-column">
+ <a
+ v-for="(n, index) in totalLines"
+ :id="`L${startingFrom + index + 1}`"
+ :key="index"
+ class="gl-ml-5 gl-text-transparent"
+ :href="`#L${startingFrom + index + 1}`"
+ :data-line-number="startingFrom + index + 1"
+ data-testid="line-number"
+ >
+ {{ startingFrom + index + 1 }}
+ </a>
+ </div>
+ <div
+ class="gl-white-space-pre-wrap! gl-text-transparent"
+ data-testid="content"
+ v-text="content"
+ ></div>
+ </div>
+ </gl-intersection-observer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
new file mode 100644
index 00000000000..1b8e4bcfec6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+import { setAttributes } from '~/lib/utils/dom_utils';
+import { BIDI_CHARS, BIDI_CHARS_CLASS_LIST, BIDI_CHAR_TOOLTIP } from '../constants';
+
+export default {
+ components: {
+ GlLink,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ number: {
+ type: Number,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ language: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ formattedContent() {
+ let { content } = this;
+
+ BIDI_CHARS.forEach((bidiChar) => {
+ if (content.includes(bidiChar)) {
+ content = content.replace(bidiChar, this.wrapBidiChar(bidiChar));
+ }
+ });
+
+ return content;
+ },
+ },
+ methods: {
+ wrapBidiChar(bidiChar) {
+ const span = document.createElement('span');
+
+ setAttributes(span, {
+ class: BIDI_CHARS_CLASS_LIST,
+ title: BIDI_CHAR_TOOLTIP,
+ 'data-testid': 'bidi-wrapper',
+ });
+
+ span.innerText = bidiChar;
+
+ return span.outerHTML;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex">
+ <div class="line-numbers gl-pt-0! gl-pb-0! gl-absolute gl-z-index-3">
+ <gl-link
+ :id="`L${number}`"
+ class="file-line-num diff-line-num gl-user-select-none"
+ :to="`#L${number}`"
+ :data-line-number="number"
+ >
+ {{ number }}
+ </gl-link>
+ </div>
+
+ <pre
+ class="code highlight gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11!"
+ ><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 9efe0147c37..bed6dd4d5c6 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
// Language map from Rouge::Lexer to highlight.js
// Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md).
// Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages).
@@ -109,3 +111,26 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
xquery: 'xquery',
yaml: 'yaml',
};
+
+export const LINES_PER_CHUNK = 70;
+
+export const BIDI_CHARS = [
+ '\u202A', // Left-to-Right Embedding (Try treating following text as left-to-right)
+ '\u202B', // Right-to-Left Embedding (Try treating following text as right-to-left)
+ '\u202D', // Left-to-Right Override (Force treating following text as left-to-right)
+ '\u202E', // Right-to-Left Override (Force treating following text as right-to-left)
+ '\u2066', // Left-to-Right Isolate (Force treating following text as left-to-right without affecting adjacent text)
+ '\u2067', // Right-to-Left Isolate (Force treating following text as right-to-left without affecting adjacent text)
+ '\u2068', // First Strong Isolate (Force treating following text in direction indicated by the next character)
+ '\u202C', // Pop Directional Formatting (Terminate nearest LRE, RLE, LRO, or RLO)
+ '\u2069', // Pop Directional Isolate (Terminate nearest LRI or RLI)
+ '\u061C', // Arabic Letter Mark (Right-to-left zero-width Arabic character)
+ '\u200F', // Right-to-Left Mark (Right-to-left zero-width character non-Arabic character)
+ '\u200E', // Left-to-Right Mark (Left-to-right zero-width character)
+];
+
+export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip';
+
+export const BIDI_CHAR_TOOLTIP = __(
+ 'Potentially unwanted character detected: Unicode BiDi Control',
+);
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 4a78cbacec0..edf2229a9a1 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -1,16 +1,22 @@
<script>
import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
-import LineNumbers from '~/vue_shared/components/line_numbers.vue';
-import { sanitize } from '~/lib/dompurify';
-import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants';
-import { wrapLines } from './utils';
-
-const LINE_SELECT_CLASS_NAME = 'hll';
+import LineHighlighter from '~/blob/line_highlighter';
+import eventHub from '~/notes/event_hub';
+import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants';
+import Chunk from './components/chunk.vue';
+/*
+ * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
+ * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
+ *
+ * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
+ * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
+ * it does not trigger a repaint on a parent element that wraps all 1000 lines.
+ */
export default {
components: {
- LineNumbers,
GlLoadingIcon,
+ Chunk,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -27,46 +33,94 @@ export default {
content: this.blob.rawTextBlob,
language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
hljs: null,
+ firstChunk: null,
+ chunks: {},
+ isLoading: true,
+ isLineSelected: false,
+ lineHighlighter: null,
};
},
computed: {
+ splitContent() {
+ return this.content.split('\n');
+ },
lineNumbers() {
- return this.content.split('\n').length;
+ return this.splitContent.length;
},
- highlightedContent() {
- let highlightedContent;
- let { language } = this;
+ },
+ async created() {
+ this.generateFirstChunk();
+ this.hljs = await this.loadHighlightJS();
- if (this.hljs) {
- if (!language) {
- const hljsHighlightAuto = this.hljs.highlightAuto(this.content);
+ if (this.language) {
+ this.languageDefinition = await this.loadLanguage();
+ }
- highlightedContent = hljsHighlightAuto.value;
- language = hljsHighlightAuto.language;
- } else if (this.languageDefinition) {
- highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
- }
+ // Highlight the first chunk as soon as highlight.js is available
+ this.highlightChunk(null, true);
+
+ window.requestIdleCallback(async () => {
+ // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
+ this.generateRemainingChunks();
+ this.isLoading = false;
+ await this.$nextTick();
+ this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
+ });
+ },
+ methods: {
+ generateFirstChunk() {
+ const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
+ this.firstChunk = this.createChunk(lines);
+ },
+ generateRemainingChunks() {
+ const result = {};
+ for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) {
+ const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
+ const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK);
+ result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK);
}
- return wrapLines(highlightedContent, language);
+ this.chunks = result;
},
- },
- watch: {
- highlightedContent() {
- this.$nextTick(() => this.selectLine());
+ createChunk(lines, startingFrom = 0) {
+ return {
+ content: lines.join('\n'),
+ startingFrom,
+ totalLines: lines.length,
+ language: this.language,
+ isHighlighted: false,
+ };
},
- $route() {
+ highlightChunk(index, isFirstChunk) {
+ const chunk = isFirstChunk ? this.firstChunk : this.chunks[index];
+
+ if (chunk.isHighlighted) {
+ return;
+ }
+
+ const { highlightedContent, language } = this.highlight(chunk.content, this.language);
+
+ Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
+
this.selectLine();
+
+ this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
},
- },
- async mounted() {
- this.hljs = await this.loadHighlightJS();
+ highlight(content, language) {
+ let detectedLanguage = language;
+ let highlightedContent;
+ if (this.hljs) {
+ if (!detectedLanguage) {
+ const hljsHighlightAuto = this.hljs.highlightAuto(content);
+ highlightedContent = hljsHighlightAuto.value;
+ detectedLanguage = hljsHighlightAuto.language;
+ } else if (this.languageDefinition) {
+ highlightedContent = this.hljs.highlight(content, { language: this.language }).value;
+ }
+ }
- if (this.language) {
- this.languageDefinition = await this.loadLanguage();
- }
- },
- methods: {
+ return { highlightedContent, language: detectedLanguage };
+ },
loadHighlightJS() {
// If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
@@ -83,21 +137,14 @@ export default {
return languageDefinition;
},
- selectLine() {
- const hash = sanitize(this.$route.hash);
- const lineToSelect = hash && this.$el.querySelector(hash);
-
- if (!lineToSelect) {
+ async selectLine() {
+ if (this.isLineSelected || !this.lineHighlighter) {
return;
}
- if (this.$options.currentlySelectedLine) {
- this.$options.currentlySelectedLine.classList.remove(LINE_SELECT_CLASS_NAME);
- }
-
- lineToSelect.classList.add(LINE_SELECT_CLASS_NAME);
- this.$options.currentlySelectedLine = lineToSelect;
- lineToSelect.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ this.isLineSelected = true;
+ await this.$nextTick();
+ this.lineHighlighter.highlightHash(this.$route.hash);
},
},
userColorScheme: window.gon.user_color_scheme,
@@ -105,16 +152,36 @@ export default {
};
</script>
<template>
- <gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" />
<div
- v-else
- class="file-content code js-syntax-highlight blob-content gl-display-flex"
+ class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
:class="$options.userColorScheme"
data-type="simple"
+ :data-path="blob.path"
data-qa-selector="blob_viewer_file_content"
>
- <line-numbers :lines="lineNumbers" />
- <pre class="code highlight gl-pb-0!"><code v-safe-html="highlightedContent"></code>
- </pre>
+ <chunk
+ v-if="firstChunk"
+ :lines="firstChunk.lines"
+ :total-lines="firstChunk.totalLines"
+ :content="firstChunk.content"
+ :starting-from="firstChunk.startingFrom"
+ :is-highlighted="firstChunk.isHighlighted"
+ :language="firstChunk.language"
+ />
+
+ <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
+ <chunk
+ v-for="(chunk, key, index) in chunks"
+ v-else
+ :key="key"
+ :lines="chunk.lines"
+ :content="chunk.content"
+ :total-lines="chunk.totalLines"
+ :starting-from="chunk.startingFrom"
+ :is-highlighted="chunk.isHighlighted"
+ :chunk-index="index"
+ :language="chunk.language"
+ @appear="highlightChunk"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
deleted file mode 100644
index d726a8a55ff..00000000000
--- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export const wrapLines = (content, language) => {
- const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_).
-
- return (
- content &&
- content
- .split('\n')
- .map((line, i) => {
- let formattedLine;
- const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`;
-
- if (line.includes('<span class="hljs') && !line.includes('</span>')) {
- /**
- * In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span
- *
- * example (before): <span class="hljs-code">```bash
- * example (after): <span id="LC67" class="hljs-code">```bash
- */
- formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `);
- } else {
- formattedLine = `<span ${attributes} class="line">${line}</span>`;
- }
-
- return formattedLine;
- })
- .join('\n')
- );
-};
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 66088b33c99..e784bba6698 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective } from '@gitlab/ui';
import timeagoMixin from '../mixins/timeago';
-import '../../lib/utils/datetime_utility';
+import '~/lib/utils/datetime_utility';
/**
* Port of ruby helper time_ago_with_tooltip
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
index f52a3471ea4..c58a5357883 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -18,7 +18,7 @@
import { GlTooltip, GlAvatar } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
+import { placeholderImage } from '~/lazy_loader';
export default {
name: 'UserAvatarImageNew',
@@ -96,11 +96,12 @@ export default {
/>
<gl-tooltip
+ v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatar.$el"
:placement="tooltipPlacement"
boundary="window"
>
- <slot> {{ tooltipText }}</slot>
+ <slot>{{ tooltipText }}</slot>
</gl-tooltip>
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
index bca10c76038..15ba8e3b39b 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
@@ -18,7 +18,7 @@
import { GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
+import { placeholderImage } from '~/lazy_loader';
export default {
name: 'UserAvatarImageOld',
@@ -100,11 +100,12 @@ export default {
class="avatar"
/>
<gl-tooltip
+ v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
>
- <slot> {{ tooltipText }}</slot>
+ <slot>{{ tooltipText }}</slot>
</gl-tooltip>
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index e19d659c179..60b26d688b2 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '~/locale';
import UserAvatarLink from './user_avatar_link.vue';
@@ -8,6 +9,7 @@ export default {
UserAvatarLink,
GlButton,
},
+ mixins: [glFeatureFlagMixin()],
props: {
items: {
type: Array,
@@ -57,6 +59,9 @@ export default {
return sprintf(__('%{count} more'), { count });
},
+ imgCssClasses() {
+ return this.glFeatures.glAvatarForAllUserAvatars ? 'gl-mr-3' : '';
+ },
},
methods: {
expand() {
@@ -80,6 +85,7 @@ export default {
:img-alt="item.name"
:tooltip-text="item.name"
:img-size="imgSize"
+ :img-css-classes="imgCssClasses"
/>
<template v-if="hasBreakpoint">
<gl-button v-if="hasHiddenItems" variant="link" @click="expand">
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 41507ca94e2..cac8f0a9aa5 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
@@ -8,7 +8,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
-import { glEmojiTag } from '../../../emoji';
+import { glEmojiTag } from '~/emoji';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
const MAX_SKELETON_LINES = 4;
@@ -69,7 +69,7 @@ export default {
<gl-popover :target="target" :delay="200" boundary="viewport" placement="top">
<div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
<div class="gl-p-2 flex-shrink-1">
- <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" />
+ <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-mr-3!" />
</div>
<div class="gl-p-2 gl-w-full gl-min-w-0">
<template v-if="userIsLoading">
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 199516b3eb3..15f84e48179 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -314,6 +314,7 @@ export default {
<local-storage-sync
storage-key="gl-web-ide-button-selected"
:value="selection"
+ as-string
@input="select"
/>
<gl-modal
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index 028d48e7e8a..20f178dfb7d 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -1,5 +1,10 @@
<script>
-import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlKeysetPagination,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlPagination,
+} from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
index 45452f2ea35..c5f41d81167 100644
--- a/app/assets/javascripts/vue_shared/mixins/timeago.js
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -1,4 +1,4 @@
-import { formatDate, getTimeago } from '../../lib/utils/datetime_utility';
+import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
/**
* Mixin with time ago methods used in some vue components
@@ -14,25 +14,5 @@ export default {
tooltipTitle(time) {
return formatDate(time);
},
-
- durationTimeFormatted(duration) {
- const date = new Date(duration * 1000);
-
- let hh = date.getUTCHours();
- let mm = date.getUTCMinutes();
- let ss = date.getSeconds();
-
- if (hh < 10) {
- hh = `0${hh}`;
- }
- if (mm < 10) {
- mm = `0${mm}`;
- }
- if (ss < 10) {
- ss = `0${ss}`;
- }
-
- return `${hh}:${mm}:${ss}`;
- },
},
};
diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js
index 616848639f1..bc1f8865261 100644
--- a/app/assets/javascripts/vue_shared/translate.js
+++ b/app/assets/javascripts/vue_shared/translate.js
@@ -1,4 +1,4 @@
-import { __, n__, s__, sprintf } from '../locale';
+import { __, n__, s__, sprintf } from '~/locale';
export default (Vue) => {
Vue.mixin({
diff --git a/app/assets/javascripts/webpack.js b/app/assets/javascripts/webpack.js
index b901f17790f..1c6e632135d 100644
--- a/app/assets/javascripts/webpack.js
+++ b/app/assets/javascripts/webpack.js
@@ -8,5 +8,5 @@
*/
if (gon && gon.webpack_public_path) {
- __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line babel/camelcase
+ __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line camelcase
}
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 79840cc4f0f..232510b108d 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -2,12 +2,9 @@
import { escape } from 'lodash';
import { __ } from '~/locale';
-import { WI_TITLE_TRACK_LABEL } from '../constants';
-
export default {
- WI_TITLE_TRACK_LABEL,
props: {
- initialTitle: {
+ title: {
type: String,
required: false,
default: '',
@@ -23,11 +20,6 @@ export default {
default: false,
},
},
- data() {
- return {
- title: this.initialTitle,
- };
- },
methods: {
getSanitizedTitle(inputEl) {
const { innerText } = inputEl;
@@ -50,7 +42,6 @@ export default {
<h2
class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block"
:class="{ 'gl-cursor-not-allowed': disabled }"
- data-testid="title"
aria-labelledby="item-title"
>
<span
@@ -59,7 +50,6 @@ export default {
role="textbox"
:aria-label="__('Title')"
:data-placeholder="placeholder"
- :data-track-label="$options.WI_TITLE_TRACK_LABEL"
:contenteditable="!disabled"
class="gl-pseudo-placeholder"
@blur="handleBlur"
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
new file mode 100644
index 00000000000..40b6fcdd204
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
+
+export default {
+ i18n: {
+ deleteWorkItem: s__('WorkItem|Delete work item'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ workItemId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ emits: ['workItemDeleted', 'error'],
+ methods: {
+ deleteWorkItem() {
+ this.$apollo
+ .mutate({
+ mutation: deleteWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ },
+ },
+ })
+ .then(({ data: { workItemDelete, errors } }) => {
+ if (errors?.length) {
+ throw new Error(errors[0].message);
+ }
+
+ if (workItemDelete?.errors.length) {
+ throw new Error(workItemDelete.errors[0]);
+ }
+
+ this.$emit('workItemDeleted');
+ })
+ .catch((e) => {
+ this.$emit(
+ 'error',
+ e.message ||
+ s__('WorkItem|Something went wrong when deleting the work item. Please try again.'),
+ );
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="canUpdate">
+ <gl-dropdown
+ icon="ellipsis_v"
+ text-sr-only
+ :text="__('More actions')"
+ category="tertiary"
+ no-caret
+ right
+ >
+ <gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{
+ $options.i18n.deleteWorkItem
+ }}</gl-dropdown-item>
+ </gl-dropdown>
+ <gl-modal
+ modal-id="work-item-confirm-delete"
+ :title="$options.i18n.deleteWorkItem"
+ :ok-title="$options.i18n.deleteWorkItem"
+ ok-variant="danger"
+ @ok="deleteWorkItem"
+ >
+ {{
+ s__(
+ 'WorkItem|Are you sure you want to delete the work item? This action cannot be reversed.',
+ )
+ }}
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
new file mode 100644
index 00000000000..f2fb1e3ccbc
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { i18n } from '../constants';
+import workItemQuery from '../graphql/work_item.query.graphql';
+import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
+import WorkItemTitle from './work_item_title.vue';
+
+export default {
+ i18n,
+ components: {
+ GlAlert,
+ WorkItemTitle,
+ },
+ props: {
+ workItemId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ error: undefined,
+ workItem: {},
+ };
+ },
+ apollo: {
+ workItem: {
+ query: workItemQuery,
+ variables() {
+ return {
+ id: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ error() {
+ this.error = this.$options.i18n.fetchError;
+ },
+ subscribeToMore: {
+ document: workItemTitleSubscription,
+ variables() {
+ return {
+ issuableId: this.workItemId,
+ };
+ },
+ },
+ },
+ },
+ computed: {
+ workItemType() {
+ return this.workItem.workItemType?.name;
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <gl-alert v-if="error" variant="danger" @dismiss="error = undefined">
+ {{ error }}
+ </gl-alert>
+
+ <work-item-title
+ :loading="$apollo.queries.workItem.loading"
+ :work-item-id="workItem.id"
+ :work-item-title="workItem.title"
+ :work-item-type="workItemType"
+ @error="error = $event"
+ />
+ </section>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index 942677bb937..a79091fb8b2 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -1,15 +1,22 @@
<script>
-import { GlModal } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import workItemQuery from '../graphql/work_item.query.graphql';
-import ItemTitle from './item_title.vue';
+import { GlAlert, GlButton, GlModal } from '@gitlab/ui';
+import WorkItemActions from './work_item_actions.vue';
+import WorkItemDetail from './work_item_detail.vue';
export default {
components: {
+ GlAlert,
+ GlButton,
GlModal,
- ItemTitle,
+ WorkItemDetail,
+ WorkItemActions,
},
props: {
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
visible: {
type: Boolean,
required: true,
@@ -20,43 +27,55 @@ export default {
default: null,
},
},
+ emits: ['workItemDeleted', 'close'],
data() {
return {
- workItem: {},
+ error: undefined,
};
},
- apollo: {
- workItem: {
- query: workItemQuery,
- variables() {
- return {
- id: this.workItemId,
- };
- },
- update(data) {
- return data.workItem;
- },
- skip() {
- return !this.workItemId;
- },
- error() {
- this.$emit(
- 'error',
- s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
- );
- },
+ methods: {
+ handleWorkItemDeleted() {
+ this.$emit('workItemDeleted');
+ this.closeModal();
},
- },
- computed: {
- workItemTitle() {
- return this.workItem?.title;
+ closeModal() {
+ this.error = '';
+ this.$emit('close');
+ },
+ setErrorMessage(message) {
+ this.error = message;
},
},
};
</script>
<template>
- <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')">
- <item-title class="gl-m-0!" :initial-title="workItemTitle" />
+ <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="closeModal">
+ <template #modal-header>
+ <div class="gl-w-full gl-display-flex gl-align-items-center gl-justify-content-end">
+ <h2 class="modal-title gl-mr-auto">{{ s__('WorkItem|Work Item') }}</h2>
+ <work-item-actions
+ :work-item-id="workItemId"
+ :can-update="canUpdate"
+ @workItemDeleted="handleWorkItemDeleted"
+ @error="setErrorMessage"
+ />
+ <gl-button category="tertiary" icon="close" :aria-label="__('Close')" @click="closeModal" />
+ </div>
+ </template>
+ <gl-alert v-if="error" variant="danger" @dismiss="error = false">
+ {{ error }}
+ </gl-alert>
+
+ <work-item-detail :work-item-id="workItemId" />
</gl-modal>
</template>
+
+<style>
+/* hide the existing close button until we can do it
+ * with https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2710
+ */
+#work-item-detail-modal .modal-header > .gl-button {
+ display: none;
+}
+</style>
diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue
new file mode 100644
index 00000000000..88a825853cc
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_title.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import Tracking from '~/tracking';
+import { i18n } from '../constants';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import ItemTitle from './item_title.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ ItemTitle,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ workItemId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ workItemTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ tracking() {
+ return {
+ category: 'workItems:show',
+ label: 'item_title',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ },
+ methods: {
+ async updateTitle(updatedTitle) {
+ if (updatedTitle === this.workItemTitle) {
+ return;
+ }
+
+ try {
+ await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ title: updatedTitle,
+ },
+ },
+ });
+ this.track('updated_title');
+ } catch {
+ this.$emit('error', i18n.updateError);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-loading-icon v-if="loading" class="gl-mt-3" size="md" />
+ <item-title v-else :title="workItemTitle" @title-changed="updateTitle" />
+</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 995c02a2c5b..d3bcaf0f95f 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -1,5 +1,6 @@
-export const widgetTypes = {
- title: 'TITLE',
-};
+import { s__ } from '~/locale';
-export const WI_TITLE_TRACK_LABEL = 'item_title';
+export const i18n = {
+ fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
+ updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
+};
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
index 9312d1c582b..7f9aaf43068 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
@@ -1,18 +1,9 @@
-#import './widget.fragment.graphql'
+#import "./work_item.fragment.graphql"
mutation createWorkItem($input: WorkItemCreateInput!) {
workItemCreate(input: $input) {
workItem {
- id
- title
- workItemType {
- id
- }
- widgets @client {
- nodes {
- ...WidgetBase
- }
- }
+ ...WorkItem
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
new file mode 100644
index 00000000000..b25210f5c74
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
@@ -0,0 +1,9 @@
+mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) {
+ workItemCreateFromTask(input: $input) {
+ workItem {
+ id
+ descriptionHtml
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/delete_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/delete_work_item.mutation.graphql
new file mode 100644
index 00000000000..c52c49ec5f6
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/delete_work_item.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deleteWorkItem($input: WorkItemDeleteInput!) {
+ workItemDelete(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 28328a840cf..3c2955ce1e2 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -1,41 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import workItemQuery from './work_item.query.graphql';
-import { resolvers } from './resolvers';
-import typeDefs from './typedefs.graphql';
export function createApolloProvider() {
Vue.use(VueApollo);
- const defaultClient = createDefaultClient(resolvers, {
- typeDefs,
- cacheConfig: {
- possibleTypes: {
- LocalWorkItemWidget: ['LocalTitleWidget'],
- },
- },
- });
-
- defaultClient.cache.writeQuery({
- query: workItemQuery,
- variables: {
- id: 'gid://gitlab/WorkItem/1',
- },
- data: {
- localWorkItem: {
- __typename: 'LocalWorkItem',
- id: 'gid://gitlab/WorkItem/1',
- type: 'FEATURE',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Test Work Item',
- widgets: {
- __typename: 'LocalWorkItemWidgetConnection',
- nodes: [],
- },
- },
- },
- });
+ const defaultClient = createDefaultClient();
return new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js
deleted file mode 100644
index fb74e27f840..00000000000
--- a/app/assets/javascripts/work_items/graphql/resolvers.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import workItemQuery from './work_item.query.graphql';
-
-export const resolvers = {
- Mutation: {
- localUpdateWorkItem(_, { input }, { cache }) {
- const workItem = {
- __typename: 'LocalWorkItem',
- type: 'FEATURE',
- id: input.id,
- title: input.title,
- widgets: {
- __typename: 'LocalWorkItemWidgetConnection',
- nodes: [],
- },
- };
-
- cache.writeQuery({
- query: workItemQuery,
- variables: { id: input.id },
- data: { localWorkItem: workItem },
- });
-
- return {
- __typename: 'LocalUpdateWorkItemPayload',
- workItem,
- };
- },
- },
-};
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
deleted file mode 100644
index 9b4811203f5..00000000000
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ /dev/null
@@ -1,56 +0,0 @@
-enum LocalWorkItemType {
- FEATURE
-}
-
-enum LocalWidgetType {
- TITLE
-}
-
-interface LocalWorkItemWidget {
- type: LocalWidgetType!
-}
-
-# Replicating Relay connection type for client schema
-type LocalWorkItemWidgetEdge {
- cursor: String!
- node: LocalWorkItemWidget
-}
-
-type LocalWorkItemWidgetConnection {
- edges: [LocalWorkItemWidgetEdge]
- nodes: [LocalWorkItemWidget]
- pageInfo: PageInfo!
-}
-
-type LocalWorkItem {
- id: ID!
- type: LocalWorkItemType!
- title: String!
- widgets: [LocalWorkItemWidgetConnection]
-}
-
-input LocalCreateWorkItemInput {
- title: String!
-}
-
-input LocalUpdateWorkItemInput {
- id: ID!
- title: String
-}
-
-type LocalCreateWorkItemPayload {
- workItem: LocalWorkItem!
-}
-
-type LocalUpdateWorkItemPayload {
- workItem: LocalWorkItem!
-}
-
-extend type Query {
- localWorkItem(id: ID!): LocalWorkItem!
-}
-
-extend type Mutation {
- localCreateWorkItem(input: LocalCreateWorkItemInput!): LocalCreateWorkItemPayload!
- localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalUpdateWorkItemPayload!
-}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
index efb1ed8d6df..c0b6e856411 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -1,18 +1,9 @@
-#import './widget.fragment.graphql'
+#import "./work_item.fragment.graphql"
mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItemUpdate(input: $input) {
workItem {
- id
- title
- workItemType {
- id
- }
- widgets @client {
- nodes {
- ...WidgetBase
- }
- }
+ ...WorkItem
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql
deleted file mode 100644
index 154367dc0d8..00000000000
--- a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-fragment WidgetBase on LocalWorkItemWidget {
- type
-}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
new file mode 100644
index 00000000000..2707d6bb790
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -0,0 +1,8 @@
+fragment WorkItem on WorkItem {
+ id
+ title
+ workItemType {
+ id
+ name
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index b32cb4f28fb..1d3dae0649d 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -1,16 +1,7 @@
-#import './widget.fragment.graphql'
+#import "./work_item.fragment.graphql"
-query WorkItem($id: ID!) {
+query workItem($id: ID!) {
workItem(id: $id) {
- id
- title
- workItemType {
- id
- }
- widgets @client {
- nodes {
- ...WidgetBase
- }
- }
+ ...WorkItem
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql
new file mode 100644
index 00000000000..2ac01b79d6f
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql
@@ -0,0 +1,8 @@
+subscription issuableTitleUpdated($issuableId: IssuableID!) {
+ issuableTitleUpdated(issuableId: $issuableId) {
+ ... on WorkItem {
+ id
+ title
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index cc90cedb110..a95da80ac95 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -1,21 +1,25 @@
<script>
-import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
+import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
import ItemTitle from '../components/item_title.vue';
export default {
+ createErrorText: s__('WorkItem|Something went wrong when creating a work item. Please try again'),
+ fetchTypesErrorText: s__(
+ 'WorkItem|Something went wrong when fetching work item types. Please try again',
+ ),
components: {
GlButton,
GlAlert,
GlLoadingIcon,
- GlDropdown,
- GlDropdownItem,
ItemTitle,
+ GlFormSelect,
},
inject: ['fullPath'],
props: {
@@ -29,6 +33,26 @@ export default {
required: false,
default: '',
},
+ issueGid: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ lineNumberStart: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ lineNumberEnd: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -36,6 +60,7 @@ export default {
error: null,
workItemTypes: [],
selectedWorkItemType: null,
+ loading: false,
};
},
apollo: {
@@ -47,12 +72,13 @@ export default {
};
},
update(data) {
- return data.workspace?.workItemTypes?.nodes;
+ return data.workspace?.workItemTypes?.nodes.map((node) => ({
+ value: node.id,
+ text: node.name,
+ }));
},
error() {
- this.error = s__(
- 'WorkItem|Something went wrong when fetching work item types. Please try again',
- );
+ this.error = this.$options.fetchTypesErrorText;
},
},
},
@@ -60,9 +86,24 @@ export default {
dropdownButtonText() {
return this.selectedWorkItemType?.name || s__('WorkItem|Type');
},
+ formOptions() {
+ return [{ value: null, text: s__('WorkItem|Select type') }, ...this.workItemTypes];
+ },
+ isButtonDisabled() {
+ return this.title.trim().length === 0 || !this.selectedWorkItemType;
+ },
},
methods: {
async createWorkItem() {
+ this.loading = true;
+ if (this.isModal) {
+ await this.createWorkItemFromTask();
+ } else {
+ await this.createStandaloneWorkItem();
+ }
+ this.loading = false;
+ },
+ async createStandaloneWorkItem() {
try {
const response = await this.$apollo.mutate({
mutation: createWorkItemMutation,
@@ -70,7 +111,7 @@ export default {
input: {
title: this.title,
projectPath: this.fullPath,
- workItemTypeId: this.selectedWorkItemType?.id,
+ workItemTypeId: this.selectedWorkItemType,
},
},
update(store, { data: { workItemCreate } }) {
@@ -87,32 +128,43 @@ export default {
id,
title,
workItemType,
- widgets: {
- __typename: 'LocalWorkItemWidgetConnection',
- nodes: [],
- },
},
},
});
},
});
-
const {
data: {
workItemCreate: {
- workItem: { id, type },
+ workItem: { id },
},
},
} = response;
- if (!this.isModal) {
- this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
- } else {
- this.$emit('onCreate', { id, title: this.title, type });
- }
+ this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
+ } catch {
+ this.error = this.$options.createErrorText;
+ }
+ },
+ async createWorkItemFromTask() {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: createWorkItemFromTaskMutation,
+ variables: {
+ input: {
+ id: this.issueGid,
+ workItemData: {
+ lockVersion: this.lockVersion,
+ title: this.title,
+ lineNumberStart: Number(this.lineNumberStart),
+ lineNumberEnd: Number(this.lineNumberEnd),
+ workItemTypeId: this.selectedWorkItemType,
+ },
+ },
+ },
+ });
+ this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml);
} catch {
- this.error = s__(
- 'WorkItem|Something went wrong when creating a work item. Please try again',
- );
+ this.error = this.$options.createErrorText;
}
},
handleTitleInput(title) {
@@ -125,9 +177,6 @@ export default {
}
this.$emit('closeModal');
},
- selectWorkItemType(type) {
- this.selectedWorkItemType = type;
- },
},
};
</script>
@@ -136,28 +185,19 @@ export default {
<form @submit.prevent="createWorkItem">
<gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert>
<div :class="{ 'gl-px-5': isModal }" data-testid="content">
- <item-title
- :initial-title="title"
- data-testid="title-input"
- @title-input="handleTitleInput"
- />
+ <item-title :title="title" data-testid="title-input" @title-input="handleTitleInput" />
<div>
- <gl-dropdown :text="dropdownButtonText">
- <gl-loading-icon
- v-if="$apollo.queries.workItemTypes.loading"
- size="md"
- data-testid="loading-types"
- />
- <template v-else>
- <gl-dropdown-item
- v-for="type in workItemTypes"
- :key="type.id"
- @click="selectWorkItemType(type)"
- >
- {{ type.name }}
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
+ <gl-loading-icon
+ v-if="$apollo.queries.workItemTypes.loading"
+ size="md"
+ data-testid="loading-types"
+ />
+ <gl-form-select
+ v-else
+ v-model="selectedWorkItemType"
+ :options="formOptions"
+ class="gl-max-w-26"
+ />
</div>
</div>
<div
@@ -166,8 +206,9 @@ export default {
>
<gl-button
variant="confirm"
- :disabled="title.length === 0"
+ :disabled="isButtonDisabled"
:class="{ 'gl-mr-3': !isModal }"
+ :loading="loading"
data-testid="create-button"
type="submit"
>
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index 32b6fc231a8..b8f2bcff25d 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -1,98 +1,26 @@
<script>
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import Tracking from '~/tracking';
-import workItemQuery from '../graphql/work_item.query.graphql';
-import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
-import { WI_TITLE_TRACK_LABEL } from '../constants';
-
-import ItemTitle from '../components/item_title.vue';
-
-const trackingMixin = Tracking.mixin();
+import WorkItemDetail from '../components/work_item_detail.vue';
export default {
- titleUpdatedEvent: 'updated_title',
components: {
- ItemTitle,
- GlAlert,
- GlLoadingIcon,
+ WorkItemDetail,
},
- mixins: [trackingMixin],
props: {
id: {
type: String,
required: true,
},
},
- data() {
- return {
- workItem: {},
- error: false,
- };
- },
- apollo: {
- workItem: {
- query: workItemQuery,
- variables() {
- return {
- id: this.gid,
- };
- },
- },
- },
computed: {
- tracking() {
- return {
- category: 'workItems:show',
- action: 'updated_title',
- label: WI_TITLE_TRACK_LABEL,
- property: '[type_work_item]',
- };
- },
gid() {
- return convertToGraphQLId('WorkItem', this.id);
- },
- },
- methods: {
- async updateWorkItem(updatedTitle) {
- try {
- await this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: {
- input: {
- id: this.gid,
- title: updatedTitle,
- },
- },
- });
- this.track();
- } catch {
- this.error = true;
- }
+ return convertToGraphQLId(TYPE_WORK_ITEM, this.id);
},
},
};
</script>
<template>
- <section>
- <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
- __('Something went wrong while updating work item. Please try again')
- }}</gl-alert>
- <!-- Title widget placeholder -->
- <div>
- <gl-loading-icon
- v-if="$apollo.queries.workItem.loading"
- size="md"
- data-testid="loading-types"
- />
- <template v-else>
- <item-title
- :initial-title="workItem.title"
- data-testid="title"
- @title-changed="updateWorkItem"
- />
- </template>
- </div>
- </section>
+ <work-item-detail :work-item-id="gid" />
</template>
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index 27ddff181c5..598ef70297c 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -1,318 +1,6 @@
-$text-color: $gl-text-color;
-
-$brand-primary: $blue-500;
-$brand-success: $green-500;
-$brand-info: $blue-500;
-$brand-warning: $orange-500;
-$brand-danger: $red-500;
-
-$border-radius-base: $gl-border-radius-base;
-
-$modal-body-bg: $white;
-$input-border: $border-color;
-
-$padding-base-vertical: $gl-vert-padding;
-$padding-base-horizontal: $gl-padding;
-
-/*
- * Scss to help with bootstrap 3 to 4 migration
- */
-body,
-.form-control,
-.search form {
- // Override default font size used in non-csslab UI
- // Use rem to keep default font-size at 14px on body so 1rem still
- // fits 8px grid, but also allow users to change browser font size
- font-size: 0.875rem;
-}
-
-legend {
- border-bottom: 1px solid $border-color;
- margin-bottom: 20px;
-}
-
-button,
-html [type='button'],
-[type='reset'],
-[type='submit'],
-[role='button'] {
- // Override bootstrap reboot
- /* stylelint-disable-next-line property-no-vendor-prefix */
- -webkit-appearance: inherit;
- cursor: pointer;
-}
-
-h1,
-.h1,
-h2,
-.h2,
-h3,
-.h3 {
- margin-top: 20px;
- margin-bottom: 10px;
-}
-
-h4,
-.h4,
-h5,
-.h5,
-h6,
-.h6 {
- margin-top: 10px;
- margin-bottom: 10px;
-}
-
-/* Our adjustments to hx & .hx above add unnecessary margins to modal-title
- and page-title in modals, so we set them to 0 in order to have properly
- formatted modal headers. */
-.modal-header {
- .modal-title,
- .page-title {
- margin-top: 0;
- margin-bottom: 0;
- }
-}
-
-h5,
-.h5 {
- font-size: $gl-font-size;
-}
-
-input[type='file'] {
- // Bootstrap 4 file input height is taller by default
- // which makes them look ugly
- line-height: 1;
-}
-
-b,
-strong {
- font-weight: bold;
-}
-
-a {
- color: $blue-600;
-}
-
-hr {
- overflow: hidden;
-}
-
-.form-group.row .col-form-label {
- // Bootstrap 4 aligns labels to the left
- // for horizontal forms
- @include media-breakpoint-up(md) {
- text-align: right;
- }
-}
-
-code {
- padding: 2px 4px;
- color: $code-color;
- background-color: $gray-50;
- border-radius: $border-radius-default;
-
- .code > &,
- .build-log & {
- background-color: inherit;
- padding: unset;
- }
-}
-
-table {
- // Remove any table border lines
- border-spacing: 0;
-}
-
-@each $breakpoint in map-keys($grid-breakpoints) {
- @include media-breakpoint-up($breakpoint) {
- $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
-
- .d#{$infix}-table-header-group {
- display: table-header-group !important;
- }
- }
-}
-
-.text-secondary {
- // Override Bootstrap's light secondary color
- // We have to use !important because bootstrap has that set as well
- color: $gl-text-color-secondary !important;
-}
-
-.bg-success,
-.bg-primary,
-.bg-info,
-.bg-danger,
-.bg-warning {
- .card-header {
- color: $white;
- }
-}
-
-// Polyfill deprecated selectors
-
-.hidden {
- display: none !important;
- visibility: hidden !important;
-}
-
-.hide {
- display: none;
-}
-
-.dropdown-toggle::after,
-.dropright .dropdown-menu-toggle::after {
- // Remove bootstrap's dropdown caret
- display: none;
-}
-
-// Add to .label so that old system notes that are saved to the db
-// will still receive the correct styling
-.badge:not(.gl-badge),
-.label {
- padding: 4px 5px;
- font-size: 12px;
- font-style: normal;
- font-weight: $gl-font-weight-normal;
- display: inline-block;
-
- &.badge-gray {
- background-color: $label-gray-bg;
- color: $gl-text-color;
- text-shadow: none;
- }
-
- &.badge-inverse {
- background-color: $label-inverse-bg;
- }
-}
-
-.divider {
- // copied rules from node_modules/bootstrap/scss/_dropdown.scss:116
- // this might be safe to just remove instead
- // most places that use divider add overrides to undo these things
- // there is also a probably-unintentional use in deprecated_dropdown_divider.scss
- // so we would end up with .gl-dropdown .dropdown-divider
- height: 0;
- margin: 4px 0;
- overflow: hidden;
- border-top: 1px solid $border-color;
-}
-
-.info-well {
- background: $gray-10;
- color: $gl-text-color;
- border: 1px solid $border-color;
- border-radius: 4px;
- margin-bottom: 16px;
-
- .well-segment {
- padding: 16px;
-
- &:not(:last-of-type) {
- border-bottom: 1px solid $well-inner-border;
- }
-
- p,
- ol,
- ul,
- .form-group {
- &:last-of-type {
- margin-bottom: 0;
- }
- }
- }
-
- .badge.badge-gray {
- background-color: $well-expand-item;
- }
-}
-
-.card {
- &.card-without-border,
- &.bg-light {
- border: 0 !important;
- }
-}
-
-.nav-tabs {
- // Override bootstrap's default border
- border-bottom: 0;
-
- .nav-link {
- border-top: 0;
- border-left: 0;
- border-right: 0;
- }
-
- .nav-item {
- margin-bottom: 0;
- }
-}
-
-pre code {
- white-space: pre-wrap;
-}
-
-.alert {
- border-radius: 0;
-}
-
-.alert-success {
- background-color: $green-500;
- border-color: $green-500;
-}
-
-.alert-info {
- background-color: $blue-500;
- border-color: $blue-500;
-}
-
-.alert-warning {
- background-color: $orange-500;
- border-color: $orange-500;
-}
-
-.alert-danger {
- background-color: $red-500;
- border-color: $red-500;
-}
-
-.alert-success,
-.alert-info,
-.alert-warning,
-.alert-danger {
- color: $white;
-
- h4,
- .alert-link {
- color: $white;
- }
-}
-
-input[type=color].form-control {
- height: $input-height;
-}
-
-.toggle-sidebar-button {
- .collapse-text,
- .icon-chevron-double-lg-left,
- .icon-chevron-double-lg-right {
- color: $gl-text-color-secondary;
- }
-}
-
-.project-templates-buttons {
- .btn {
- vertical-align: unset;
- }
-}
-
-/*
- Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons,
- so we need to reset the vertical alignment to the default value. See:
- - https://gitlab.com/gitlab-org/gitlab-foss/issues/51362
- */
-svg {
- vertical-align: baseline;
-}
+// ---
+// Scss to help with bootstrap 3 to 4 migration
+// ---
+@import 'bootstrap_migration_variables';
+@import 'bootstrap_migration_reset';
+@import 'bootstrap_migration_components';
diff --git a/app/assets/stylesheets/bootstrap_migration_components.scss b/app/assets/stylesheets/bootstrap_migration_components.scss
new file mode 100644
index 00000000000..b6cecbe5806
--- /dev/null
+++ b/app/assets/stylesheets/bootstrap_migration_components.scss
@@ -0,0 +1,216 @@
+// ---
+// Scss to help with bootstrap 3 to 4 migration of bootstrap components
+// ---
+.form-control,
+.search form {
+ // Override default font size used in non-csslab UI
+ // Use rem to keep default font-size at 14px on body so 1rem still
+ // fits 8px grid, but also allow users to change browser font size
+ font-size: 0.875rem;
+}
+
+/* Our adjustments to hx & .hx above add unnecessary margins to modal-title
+ and page-title in modals, so we set them to 0 in order to have properly
+ formatted modal headers. */
+.modal-header {
+ .modal-title,
+ .page-title {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+}
+
+input[type='file'] {
+ // Bootstrap 4 file input height is taller by default
+ // which makes them look ugly
+ line-height: 1;
+}
+
+.form-group.row .col-form-label {
+ // Bootstrap 4 aligns labels to the left
+ // for horizontal forms
+ @include media-breakpoint-up(md) {
+ text-align: right;
+ }
+}
+
+@each $breakpoint in map-keys($grid-breakpoints) {
+ @include media-breakpoint-up($breakpoint) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+
+ .d#{$infix}-table-header-group {
+ display: table-header-group !important;
+ }
+ }
+}
+
+.text-secondary {
+ // Override Bootstrap's light secondary color
+ // We have to use !important because bootstrap has that set as well
+ color: $gl-text-color-secondary !important;
+}
+
+.bg-success,
+.bg-primary,
+.bg-info,
+.bg-danger,
+.bg-warning {
+ .card-header {
+ color: $white;
+ }
+}
+
+// Polyfill deprecated selectors
+
+.hidden {
+ display: none !important;
+ visibility: hidden !important;
+}
+
+.hide {
+ display: none;
+}
+
+.dropdown-toggle::after,
+.dropright .dropdown-menu-toggle::after {
+ // Remove bootstrap's dropdown caret
+ display: none;
+}
+
+// Add to .label so that old system notes that are saved to the db
+// will still receive the correct styling
+.badge:not(.gl-badge),
+.label {
+ padding: 4px 5px;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: $gl-font-weight-normal;
+ display: inline-block;
+
+ &.badge-gray {
+ background-color: $label-gray-bg;
+ color: $gl-text-color;
+ text-shadow: none;
+ }
+
+ &.badge-inverse {
+ background-color: $label-inverse-bg;
+ }
+}
+
+.divider {
+ // copied rules from node_modules/bootstrap/scss/_dropdown.scss:116
+ // this might be safe to just remove instead
+ // most places that use divider add overrides to undo these things
+ // there is also a probably-unintentional use in deprecated_dropdown_divider.scss
+ // so we would end up with .gl-dropdown .dropdown-divider
+ height: 0;
+ margin: 4px 0;
+ overflow: hidden;
+ border-top: 1px solid $border-color;
+}
+
+.info-well {
+ background: $gray-10;
+ color: $gl-text-color;
+ border: 1px solid $border-color;
+ border-radius: 4px;
+ margin-bottom: 16px;
+
+ .well-segment {
+ padding: 16px;
+
+ &:not(:last-of-type) {
+ border-bottom: 1px solid $well-inner-border;
+ }
+
+ p,
+ ol,
+ ul,
+ .form-group {
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .badge.badge-gray {
+ background-color: $well-expand-item;
+ }
+}
+
+.card {
+ &.card-without-border,
+ &.bg-light {
+ border: 0 !important;
+ }
+}
+
+.nav-tabs {
+ // Override bootstrap's default border
+ border-bottom: 0;
+
+ .nav-link {
+ border-top: 0;
+ border-left: 0;
+ border-right: 0;
+ }
+
+ .nav-item {
+ margin-bottom: 0;
+ }
+}
+
+.alert {
+ border-radius: 0;
+}
+
+.alert-success {
+ background-color: $green-500;
+ border-color: $green-500;
+}
+
+.alert-info {
+ background-color: $blue-500;
+ border-color: $blue-500;
+}
+
+.alert-warning {
+ background-color: $orange-500;
+ border-color: $orange-500;
+}
+
+.alert-danger {
+ background-color: $red-500;
+ border-color: $red-500;
+}
+
+.alert-success,
+.alert-info,
+.alert-warning,
+.alert-danger {
+ color: $white;
+
+ h4,
+ .alert-link {
+ color: $white;
+ }
+}
+
+input[type=color].form-control {
+ height: $input-height;
+}
+
+.toggle-sidebar-button {
+ .collapse-text,
+ .icon-chevron-double-lg-left,
+ .icon-chevron-double-lg-right {
+ color: $gl-text-color-secondary;
+ }
+}
+
+.project-templates-buttons {
+ .btn {
+ vertical-align: unset;
+ }
+}
diff --git a/app/assets/stylesheets/bootstrap_migration_reset.scss b/app/assets/stylesheets/bootstrap_migration_reset.scss
new file mode 100644
index 00000000000..ad315c4ada1
--- /dev/null
+++ b/app/assets/stylesheets/bootstrap_migration_reset.scss
@@ -0,0 +1,94 @@
+// ---
+// Scss to help with bootstrap 3 to 4 migration of core elements
+// ---
+body {
+ // Override default font size used in non-csslab UI
+ // Use rem to keep default font-size at 14px on body so 1rem still
+ // fits 8px grid, but also allow users to change browser font size
+ font-size: 0.875rem;
+}
+
+legend {
+ border-bottom: 1px solid $border-color;
+ margin-bottom: 20px;
+}
+
+button,
+html [type='button'],
+[type='reset'],
+[type='submit'],
+[role='button'] {
+ // Override bootstrap reboot
+ /* stylelint-disable-next-line property-no-vendor-prefix */
+ -webkit-appearance: inherit;
+ cursor: pointer;
+}
+
+h1,
+.h1,
+h2,
+.h2,
+h3,
+.h3 {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+h4,
+.h4,
+h5,
+.h5,
+h6,
+.h6 {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+h5,
+.h5 {
+ font-size: $gl-font-size;
+}
+
+b,
+strong {
+ font-weight: bold;
+}
+
+a {
+ color: $blue-600;
+}
+
+hr {
+ overflow: hidden;
+}
+
+code {
+ padding: 2px 4px;
+ color: $code-color;
+ background-color: $gray-50;
+ border-radius: $border-radius-default;
+
+ .code > &,
+ .build-log & {
+ background-color: inherit;
+ padding: unset;
+ }
+}
+
+table {
+ // Remove any table border lines
+ border-spacing: 0;
+}
+
+pre code {
+ white-space: pre-wrap;
+}
+
+/*
+ Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons,
+ so we need to reset the vertical alignment to the default value. See:
+ - https://gitlab.com/gitlab-org/gitlab-foss/issues/51362
+ */
+svg {
+ vertical-align: baseline;
+}
diff --git a/app/assets/stylesheets/bootstrap_migration_variables.scss b/app/assets/stylesheets/bootstrap_migration_variables.scss
new file mode 100644
index 00000000000..f3888de4ed8
--- /dev/null
+++ b/app/assets/stylesheets/bootstrap_migration_variables.scss
@@ -0,0 +1,15 @@
+$text-color: $gl-text-color;
+
+$brand-primary: $blue-500;
+$brand-success: $green-500;
+$brand-info: $blue-500;
+$brand-warning: $orange-500;
+$brand-danger: $red-500;
+
+$border-radius-base: $gl-border-radius-base;
+
+$modal-body-bg: $white;
+$input-border: $border-color;
+
+$padding-base-vertical: $gl-vert-padding;
+$padding-base-horizontal: $gl-padding;
diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss
index 5d1709c22ec..94d295c324b 100644
--- a/app/assets/stylesheets/components/milestone_combobox.scss
+++ b/app/assets/stylesheets/components/milestone_combobox.scss
@@ -1,13 +1,4 @@
.milestone-combobox {
- .selected-item {
- /* stylelint-disable-next-line function-url-quotes */
- background: url(asset_path('checkmark.png')) no-repeat 0 2px;
- }
-
- .dropdown-item-space {
- padding: 8px 12px;
- }
-
.dropdown-menu.show {
overflow: hidden;
}
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 2c72c4b0f65..1192c51b9aa 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -244,9 +244,13 @@
// above will be deprecated once all instances of "award emoji" are
// migrated to Vue.
-.gl-button .award-emoji-block gl-emoji {
- margin-top: -1px;
- margin-bottom: -1px;
+.gl-button .award-emoji-block {
+ display: contents;
+
+ gl-emoji {
+ margin-top: -1px;
+ margin-bottom: -1px;
+ }
}
.add-reaction-button {
diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss
index b8934d2797a..58f986ec0ae 100644
--- a/app/assets/stylesheets/framework/broadcast_messages.scss
+++ b/app/assets/stylesheets/framework/broadcast_messages.scss
@@ -15,6 +15,10 @@
.broadcast-banner-message {
text-align: center;
+
+ p {
+ margin-bottom: 0;
+ }
}
.broadcast-notification-message {
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 36a0d3ca3ca..be8a890320f 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -254,10 +254,9 @@ li.note {
}
img.emoji {
- height: 20px;
+ height: 16px;
vertical-align: top;
width: 20px;
- margin-top: 1px;
}
.chart {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 7f960e3da51..5c6d9266f7c 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -503,6 +503,7 @@
&.dropdown-menu-user-link::before {
top: 50%;
+ transform: translateY(-50%);
}
}
@@ -520,8 +521,22 @@
}
&.is-active {
- /* stylelint-disable-next-line function-url-quotes */
- background: url(asset_path('checkmark.png')) no-repeat 14px center;
+ position: relative;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0.5rem;
+ left: 1rem;
+ width: 1rem;
+ height: 1rem;
+ mask-image: asset_url('icons-stacked.svg#check');
+ mask-repeat: no-repeat;
+ mask-size: cover;
+ mask-position: center center;
+ background: $black-normal;
+ }
}
}
}
@@ -692,7 +707,7 @@
.dropdown-label-box {
position: relative;
- top: 3px;
+ top: 0;
margin-right: 5px;
display: inline-block;
width: 15px;
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 1004383cfd3..f44123fc2ed 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -1,5 +1,3 @@
-$top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important;
-
.navbar-gitlab {
padding: 0 16px;
z-index: $header-zindex;
@@ -54,7 +52,7 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
white-space: nowrap;
img {
- height: 28px;
+ height: 24px;
+ .logo-text {
margin-left: 8px;
@@ -460,7 +458,6 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
vertical-align: text-top;
}
- a.upgrade-plan-link gl-emoji,
a.ci-minutes-emoji gl-emoji,
a.trial-link gl-emoji {
font-size: $gl-font-size;
@@ -574,7 +571,7 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
}
.frequent-items-list-item-container > a:hover {
- background-color: $top-nav-hover-bg;
+ background-color: $nav-active-bg;
}
}
@@ -589,11 +586,9 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
}
.top-nav-menu-item {
- color: var(--indigo-900, $theme-indigo-900) !important;
-
&.active,
&:hover {
- background-color: $top-nav-hover-bg;
+ background-color: $nav-active-bg;
}
.gl-icon {
@@ -603,7 +598,6 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
.top-nav-responsive {
@include gl-display-none;
- color: var(--indigo-900, $theme-indigo-900);
}
.top-nav-responsive-open {
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 7731ec751c9..7522f791b8e 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -72,7 +72,6 @@
@include media-breakpoint-down(xs) {
.nav-item {
flex: 1;
- border-bottom: 1px solid $border-color;
}
.gl-tab-nav-item {
@@ -84,7 +83,8 @@
width: 100%;
display: flex;
flex-wrap: wrap;
- margin-top: $gl-padding-8;
+ padding-top: $gl-padding-8;
+ border-top: 1px solid $border-color;
}
}
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index e77971d5280..d270f802c56 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -226,3 +226,29 @@
.edit-link {
margin-right: -$gl-spacing-scale-2;
}
+
+.assignee-grid {
+ grid-template-areas: ' attention user';
+ grid-template-columns: min-content 1fr;
+}
+
+.reviewer-grid {
+ grid-template-areas: ' user approval rerequest';
+ grid-template-columns: 1fr min-content min-content;
+
+ &.attention-requests {
+ grid-template-areas: ' attention user approval';
+ grid-template-columns: min-content 1fr min-content;
+ }
+}
+
+.assignee-grid,
+.reviewer-grid {
+ [data-css-area='attention'] {
+ grid-area: attention;
+ }
+
+ [data-css-area='user'] {
+ grid-area: user;
+ }
+}
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index c6bc8fa0eac..6348703e9e1 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -48,7 +48,7 @@ table {
th {
padding: 10px $gl-padding;
line-height: 20px;
- vertical-align: middle;
+ vertical-align: top;
}
th {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 31ef5ae0646..8e3b34e4eaf 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -363,6 +363,7 @@ $well-expand-item: #e8f2f7 !default;
$well-inner-border: #eef0f2 !default;
$well-light-border: #f1f1f1;
$well-light-text-color: #5b6169;
+$nav-active-bg: var(--nav-active-bg, rgba($black, 0.08)) !important;
/*
* Text
diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss
new file mode 100644
index 00000000000..30895a55711
--- /dev/null
+++ b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss
@@ -0,0 +1,36 @@
+/**
+* CSS variables used below are declared in `app/views/layouts/_diffs_colors_css.haml`
+*/
+.diff-custom-addition-color {
+ .code {
+ .line_holder {
+ .diff-line-num,
+ .line-coverage,
+ .line-codequality,
+ .line_content {
+ &.new {
+ &:not(.hll) {
+ background: var(--diff-addition-color);
+ }
+
+ &.line_content span.idiff {
+ background: var(--diff-addition-color) !important;
+ }
+
+ &::before,
+ a {
+ mix-blend-mode: luminosity;
+ }
+ }
+ }
+ }
+
+ .gd {
+ background-color: var(--diff-addition-color);
+ }
+ }
+
+ .idiff.addition {
+ background: var(--diff-addition-color) !important;
+ }
+}
diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss b/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss
new file mode 100644
index 00000000000..a8ab43909eb
--- /dev/null
+++ b/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss
@@ -0,0 +1,36 @@
+/**
+* CSS variables used below are declared in `app/views/layouts/_diffs_colors_css.haml`
+*/
+.diff-custom-deletion-color {
+ .code {
+ .line_holder {
+ .diff-line-num,
+ .line-coverage,
+ .line-codequality,
+ .line_content {
+ &.old {
+ &:not(.hll) {
+ background: var(--diff-deletion-color);
+ }
+
+ &.line_content span.idiff {
+ background: var(--diff-deletion-color) !important;
+ }
+
+ &::before,
+ a {
+ mix-blend-mode: luminosity;
+ }
+ }
+ }
+ }
+
+ .gd {
+ background-color: var(--diff-deletion-color);
+ }
+ }
+
+ .idiff.deletion {
+ background: var(--diff-deletion-color) !important;
+ }
+}
diff --git a/app/assets/stylesheets/highlight/hljs.scss b/app/assets/stylesheets/highlight/hljs.scss
new file mode 100644
index 00000000000..2e31e7c1f6d
--- /dev/null
+++ b/app/assets/stylesheets/highlight/hljs.scss
@@ -0,0 +1,125 @@
+.code.highlight {
+ .hljs-comment {
+ color: var(--color-hljs-comment);
+ }
+
+ .hljs-link {
+ color: var(--color-hljs-link);
+ }
+
+ .hljs-meta {
+ color: var(--color-hljs-meta);
+ }
+
+ .hljs-keyword {
+ color: var(--color-hljs-keyword);
+ }
+
+ .hljs-type {
+ color: var(--color-hljs-type);
+ }
+
+ .hljs-attr,
+ .hljs-property {
+ color: var(--color-hljs-attr);
+ }
+
+ .hljs-built_in {
+ color: var(--color-hljs-builtin);
+ }
+
+ .hljs-literal {
+ color: var(--color-hljs-literal);
+ }
+
+ .hljs-title {
+ color: var(--color-hljs-title);
+
+ &.class_ {
+ color: var(--color-hljs-class);
+ }
+
+ &.function_ {
+ color: var(--color-hljs-function);
+ }
+ }
+
+ .hljs-tag ,
+ .hljs-name {
+ color: var(--color-hljs-tag);
+ }
+
+ .hljs-number {
+ color: var(--color-hljs-number);
+ }
+
+ .hljs-subst {
+ color: var(--color-hljs-subst);
+ }
+
+ .hljs-string,
+ .hljs-section,
+ .hljs-bullet {
+ color: var(--color-hljs-string);
+ }
+
+ .hljs-symbol {
+ color: var(--color-hljs-symbol);
+ }
+
+ .hljs-variable {
+ color: var(--color-hljs-variable);
+
+ &.language_ {
+ color: var(--color-hljs-language);
+ }
+
+ &.constant_ {
+ color: var(--color-hljs-constant);
+ }
+ }
+
+ .hljs-attribute {
+ color: var(--color-hljs-attribute);
+ }
+
+ .hljs-operator {
+ color: var(--color-hljs-operator);
+ }
+
+ .hljs-punctuation {
+ color: var(--color-hljs-punctuation);
+ }
+
+ .hljs-regexp {
+ color: var(--color-hljs-regexp);
+ }
+
+ .hljs-params {
+ color: var(--color-hljs-params);
+ }
+
+ .hljs-doctag {
+ color: var(--color-hljs-doctag);
+ }
+
+ .hljs-selector-tag {
+ color: var(--color-hljs-selector-tag);
+ }
+
+ .hljs-selector-class {
+ color: var(--color-hljs-selector-class);
+ }
+
+ .hljs-selector-id {
+ color: var(--color-hljs-selector-id);
+ }
+
+ .hljs-selector-attr {
+ color: var(--color-hljs-selector-attr);
+ }
+
+ .hljs-selector-pseudo {
+ color: var(--color-hljs-selector-pseudo);
+ }
+}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 28878280d24..c51b1f04757 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -1,6 +1,7 @@
/* https://github.com/MozMorris/tomorrow-pygments */
@import '../common';
+@import '../hljs';
/*
* Dark syntax colors
@@ -88,6 +89,41 @@ $dark-vg: #c66;
$dark-vi: #c66;
$dark-il: #de935f;
+:root {
+ --color-hljs-comment: #{$dark-c};
+ --color-hljs-variable: #{$dark-k};
+ --color-hljs-link: #{$dark-l};
+ --color-hljs-meta: #{$dark-cp};
+ --color-hljs-keyword: #{$dark-kd};
+ --color-hljs-type: #{$dark-kt};
+ --color-hljs-attr: #{$dark-na};
+ --color-hljs-builtin: #{$dark-nb};
+ --color-hljs-title: #{$dark-n};
+ --color-hljs-class: #{$dark-nc};
+ --color-hljs-function: #{$dark-nf};
+ --color-hljs-tag: #{$dark-nt};
+ --color-hljs-number: #{$dark-mi};
+ --color-hljs-subst: #{$dark-sc};
+ --color-hljs-string: #{$dark-s1};
+ --color-hljs-symbol: #{$dark-ss};
+ --color-hljs-variable: #{$dark-vi};
+ --color-hljs-operator: #{$dark-o};
+ --color-hljs-punctuation: #{$dark-p};
+ --color-hljs-regexp: #{$dark-sr};
+ --color-hljs-constant: #{$dark-nx};
+ --color-hljs-literal: #{$dark-kc};
+ --color-hljs-language: #{$dark-nx};
+ --color-hljs-params: #{$dark-nx};
+ --color-hljs-selector-doctag: #{$dark-cm};
+ --color-hljs-selector-tag: #{$dark-nt};
+ --color-hljs-selector-class: #{$dark-nc};
+ --color-hljs-selector-id: #{$dark-nn};
+ --color-hljs-selector-attr: #{$dark-nt};
+ --color-hljs-selector-pseudo: #{$dark-nd};
+ --default-diff-color-deletion: #ff3333;
+ --default-diff-color-addition: #288f2a;
+}
+
.code.dark {
// Line numbers
.file-line-num {
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 6faf1cffdef..226bb44f0e7 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -89,6 +89,11 @@ $monokai-gd: #f92672;
$monokai-gi: #a6e22e;
$monokai-gh: #75715e;
+:root {
+ --default-diff-color-deletion: #c87872;
+ --default-diff-color-addition: #678528;
+}
+
.code.monokai {
// Line numbers
.file-line-num {
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 9c28d9463dc..7a36aba8be7 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -9,6 +9,11 @@
background-color: $white-normal;
}
+:root {
+ --default-diff-color-deletion: #b4b4b4;
+ --default-diff-color-addition: #b4b4b4;
+}
+
.code.none {
// Line numbers
.file-line-num {
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index c9f889c79fc..acd401e1694 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -92,6 +92,11 @@ $solarized-dark-vg: #268bd2;
$solarized-dark-vi: #268bd2;
$solarized-dark-il: #2aa198;
+:root {
+ --default-diff-color-deletion: #ff362c;
+ --default-diff-color-addition: #647e0e;
+}
+
.code.solarized-dark {
// Line numbers
.file-line-num {
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 0108d7e496f..ddcecc4cbcf 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -94,6 +94,11 @@ $solarized-light-vg: #268bd2;
$solarized-light-vi: #268bd2;
$solarized-light-il: #2aa198;
+:root {
+ --default-diff-color-deletion: #dc322f;
+ --default-diff-color-addition: #859900;
+}
+
@mixin match-line {
color: $black-transparent;
background: $solarized-light-matchline-bg;
diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss
index ed1d9c924c0..8698e448c94 100644
--- a/app/assets/stylesheets/highlight/themes/white.scss
+++ b/app/assets/stylesheets/highlight/themes/white.scss
@@ -3,3 +3,8 @@
@include conflict-colors('white');
}
+
+:root {
+ --default-diff-color-deletion: #eb919b;
+ --default-diff-color-addition: #a0f5b4;
+} \ No newline at end of file
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 91d8f4a1ba5..20a36d2e8b1 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -149,7 +149,6 @@ pre.code,
.diff-line-num {
&.old {
background-color: $line-number-old;
- border-color: $line-removed-dark;
a {
color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
@@ -158,7 +157,6 @@ pre.code,
&.new {
background-color: $line-number-new;
- border-color: $line-added-dark;
a {
color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
diff --git a/app/assets/stylesheets/notify_base.scss b/app/assets/stylesheets/notify_base.scss
index 8c6f9a27077..0ca1398c609 100644
--- a/app/assets/stylesheets/notify_base.scss
+++ b/app/assets/stylesheets/notify_base.scss
@@ -1,5 +1,6 @@
-@import 'framework/mixins';
@import 'framework/variables';
+@import 'framework/variables_overrides';
+@import 'framework/mixins';
img {
max-width: 100%;
diff --git a/app/assets/stylesheets/notify_enhanced.scss b/app/assets/stylesheets/notify_enhanced.scss
index 5df5a8592bf..a366498ea03 100644
--- a/app/assets/stylesheets/notify_enhanced.scss
+++ b/app/assets/stylesheets/notify_enhanced.scss
@@ -2,20 +2,30 @@
// keep parts that affect elements that can appear in emails;
// omit Bootstrap Reboot since it adds unnecessary styles to every element.
@import 'notify_base';
+
+// bootstrap variables, mixins, functions
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins';
+
+// bootstrap styles
@import 'bootstrap/scss/code';
+
+// @gitlab/ui variables, mixins, functions
@import '@gitlab/ui/src/scss/variables';
@import '@gitlab/ui/src/scss/utility-mixins/index';
-@import '@gitlab/ui/src/scss/components';
-@import 'bootstrap_migration';
-@import 'framework/common';
+
+// @gitlab/ui styles
+@import '@gitlab/ui/src/components/base/icon/icon';
+@import '@gitlab/ui/src/components/base/label/label';
+
+// gitlab styles
+@import 'bootstrap_migration_variables';
+@import 'bootstrap_migration_reset';
@import 'framework/gfm';
@import 'framework/kbd';
@import 'framework/tables';
@import 'framework/typography';
-@import 'framework/emojis';
body {
font-family: $regular-font;
@@ -26,11 +36,15 @@ a {
text-decoration: none;
}
-.content {
- .md {
- padding: 1rem 0;
- }
+.gl-mb-5 {
+ @include gl-mb-5;
+}
+.gl-mt-5 {
+ @include gl-mt-5;
+}
+
+.content {
hr {
border: 1px solid #e1e1e1;
}
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index f91ca489bdf..eecd4954e39 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -198,7 +198,7 @@
border-bottom: 1px solid var(--gray-100, $gray-100);
height: 3rem;
- .js-max-issue-size::before {
+ .max-issue-size::before {
content: '/';
}
}
diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss
index b7a4d9564fe..cd5e6d32e4e 100644
--- a/app/assets/stylesheets/page_bundles/import.scss
+++ b/app/assets/stylesheets/page_bundles/import.scss
@@ -1,18 +1,22 @@
@import 'mixins_and_variables_and_functions';
+.import-jobs-from-col {
+ width: 37%;
+}
+
+
.import-jobs-to-col {
- width: 39%;
+ width: 37%;
}
.import-jobs-status-col {
- width: 15%;
+ width: 25%;
}
.import-jobs-cta-col {
width: 1%;
}
-
.import-entities-target-select {
&.disabled {
.import-entities-target-select-separator {
diff --git a/app/assets/stylesheets/page_bundles/jira_connect_users.scss b/app/assets/stylesheets/page_bundles/jira_connect_users.scss
index 6725bf8f1a1..602910adad9 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect_users.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect_users.scss
@@ -1,13 +1 @@
-@import 'mixins_and_variables_and_functions';
-
-.jira-connect-users-container {
- margin-left: auto;
- margin-right: auto;
- width: px-to-rem(350px);
-}
-
-.devise-layout-html body .navless-container {
- @include media-breakpoint-down(xs) {
- padding-top: 65px;
- }
-}
+@import '../themes/theme_indigo';
diff --git a/app/assets/stylesheets/page_bundles/learn_gitlab.scss b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
index 10a4a210d41..189aefb330b 100644
--- a/app/assets/stylesheets/page_bundles/learn_gitlab.scss
+++ b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
@@ -1,11 +1,3 @@
.learn-gitlab-info-card-content {
height: 200px;
}
-
-.learn-gitlab-section-card {
- height: 400px;
-}
-
-.learn-gitlab-section-card-header {
- height: 165px;
-}
diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss
index 989219552a6..aa582db10d2 100644
--- a/app/assets/stylesheets/page_bundles/milestone.scss
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -7,21 +7,7 @@ $status-box-line-height: 26px;
}
.milestones {
- padding: $gl-padding-8;
- margin-top: $gl-padding-8;
- border-radius: $border-radius-default;
- background-color: var(--gray-100, $gray-100);
-
.milestone {
- border: 0;
- padding: $gl-padding-top $gl-padding;
- border-radius: $border-radius-default;
- background-color: var(--white, $white);
-
- &:not(:last-child) {
- margin-bottom: $gl-padding-4;
- }
-
h4 {
font-weight: $gl-font-weight-bold;
}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index d9ad82d4e4b..27d81d8e53b 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -1,12 +1,4 @@
.clusters-container {
- .empty-state .svg-content {
- @include gl-pb-0;
-
- img {
- width: 100px;
- }
- }
-
@include media-breakpoint-down(xs) {
.nav-controls {
@include gl-w-full;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 9bb4c5357e7..f127b0dc66c 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -311,7 +311,7 @@ ul.related-merge-requests > li gl-emoji {
.description.work-items-enabled {
ul.task-list {
> li.task-list-item {
- padding-inline-start: 2.25rem;
+ padding-inline-start: 2.5rem;
.js-add-task {
svg {
@@ -324,7 +324,7 @@ ul.related-merge-requests > li gl-emoji {
}
> input.task-list-item-checkbox {
- left: 0.875rem;
+ left: 1.25rem;
}
&:hover,
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index c84a83c1fab..18a0f119edf 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -45,6 +45,36 @@ input[type='checkbox']:hover {
transition: border-color ease-in-out $default-transition-duration,
background-color ease-in-out $default-transition-duration;
}
+
+ &.is-not-active {
+ .btn.gl-clear-icon-button {
+ display: none;
+ }
+
+ &::after {
+ content: '/';
+ display: inline-block;
+ position: absolute;
+ top: 0;
+ right: 8px;
+ transform: translateY(calc(50% - 4px));
+ padding: 4px 5px;
+ font-size: $gl-font-size-small;
+ font-family: $monospace-font;
+ line-height: 1;
+ vertical-align: middle;
+ border-width: 0;
+ border-style: solid;
+ border-image: none;
+ border-radius: 3px;
+ box-shadow: none;
+ white-space: pre-wrap;
+ // Safari
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ word-break: keep-all;
+ }
+ }
}
.header-search-dropdown-menu {
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 5956368a977..0c7b74684cc 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -110,9 +110,14 @@
.bs-callout,
.form-check:first-child,
- .form-text.text-muted {
+ .form-check .form-text.text-muted,
+ .form-check + .form-text.text-muted {
margin-top: 0;
}
+
+ .form-check .form-text.text-muted {
+ margin-bottom: $grid-size;
+ }
}
.settings-list-icon {
diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss
index d38c1818f53..f79237eee3d 100644
--- a/app/assets/stylesheets/snippets.scss
+++ b/app/assets/stylesheets/snippets.scss
@@ -33,6 +33,10 @@
text-align: left;
}
+ .file-holder {
+ margin: 0;
+ }
+
.file-content.code {
border: $border-style;
border-radius: 0 0 $border-radius-default $border-radius-default;
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 00195f553dc..62d45332204 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -9,7 +9,6 @@ body.gl-dark {
--gray-900: #fafafa;
--green-100: #0d532a;
--green-700: #91d4a8;
- --indigo-900-alpha-008: rgba(235, 235, 250, 0.08);
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
--black: #fff;
@@ -453,9 +452,12 @@ a.gl-badge.badge-warning:active {
.gl-form-input.form-control:disabled,
.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
background-color: #1f1f1f;
- color: #868686;
box-shadow: inset 0 0 0 1px #404040;
+}
+.gl-form-input:disabled,
+.gl-form-input.form-control:disabled {
cursor: not-allowed;
+ color: #868686;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
@@ -544,9 +546,7 @@ a.gl-badge.badge-warning:active {
padding-right: 2rem;
padding-left: 1.75rem;
}
-body,
-.form-control,
-.search form {
+body {
font-size: 0.875rem;
}
button,
@@ -564,6 +564,13 @@ strong {
a {
color: #63a6e9;
}
+svg {
+ vertical-align: baseline;
+}
+.form-control,
+.search form {
+ font-size: 0.875rem;
+}
.hidden {
display: none !important;
visibility: hidden !important;
@@ -588,9 +595,6 @@ a {
.toggle-sidebar-button .icon-chevron-double-lg-left {
color: #999;
}
-svg {
- vertical-align: baseline;
-}
html {
overflow-y: scroll;
}
@@ -804,7 +808,7 @@ input {
white-space: nowrap;
}
.navbar-gitlab .header-content .title img {
- height: 28px;
+ height: 24px;
}
.navbar-gitlab .header-content .title img + .logo-text {
margin-left: 8px;
@@ -1512,6 +1516,29 @@ svg.s16 {
.header-search {
width: 320px;
}
+.header-search.is-not-active::after {
+ content: "/";
+ display: inline-block;
+ position: absolute;
+ top: 0;
+ right: 8px;
+ transform: translateY(calc(50% - 4px));
+ padding: 4px 5px;
+ font-size: 12px;
+ font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
+ "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
+ line-height: 1;
+ vertical-align: middle;
+ border-width: 0;
+ border-style: solid;
+ border-image: none;
+ border-radius: 3px;
+ box-shadow: none;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ word-break: keep-all;
+}
.search {
margin: 0 8px;
}
@@ -1739,7 +1766,6 @@ body.gl-dark {
--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;
@@ -1769,7 +1795,7 @@ body.gl-dark {
box-shadow: none;
}
.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
- background-color: var(--indigo-900-alpha-008);
+ background-color: var(--nav-active-bg);
}
body.gl-dark {
--gl-theme-accent: #868686;
@@ -1851,6 +1877,10 @@ body.gl-dark .header-search input::placeholder {
body.gl-dark .header-search input:active::placeholder {
color: #868686;
}
+body.gl-dark .header-search.is-not-active::after {
+ color: #fafafa;
+ background-color: rgba(250, 250, 250, 0.2);
+}
body.gl-dark .search form {
background-color: rgba(250, 250, 250, 0.2);
}
@@ -1972,7 +2002,6 @@ body.gl-dark {
--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;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 6d66e207bdc..a8b7299b935 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -438,9 +438,12 @@ a.gl-badge.badge-warning:active {
.gl-form-input.form-control:disabled,
.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
background-color: #fafafa;
- color: #868686;
box-shadow: inset 0 0 0 1px #dbdbdb;
+}
+.gl-form-input:disabled,
+.gl-form-input.form-control:disabled {
cursor: not-allowed;
+ color: #868686;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
@@ -529,9 +532,7 @@ a.gl-badge.badge-warning:active {
padding-right: 2rem;
padding-left: 1.75rem;
}
-body,
-.form-control,
-.search form {
+body {
font-size: 0.875rem;
}
button,
@@ -549,6 +550,13 @@ strong {
a {
color: #1068bf;
}
+svg {
+ vertical-align: baseline;
+}
+.form-control,
+.search form {
+ font-size: 0.875rem;
+}
.hidden {
display: none !important;
visibility: hidden !important;
@@ -573,9 +581,6 @@ a {
.toggle-sidebar-button .icon-chevron-double-lg-left {
color: #666;
}
-svg {
- vertical-align: baseline;
-}
html {
overflow-y: scroll;
}
@@ -789,7 +794,7 @@ input {
white-space: nowrap;
}
.navbar-gitlab .header-content .title img {
- height: 28px;
+ height: 24px;
}
.navbar-gitlab .header-content .title img + .logo-text {
margin-left: 8px;
@@ -1497,6 +1502,29 @@ svg.s16 {
.header-search {
width: 320px;
}
+.header-search.is-not-active::after {
+ content: "/";
+ display: inline-block;
+ position: absolute;
+ top: 0;
+ right: 8px;
+ transform: translateY(calc(50% - 4px));
+ padding: 4px 5px;
+ font-size: 12px;
+ font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
+ "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
+ line-height: 1;
+ vertical-align: middle;
+ border-width: 0;
+ border-style: solid;
+ border-image: none;
+ border-radius: 3px;
+ box-shadow: none;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ word-break: keep-all;
+}
.search {
margin: 0 8px;
}
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 213d1c013a0..751ad26ca21 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -301,9 +301,12 @@ fieldset:disabled a.btn {
.gl-form-input.form-control:disabled,
.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
background-color: #fafafa;
- color: #868686;
box-shadow: inset 0 0 0 1px #dbdbdb;
+}
+.gl-form-input:disabled,
+.gl-form-input.form-control:disabled {
cursor: not-allowed;
+ color: #868686;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
@@ -369,8 +372,7 @@ fieldset:disabled a.btn {
outline: none;
background-color: #0b5cad;
}
-body,
-.form-control {
+body {
font-size: 0.875rem;
}
[type="submit"] {
@@ -387,6 +389,12 @@ a {
hr {
overflow: hidden;
}
+svg {
+ vertical-align: baseline;
+}
+.form-control {
+ font-size: 0.875rem;
+}
.hidden {
display: none !important;
visibility: hidden !important;
@@ -394,9 +402,6 @@ hr {
.hide {
display: none;
}
-svg {
- vertical-align: baseline;
-}
html {
overflow-y: scroll;
}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 3cb8c58a380..550e3981401 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -70,7 +70,6 @@ $indigo-700: #a6a6de;
$indigo-800: #d1d1f0;
$indigo-900: #ebebfa;
$indigo-950: #f7f7ff;
-$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$purple-50: #232150;
$purple-100: #2f2a6b;
@@ -174,7 +173,6 @@ body.gl-dark {
--indigo-800: #{$indigo-800};
--indigo-900: #{$indigo-900};
--indigo-950: #{$indigo-950};
- --indigo-900-alpha-008: #{$indigo-900-alpha-008};
--purple-50: #{$purple-50};
--purple-100: #{$purple-100};
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index bb9a9cf0497..83254fe1a52 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -48,7 +48,7 @@
}
> a:hover {
- background-color: var(--indigo-900-alpha-008);
+ background-color: var(--nav-active-bg);
}
&.active {
@@ -56,7 +56,7 @@
&:not(.fly-out-top-item) {
> a:not(.has-sub-items) {
- background-color: var(--indigo-900-alpha-008);
+ background-color: var(--nav-active-bg);
}
}
}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index c6e29c7f8b0..07194e2b532 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -176,6 +176,11 @@
}
}
}
+
+ &.is-not-active::after {
+ color: $search-and-nav-links;
+ background-color: rgba($search-and-nav-links, 0.2);
+ }
}
.search {
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 0511a179980..d7a5e21e303 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -366,8 +366,3 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
/* stylelint-disable property-no-vendor-prefix */
-webkit-backdrop-filter: blur(2px); // still required by Safari
}
-
-// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2708
-.gl-inset-border-l-3-red-600 {
- box-shadow: inset $gl-border-size-3 0 0 0 $red-600;
-}
diff --git a/app/components/diffs/base_component.rb b/app/components/diffs/base_component.rb
new file mode 100644
index 00000000000..9e1347d1e84
--- /dev/null
+++ b/app/components/diffs/base_component.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Diffs
+ class BaseComponent < ViewComponent::Base
+ # To make converting the partials to components easier,
+ # we delegate all missing methods to the helpers,
+ # where they probably are.
+ delegate_missing_to :helpers
+ end
+end
diff --git a/app/components/diffs/overflow_warning_component.html.haml b/app/components/diffs/overflow_warning_component.html.haml
new file mode 100644
index 00000000000..907d066e73d
--- /dev/null
+++ b/app/components/diffs/overflow_warning_component.html.haml
@@ -0,0 +1,9 @@
+= render Pajamas::AlertComponent.new(title: _('Too many changes to show.'),
+ variant: :warning,
+ alert_class: 'gl-mb-5') do
+ .gl-alert-body
+ = message
+
+ .gl-alert-actions
+ = diff_link
+ = patch_link
diff --git a/app/components/diffs/overflow_warning_component.rb b/app/components/diffs/overflow_warning_component.rb
new file mode 100644
index 00000000000..0d0e225beb4
--- /dev/null
+++ b/app/components/diffs/overflow_warning_component.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module Diffs
+ class OverflowWarningComponent < BaseComponent
+ # Skipping coverage because of https://gitlab.com/gitlab-org/gitlab/-/issues/357381
+ #
+ # This is fully tested by the output in the view part of this component,
+ # but undercoverage doesn't understand the relationship between the two parts.
+ #
+ # :nocov:
+ def initialize(diffs:, diff_files:, project:, commit: nil, merge_request: nil)
+ @diffs = diffs
+ @diff_files = diff_files
+ @project = project
+ @commit = commit
+ @merge_request = merge_request
+ end
+
+ def message
+ html_escape(message_text) % {
+ display_size: @diff_files.size,
+ real_size: @diffs.real_size,
+ strong_open: '<strong>'.html_safe,
+ strong_close: '</strong>'.html_safe
+ }
+ end
+
+ def diff_link
+ text = _("Plain diff")
+
+ if commit?
+ link_to text, project_commit_path(@project, @commit, format: :diff), class: button_classes
+ elsif merge_request?
+ link_to text, merge_request_path(@merge_request, format: :diff), class: button_classes
+ end
+ end
+
+ def patch_link
+ text = _("Email patch")
+
+ if commit?
+ link_to text, project_commit_path(@project, @commit, format: :patch), class: button_classes
+ elsif merge_request?
+ link_to text, merge_request_path(@merge_request, format: :patch), class: button_classes
+ end
+ end
+
+ private
+
+ def commit?
+ current_controller?(:commit) &&
+ @commit.present?
+ end
+
+ def merge_request?
+ current_controller?("projects/merge_requests/diffs") &&
+ @merge_request.present? &&
+ @merge_request.persisted?
+ end
+
+ def message_text
+ _(
+ "To preserve performance only %{strong_open}%{display_size} " \
+ "of %{real_size}%{strong_close} files are displayed."
+ )
+ end
+
+ def button_classes
+ "btn gl-alert-action btn-default gl-button btn-default-secondary"
+ end
+ # :nocov:
+ end
+end
diff --git a/app/components/diffs/stats_component.html.haml b/app/components/diffs/stats_component.html.haml
new file mode 100644
index 00000000000..c0e816639a7
--- /dev/null
+++ b/app/components/diffs/stats_component.html.haml
@@ -0,0 +1 @@
+.js-diff-stats-dropdown{ data: { changed: @changed, added: @added, deleted: @removed, files: diff_files_data } }
diff --git a/app/components/diffs/stats_component.rb b/app/components/diffs/stats_component.rb
new file mode 100644
index 00000000000..55589c7b015
--- /dev/null
+++ b/app/components/diffs/stats_component.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Diffs
+ class StatsComponent < BaseComponent
+ attr_reader :diff_files
+
+ def initialize(diff_files:)
+ @diff_files = diff_files
+ @changed ||= diff_files.size
+ @added ||= diff_files.sum(&:added_lines)
+ @removed ||= diff_files.sum(&:removed_lines)
+ end
+
+ def diff_files_data
+ diffs_map = @diff_files.map do |f|
+ {
+ href: "##{helpers.hexdigest(f.file_path)}",
+ title: f.new_path,
+ name: f.file_path,
+ path: diff_file_path_text(f),
+ icon: diff_file_changed_icon(f),
+ iconColor: "#{diff_file_changed_icon_color(f)}",
+ added: f.added_lines,
+ removed: f.removed_lines
+ }
+ end
+
+ Gitlab::Json.dump(diffs_map)
+ end
+
+ # Disabled undercoverage reports for this method
+ # as it returns a false positive on the last line,
+ # which is covered in the tests
+ #
+ # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/357381
+ #
+ # :nocov:
+ def diff_file_path_text(diff_file, max: 60)
+ path = diff_file.new_path
+
+ return path unless path.size > max && max > 3
+
+ "...#{path[-(max - 3)..]}"
+ end
+ # :nocov:
+
+ private
+
+ def diff_file_changed_icon(diff_file)
+ if diff_file.deleted_file?
+ "file-deletion"
+ elsif diff_file.new_file?
+ "file-addition"
+ else
+ "file-modified"
+ end
+ end
+
+ def diff_file_changed_icon_color(diff_file)
+ if diff_file.deleted_file?
+ "danger"
+ elsif diff_file.new_file?
+ "success"
+ end
+ end
+ end
+end
diff --git a/app/components/pajamas/alert_component.html.haml b/app/components/pajamas/alert_component.html.haml
new file mode 100644
index 00000000000..a1d3c700e57
--- /dev/null
+++ b/app/components/pajamas/alert_component.html.haml
@@ -0,0 +1,13 @@
+.gl-alert{ role: 'alert', class: ["gl-alert-#{@variant}", @alert_class], data: @alert_data }
+ = sprite_icon(icon, css_class: icon_classes)
+ - if @dismissible
+ %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ type: 'button',
+ aria: { label: _('Dismiss') },
+ class: @close_button_class,
+ data: @close_button_data }
+ = sprite_icon('close')
+ .gl-alert-content{ role: 'alert' }
+ - if @title
+ %h4.gl-alert-title
+ = @title
+ = content
diff --git a/app/components/pajamas/alert_component.rb b/app/components/pajamas/alert_component.rb
new file mode 100644
index 00000000000..4bb6c41661b
--- /dev/null
+++ b/app/components/pajamas/alert_component.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+# Renders a GlAlert root element
+module Pajamas
+ class AlertComponent < Pajamas::Component
+ # @param [String] title
+ # @param [Symbol] variant
+ # @param [Boolean] dismissible
+ # @param [String] alert_class
+ # @param [Hash] alert_data
+ # @param [String] close_button_class
+ # @param [Hash] close_button_data
+ def initialize(
+ title: nil, variant: :info, dismissible: true,
+ alert_class: nil, alert_data: {}, close_button_class: nil, close_button_data: {})
+ @title = title
+ @variant = variant
+ @dismissible = dismissible
+ @alert_class = alert_class
+ @alert_data = alert_data
+ @close_button_class = close_button_class
+ @close_button_data = close_button_data
+ end
+
+ private
+
+ delegate :sprite_icon, to: :helpers
+
+ ICONS = {
+ info: 'information-o',
+ warning: 'warning',
+ success: 'check-circle',
+ danger: 'error',
+ tip: 'bulb'
+ }.freeze
+
+ def icon
+ ICONS[@variant]
+ end
+
+ def icon_classes
+ "gl-alert-icon#{' gl-alert-icon-no-title' if @title.nil?}"
+ end
+ end
+end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index a680c1f4517..75d1e4bf6a0 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -13,7 +13,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action :disable_query_limiting, only: [:usage_data]
- feature_category :not_owned, [
+ feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
:general, :reporting, :metrics_and_profiling, :network,
:preferences, :update, :reset_health_check_token
]
@@ -53,6 +53,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def service_usage_data
+ @service_ping_data_present = Rails.cache.exist?('usage_data')
end
def update
diff --git a/app/controllers/admin/background_jobs_controller.rb b/app/controllers/admin/background_jobs_controller.rb
index d4b906d5e33..4eda35d66f6 100644
--- a/app/controllers/admin/background_jobs_controller.rb
+++ b/app/controllers/admin/background_jobs_controller.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class Admin::BackgroundJobsController < Admin::ApplicationController
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
end
diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb
index e21e6fd2dcb..42b89a3317e 100644
--- a/app/controllers/admin/background_migrations_controller.rb
+++ b/app/controllers/admin/background_migrations_controller.rb
@@ -6,8 +6,8 @@ class Admin::BackgroundMigrationsController < Admin::ApplicationController
def index
@relations_by_tab = {
'queued' => batched_migration_class.queued.queue_order,
- 'failed' => batched_migration_class.failed.queue_order,
- 'finished' => batched_migration_class.finished.queue_order.reverse_order
+ 'failed' => batched_migration_class.with_status(:failed).queue_order,
+ 'finished' => batched_migration_class.with_status(:finished).queue_order.reverse_order
}
@current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued'
@@ -17,14 +17,14 @@ class Admin::BackgroundMigrationsController < Admin::ApplicationController
def pause
migration = batched_migration_class.find(params[:id])
- migration.paused!
+ migration.pause!
redirect_back fallback_location: { action: 'index' }
end
def resume
migration = batched_migration_class.find(params[:id])
- migration.active!
+ migration.execute!
redirect_back fallback_location: { action: 'index' }
end
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index ef843a84e6c..8b672929f88 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -45,8 +45,8 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
end
def preview
- broadcast_message = BroadcastMessage.new(broadcast_message_params)
- render json: { message: render_broadcast_message(broadcast_message) }
+ @broadcast_message = BroadcastMessage.new(broadcast_message_params)
+ render partial: 'admin/broadcast_messages/preview'
end
protected
@@ -58,8 +58,8 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
def broadcast_message_params
params.require(:broadcast_message).permit(%i(
color
+ theme
ends_at
- font
message
starts_at
target_path
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index d12ccfc7423..20e36e5fd84 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -5,7 +5,7 @@ class Admin::DashboardController < Admin::ApplicationController
COUNTED_ITEMS = [Project, User, Group].freeze
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
# rubocop: disable CodeReuse/ActiveRecord
def index
diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb
index 5733929c25e..3614f83723d 100644
--- a/app/controllers/admin/health_check_controller.rb
+++ b/app/controllers/admin/health_check_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::HealthCheckController < Admin::ApplicationController
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
def show
@errors = HealthCheck::Utils.process_checks(checks)
diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb
index db9835e65ec..ad0ee0b2cef 100644
--- a/app/controllers/admin/integrations_controller.rb
+++ b/app/controllers/admin/integrations_controller.rb
@@ -5,10 +5,6 @@ class Admin::IntegrationsController < Admin::ApplicationController
before_action :not_found, unless: -> { instance_level_integrations? }
- before_action do
- push_frontend_feature_flag(:integration_form_sections, default_enabled: :yaml)
- end
-
feature_category :integrations
def overrides
diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb
index 420fd93fad5..7bfbabe8dfc 100644
--- a/app/controllers/admin/plan_limits_controller.rb
+++ b/app/controllers/admin/plan_limits_controller.rb
@@ -5,7 +5,7 @@ class Admin::PlanLimitsController < Admin::ApplicationController
before_action :set_plan_limits
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
def create
redirect_path = referer_path(request) || general_admin_application_settings_path
@@ -38,6 +38,14 @@ class Admin::PlanLimitsController < Admin::ApplicationController
pypi_max_file_size
terraform_module_max_file_size
generic_packages_max_file_size
+ ci_pipeline_size
+ ci_active_jobs
+ ci_active_pipelines
+ ci_project_subscriptions
+ ci_pipeline_schedules
+ ci_needs_size_limit
+ ci_registered_group_runners
+ ci_registered_project_runners
])
end
end
diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb
index fbbe8c24637..b60cb7ff9c2 100644
--- a/app/controllers/admin/requests_profiles_controller.rb
+++ b/app/controllers/admin/requests_profiles_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::RequestsProfilesController < Admin::ApplicationController
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
def index
@profile_token = Gitlab::RequestProfiler.profile_token
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 2744be0150c..06880ace899 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -4,6 +4,9 @@ class Admin::RunnersController < Admin::ApplicationController
include RunnerSetupScripts
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
+ before_action only: [:index] do
+ push_frontend_feature_flag(:admin_runners_bulk_delete, default_enabled: :yaml)
+ end
feature_category :runner
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 67d991c8b03..e4e866a8b60 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::SpamLogsController < Admin::ApplicationController
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
# rubocop: disable CodeReuse/ActiveRecord
def index
diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb
index f14305528a3..7ae930abb84 100644
--- a/app/controllers/admin/system_info_controller.rb
+++ b/app/controllers/admin/system_info_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::SystemInfoController < Admin::ApplicationController
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
EXCLUDED_MOUNT_OPTIONS = %w[
nobrowse
diff --git a/app/controllers/admin/version_check_controller.rb b/app/controllers/admin/version_check_controller.rb
index dde1a7abafa..f5c70dc9e1b 100644
--- a/app/controllers/admin/version_check_controller.rb
+++ b/app/controllers/admin/version_check_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::VersionCheckController < Admin::ApplicationController
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
def version_check
response = VersionCheck.new.response
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 1d17e8aa085..572ec40ef16 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -196,6 +196,27 @@ class ApplicationController < ActionController::Base
end
end
+ # Devise defines current_user to be:
+ #
+ # def current_user
+ # @current_user ||= warden.authenticate(scope: mapping)
+ # end
+ #
+ # That means whenever current_user is called and `@current_user` is
+ # nil, Warden will attempt to authenticate a user. To avoid
+ # reauthenticating anonymous users, we may need to invalidate
+ # the user.
+ def reset_auth_user!
+ return if strong_memoized?(:auth_user) && auth_user
+
+ # Controllers usually call auth_user first, but for some controllers
+ # authenticate_sessionless_user! is called after that. If we relied
+ # on the memoized auth_user, the value would always be nil for
+ # sessionless users.
+ clear_memoization(:auth_user)
+ auth_user
+ end
+
def log_exception(exception)
# At this point, the controller already exits set_current_context around
# block. To maintain the context while handling error exception, we need to
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 4bcd1be9f53..663e3cf8648 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -13,6 +13,7 @@ class AutocompleteController < ApplicationController
feature_category :continuous_delivery, [:deploy_keys_with_owners]
urgency :low, [:merge_request_target_branches]
+ urgency :default, [:users]
def users
group = Autocomplete::GroupFinder
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index d9179129983..939c0ef220c 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -14,7 +14,7 @@ class Clusters::ClustersController < Clusters::BaseController
before_action :authorize_create_cluster!, only: [:new, :connect, :authorize_aws_role]
before_action :authorize_update_cluster!, only: [:update]
before_action :update_applications_status, only: [:cluster_status]
- before_action :ensure_feature_enabled!, except: :index
+ before_action :ensure_feature_enabled!, except: [:index, :new_cluster_docs]
helper_method :token_in_session
@@ -184,7 +184,7 @@ class Clusters::ClustersController < Clusters::BaseController
def cluster_list
return [] unless certificate_based_clusters_enabled?
- finder = ClusterAncestorsFinder.new(clusterable.subject, current_user)
+ finder = ClusterAncestorsFinder.new(clusterable.__subject__, current_user)
clusters = finder.execute
@has_ancestor_clusters = finder.has_ancestor_clusters?
@@ -253,7 +253,7 @@ class Clusters::ClustersController < Clusters::BaseController
]).merge(
provider_type: :gcp,
platform_type: :kubernetes,
- clusterable: clusterable.subject
+ clusterable: clusterable.__subject__
)
end
@@ -274,7 +274,7 @@ class Clusters::ClustersController < Clusters::BaseController
]).merge(
provider_type: :aws,
platform_type: :kubernetes,
- clusterable: clusterable.subject
+ clusterable: clusterable.__subject__
)
end
@@ -291,7 +291,7 @@ class Clusters::ClustersController < Clusters::BaseController
]).merge(
provider_type: :user,
platform_type: :kubernetes,
- clusterable: clusterable.subject
+ clusterable: clusterable.__subject__
)
end
@@ -313,7 +313,7 @@ class Clusters::ClustersController < Clusters::BaseController
end
def gcp_cluster
- cluster = Clusters::BuildService.new(clusterable.subject).execute
+ cluster = Clusters::BuildService.new(clusterable.__subject__).execute
cluster.build_provider_gcp
@gcp_cluster = cluster.present(current_user: current_user)
end
@@ -343,7 +343,7 @@ class Clusters::ClustersController < Clusters::BaseController
end
def user_cluster
- cluster = Clusters::BuildService.new(clusterable.subject).execute
+ cluster = Clusters::BuildService.new(clusterable.__subject__).execute
cluster.build_platform_kubernetes
@user_cluster = cluster.present(current_user: current_user)
end
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index c67e73d4e78..b1b6e21644e 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -10,6 +10,11 @@
module EnforcesTwoFactorAuthentication
extend ActiveSupport::Concern
+ MFA_HELP_PAGE = Rails.application.routes.url_helpers.help_page_url(
+ 'user/profile/account/two_factor_authentication.html',
+ anchor: 'enable-two-factor-authentication'
+ )
+
included do
before_action :check_two_factor_requirement, except: [:route_not_found]
@@ -24,7 +29,16 @@ module EnforcesTwoFactorAuthentication
return unless respond_to?(:current_user)
if two_factor_authentication_required? && current_user_requires_two_factor?
- redirect_to profile_two_factor_auth_path
+ case self
+ when GraphqlController
+ render_error(
+ _("Authentication error: enable 2FA in your profile settings to continue using GitLab: %{mfa_help_page}") %
+ { mfa_help_page: MFA_HELP_PAGE },
+ status: :unauthorized
+ )
+ else
+ redirect_to profile_two_factor_auth_path
+ end
end
end
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 80acb369cb2..d256b331174 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -89,7 +89,7 @@ module Integrations
param_values = return_value[:integration]
if param_values.is_a?(ActionController::Parameters)
- integration.password_fields.each do |param|
+ integration.secret_fields.each do |param|
param_values.delete(param) if param_values[param].blank?
end
end
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb
index 48daacc09c2..7ec9be6baaf 100644
--- a/app/controllers/concerns/sessionless_authentication.rb
+++ b/app/controllers/concerns/sessionless_authentication.rb
@@ -20,16 +20,21 @@ module SessionlessAuthentication
end
def sessionless_sign_in(user)
- if user.can_log_in_with_non_expired_password?
- # Notice we are passing store false, so the user is not
- # actually stored in the session and a token is needed
- # for every request. If you want the token to work as a
- # sign in token, you can simply remove store: false.
- sign_in(user, store: false, message: :sessionless_sign_in)
- elsif request_authenticator.can_sign_in_bot?(user)
- # we suppress callbacks to avoid redirecting the bot
- sign_in(user, store: false, message: :sessionless_sign_in, run_callbacks: false)
- end
+ signed_in_user =
+ if user.can_log_in_with_non_expired_password?
+ # Notice we are passing store false, so the user is not
+ # actually stored in the session and a token is needed
+ # for every request. If you want the token to work as a
+ # sign in token, you can simply remove store: false.
+ sign_in(user, store: false, message: :sessionless_sign_in)
+ elsif request_authenticator.can_sign_in_bot?(user)
+ # we suppress callbacks to avoid redirecting the bot
+ sign_in(user, store: false, message: :sessionless_sign_in, run_callbacks: false)
+ end
+
+ reset_auth_user! if respond_to?(:reset_auth_user!, true)
+
+ signed_in_user
end
def sessionless_bypass_admin_mode!(&block)
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 714a6f280f3..91de1d8aeae 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -21,10 +21,6 @@ module WikiActions
before_action :load_sidebar, except: [:pages]
before_action :set_content_class
- before_action do
- push_frontend_feature_flag(:wiki_switch_between_content_editor_raw_markdown, @group, default_enabled: :yaml)
- end
-
before_action only: [:show, :edit, :update] do
@valid_encoding = valid_encoding?
end
@@ -223,7 +219,7 @@ module WikiActions
def page
strong_memoize(:page) do
- wiki.find_page(*page_params)
+ wiki.find_page(*page_params, load_content: load_content?)
end
end
@@ -310,6 +306,12 @@ module WikiActions
def send_wiki_file_blob(wiki, file_blob)
send_blob(wiki.repository, file_blob)
end
+
+ def load_content?
+ return false if %w[history destroy diff show].include?(params[:action])
+
+ true
+ end
end
WikiActions.prepend_mod
diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb
index 0e9fdc60363..95deacdc5b9 100644
--- a/app/controllers/dashboard/application_controller.rb
+++ b/app/controllers/dashboard/application_controller.rb
@@ -11,6 +11,6 @@ class Dashboard::ApplicationController < ApplicationController
private
def projects
- @projects ||= current_user.authorized_projects.sorted_by_activity.non_archived
+ @projects ||= current_user.authorized_projects.sorted_by_updated_desc.non_archived
end
end
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index f8a6d9f808e..23e0143506e 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -25,6 +25,9 @@ class Explore::ProjectsController < Explore::ApplicationController
feature_category :projects
+ # TODO: Set higher urgency after addressing https://gitlab.com/gitlab-org/gitlab/-/issues/357913
+ urgency :low, [:index]
+
def index
show_alert_if_search_is_disabled
@projects = load_projects
@@ -110,7 +113,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def load_topic
- @topic = Projects::Topic.find_by_name(params[:topic_name])
+ @topic = Projects::Topic.find_by_name_case_insensitive(params[:topic_name])
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index ef229a2abec..b00d85b6b0f 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -32,6 +32,7 @@ class GraphqlController < ApplicationController
before_action :set_user_last_activity
before_action :track_vs_code_usage
before_action :track_jetbrains_usage
+ before_action :track_gitlab_cli_usage
before_action :disable_query_limiting
before_action :limit_query_size
@@ -43,7 +44,7 @@ class GraphqlController < ApplicationController
around_action :sessionless_bypass_admin_mode!, if: :sessionless_user?
# The default feature category is overridden to read from request
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
# We don't know what the query is going to be, so we can't set a high urgency
# See https://gitlab.com/groups/gitlab-org/-/epics/5841 for the work that will
@@ -143,6 +144,11 @@ class GraphqlController < ApplicationController
.track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
end
+ def track_gitlab_cli_usage
+ Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter
+ .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
+ end
+
def execute_multiplex
GitlabSchema.multiplex(multiplex_queries, context: context)
end
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 641b3adb12b..c65232c0fea 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -8,7 +8,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
- push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
+ push_frontend_feature_flag(:realtime_labels, group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control { }
e.candidate { }
@@ -43,11 +43,11 @@ class Groups::BoardsController < Groups::ApplicationController
def assign_endpoint_vars
@boards_endpoint = group_boards_path(group)
- @namespace_path = group.to_param
- @labels_endpoint = group_labels_path(group)
end
def authorize_read_board!
access_denied! unless can?(current_user, :read_issue_board, group)
end
end
+
+Groups::BoardsController.prepend_mod
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
index 10a6ad06ae5..d10c52f0301 100644
--- a/app/controllers/groups/children_controller.rb
+++ b/app/controllers/groups/children_controller.rb
@@ -9,6 +9,9 @@ module Groups
feature_category :subgroups
+ # TODO: Set to higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/331494
+ urgency :low, [:index]
+
def index
params[:sort] ||= @group_projects_sort
parent = if params[:parent_id].present?
diff --git a/app/controllers/groups/crm/organizations_controller.rb b/app/controllers/groups/crm/organizations_controller.rb
index f8536b4f538..846995ecba5 100644
--- a/app/controllers/groups/crm/organizations_controller.rb
+++ b/app/controllers/groups/crm/organizations_controller.rb
@@ -10,6 +10,10 @@ class Groups::Crm::OrganizationsController < Groups::ApplicationController
render action: "index"
end
+ def edit
+ render action: "index"
+ end
+
private
def authorize_read_crm_organization!
diff --git a/app/controllers/groups/email_campaigns_controller.rb b/app/controllers/groups/email_campaigns_controller.rb
index 520ad768939..70c8a23d918 100644
--- a/app/controllers/groups/email_campaigns_controller.rb
+++ b/app/controllers/groups/email_campaigns_controller.rb
@@ -40,7 +40,7 @@ class Groups::EmailCampaignsController < Groups::ApplicationController
project_pipelines_url(group.projects.first)
when :trial, :trial_short
'https://about.gitlab.com/free-trial/'
- when :team, :team_short, :invite_team
+ when :team, :team_short
group_group_members_url(group)
when :admin_verify
project_settings_ci_cd_path(group.projects.first, ci_runner_templates: true, anchor: 'js-runners-settings')
@@ -59,11 +59,6 @@ class Groups::EmailCampaignsController < Groups::ApplicationController
@track = params[:track]&.to_sym
@series = params[:series]&.to_i
- # There is only one email that will be sent for invite team track so series
- # should only have the value 0. Return early if track is invite team and
- # condition for series value is met
- return if @track == Namespaces::InviteTeamEmailService::TRACK && @series == 0
-
track_valid = @track.in?(Namespaces::InProductMarketingEmailsService::TRACKS.keys)
return render_404 unless track_valid
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index 0655d779a4e..cc2ca728592 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -6,30 +6,12 @@ class Groups::GroupLinksController < Groups::ApplicationController
feature_category :subgroups
- def create
- shared_with_group = Group.find(params[:shared_with_group_id]) if params[:shared_with_group_id].present?
-
- if shared_with_group
- result = Groups::GroupLinks::CreateService
- .new(group, shared_with_group, current_user, group_link_create_params)
- .execute
-
- return render_404 if result[:http_status] == 404
-
- flash[:alert] = result[:message] if result[:status] == :error
- else
- flash[:alert] = _('Please select a group.')
- end
-
- redirect_to group_group_members_path(group)
- end
-
def update
Groups::GroupLinks::UpdateService.new(@group_link).execute(group_link_params)
if @group_link.expires?
render json: {
- expires_in: helpers.distance_of_time_in_words_to_now(@group_link.expires_at),
+ expires_in: helpers.time_ago_with_tooltip(@group_link.expires_at),
expires_soon: @group_link.expires_soon?
}
else
@@ -54,10 +36,6 @@ class Groups::GroupLinksController < Groups::ApplicationController
@group_link ||= group.shared_with_group_links.find(params[:id])
end
- def group_link_create_params
- params.permit(:shared_group_access, :expires_at)
- end
-
def group_link_params
params.require(:group_link).permit(:group_access, :expires_at)
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index ece1083d4d1..51778f31f65 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -20,10 +20,13 @@ class Groups::GroupMembersController < Groups::ApplicationController
:approve_access_request, :leave, :resend_invite,
:override
- feature_category :authentication_and_authorization
+ feature_category :subgroups
def index
+ push_frontend_feature_flag(:group_member_inherited_group, @group)
+
@sort = params[:sort].presence || sort_value_name
+ @include_relations ||= requested_relations
if can?(current_user, :admin_group_member, @group)
@invited_members = invited_members
diff --git a/app/controllers/groups/releases_controller.rb b/app/controllers/groups/releases_controller.rb
index db5385ecc71..e87135cc104 100644
--- a/app/controllers/groups/releases_controller.rb
+++ b/app/controllers/groups/releases_controller.rb
@@ -17,8 +17,10 @@ module Groups
def releases
if Feature.enabled?(:group_releases_finder_inoperator)
Releases::GroupReleasesFinder
- .new(@group, current_user, { include_subgroups: true, page: params[:page], per: 30 })
+ .new(@group, current_user)
.execute(preload: false)
+ .page(params[:page])
+ .per(30)
else
ReleasesFinder
.new(@group, current_user, { include_subgroups: true })
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index dabef978ee1..a2be4d9d7e1 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -11,6 +11,8 @@ class Groups::RunnersController < Groups::ApplicationController
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::Tracking.event(self.class.name, 'index', user: current_user, namespace: @group)
end
def runner_list_group_view_vue_ui_enabled
diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb
index ec64e75a68e..0a63c3d304b 100644
--- a/app/controllers/groups/settings/integrations_controller.rb
+++ b/app/controllers/groups/settings/integrations_controller.rb
@@ -7,10 +7,6 @@ module Groups
before_action :authorize_admin_group!
- before_action do
- push_frontend_feature_flag(:integration_form_sections, group, default_enabled: :yaml)
- end
-
feature_category :integrations
layout 'group_settings'
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index b53d9b1be04..995d5abf045 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -15,7 +15,6 @@ class GroupsController < Groups::ApplicationController
prepend_before_action(only: [:show, :issues]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) }
- prepend_before_action :ensure_export_enabled, only: [:export, :download_export]
prepend_before_action :check_captcha, only: :create, if: -> { captcha_enabled? }
before_action :authenticate_user!, only: [:new, :create]
@@ -33,7 +32,6 @@ class GroupsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issues_list, @group, default_enabled: :yaml)
- push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml)
end
before_action :check_export_rate_limit!, only: [:export, :download_export]
@@ -61,7 +59,8 @@ class GroupsController < Groups::ApplicationController
feature_category :importers, [:export, :download_export]
urgency :high, [:unfoldered_environment_names]
- urgency :low, [:merge_requests]
+ # TODO: Set #show to higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/334795
+ urgency :low, [:merge_requests, :show]
def index
redirect_to(current_user ? dashboard_groups_path : explore_groups_path)
@@ -218,6 +217,8 @@ class GroupsController < Groups::ApplicationController
@has_projects = group_projects.exists?
+ set_sort_order
+
respond_to do |format|
format.html
end
@@ -293,7 +294,7 @@ class GroupsController < Groups::ApplicationController
:setup_for_company,
:jobs_to_be_done,
:crm_enabled
- ]
+ ] + [group_feature_attributes: group_feature_attributes]
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -338,10 +339,6 @@ class GroupsController < Groups::ApplicationController
check_rate_limit!(prefixed_action, scope: [current_user, scope].compact)
end
- def ensure_export_enabled
- render_404 unless Feature.enabled?(:group_import_export, @group, default_enabled: true)
- end
-
private
def load_recaptcha
@@ -400,6 +397,10 @@ class GroupsController < Groups::ApplicationController
experiment(:require_verification_for_namespace_creation, user: current_user).track(:start_create_group)
end
+
+ def group_feature_attributes
+ []
+ end
end
GroupsController.prepend_mod_with('GroupsController')
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index f267d383804..5be2d7527ff 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -3,7 +3,7 @@
class HelpController < ApplicationController
skip_before_action :authenticate_user!, unless: :public_visibility_restricted?
skip_before_action :check_two_factor_requirement
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
layout 'help'
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 44beceb4f48..9494a686467 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -18,6 +18,8 @@ class IdeController < ApplicationController
feature_category :web_ide
+ urgency :low
+
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
end
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 7ad3a2ee358..51ca12370e6 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -13,7 +13,13 @@ class Import::BaseController < ApplicationController
provider_repos: serialized_provider_repos,
incompatible_repos: serialized_incompatible_repos }
end
- format.html
+ format.html do
+ if params[:namespace_id]&.present?
+ @namespace = Namespace.find_by_id(params[:namespace_id])
+
+ render_404 unless current_user.can?(:create_projects, @namespace)
+ end
+ end
end
end
@@ -70,7 +76,7 @@ class Import::BaseController < ApplicationController
end
def already_added_projects
- @already_added_projects ||= filtered(find_already_added_projects(provider_name))
+ @already_added_projects ||= find_already_added_projects(provider_name)
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index cfd86429df0..7c9525d1744 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -12,14 +12,21 @@ class Import::BitbucketController < Import::BaseController
rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized
def callback
- response = oauth_client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url)
+ auth_state = session[:bitbucket_auth_state]
+ session[:bitbucket_auth_state] = nil
- session[:bitbucket_token] = response.token
- session[:bitbucket_expires_at] = response.expires_at
- session[:bitbucket_expires_in] = response.expires_in
- session[:bitbucket_refresh_token] = response.refresh_token
+ if auth_state.blank? || !ActiveSupport::SecurityUtils.secure_compare(auth_state, params[:state])
+ go_to_bitbucket_for_permissions
+ else
+ response = oauth_client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url)
+
+ session[:bitbucket_token] = response.token
+ session[:bitbucket_expires_at] = response.expires_at
+ session[:bitbucket_expires_in] = response.expires_in
+ session[:bitbucket_refresh_token] = response.refresh_token
- redirect_to status_import_bitbucket_url
+ redirect_to status_import_bitbucket_url
+ end
end
def status
@@ -113,7 +120,9 @@ class Import::BitbucketController < Import::BaseController
end
def go_to_bitbucket_for_permissions
- redirect_to oauth_client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url)
+ state = SecureRandom.base64(64)
+ session[:bitbucket_auth_state] = state
+ redirect_to oauth_client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url, state: state)
end
def bitbucket_unauthorized(exception)
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 55f4563285d..9bd8f893614 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -23,24 +23,25 @@ class Import::GithubController < Import::BaseController
if !ci_cd_only? && github_import_configured? && logged_in_with_provider?
go_to_provider_for_permissions
elsif session[access_token_key]
- redirect_to status_import_url
+ redirect_to status_import_url(namespace_id: params[:namespace_id])
end
end
def callback
- auth_state = session[auth_state_key]
- session[auth_state_key] = nil
+ auth_state = session.delete(auth_state_key)
+ namespace_id = session.delete(:namespace_id)
+
if auth_state.blank? || !ActiveSupport::SecurityUtils.secure_compare(auth_state, params[:state])
provider_unauthorized
else
session[access_token_key] = get_token(params[:code])
- redirect_to status_import_url
+ redirect_to status_import_url(namespace_id: namespace_id)
end
end
def personal_access_token
session[access_token_key] = params[:personal_access_token]&.strip
- redirect_to status_import_url
+ redirect_to status_import_url(namespace_id: params[:namespace_id].presence)
end
def status
@@ -62,7 +63,15 @@ class Import::GithubController < Import::BaseController
end
def realtime_changes
- super
+ Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
+ render json: already_added_projects.map { |project|
+ {
+ id: project.id,
+ import_status: project.import_status,
+ stats: ::Gitlab::GithubImport::ObjectCounter.summary(project)
+ }
+ }
end
protected
@@ -201,8 +210,8 @@ class Import::GithubController < Import::BaseController
public_send("new_import_#{provider_name}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
- def status_import_url
- public_send("status_import_#{provider_name}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
+ def status_import_url(namespace_id: nil)
+ public_send("status_import_#{provider_name}_url", extra_import_params.merge({ namespace_id: namespace_id })) # rubocop:disable GitlabSecurity/PublicSend
end
def callback_import_url
@@ -248,6 +257,7 @@ class Import::GithubController < Import::BaseController
def provider_auth
if !ci_cd_only? && session[access_token_key].blank?
+ session[:namespace_id] = params[:namespace_id]
go_to_provider_for_permissions
end
end
diff --git a/app/controllers/import/gitlab_groups_controller.rb b/app/controllers/import/gitlab_groups_controller.rb
index aca71f6d57a..c9d5e9986dc 100644
--- a/app/controllers/import/gitlab_groups_controller.rb
+++ b/app/controllers/import/gitlab_groups_controller.rb
@@ -3,7 +3,6 @@
class Import::GitlabGroupsController < ApplicationController
include WorkhorseAuthorization
- before_action :ensure_group_import_enabled
before_action :check_import_rate_limit!, only: %i[create]
feature_category :importers
@@ -51,10 +50,6 @@ class Import::GitlabGroupsController < ApplicationController
end
end
- def ensure_group_import_enabled
- render_404 unless Feature.enabled?(:group_import_export, @group, default_enabled: true)
- end
-
def check_import_rate_limit!
check_rate_limit!(:group_import, scope: current_user) do
redirect_to new_group_path, alert: _('This endpoint has been requested too many times. Try again later.')
diff --git a/app/controllers/import/history_controller.rb b/app/controllers/import/history_controller.rb
new file mode 100644
index 00000000000..69e31392f21
--- /dev/null
+++ b/app/controllers/import/history_controller.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class Import::HistoryController < ApplicationController
+ feature_category :importers
+end
diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb
index 352e78d6255..9b3bff062dd 100644
--- a/app/controllers/jira_connect/application_controller.rb
+++ b/app/controllers/jira_connect/application_controller.rb
@@ -22,6 +22,8 @@ class JiraConnect::ApplicationController < ApplicationController
def verify_qsh_claim!
payload, _ = decode_auth_token!
+ return if request.format.json? && payload['qsh'] == 'context-qsh'
+
# Make sure `qsh` claim matches the current request
render_403 unless payload['qsh'] == Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url)
rescue StandardError
diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb
index 327192857f6..3c78f63e069 100644
--- a/app/controllers/jira_connect/events_controller.rb
+++ b/app/controllers/jira_connect/events_controller.rb
@@ -7,10 +7,6 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController
before_action :verify_asymmetric_atlassian_jwt!
def installed
- unless Feature.enabled?(:jira_connect_installation_update, default_enabled: :yaml)
- return head :ok if current_jira_installation
- end
-
success = current_jira_installation ? update_installation : create_installation
if success
diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb
index ec6ba07a125..d8ce67d6267 100644
--- a/app/controllers/jira_connect/subscriptions_controller.rb
+++ b/app/controllers/jira_connect/subscriptions_controller.rb
@@ -11,7 +11,9 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
style_src_values = Array.wrap(p.directives['style-src']) | %w('self' 'unsafe-inline')
# rubocop: enable Lint/PercentStringArray
- p.frame_ancestors :self, 'https://*.atlassian.net'
+ # *.jira.com is needed for some legacy Jira Cloud instances, new ones will use *.atlassian.net
+ # https://support.atlassian.com/organization-administration/docs/ip-addresses-and-domains-for-atlassian-cloud-products/
+ p.frame_ancestors :self, 'https://*.atlassian.net', 'https://*.jira.com'
p.script_src(*script_src_values)
p.style_src(*style_src_values)
end
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 010b85e81bf..8eebf9fbf6b 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -9,6 +9,8 @@ class JwtController < ApplicationController
prepend_before_action :auth_user, :authenticate_project_or_user
feature_category :authentication_and_authorization
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/357037
+ urgency :low
SERVICES = {
::Auth::ContainerRegistryAuthenticationService::AUDIENCE => ::Auth::ContainerRegistryAuthenticationService,
diff --git a/app/controllers/oauth/jira/authorizations_controller.rb b/app/controllers/oauth/jira_dvcs/authorizations_controller.rb
index 8169b5fcbb0..613999f4ca7 100644
--- a/app/controllers/oauth/jira/authorizations_controller.rb
+++ b/app/controllers/oauth/jira_dvcs/authorizations_controller.rb
@@ -4,7 +4,7 @@
# flow routes for Jira DVCS integration.
# See https://gitlab.com/gitlab-org/gitlab/issues/2381
#
-class Oauth::Jira::AuthorizationsController < ApplicationController
+class Oauth::JiraDvcs::AuthorizationsController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
@@ -17,7 +17,7 @@ class Oauth::Jira::AuthorizationsController < ApplicationController
redirect_to oauth_authorization_path(client_id: params['client_id'],
response_type: 'code',
scope: normalize_scope(params['scope']),
- redirect_uri: oauth_jira_callback_url)
+ redirect_uri: oauth_jira_dvcs_callback_url)
end
# 2. Handle the callback call as we were a Github Enterprise instance client.
@@ -33,7 +33,7 @@ class Oauth::Jira::AuthorizationsController < ApplicationController
# 3. Rewire and adjust access_token request accordingly.
def access_token
# We have to modify request.parameters because Doorkeeper::Server reads params from there
- request.parameters[:redirect_uri] = oauth_jira_callback_url
+ request.parameters[:redirect_uri] = oauth_jira_dvcs_callback_url
strategy = Doorkeeper::Server.new(self).token_request('authorization_code')
response = strategy.authorize
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index 8cfec247b7a..ae757c30d1c 100644
--- a/app/controllers/profiles/chat_names_controller.rb
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -4,7 +4,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
before_action :chat_name_token, only: [:new]
before_action :chat_name_params, only: [:new, :create, :deny]
- feature_category :users
+ feature_category :integrations
def index
@chat_names = current_user.chat_names
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index 6ef0ed6d365..ccfd360a781 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Profiles::NotificationsController < Profiles::ApplicationController
- feature_category :users
+ feature_category :team_planning
def show
@user = current_user
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index adecb56ea38..820b6520f6c 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -36,6 +36,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController
def preferences_param_names
[
:color_scheme_id,
+ :diffs_deletion_color,
+ :diffs_addition_color,
:layout,
:dashboard,
:project_view,
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index 77fae34e2d2..48b0d313d3c 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -4,6 +4,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_two_factor_requirement
before_action :ensure_verified_primary_email, only: [:show, :create]
before_action :validate_current_password, only: [:create, :codes, :destroy], if: :current_password_required?
+ before_action :update_current_user_otp!, only: [:show]
helper_method :current_password_required?
@@ -14,16 +15,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
feature_category :authentication_and_authorization
def show
- unless current_user.two_factor_enabled?
- current_user.otp_secret = User.generate_otp_secret(32)
- end
-
- unless current_user.otp_grace_period_started_at && two_factor_grace_period
- current_user.otp_grace_period_started_at = Time.current
- end
-
- Users::UpdateService.new(current_user, user: current_user).execute!
-
if two_factor_authentication_required? && !current_user.two_factor_enabled?
two_factor_authentication_reason(
global: lambda do
@@ -68,6 +59,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
else
@error = { message: _('Invalid pin code.') }
@qr_code = build_qr_code
+ @account_string = account_string
if Feature.enabled?(:webauthn, default_enabled: :yaml)
setup_webauthn_registration
@@ -138,6 +130,18 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
private
+ def update_current_user_otp!
+ if current_user.needs_new_otp_secret?
+ current_user.update_otp_secret!
+ end
+
+ unless current_user.otp_grace_period_started_at && two_factor_grace_period
+ current_user.otp_grace_period_started_at = Time.current
+ end
+
+ Users::UpdateService.new(current_user, user: current_user).execute!
+ end
+
def validate_current_password
return if current_user.valid_password?(params[:current_password])
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 7bb3ed1d109..feed94708f6 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -5,6 +5,8 @@ class Projects::ArtifactsController < Projects::ApplicationController
include RendersBlob
include SendFileUpload
+ urgency :low, [:browse, :file, :latest_succeeded]
+
layout 'project'
before_action :authorize_read_build!
before_action :authorize_update_build!, only: [:keep]
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index c44a0830e2e..7a30e68d9a2 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -8,7 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :assign_endpoint_vars
before_action do
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(:realtime_labels, project&.group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control { }
e.candidate { }
@@ -44,11 +44,11 @@ class Projects::BoardsController < Projects::ApplicationController
def assign_endpoint_vars
@boards_endpoint = project_boards_path(project)
@bulk_issues_path = bulk_update_project_issues_path(project)
- @namespace_path = project.namespace.full_path
- @labels_endpoint = project_labels_path(project)
end
def authorize_read_board!
access_denied! unless can?(current_user, :read_issue_board, project)
end
end
+
+Projects::BoardsController.prepend_mod
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index dad73c37fea..6264f10ce2d 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -34,11 +34,9 @@ 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)
-
+ rescue Gitlab::Git::CommandError
@gitaly_unavailable = true
- render
+ render status: :service_unavailable
end
format.json do
branches = BranchesFinder.new(@repository, params).execute
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 0c26b402876..2b2764d2e34 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -31,7 +31,7 @@ class Projects::CommitController < Projects::ApplicationController
respond_to do |format|
format.html do
- render
+ render locals: { pagination_params: params.permit(:page) }
end
format.diff do
send_git_diff(@project.repository, @commit.diff_refs)
@@ -106,6 +106,8 @@ class Projects::CommitController < Projects::ApplicationController
end
def revert
+ return render_404 unless @commit
+
assign_change_commit_vars
return render_404 if @start_branch.blank?
@@ -117,6 +119,8 @@ class Projects::CommitController < Projects::ApplicationController
end
def cherry_pick
+ return render_404 unless @commit
+
assign_change_commit_vars
return render_404 if @start_branch.blank?
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 243cc7a346c..3ced5f21b24 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -34,7 +34,7 @@ class Projects::CompareController < Projects::ApplicationController
def show
apply_diff_view_cookie!
- render
+ render locals: { pagination_params: params.permit(:page) }
end
def diff_for_path
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index eabc048e341..8e81e75ad13 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -104,11 +104,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def stop
return render_404 unless @environment.available?
- stop_action = @environment.stop_with_action!(current_user)
+ stop_actions = @environment.stop_with_actions!(current_user)
action_or_env_url =
- if stop_action
- polymorphic_url([project, stop_action])
+ if stop_actions&.count == 1
+ polymorphic_url([project, stop_actions.first])
else
project_environment_url(project, @environment)
end
diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb
index f293ec752ab..0d65431d870 100644
--- a/app/controllers/projects/google_cloud/base_controller.rb
+++ b/app/controllers/projects/google_cloud/base_controller.rb
@@ -25,7 +25,11 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
end
def feature_flag_enabled!
- unless Feature.enabled?(:incubation_5mp_google_cloud)
+ enabled_for_user = Feature.enabled?(:incubation_5mp_google_cloud, current_user)
+ enabled_for_group = Feature.enabled?(:incubation_5mp_google_cloud, project.group)
+ enabled_for_project = Feature.enabled?(:incubation_5mp_google_cloud, project)
+ feature_is_enabled = enabled_for_user || enabled_for_group || enabled_for_project
+ unless feature_is_enabled
track_event('feature_flag_enabled!', 'access_denied', 'feature_flag_not_enabled')
access_denied!
end
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index d3a05736a47..606f6ac7941 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -102,3 +102,5 @@ class Projects::GraphsController < Projects::ApplicationController
render json: @log.to_json
end
end
+
+Projects::GraphsController.prepend_mod
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 6bc81381d92..6007e09f109 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -7,21 +7,6 @@ class Projects::GroupLinksController < Projects::ApplicationController
feature_category :subgroups
- def create
- group = Group.find(params[:link_group_id]) if params[:link_group_id].present?
-
- if group
- result = Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group)
- return render_404 if result[:http_status] == 404
-
- flash[:alert] = result[:message] if result[:http_status] == 409
- else
- flash[:alert] = _('Please select a group.')
- end
-
- redirect_to project_project_members_path(project)
- end
-
def update
group_link = @project.project_group_links.find(params[:id])
Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params)
@@ -54,10 +39,4 @@ class Projects::GroupLinksController < Projects::ApplicationController
def group_link_params
params.require(:group_link).permit(:group_access, :expires_at)
end
-
- def group_link_create_params
- params.permit(:link_group_access, :expires_at)
- end
end
-
-Projects::GroupLinksController.prepend_mod_with('Projects::GroupLinksController')
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 293581a6744..dd1e51bb9bd 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -7,9 +7,8 @@ class Projects::IncidentsController < Projects::ApplicationController
before_action :authorize_read_issue!
before_action :load_incident, only: [:show]
before_action do
- push_frontend_feature_flag(:incident_escalations, @project)
- push_frontend_feature_flag(:incident_timeline_event_tab, @project, default_enabled: :yaml)
- push_licensed_feature(:incident_timeline_events) if @project.licensed_feature_available?(:incident_timeline_events)
+ push_frontend_feature_flag(:incident_escalations, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:incident_timeline, @project, default_enabled: :yaml)
end
feature_category :incident_management
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index d4474b9d5a3..46943e7214a 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController
include RecordUserLastActivity
ISSUES_EXCEPT_ACTIONS = %i[index calendar new create bulk_update import_csv export_csv service_desk].freeze
- SET_ISSUABLES_INDEX_ONLY_ACTIONS = %i[calendar service_desk].freeze
+ SET_ISSUABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
@@ -22,7 +22,9 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :issue, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
after_action :log_issue_show, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
- before_action :set_issuables_index, if: ->(c) { SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) }
+ before_action :set_issuables_index, if: ->(c) {
+ SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) && !vue_issues_list?
+ }
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
@@ -37,18 +39,17 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_download_code!, only: [:related_branches]
before_action do
- push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
- push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:contacts_autocomplete, project&.group, default_enabled: :yaml)
- push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:incident_timeline, project, default_enabled: :yaml)
end
before_action only: :show do
push_frontend_feature_flag(:confidential_notes, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_issue_discussions, project, default_enabled: :yaml)
- push_frontend_feature_flag(:work_items, project&.group, default_enabled: :yaml)
+ push_frontend_feature_flag(:realtime_labels, project, default_enabled: :yaml)
+ push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -72,10 +73,9 @@ class Projects::IssuesController < Projects::ApplicationController
attr_accessor :vulnerability_id
def index
- if html_request? && Feature.enabled?(:vue_issues_list, project&.group, default_enabled: :yaml)
+ if vue_issues_list?
set_sort_order
else
- set_issuables_index
@issues = @issuables
end
@@ -249,6 +249,12 @@ class Projects::IssuesController < Projects::ApplicationController
protected
+ def vue_issues_list?
+ action_name.to_sym == :index &&
+ html_request? &&
+ Feature.enabled?(:vue_issues_list, project&.group, default_enabled: :yaml)
+ end
+
def sorting_field
Issue::SORTING_PREFERENCE_FIELD
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index b0f032a01e5..4189419c3ba 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -18,6 +18,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action :push_jobs_table_vue, only: [:index]
+ before_action :push_jobs_table_vue_search, only: [:index]
before_action do
push_frontend_feature_flag(:infinitely_collapsible_sections, @project, default_enabled: :yaml)
@@ -77,10 +78,13 @@ class Projects::JobsController < Projects::ApplicationController
end
def retry
- return respond_422 unless @build.retryable?
+ response = Ci::RetryJobService.new(project, current_user).execute(@build)
- build = Ci::Build.retry(@build, current_user)
- redirect_to build_path(build)
+ if response.success?
+ redirect_to build_path(response[:job])
+ else
+ respond_422
+ end
end
def play
@@ -269,4 +273,8 @@ class Projects::JobsController < Projects::ApplicationController
def push_jobs_table_vue
push_frontend_feature_flag(:jobs_table_vue, @project, default_enabled: :yaml)
end
+
+ def push_jobs_table_vue_search
+ push_frontend_feature_flag(:jobs_table_vue_search, @project, default_enabled: :yaml)
+ end
end
diff --git a/app/controllers/projects/learn_gitlab_controller.rb b/app/controllers/projects/learn_gitlab_controller.rb
index 177533b89c8..b9f9a1810b7 100644
--- a/app/controllers/projects/learn_gitlab_controller.rb
+++ b/app/controllers/projects/learn_gitlab_controller.rb
@@ -4,6 +4,7 @@ class Projects::LearnGitlabController < Projects::ApplicationController
before_action :authenticate_user!
before_action :check_experiment_enabled?
before_action :enable_invite_for_help_continuous_onboarding_experiment
+ before_action :enable_video_tutorials_continuous_onboarding_experiment
feature_category :users
@@ -24,4 +25,8 @@ class Projects::LearnGitlabController < Projects::ApplicationController
e.publish_to_database
end
end
+
+ def enable_video_tutorials_continuous_onboarding_experiment
+ experiment(:video_tutorials_continuous_onboarding, namespace: project&.namespace).publish
+ end
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 60d7920f83e..03bb132fe47 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -37,17 +37,12 @@ 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(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_mr_widgets_extensions, project, default_enabled: :yaml)
push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml)
- push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml)
push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
- # Usage data feature flags
- push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
- push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
- push_frontend_feature_flag(:usage_data_diff_searches, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:realtime_labels, project, default_enabled: :yaml)
end
before_action do
@@ -85,7 +80,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
:destroy,
:rebase,
:discussions,
- :pipelines
+ :pipelines,
+ :test_reports
+ ]
+ urgency :low, [
+ :codequality_mr_diff_reports,
+ :codequality_reports
]
def index
@@ -130,9 +130,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
set_pipeline_variables
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do
- @number_of_pipelines = @pipelines.size
- end
+ @number_of_pipelines = @pipelines.size
render
end
@@ -196,17 +194,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
Gitlab::PollingInterval.set_header(response, interval: 10_000)
- ::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
- }
+ 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/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 5dc9718d7a4..b896e2543ff 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -26,6 +26,7 @@ class Projects::MilestonesController < Projects::ApplicationController
respond_to do |format|
format.html do
+ @milestone_states = Milestone.states_count(@project)
# We need to show group milestones in the JSON response
# so that people can filter by and assign group milestones,
# but we don't need to show them on the project milestones page itself.
diff --git a/app/controllers/projects/packages/infrastructure_registry_controller.rb b/app/controllers/projects/packages/infrastructure_registry_controller.rb
index 2fe353b7acb..99d75afc63a 100644
--- a/app/controllers/projects/packages/infrastructure_registry_controller.rb
+++ b/app/controllers/projects/packages/infrastructure_registry_controller.rb
@@ -9,7 +9,6 @@ module Projects
def show
@package = project.packages.find(params[:id])
- @package_files = @package.installable_package_files.recent
end
end
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 271c31b6429..ac94cc001dd 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -10,10 +10,6 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play]
before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
- before_action do
- push_frontend_feature_flag(:pipeline_schedules_with_tags, @project, default_enabled: :yaml)
- end
-
feature_category :continuous_integration
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb
index 602fc02686a..4daf700a8bd 100644
--- a/app/controllers/projects/pipelines/tests_controller.rb
+++ b/app/controllers/projects/pipelines/tests_controller.rb
@@ -3,6 +3,8 @@
module Projects
module Pipelines
class TestsController < Projects::Pipelines::ApplicationController
+ urgency :low, [:show, :summary]
+
before_action :authorize_read_build!
before_action :builds, only: [:show]
@@ -21,9 +23,13 @@ module Projects
def show
respond_to do |format|
format.json do
- render json: TestSuiteSerializer
- .new(project: project, current_user: @current_user)
- .represent(test_suite, details: true)
+ if Feature.enabled?(:ci_test_report_artifacts_expired, project, default_enabled: :yaml) && pipeline.has_expired_test_reports?
+ render json: { errors: 'Test report artifacts have expired' }, status: :not_found
+ else
+ render json: TestSuiteSerializer
+ .new(project: project, current_user: @current_user)
+ .represent(test_suite, details: true)
+ end
end
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 8279bb20769..02f041637ba 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -5,7 +5,7 @@ class Projects::PipelinesController < Projects::ApplicationController
include RedisTracking
urgency :default, [:status]
- urgency :low, [:index, :new, :builds, :show, :failures, :create, :stage, :retry, :dag, :cancel]
+ urgency :low, [:index, :new, :builds, :show, :failures, :create, :stage, :retry, :dag, :cancel, :test_report]
before_action :disable_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables]
@@ -17,6 +17,10 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts]
+ before_action do
+ push_frontend_feature_flag(:pipeline_tabs_vue, @project, default_enabled: :yaml)
+ end
+
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
@@ -264,7 +268,7 @@ class Projects::PipelinesController < Projects::ApplicationController
project
.all_pipelines
.includes(builds: :tags, user: :status)
- .find_by!(id: params[:id])
+ .find(params[:id])
.present(current_user: current_user)
end
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index 0279a65f262..49618c89672 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -8,7 +8,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
- feature_category :authentication_and_authorization
+ feature_category :projects
def index
@sort = params[:sort].presence || sort_value_name
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index b070f9419fc..72af3280a39 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -43,12 +43,7 @@ class Projects::RefsController < Projects::ApplicationController
end
def logs_tree
- tree_summary = ::Gitlab::TreeSummary.new(
- @commit, @project, current_user,
- path: @path, offset: permitted_params[:offset], limit: 25)
-
respond_to do |format|
- format.html { render_404 }
format.json do
logs, next_offset = tree_summary.fetch_logs
@@ -61,6 +56,13 @@ class Projects::RefsController < Projects::ApplicationController
private
+ def tree_summary
+ ::Gitlab::TreeSummary.new(
+ @commit, @project, current_user,
+ path: @path, offset: permitted_params[:offset], limit: 25
+ )
+ end
+
def validate_ref_id
return not_found if permitted_params[:id].present? && permitted_params[:id] !~ Gitlab::PathRegex.git_reference_regex
end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 1a2baf96020..19413d97d9d 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -8,9 +8,6 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new
before_action :validate_suffix_path, :fetch_latest_tag, only: :latest_permalink
- before_action only: :index do
- push_frontend_feature_flag(:releases_index_apollo_client, project, default_enabled: :yaml)
- end
feature_category :release_orchestration
diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb
index 7b799cc0aa6..cdb02047215 100644
--- a/app/controllers/projects/security/configuration_controller.rb
+++ b/app/controllers/projects/security/configuration_controller.rb
@@ -6,6 +6,7 @@ module Projects
include SecurityAndCompliancePermissions
feature_category :static_application_security_testing, [:show]
+ urgency :low, [: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 b6f77a6d515..7352edaaab2 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -6,7 +6,7 @@ module Projects
before_action :ensure_feature_enabled!
before_action :authorize_read_cluster!
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
def index
respond_to do |format|
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 105f8efde7b..1321111faaf 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -13,10 +13,6 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :set_deprecation_notice_for_prometheus_integration, only: [:edit, :update]
before_action :redirect_deprecated_prometheus_integration, only: [:update]
- before_action do
- push_frontend_feature_flag(:integration_form_sections, project, default_enabled: :yaml)
- end
-
respond_to :html
layout "project_settings"
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 97f9c5814e2..c861b24d9ec 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -14,9 +14,7 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
before_action :authorize_read_snippet!, except: [:new, :index]
before_action :authorize_update_snippet!, only: :edit
- before_action only: [:show] do
- push_frontend_feature_flag(:improved_emoji_picker, @project, default_enabled: :yaml)
- end
+ urgency :low, [:index]
def index
@snippet_counts = ::Snippets::CountService
diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb
index 0d9a6f568a1..fed6307514e 100644
--- a/app/controllers/projects/static_site_editor_controller.rb
+++ b/app/controllers/projects/static_site_editor_controller.rb
@@ -3,6 +3,7 @@
class Projects::StaticSiteEditorController < Projects::ApplicationController
include ExtractsPath
include CreatesCommit
+ include BlobHelper
layout 'fullscreen'
@@ -24,28 +25,7 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
end
def show
- service_response = ::StaticSiteEditor::ConfigService.new(
- container: project,
- current_user: current_user,
- params: {
- ref: @ref,
- path: @path,
- return_url: params[:return_url]
- }
- ).execute
-
- if service_response.success?
- Gitlab::UsageDataCounters::StaticSiteEditorCounter.increment_views_count
-
- @data = serialize_necessary_payload_values_to_json(service_response.payload)
- else
- # TODO: For now, if the service returns any error, the user is redirected
- # to the root project page with the error message displayed as an alert.
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/213285#note_414808004
- # for discussion of plans to handle this via a page owned by the Static Site Editor.
- flash[:alert] = service_response.message
- redirect_to project_path(project)
- end
+ redirect_to ide_edit_path(project, @ref, @path)
end
private
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index e447fc3f3fe..a70795f2065 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -18,7 +18,6 @@ class Projects::TreeController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index ed5bd73d6d1..e6e91231ba2 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -11,7 +11,7 @@ class Projects::UploadsController < Projects::ApplicationController
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
private
diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb
index 680874ffee4..f45ee265432 100644
--- a/app/controllers/projects/usage_quotas_controller.rb
+++ b/app/controllers/projects/usage_quotas_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Projects::UsageQuotasController < Projects::ApplicationController
- before_action :authorize_admin_project!
+ before_action :authorize_read_usage_quotas!
layout "project_settings"
diff --git a/app/controllers/projects/web_ide_schemas_controller.rb b/app/controllers/projects/web_ide_schemas_controller.rb
index 84a191815f4..cdc416de6c9 100644
--- a/app/controllers/projects/web_ide_schemas_controller.rb
+++ b/app/controllers/projects/web_ide_schemas_controller.rb
@@ -5,6 +5,8 @@ class Projects::WebIdeSchemasController < Projects::ApplicationController
feature_category :web_ide
+ urgency :low
+
def show
return respond_422 unless branch_sha
diff --git a/app/controllers/projects/web_ide_terminals_controller.rb b/app/controllers/projects/web_ide_terminals_controller.rb
index 1d179765ad9..350b091edfa 100644
--- a/app/controllers/projects/web_ide_terminals_controller.rb
+++ b/app/controllers/projects/web_ide_terminals_controller.rb
@@ -57,11 +57,13 @@ class Projects::WebIdeTerminalsController < Projects::ApplicationController
end
def retry
- return respond_422 unless build.retryable?
+ response = Ci::RetryJobService.new(build.project, current_user).execute(build)
- new_build = Ci::Build.retry(build, current_user)
-
- render_terminal(new_build)
+ if response.success?
+ render_terminal(response[:job])
+ else
+ respond_422
+ end
end
private
diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb
index 1bd2762f277..d39664e1deb 100644
--- a/app/controllers/projects/work_items_controller.rb
+++ b/app/controllers/projects/work_items_controller.rb
@@ -2,12 +2,12 @@
class Projects::WorkItemsController < Projects::ApplicationController
before_action do
- push_frontend_feature_flag(:work_items, project, default_enabled: :yaml)
+ push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
end
- feature_category :not_owned
+ feature_category :team_planning
def index
- render_404 unless Feature.enabled?(:work_items, project, default_enabled: :yaml)
+ render_404 unless project&.work_items_feature_flag_enabled?
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 507a8b66942..6cdfdfa9e2f 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -39,9 +39,8 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml)
push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
- push_frontend_feature_flag(:work_items, @project, default_enabled: :yaml)
+ push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
end
layout :determine_layout
@@ -57,7 +56,8 @@ class ProjectsController < Projects::ApplicationController
feature_category :code_review, [:unfoldered_environment_names]
feature_category :portfolio_management, [:planning_hierarchy]
- urgency :low, [:refs]
+ # TODO: Set high urgency for #show https://gitlab.com/gitlab-org/gitlab/-/issues/334444
+ urgency :low, [:refs, :show]
urgency :high, [:unfoldered_environment_names]
def index
@@ -69,6 +69,13 @@ class ProjectsController < Projects::ApplicationController
@namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
return access_denied! if @namespace && !can?(current_user, :create_projects, @namespace)
+ @current_user_group =
+ if current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).count == 1
+ current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).first
+ else
+ nil
+ end
+
@project = Project.new(namespace_id: @namespace&.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -82,13 +89,6 @@ 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 }
@@ -305,12 +305,7 @@ class ProjectsController < Projects::ApplicationController
end
if find_tags && @repository.tag_count.nonzero?
- tags = begin
- TagsFinder.new(@repository, refs_params).execute
- rescue Gitlab::Git::CommandError
- []
- end
-
+ tags = TagsFinder.new(@repository, refs_params).execute
options['Tags'] = tags.take(100).map(&:name)
end
@@ -321,6 +316,8 @@ class ProjectsController < Projects::ApplicationController
end
render json: options.to_json
+ rescue Gitlab::Git::CommandError
+ render json: { error: _('Unable to load refs') }, status: :service_unavailable
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -545,9 +542,9 @@ class ProjectsController < Projects::ApplicationController
def check_export_rate_limit!
prefixed_action = "project_#{params[:action]}".to_sym
- project_scope = params[:action] == 'download_export' ? @project : nil
+ group_scope = params[:action] == 'download_export' ? @project.namespace : nil
- check_rate_limit!(prefixed_action, scope: [current_user, project_scope].compact)
+ check_rate_limit!(prefixed_action, scope: [current_user, group_scope].compact)
end
def render_edit
diff --git a/app/controllers/sandbox_controller.rb b/app/controllers/sandbox_controller.rb
index a87c2b38e60..a48b2b8a314 100644
--- a/app/controllers/sandbox_controller.rb
+++ b/app/controllers/sandbox_controller.rb
@@ -3,7 +3,7 @@
class SandboxController < ApplicationController # rubocop:disable Gitlab/NamespacedClass
skip_before_action :authenticate_user!
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
def mermaid
render layout: false
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 817da658f14..b4e2da0c7b3 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -169,15 +169,17 @@ class SearchController < ApplicationController
search_allowed = case params[:scope]
when 'blobs'
- Feature.enabled?(:global_search_code_tab, current_user, type: :ops, default_enabled: true)
+ Feature.enabled?(:global_search_code_tab, current_user, type: :ops, default_enabled: :yaml)
when 'commits'
- Feature.enabled?(:global_search_commits_tab, current_user, type: :ops, default_enabled: true)
+ Feature.enabled?(:global_search_commits_tab, current_user, type: :ops, default_enabled: :yaml)
when 'issues'
- Feature.enabled?(:global_search_issues_tab, current_user, type: :ops, default_enabled: true)
+ Feature.enabled?(:global_search_issues_tab, current_user, type: :ops, default_enabled: :yaml)
when 'merge_requests'
- Feature.enabled?(:global_search_merge_requests_tab, current_user, type: :ops, default_enabled: true)
+ Feature.enabled?(:global_search_merge_requests_tab, current_user, type: :ops, default_enabled: :yaml)
when 'wiki_blobs'
- Feature.enabled?(:global_search_wiki_tab, current_user, type: :ops, default_enabled: true)
+ Feature.enabled?(:global_search_wiki_tab, current_user, type: :ops, default_enabled: :yaml)
+ when 'users'
+ Feature.enabled?(:global_search_users_tab, current_user, type: :ops, default_enabled: :yaml)
else
true
end
diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb
index 64d66ee86f1..ebadfd1cdfb 100644
--- a/app/controllers/sent_notifications_controller.rb
+++ b/app/controllers/sent_notifications_controller.rb
@@ -3,7 +3,7 @@
class SentNotificationsController < ApplicationController
skip_before_action :authenticate_user!
- feature_category :users
+ feature_category :team_planning
def unsubscribe
@sent_notification = SentNotification.for(params[:id])
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index e907e291eeb..3e11e0940bf 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -126,7 +126,7 @@ class SessionsController < Devise::SessionsController
flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
flash.delete :recaptcha_error
- respond_with_navigational(resource) { render :new }
+ redirect_to new_user_session_path
end
end
diff --git a/app/controllers/snippets/blobs_controller.rb b/app/controllers/snippets/blobs_controller.rb
index d7c4bbcf8f2..c9a78f39c89 100644
--- a/app/controllers/snippets/blobs_controller.rb
+++ b/app/controllers/snippets/blobs_controller.rb
@@ -2,6 +2,7 @@
class Snippets::BlobsController < Snippets::ApplicationController
include Snippets::BlobsActions
+ urgency :low
skip_before_action :authenticate_user!, only: [:raw]
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 4df0ef78907..97bbb96eae6 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -14,7 +14,8 @@ class UploadsController < ApplicationController
"appearance" => Appearance,
"personal_snippet" => PersonalSnippet,
"projects/topic" => Projects::Topic,
- nil => PersonalSnippet
+ 'alert_management_metric_image' => ::AlertManagement::MetricImage,
+ nil => PersonalSnippet
}.freeze
rescue_from UnknownUploadModelError, with: :render_404
@@ -26,7 +27,7 @@ class UploadsController < ApplicationController
before_action :authorize_create_access!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
- feature_category :not_owned
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
def self.model_classes
MODEL_CLASSES
@@ -56,6 +57,8 @@ class UploadsController < ApplicationController
true
when Projects::Topic
true
+ when ::AlertManagement::MetricImage
+ can?(current_user, :read_alert_management_metric_image, model.alert)
else
can?(current_user, "read_#{model.class.underscore}".to_sym, model)
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index dc02e4a3e87..228ef710749 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -27,7 +27,14 @@ class UsersController < ApplicationController
check_rate_limit!(:username_exists, scope: request.ip)
end
- feature_category :users
+ feature_category :users, [:show, :activity, :groups, :projects, :contributed, :starred,
+ :followers, :following, :calendar, :calendar_activities,
+ :exists, :activity, :follow, :unfollow, :ssh_keys, :gpg_keys]
+
+ feature_category :snippets, [:snippets]
+
+ # TODO: Set higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/357914
+ urgency :low, [:show]
def show
respond_to do |format|
diff --git a/app/events/ci/pipeline_created_event.rb b/app/events/ci/pipeline_created_event.rb
index 8b971b63cea..2f2bee0903a 100644
--- a/app/events/ci/pipeline_created_event.rb
+++ b/app/events/ci/pipeline_created_event.rb
@@ -5,6 +5,7 @@ module Ci
def schema
{
'type' => 'object',
+ 'required' => ['pipeline_id'],
'properties' => {
'pipeline_id' => { 'type' => 'integer' }
}
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index f74e7fe3b1d..e5b67527cb1 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -3,6 +3,11 @@
class ApplicationExperiment < Gitlab::Experiment
control { nil } # provide a default control for anonymous experiments
+ # Documented in:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/357904
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/345932
+ #
+ # @deprecated
def publish_to_database
ActiveSupport::Deprecation.warn('publish_to_database is deprecated and should not be used for reporting anymore')
@@ -21,10 +26,13 @@ class ApplicationExperiment < Gitlab::Experiment
# define a default nil control behavior so we can omit it when not needed
end
- # TODO: remove
# This is deprecated logic as of v0.6.0 and should eventually be removed, but
# needs to stay intact for actively running experiments. The new strategy
# utilizes Digest::SHA2, a secret seed, and generates a 64-byte string.
+ #
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/334590
+ #
+ # @deprecated
def key_for(source, seed = name)
source = source.keys + source.values if source.is_a?(Hash)
diff --git a/app/experiments/ios_specific_templates_experiment.rb b/app/experiments/ios_specific_templates_experiment.rb
new file mode 100644
index 00000000000..1731fa87be8
--- /dev/null
+++ b/app/experiments/ios_specific_templates_experiment.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class IosSpecificTemplatesExperiment < ApplicationExperiment
+ before_run(if: :skip_experiment) { throw(:abort) } # rubocop:disable Cop/BanCatchThrow
+
+ private
+
+ def skip_experiment
+ actor_not_able_to_create_pipelines? ||
+ project_targets_non_ios_platforms? ||
+ project_has_gitlab_ci? ||
+ project_has_pipelines?
+ end
+
+ def actor_not_able_to_create_pipelines?
+ !context.actor.is_a?(User) || !context.actor.can?(:create_pipeline, context.project)
+ end
+
+ def project_targets_non_ios_platforms?
+ context.project.project_setting.target_platforms.exclude?('ios')
+ end
+
+ def project_has_gitlab_ci?
+ context.project.has_ci? && context.project.builds_enabled?
+ end
+
+ def project_has_pipelines?
+ context.project.all_pipelines.count > 0
+ end
+end
diff --git a/app/experiments/logged_out_marketing_header_experiment.rb b/app/experiments/logged_out_marketing_header_experiment.rb
new file mode 100644
index 00000000000..3d88d94aec4
--- /dev/null
+++ b/app/experiments/logged_out_marketing_header_experiment.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class LoggedOutMarketingHeaderExperiment < ApplicationExperiment
+ # These default behaviors are overriden in ApplicationHelper and header
+ # template partial
+ control {}
+ candidate {}
+ variant(:trial_focused) {}
+end
diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb
deleted file mode 100644
index 4aca4c875b2..00000000000
--- a/app/experiments/new_project_sast_enabled_experiment.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-class NewProjectSastEnabledExperiment < ApplicationExperiment
- control { }
- variant(:candidate) { }
- variant(:free_indicator) { }
- variant(:unchecked_candidate) { }
- variant(:unchecked_free_indicator) { }
-
- def publish(*args)
- super
-
- publish_to_database
- end
-end
diff --git a/app/experiments/video_tutorials_continuous_onboarding_experiment.rb b/app/experiments/video_tutorials_continuous_onboarding_experiment.rb
new file mode 100644
index 00000000000..3cb676b25f2
--- /dev/null
+++ b/app/experiments/video_tutorials_continuous_onboarding_experiment.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class VideoTutorialsContinuousOnboardingExperiment < ApplicationExperiment
+ control { }
+ candidate { }
+end
diff --git a/app/finders/bulk_imports/entities_finder.rb b/app/finders/bulk_imports/entities_finder.rb
index 2947d155668..78446f104d0 100644
--- a/app/finders/bulk_imports/entities_finder.rb
+++ b/app/finders/bulk_imports/entities_finder.rb
@@ -2,10 +2,10 @@
module BulkImports
class EntitiesFinder
- def initialize(user:, bulk_import: nil, status: nil)
+ def initialize(user:, bulk_import: nil, params: {})
@user = user
@bulk_import = bulk_import
- @status = status
+ @params = params
end
def execute
@@ -14,6 +14,7 @@ module BulkImports
.by_user_id(user.id)
.then(&method(:filter_by_bulk_import))
.then(&method(:filter_by_status))
+ .then(&method(:sort))
end
private
@@ -23,13 +24,19 @@ module BulkImports
def filter_by_bulk_import(entities)
return entities unless bulk_import
- entities.where(bulk_import_id: bulk_import.id) # rubocop: disable CodeReuse/ActiveRecord
+ entities.by_bulk_import_id(bulk_import.id)
end
def filter_by_status(entities)
- return entities unless ::BulkImports::Entity.all_human_statuses.include?(status)
+ return entities unless ::BulkImports::Entity.all_human_statuses.include?(@params[:status])
- entities.with_status(status)
+ entities.with_status(@params[:status])
+ end
+
+ def sort(entities)
+ return entities unless @params[:sort]
+
+ entities.order_by_created_at(@params[:sort])
end
end
end
diff --git a/app/finders/bulk_imports/imports_finder.rb b/app/finders/bulk_imports/imports_finder.rb
index b554bbfa5e7..d682080576f 100644
--- a/app/finders/bulk_imports/imports_finder.rb
+++ b/app/finders/bulk_imports/imports_finder.rb
@@ -2,13 +2,14 @@
module BulkImports
class ImportsFinder
- def initialize(user:, status: nil)
+ def initialize(user:, params: {})
@user = user
- @status = status
+ @params = params
end
def execute
- filter_by_status(user.bulk_imports)
+ imports = filter_by_status(user.bulk_imports)
+ sort(imports)
end
private
@@ -16,9 +17,15 @@ module BulkImports
attr_reader :user, :status
def filter_by_status(imports)
- return imports unless BulkImport.all_human_statuses.include?(status)
+ return imports unless BulkImport.all_human_statuses.include?(@params[:status])
- imports.with_status(status)
+ imports.with_status(@params[:status])
+ end
+
+ def sort(imports)
+ return imports unless @params[:sort]
+
+ imports.order_by_created_at(@params[:sort])
end
end
end
diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb
index 5fc9c0e1778..152eb271694 100644
--- a/app/finders/ci/jobs_finder.rb
+++ b/app/finders/ci/jobs_finder.rb
@@ -76,7 +76,7 @@ module Ci
unknown_statuses = params[:scope] - ::CommitStatus::AVAILABLE_STATUSES
raise ArgumentError, 'Scope contains invalid value(s)' unless unknown_statuses.empty?
- builds.where(status: params[:scope]) # rubocop: disable CodeReuse/ActiveRecord
+ builds.with_statuses(params[:scope])
end
def jobs_by_type(relation, type)
diff --git a/app/finders/concerns/finder_methods.rb b/app/finders/concerns/finder_methods.rb
index 193b52b1694..ce6001a01d7 100644
--- a/app/finders/concerns/finder_methods.rb
+++ b/app/finders/concerns/finder_methods.rb
@@ -13,16 +13,23 @@ module FinderMethods
end
# rubocop: enable CodeReuse/ActiveRecord
+ # rubocop: disable CodeReuse/ActiveRecord
def find(*args)
- raise_not_found_unless_authorized model.find(*args)
+ raise_not_found_unless_authorized execute.reorder(nil).find(*args)
end
+ # rubocop: enable CodeReuse/ActiveRecord
private
def raise_not_found_unless_authorized(result)
result = if_authorized(result)
- raise(ActiveRecord::RecordNotFound, "Couldn't find #{model}") unless result
+ unless result
+ # This fetches the model from the `ActiveRecord::Relation` but does not
+ # actually execute the query.
+ model = execute.model
+ raise ActiveRecord::RecordNotFound, "Couldn't find #{model}"
+ end
result
end
@@ -32,11 +39,7 @@ module FinderMethods
# this is currently the case in the `MilestoneFinder`
return result unless respond_to?(:current_user, true)
- if can_read_object?(result)
- result
- else
- nil
- end
+ result if can_read_object?(result)
end
def can_read_object?(object)
@@ -53,10 +56,4 @@ module FinderMethods
# Not all objects define `#to_ability_name`, so attempt to derive it:
object.model_name.singular
end
-
- # This fetches the model from the `ActiveRecord::Relation` but does not
- # actually execute the query.
- def model
- execute.model
- end
end
diff --git a/app/finders/keys_finder.rb b/app/finders/keys_finder.rb
index 9c357e12205..4b5cc02f012 100644
--- a/app/finders/keys_finder.rb
+++ b/app/finders/keys_finder.rb
@@ -52,11 +52,11 @@ class KeysFinder
end
def valid_fingerprint_param?
- if fingerprint_type == "sha256"
- Base64.decode64(fingerprint).length == 32
- else
- fingerprint =~ /^(\h{2}:){15}\h{2}/
- end
+ return Base64.decode64(fingerprint).length == 32 if fingerprint_type == "sha256"
+
+ return false if Gitlab::FIPS.enabled?
+
+ fingerprint =~ /^(\h{2}:){15}\h{2}/
end
def fingerprint_query
diff --git a/app/finders/packages/build_infos_for_many_packages_finder.rb b/app/finders/packages/build_infos_for_many_packages_finder.rb
new file mode 100644
index 00000000000..8f9805f51d0
--- /dev/null
+++ b/app/finders/packages/build_infos_for_many_packages_finder.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Packages
+ # TODO rename to BuildInfosFinder when cleaning up packages_graphql_pipelines_resolver
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/358432
+ class BuildInfosForManyPackagesFinder
+ include ActiveRecord::ConnectionAdapters::Quoting
+
+ MAX_PAGE_SIZE = 100
+
+ def initialize(package_ids, params)
+ @package_ids = package_ids
+ @params = params
+ end
+
+ def execute
+ return Packages::BuildInfo.none if @package_ids.blank?
+
+ # This is a highly custom query that
+ # will not be re-used elsewhere
+ # rubocop: disable CodeReuse/ActiveRecord
+ query = Packages::Package.id_in(@package_ids)
+ .select('build_infos.*')
+ .from([Packages::Package.arel_table, lateral_query.arel.lateral.as('build_infos')])
+ .order('build_infos.id DESC')
+
+ # We manually select build_infos fields from the lateral query.
+ # Thus, we need to instruct ActiveRecord that returned rows are
+ # actually Packages::BuildInfo objects
+ Packages::BuildInfo.find_by_sql(query.to_sql)
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+
+ private
+
+ def lateral_query
+ order_direction = last ? :asc : :desc
+
+ # This is a highly custom query that
+ # will not be re-used elsewhere
+ # rubocop: disable CodeReuse/ActiveRecord
+ where_condition = Packages::BuildInfo.arel_table[:package_id]
+ .eq(Arel.sql("#{Packages::Package.table_name}.id"))
+ build_infos = ::Packages::BuildInfo.without_empty_pipelines
+ .where(where_condition)
+ .order(id: order_direction)
+ .limit(max_rows_per_package_id)
+ # rubocop: enable CodeReuse/ActiveRecord
+ apply_cursor(build_infos)
+ end
+
+ def max_rows_per_package_id
+ limit = [first, last, max_page_size, MAX_PAGE_SIZE].compact.min
+ limit += 1 if support_next_page
+ limit
+ end
+
+ def apply_cursor(build_infos)
+ if before
+ build_infos.with_pipeline_id_greater_than(before)
+ elsif after
+ build_infos.with_pipeline_id_less_than(after)
+ else
+ build_infos
+ end
+ end
+
+ def first
+ @params[:first]
+ end
+
+ def last
+ @params[:last]
+ end
+
+ def max_page_size
+ @params[:max_page_size]
+ end
+
+ def before
+ @params[:before]
+ end
+
+ def after
+ @params[:after]
+ end
+
+ def support_next_page
+ @params[:support_next_page]
+ end
+ end
+end
diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb
index 23b0e71d836..1d1ae59674a 100644
--- a/app/finders/packages/group_packages_finder.rb
+++ b/app/finders/packages/group_packages_finder.rb
@@ -22,11 +22,11 @@ module Packages
def packages_for_group_projects(installable_only: false)
packages = ::Packages::Package
- .preload_pipelines
.including_project_route
.including_tags
.for_projects(group_projects_visible_to_current_user.select(:id))
.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
+ packages = packages.preload_pipelines if preload_pipelines
packages = filter_with_version(packages)
packages = filter_by_package_type(packages)
@@ -59,5 +59,9 @@ module Packages
def exclude_subgroups?
params[:exclude_subgroups]
end
+
+ def preload_pipelines
+ params.fetch(:preload_pipelines, true)
+ end
end
end
diff --git a/app/finders/packages/packages_finder.rb b/app/finders/packages/packages_finder.rb
index 3bc348c8dc8..b3d14e15953 100644
--- a/app/finders/packages/packages_finder.rb
+++ b/app/finders/packages/packages_finder.rb
@@ -14,9 +14,9 @@ module Packages
def execute
packages = project.packages
- .preload_pipelines
.including_project_route
.including_tags
+ packages = packages.preload_pipelines if preload_pipelines
packages = filter_with_version(packages)
packages = filter_by_package_type(packages)
@@ -32,5 +32,9 @@ module Packages
def order_packages(packages)
packages.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
end
+
+ def preload_pipelines
+ params.fetch(:preload_pipelines, true)
+ end
end
end
diff --git a/app/finders/releases/group_releases_finder.rb b/app/finders/releases/group_releases_finder.rb
index d87ba8c0b03..8b1b0c552fd 100644
--- a/app/finders/releases/group_releases_finder.rb
+++ b/app/finders/releases/group_releases_finder.rb
@@ -6,9 +6,8 @@ module Releases
#
# order_by - only ordering by released_at is supported
# filter by tag - currently not supported
+ # include_subgroups - always true for group releases finder
class GroupReleasesFinder
- include Gitlab::Utils::StrongMemoize
-
attr_reader :parent, :current_user, :params
def initialize(parent, current_user = nil, params = {})
@@ -16,59 +15,34 @@ module Releases
@current_user = current_user
@params = params
- params[:order_by] ||= 'released_at'
params[:sort] ||= 'desc'
- params[:page] ||= 0
- params[:per] ||= 30
end
def execute(preload: true)
return Release.none unless Ability.allowed?(current_user, :read_release, parent)
- releases = get_releases(preload: preload)
-
- paginate_releases(releases)
+ releases = get_releases
+ releases.preloaded if preload
+ releases
end
private
- def include_subgroups?
- params.fetch(:include_subgroups, false)
- end
-
- def accessible_projects_scope
- if include_subgroups?
- Project.for_group_and_its_subgroups(parent)
- else
- parent.projects
- end
- end
-
# rubocop: disable CodeReuse/ActiveRecord
- def get_releases(preload: true)
+ def get_releases
Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
- scope: releases_scope(preload: preload),
- array_scope: accessible_projects_scope.select(:id),
+ scope: releases_scope,
+ array_scope: Project.for_group_and_its_subgroups(parent).select(:id),
array_mapping_scope: -> (project_id_expression) { Release.where(Release.arel_table[:project_id].eq(project_id_expression)) },
finder_query: -> (order_by, id_expression) { Release.where(Release.arel_table[:id].eq(id_expression)) }
)
.execute
end
- def releases_scope(preload: true)
- scope = Release.all
- scope = order_releases(scope)
- scope = scope.preloaded if preload
- scope
- end
-
- def order_releases(scope)
- scope.sort_by_attribute("released_at_#{params[:sort]}").order(id: params[:sort])
+ def releases_scope
+ Release.sort_by_attribute("released_at_#{params[:sort]}").order(id: params[:sort])
end
- def paginate_releases(releases)
- releases.page(params[:page].to_i).per(params[:per])
- end
# rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb
index 96120d9412f..64903c67573 100644
--- a/app/finders/user_recent_events_finder.rb
+++ b/app/finders/user_recent_events_finder.rb
@@ -73,10 +73,20 @@ class UserRecentEventsFinder
return Event.none if users.empty?
- if event_filter.filter == EventFilter::ALL
- execute_optimized_multi(users)
+ if Feature.enabled?(:optimized_followed_users_queries, current_user)
+ query_builder_params = event_filter.in_operator_query_builder_params(users)
+
+ Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
+ .new(**query_builder_params)
+ .execute
+ .limit(limit)
+ .offset(params[:offset] || 0)
else
- event_filter.apply_filter(Event.where(author: users).limit_recent(limit, params[:offset] || 0))
+ if event_filter.filter == EventFilter::ALL
+ execute_optimized_multi(users)
+ else
+ event_filter.apply_filter(Event.where(author: users).limit_recent(limit, params[:offset] || 0))
+ end
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index b983882b272..9c2462b42a6 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -47,6 +47,7 @@ class UsersFinder
users = by_without_projects(users)
users = by_custom_attributes(users)
users = by_non_internal(users)
+ users = by_without_project_bots(users)
order(users)
end
@@ -54,7 +55,8 @@ class UsersFinder
private
def base_scope
- User.all.order_id_desc
+ scope = current_user&.admin? ? User.all : User.without_forbidden_states
+ scope.order_id_desc
end
def by_username(users)
@@ -138,6 +140,12 @@ class UsersFinder
users.non_internal
end
+ def by_without_project_bots(users)
+ return users unless params[:without_project_bots]
+
+ users.without_project_bot
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def order(users)
return users unless params[:sort]
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index ac1a4a6b9ef..342cff83e90 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -12,4 +12,8 @@ module GraphqlTriggers
def self.issuable_title_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable)
end
+
+ def self.issuable_labels_updated(issuable)
+ GitlabSchema.subscriptions.trigger('issuableLabelsUpdated', { issuable_id: issuable.to_gid }, issuable)
+ end
end
diff --git a/app/graphql/mutations/ci/job/retry.rb b/app/graphql/mutations/ci/job/retry.rb
index 9af357ab216..50e9c51c9e7 100644
--- a/app/graphql/mutations/ci/job/retry.rb
+++ b/app/graphql/mutations/ci/job/retry.rb
@@ -17,11 +17,19 @@ module Mutations
job = authorized_find!(id: id)
project = job.project
- ::Ci::RetryBuildService.new(project, current_user).execute(job)
- {
- job: job,
- errors: errors_on_object(job)
- }
+ response = ::Ci::RetryJobService.new(project, current_user).execute(job)
+
+ if response.success?
+ {
+ job: response[:job],
+ errors: []
+ }
+ else
+ {
+ job: nil,
+ errors: [response.message]
+ }
+ end
end
end
end
diff --git a/app/graphql/mutations/ci/pipeline/cancel.rb b/app/graphql/mutations/ci/pipeline/cancel.rb
index 3fb34a37cfc..3ec6eee9f54 100644
--- a/app/graphql/mutations/ci/pipeline/cancel.rb
+++ b/app/graphql/mutations/ci/pipeline/cancel.rb
@@ -13,6 +13,8 @@ module Mutations
if pipeline.cancelable?
pipeline.cancel_running
+ pipeline.cancel
+
{ success: true, errors: [] }
else
{ success: false, errors: ['Pipeline is not cancelable'] }
diff --git a/app/graphql/mutations/environments/canary_ingress/update.rb b/app/graphql/mutations/environments/canary_ingress/update.rb
index e4ba08e6dcc..ce24b8842c6 100644
--- a/app/graphql/mutations/environments/canary_ingress/update.rb
+++ b/app/graphql/mutations/environments/canary_ingress/update.rb
@@ -5,6 +5,7 @@ module Mutations
module CanaryIngress
class Update < ::Mutations::BaseMutation
graphql_name 'EnvironmentsCanaryIngressUpdate'
+ description '**Deprecated** This endpoint is planned to be removed along with certificate-based clusters. [See this epic](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) for more information.'
authorize :update_environment
@@ -18,7 +19,13 @@ module Mutations
required: true,
description: 'Weight of the Canary Ingress.'
+ REMOVAL_ERR_MSG = 'This endpoint was deactivated as part of the certificate-based' \
+ 'kubernetes integration removal. See Epic:' \
+ 'https://gitlab.com/groups/gitlab-org/configure/-/epics/8'
+
def resolve(id:, **kwargs)
+ return { errors: [REMOVAL_ERR_MSG] } if cert_based_clusters_ff_disabled?
+
environment = authorized_find!(id: id)
result = ::Environments::CanaryIngress::UpdateService
@@ -33,6 +40,12 @@ module Mutations
id = ::Types::GlobalIDType[::Environment].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
+
+ private
+
+ def cert_based_clusters_ff_disabled?
+ Feature.disabled?(:certificate_based_clusters, default_enabled: :yaml, type: :ops)
+ end
end
end
end
diff --git a/app/graphql/mutations/notes/update/note.rb b/app/graphql/mutations/notes/update/note.rb
index c7ee0148f94..a483294169f 100644
--- a/app/graphql/mutations/notes/update/note.rb
+++ b/app/graphql/mutations/notes/update/note.rb
@@ -15,7 +15,8 @@ module Mutations
argument :confidential,
GraphQL::Types::Boolean,
required: false,
- description: 'Confidentiality flag of a note. Default is false.'
+ description: 'Confidentiality flag of a note. Default is false.',
+ deprecated: { reason: 'No longer allowed to update confidentiality of notes', milestone: '14.10' }
private
diff --git a/app/graphql/mutations/saved_replies/base.rb b/app/graphql/mutations/saved_replies/base.rb
index 468263b0f9d..59871df687f 100644
--- a/app/graphql/mutations/saved_replies/base.rb
+++ b/app/graphql/mutations/saved_replies/base.rb
@@ -5,7 +5,7 @@ module Mutations
class Base < BaseMutation
field :saved_reply, Types::SavedReplyType,
null: true,
- description: 'Updated saved reply.'
+ description: 'Saved reply after mutation.'
private
diff --git a/app/graphql/mutations/saved_replies/destroy.rb b/app/graphql/mutations/saved_replies/destroy.rb
new file mode 100644
index 00000000000..7cd0f21ad45
--- /dev/null
+++ b/app/graphql/mutations/saved_replies/destroy.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Mutations
+ module SavedReplies
+ class Destroy < Base
+ graphql_name 'SavedReplyDestroy'
+
+ authorize :destroy_saved_replies
+
+ argument :id, Types::GlobalIDType[::Users::SavedReply],
+ required: true,
+ description: copy_field_description(Types::SavedReplyType, :id)
+
+ def resolve(id:)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled?
+
+ saved_reply = authorized_find!(id)
+ result = ::Users::SavedReplies::DestroyService.new(saved_reply: saved_reply).execute
+ present_result(result)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/saved_replies/update.rb b/app/graphql/mutations/saved_replies/update.rb
index bacc6ceb39e..d9368de7547 100644
--- a/app/graphql/mutations/saved_replies/update.rb
+++ b/app/graphql/mutations/saved_replies/update.rb
@@ -23,7 +23,7 @@ module Mutations
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled?
saved_reply = authorized_find!(id)
- result = ::Users::SavedReplies::UpdateService.new(current_user: current_user, saved_reply: saved_reply, name: name, content: content).execute
+ result = ::Users::SavedReplies::UpdateService.new(saved_reply: saved_reply, name: name, content: content).execute
present_result(result)
end
end
diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb
index 7dd06cc8293..67a822c1067 100644
--- a/app/graphql/mutations/todos/mark_all_done.rb
+++ b/app/graphql/mutations/todos/mark_all_done.rb
@@ -7,14 +7,22 @@ module Mutations
authorize :update_user
+ TodoableID = Types::GlobalIDType[Todoable]
+
+ argument :target_id,
+ TodoableID,
+ required: false,
+ description: "Global ID of the to-do item's parent. Issues, merge requests, designs, and epics are supported. " \
+ "If argument is omitted, all pending to-do items of the current user are marked as done."
+
field :todos, [::Types::TodoType],
null: false,
description: 'Updated to-do items.'
- def resolve
+ def resolve(**args)
authorize!(current_user)
- updated_ids = mark_all_todos_done
+ updated_ids = mark_all_todos_done(**args)
{
todos: Todo.id_in(updated_ids),
@@ -24,10 +32,23 @@ module Mutations
private
- def mark_all_todos_done
+ def mark_all_todos_done(**args)
return [] unless current_user
- todos = TodosFinder.new(current_user).execute
+ finder_params = { state: :pending }
+
+ if args[:target_id].present?
+ target = Gitlab::Graphql::Lazy.force(
+ GitlabSchema.find_by_gid(TodoableID.coerce_isolated_input(args[:target_id]))
+ )
+
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Resource not available: #{args[:target_id]}" if target.nil?
+
+ finder_params[:type] = target.class.name
+ finder_params[:target_id] = target.id
+ end
+
+ todos = TodosFinder.new(current_user, finder_params).execute
TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_all_done)
end
diff --git a/app/graphql/mutations/user_preferences/update.rb b/app/graphql/mutations/user_preferences/update.rb
index c92c6d725b7..eface536a87 100644
--- a/app/graphql/mutations/user_preferences/update.rb
+++ b/app/graphql/mutations/user_preferences/update.rb
@@ -14,6 +14,15 @@ module Mutations
null: true,
description: 'User preferences after mutation.'
+ def ready?(**args)
+ if disabled_sort_value?(args)
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'Feature flag `incident_escalations` must be enabled to use this sort order.'
+ end
+
+ super
+ end
+
def resolve(**attributes)
user_preferences = current_user.user_preference
user_preferences.update(attributes)
@@ -23,6 +32,14 @@ module Mutations
errors: errors_on_object(user_preferences)
}
end
+
+ private
+
+ def disabled_sort_value?(args)
+ return false unless [:escalation_status_asc, :escalation_status_desc].include?(args[:issues_sort])
+
+ Feature.disabled?(:incident_escalations, default_enabled: :yaml)
+ end
end
end
end
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index 48f0f470988..c29dbb899b5 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -33,7 +33,7 @@ module Mutations
def resolve(project_path:, **attributes)
project = authorized_find!(project_path)
- unless Feature.enabled?(:work_items, project, default_enabled: :yaml)
+ unless project.work_items_feature_flag_enabled?
return { errors: ['`work_items` feature flag disabled for this project'] }
end
diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb
index 16d1e646167..278c1bc65a9 100644
--- a/app/graphql/mutations/work_items/create_from_task.rb
+++ b/app/graphql/mutations/work_items/create_from_task.rb
@@ -31,7 +31,7 @@ module Mutations
def resolve(id:, work_item_data:)
work_item = authorized_find!(id: id)
- unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml)
+ unless work_item.project.work_items_feature_flag_enabled?
return { errors: ['`work_items` feature flag disabled for this project'] }
end
diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb
index f32354878ec..3d72ebbd95d 100644
--- a/app/graphql/mutations/work_items/delete.rb
+++ b/app/graphql/mutations/work_items/delete.rb
@@ -20,7 +20,7 @@ module Mutations
def resolve(id:)
work_item = authorized_find!(id: id)
- unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml)
+ unless work_item.project.work_items_feature_flag_enabled?
return { errors: ['`work_items` feature flag disabled for this project'] }
end
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index 2700cbdb709..091237d6fa0 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -28,7 +28,7 @@ module Mutations
def resolve(id:, **attributes)
work_item = authorized_find!(id: id)
- unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml)
+ unless work_item.project.work_items_feature_flag_enabled?
return { errors: ['`work_items` feature flag disabled for this project'] }
end
diff --git a/app/graphql/queries/container_registry/get_container_repositories.query.graphql b/app/graphql/queries/container_registry/get_container_repositories.query.graphql
index 40e2934a038..264878ccaa2 100644
--- a/app/graphql/queries/container_registry/get_container_repositories.query.graphql
+++ b/app/graphql/queries/container_registry/get_container_repositories.query.graphql
@@ -23,6 +23,7 @@ query getProjectContainerRepositories(
__typename
nodes {
id
+ migrationState
name
path
status
@@ -57,6 +58,7 @@ query getProjectContainerRepositories(
__typename
nodes {
id
+ migrationState
name
path
status
diff --git a/app/graphql/queries/releases/all_releases.query.graphql b/app/graphql/queries/releases/all_releases.query.graphql
deleted file mode 100644
index 150f59832f3..00000000000
--- a/app/graphql/queries/releases/all_releases.query.graphql
+++ /dev/null
@@ -1,109 +0,0 @@
-# This query is identical to
-# `app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql`.
-# These two queries should be kept in sync.
-query allReleases(
- $fullPath: ID!
- $first: Int
- $last: Int
- $before: String
- $after: String
- $sort: ReleaseSort
-) {
- project(fullPath: $fullPath) {
- __typename
- id
- releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
- __typename
- nodes {
- __typename
- name
- tagName
- tagPath
- descriptionHtml
- releasedAt
- createdAt
- upcomingRelease
- assets {
- __typename
- count
- sources {
- __typename
- nodes {
- __typename
- format
- url
- }
- }
- links {
- __typename
- nodes {
- __typename
- id
- name
- url
- directAssetUrl
- linkType
- external
- }
- }
- }
- evidences {
- __typename
- nodes {
- __typename
- id
- filepath
- collectedAt
- sha
- }
- }
- links {
- __typename
- editUrl
- selfUrl
- openedIssuesUrl
- closedIssuesUrl
- openedMergeRequestsUrl
- mergedMergeRequestsUrl
- closedMergeRequestsUrl
- }
- commit {
- __typename
- id
- sha
- webUrl
- title
- }
- author {
- __typename
- id
- webUrl
- avatarUrl
- username
- }
- milestones {
- __typename
- nodes {
- __typename
- id
- title
- description
- webPath
- stats {
- __typename
- totalIssuesCount
- closedIssuesCount
- }
- }
- }
- }
- pageInfo {
- __typename
- startCursor
- hasPreviousPage
- hasNextPage
- endCursor
- }
- }
- }
-}
diff --git a/app/graphql/resolvers/base_issues_resolver.rb b/app/graphql/resolvers/base_issues_resolver.rb
index 3e7509b4068..4cae7866a49 100644
--- a/app/graphql/resolvers/base_issues_resolver.rb
+++ b/app/graphql/resolvers/base_issues_resolver.rb
@@ -12,12 +12,14 @@ module Resolvers
required: false,
description: 'Current state of this issue.'
- type Types::IssueType.connection_type, null: true
+ # see app/graphql/types/issue_connection.rb
+ type 'Types::IssueConnection', null: true
NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc
popularity_asc popularity_desc
label_priority_asc label_priority_desc
- milestone_due_asc milestone_due_desc].freeze
+ milestone_due_asc milestone_due_desc
+ escalation_status_asc escalation_status_desc].freeze
def continue_issue_resolve(parent, finder, **args)
issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) }
@@ -31,6 +33,13 @@ module Resolvers
end
end
+ def prepare_params(args, parent)
+ return unless [:escalation_status_asc, :escalation_status_desc].include?(args[:sort])
+ return if Feature.enabled?(:incident_escalations, parent, default_enabled: :yaml)
+
+ args[:sort] = :created_desc # default for sort argument
+ end
+
private
def unconditional_includes
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 20ed089d159..dbded8f60a0 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -142,7 +142,7 @@ module Resolvers
def object
super.tap do |obj|
# If the field this resolver is used in is wrapped in a presenter, unwrap its subject
- break obj.subject if obj.is_a?(Gitlab::View::Presenter::Base)
+ break obj.__subject__ if obj.is_a?(Gitlab::View::Presenter::Base)
end
end
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index 38c79ff52ac..432d6f48607 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -84,6 +84,7 @@ module IssueResolverArguments
prepare_assignee_username_params(args)
prepare_release_tag_params(args)
+ prepare_params(args, parent) if defined?(prepare_params)
finder = IssuesFinder.new(current_user, args)
diff --git a/app/graphql/resolvers/groups_resolver.rb b/app/graphql/resolvers/groups_resolver.rb
index abd3bf9e6e0..6cfdba240f0 100644
--- a/app/graphql/resolvers/groups_resolver.rb
+++ b/app/graphql/resolvers/groups_resolver.rb
@@ -30,7 +30,7 @@ module Resolvers
GroupsFinder
.new(context[:current_user], args.merge(parent: parent))
.execute
- .reorder('name ASC')
+ .reorder(name: :asc)
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/graphql/resolvers/work_item_resolver.rb b/app/graphql/resolvers/work_item_resolver.rb
index 7cf52339815..ad510849f31 100644
--- a/app/graphql/resolvers/work_item_resolver.rb
+++ b/app/graphql/resolvers/work_item_resolver.rb
@@ -12,7 +12,7 @@ module Resolvers
def resolve(id:)
work_item = authorized_find!(id: id)
- return unless Feature.enabled?(:work_items, work_item.project, default_enabled: :yaml)
+ return unless work_item.project.work_items_feature_flag_enabled?
work_item
end
diff --git a/app/graphql/resolvers/work_items/types_resolver.rb b/app/graphql/resolvers/work_items/types_resolver.rb
index 67a9d57d42f..5f9f8ab5572 100644
--- a/app/graphql/resolvers/work_items/types_resolver.rb
+++ b/app/graphql/resolvers/work_items/types_resolver.rb
@@ -11,7 +11,7 @@ module Resolvers
' Argument is experimental and can be removed in the future without notice.'
def resolve(taskable: nil)
- return unless Feature.enabled?(:work_items, object, default_enabled: :yaml)
+ return unless feature_flag_enabled_for_parent?(object)
# This will require a finder in the future when groups/projects get their work item types
# All groups/projects use the default types for now
@@ -20,6 +20,14 @@ module Resolvers
base_scope.order_by_name_asc
end
+
+ private
+
+ def feature_flag_enabled_for_parent?(parent)
+ return false unless parent.is_a?(::Project) || parent.is_a?(::Group)
+
+ parent.work_items_feature_flag_enabled?
+ end
end
end
end
diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb
index b5797f07aa6..4ad88e43f52 100644
--- a/app/graphql/types/base_object.rb
+++ b/app/graphql/types/base_object.rb
@@ -9,6 +9,13 @@ module Types
field_class Types::BaseField
edge_type_class Types::BaseEdge
+ def self.authorize(*args)
+ raise 'Cannot redefine authorize' if @authorize_args && args.any?
+
+ @authorize_args = args.freeze if args.any?
+ @authorize_args || (superclass.respond_to?(:authorize) ? superclass.authorize : nil)
+ end
+
def self.accepts(*types)
@accepts ||= []
@accepts += types
diff --git a/app/graphql/types/ci/job_kind_enum.rb b/app/graphql/types/ci/job_kind_enum.rb
new file mode 100644
index 00000000000..dd1d80f806c
--- /dev/null
+++ b/app/graphql/types/ci/job_kind_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class JobKindEnum < BaseEnum
+ graphql_name 'CiJobKind'
+
+ value 'BUILD', value: ::Ci::Build, description: 'Standard CI job.'
+ value 'BRIDGE', value: ::Ci::Bridge, description: 'Bridge CI job connecting a parent and child pipeline.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 83054553bd8..f25fc56a588 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -17,6 +17,8 @@ module Types
description: 'Duration of the job in seconds.'
field :id, ::Types::GlobalIDType[::CommitStatus].as('JobID'), null: true,
description: 'ID of the job.'
+ field :kind, type: ::Types::Ci::JobKindEnum, null: false,
+ description: 'Indicates the type of job.'
field :name, GraphQL::Types::String, null: true,
description: 'Name of the job.'
field :needs, BuildNeedType.connection_type, null: true,
@@ -87,6 +89,12 @@ module Types
field :triggered, GraphQL::Types::Boolean, null: true,
description: 'Whether the job was triggered.'
+ def kind
+ return ::Ci::Build unless [::Ci::Build, ::Ci::Bridge].include?(object.class)
+
+ object.class
+ end
+
def pipeline
Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, object.pipeline_id).find
end
diff --git a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb b/app/graphql/types/ci/runner_upgrade_status_type_enum.rb
new file mode 100644
index 00000000000..e3d77e485bc
--- /dev/null
+++ b/app/graphql/types/ci/runner_upgrade_status_type_enum.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class RunnerUpgradeStatusTypeEnum < BaseEnum
+ graphql_name 'CiRunnerUpgradeStatusType'
+
+ value 'NOT_AVAILABLE',
+ description: "An update is not available for the runner.",
+ value: :not_available
+
+ value 'AVAILABLE',
+ description: "An update is available for the runner.",
+ value: :available
+
+ value 'RECOMMENDED',
+ description: "An update is available and recommended for the runner.",
+ value: :recommended
+ end
+ end
+end
diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb
index 3cd3730010b..dddf9a3ee97 100644
--- a/app/graphql/types/container_repository_type.rb
+++ b/app/graphql/types/container_repository_type.rb
@@ -14,6 +14,7 @@ module Types
field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.'
field :id, GraphQL::Types::ID, null: false, description: 'ID of the container repository.'
field :location, GraphQL::Types::String, null: false, description: 'URL of the container repository.'
+ field :migration_state, GraphQL::Types::String, null: false, description: 'Migration state of the container repository.'
field :name, GraphQL::Types::String, null: false, description: 'Name of the container repository.'
field :path, GraphQL::Types::String, null: false, description: 'Path of the container repository.'
field :project, Types::ProjectType, null: false, description: 'Project of the container registry.'
diff --git a/app/graphql/types/dependency_proxy/manifest_type.rb b/app/graphql/types/dependency_proxy/manifest_type.rb
index ab22f540f48..f7e751e30d3 100644
--- a/app/graphql/types/dependency_proxy/manifest_type.rb
+++ b/app/graphql/types/dependency_proxy/manifest_type.rb
@@ -15,6 +15,10 @@ module Types
field :image_name, GraphQL::Types::String, null: false, description: 'Name of the image.'
field :size, GraphQL::Types::String, null: false, description: 'Size of the manifest file.'
field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
+ field :status,
+ Types::DependencyProxy::ManifestTypeEnum,
+ null: false,
+ description: "Status of the manifest (#{::DependencyProxy::Manifest.statuses.keys.join(', ')})"
def image_name
object.file_name.chomp(File.extname(object.file_name))
diff --git a/app/graphql/types/dependency_proxy/manifest_type_enum.rb b/app/graphql/types/dependency_proxy/manifest_type_enum.rb
new file mode 100644
index 00000000000..ddd1652eeea
--- /dev/null
+++ b/app/graphql/types/dependency_proxy/manifest_type_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class DependencyProxy::ManifestTypeEnum < BaseEnum
+ graphql_name 'DependencyProxyManifestStatus'
+
+ ::DependencyProxy::Manifest.statuses.keys.each do |status|
+ value status.upcase, description: "Dependency proxy manifest has a status of #{status}.", value: status
+ end
+ end
+end
diff --git a/app/graphql/types/issue_connection.rb b/app/graphql/types/issue_connection.rb
new file mode 100644
index 00000000000..8e5c88648ea
--- /dev/null
+++ b/app/graphql/types/issue_connection.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+# Normally this wouldn't be needed and we could use
+# type Types::IssueType.connection_type, null: true
+# in a resolver. However we can end up with cyclic definitions,
+# which can result in errors like
+# NameError: uninitialized constant Resolvers::GroupIssuesResolver
+#
+# Now we would use
+# type "Types::IssueConnection", null: true
+# which gives a delayed resolution, and the proper connection type.
+# See app/graphql/resolvers/base_issues_resolver.rb
+# Reference: https://github.com/rmosolgo/graphql-ruby/issues/3974#issuecomment-1084444214
+
+Types::IssueConnection = Types::IssueType.connection_type
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
index f8825ff6c46..db51e491d4e 100644
--- a/app/graphql/types/issue_sort_enum.rb
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -14,6 +14,8 @@ module Types
value 'TITLE_DESC', 'Title by descending order.', value: :title_desc
value 'POPULARITY_ASC', 'Number of upvotes (awarded "thumbs up" emoji) by ascending order.', value: :popularity_asc
value 'POPULARITY_DESC', 'Number of upvotes (awarded "thumbs up" emoji) by descending order.', value: :popularity_desc
+ value 'ESCALATION_STATUS_ASC', 'Status from triggered to resolved. Defaults to `CREATED_DESC` if `incident_escalations` feature flag is disabled.', value: :escalation_status_asc
+ value 'ESCALATION_STATUS_DESC', 'Status from resolved to triggered. Defaults to `CREATED_DESC` if `incident_escalations` feature flag is disabled.', value: :escalation_status_desc
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index e6072820eea..2297912ac35 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -131,6 +131,7 @@ module Types
mount_mutation Mutations::WorkItems::Update
mount_mutation Mutations::SavedReplies::Create
mount_mutation Mutations::SavedReplies::Update
+ mount_mutation Mutations::SavedReplies::Destroy
end
end
diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb
index 652e2882584..dd5c70887de 100644
--- a/app/graphql/types/repository/blob_type.rb
+++ b/app/graphql/types/repository/blob_type.rb
@@ -101,10 +101,6 @@ module Types
description: 'Web path to blob on an environment.',
calls_gitaly: true
- field :code_owners, [Types::UserType], null: true,
- description: 'List of code owners for the blob.',
- calls_gitaly: true
-
field :file_type, GraphQL::Types::String, null: true,
description: 'Expected format of the blob based on the extension.'
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
index db6a247179d..de3f71090f6 100644
--- a/app/graphql/types/subscription_type.rb
+++ b/app/graphql/types/subscription_type.rb
@@ -12,5 +12,8 @@ module Types
field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the title of an issuable is updated.'
+
+ field :issuable_labels_updated, subscription: Subscriptions::IssuableUpdated, null: true,
+ description: 'Triggered when the labels of an issuable are updated.'
end
end
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index 2c9592a7f5a..1c8a1352c72 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -118,7 +118,8 @@ module Types
field :saved_replies,
Types::SavedReplyType.connection_type,
null: true,
- description: 'Saved replies authored by the user.'
+ description: 'Saved replies authored by the user. ' \
+ 'Will not return saved replies if `saved_replies` feature flag is disabled.'
field :gitpod_enabled, GraphQL::Types::Boolean, null: true,
description: 'Whether Gitpod is enabled at the user level.'
diff --git a/app/helpers/admin/background_migrations_helper.rb b/app/helpers/admin/background_migrations_helper.rb
index 6516ea27b2c..79bb13810bb 100644
--- a/app/helpers/admin/background_migrations_helper.rb
+++ b/app/helpers/admin/background_migrations_helper.rb
@@ -4,13 +4,13 @@ module Admin
module BackgroundMigrationsHelper
def batched_migration_status_badge_variant(migration)
variants = {
- 'active' => :info,
- 'paused' => :warning,
- 'failed' => :danger,
- 'finished' => :success
+ active: :info,
+ paused: :warning,
+ failed: :danger,
+ finished: :success
}
- variants[migration.status]
+ variants[migration.status_name]
end
# The extra logic here is needed because total_tuple_count is just
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index a9c13b2fdeb..57e08eeb4f4 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -37,9 +37,15 @@ module ApplicationSettingsHelper
end
def storage_weights
- Gitlab.config.repositories.storages.keys.each_with_object(OpenStruct.new) do |storage, weights|
- weights[storage.to_sym] = @application_setting.repository_storages_weighted[storage] || 0
+ # Instead of using a `Struct` we could wrap this into an object.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/358419
+ weights = Struct.new(*Gitlab.config.repositories.storages.keys.map(&:to_sym))
+
+ values = Gitlab.config.repositories.storages.keys.map do |storage|
+ @application_setting.repository_storages_weighted[storage] || 0
end
+
+ weights.new(*values)
end
def all_protocols_enabled?
@@ -63,39 +69,31 @@ module ApplicationSettingsHelper
end
end
- # Return a group of checkboxes that use Bootstrap's button plugin for a
- # toggle button effect.
- def restricted_level_checkboxes(help_block_id, checkbox_name, options = {})
+ def restricted_level_checkboxes(form)
Gitlab::VisibilityLevel.values.map do |level|
checked = restricted_visibility_levels(true).include?(level)
- css_class = checked ? 'active' : ''
- tag_name = "application_setting_visibility_level_#{level}"
-
- label_tag(tag_name, class: css_class) do
- check_box_tag(checkbox_name, level, checked,
- autocomplete: 'off',
- 'aria-describedby' => help_block_id,
- 'class' => options[:class],
- id: tag_name) + visibility_level_icon(level) + visibility_level_label(level)
- end
+
+ form.gitlab_ui_checkbox_component(
+ :restricted_visibility_levels,
+ "#{visibility_level_icon(level)} #{visibility_level_label(level)}".html_safe,
+ checkbox_options: { checked: checked, multiple: true, autocomplete: 'off' },
+ checked_value: level,
+ unchecked_value: nil
+ )
end
end
- # Return a group of checkboxes that use Bootstrap's button plugin for a
- # toggle button effect.
- def import_sources_checkboxes(help_block_id, options = {})
+ def import_sources_checkboxes(form)
Gitlab::ImportSources.options.map do |name, source|
checked = @application_setting.import_sources.include?(source)
- css_class = checked ? 'active' : ''
- checkbox_name = 'application_setting[import_sources][]'
-
- label_tag(name, class: css_class) do
- check_box_tag(checkbox_name, source, checked,
- autocomplete: 'off',
- 'aria-describedby' => help_block_id,
- 'class' => options[:class],
- id: name.tr(' ', '_')) + name
- end
+
+ form.gitlab_ui_checkbox_component(
+ :import_sources,
+ name,
+ checkbox_options: { checked: checked, multiple: true, autocomplete: 'off' },
+ checked_value: source,
+ unchecked_value: nil
+ )
end
end
@@ -223,6 +221,7 @@ module ApplicationSettingsHelper
:default_project_visibility,
:default_projects_limit,
:default_snippet_visibility,
+ :delete_inactive_projects,
:disable_feed_token,
:disabled_oauth_sign_in_sources,
:domain_denylist,
@@ -266,7 +265,6 @@ module ApplicationSettingsHelper
:help_page_text,
:hide_third_party_offers,
:home_page_url,
- :housekeeping_bitmaps_enabled,
:housekeeping_enabled,
:housekeeping_full_repack_period,
:housekeeping_gc_period,
@@ -274,6 +272,9 @@ module ApplicationSettingsHelper
:html_emails_enabled,
:import_sources,
:in_product_marketing_emails_enabled,
+ :inactive_projects_delete_after_months,
+ :inactive_projects_min_size_mb,
+ :inactive_projects_send_warning_email_after_months,
:invisible_captcha_enabled,
:max_artifacts_size,
:max_attachment_size,
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index ba6c0380edf..6ac4a12bcd5 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -2,6 +2,7 @@
module AuthHelper
PROVIDERS_WITH_ICONS = %w(
+ alicloud
atlassian_oauth2
auth0
authentiq
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 28cd61e10d9..f849f36bf84 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -16,6 +16,7 @@ module BoardsHelper
bulk_update_path: @bulk_issues_path,
can_update: can_update?.to_s,
can_admin_list: can_admin_list?.to_s,
+ can_admin_board: can_admin_board?.to_s,
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
parent: current_board_parent.model_name.param_key,
group_id: group_id,
@@ -23,7 +24,11 @@ module BoardsHelper
labels_fetch_path: labels_fetch_path,
labels_manage_path: labels_manage_path,
releases_fetch_path: releases_fetch_path,
- board_type: board.to_type
+ board_type: board.to_type,
+ has_scope: board.scoped?.to_s,
+ has_missing_boards: has_missing_boards?.to_s,
+ multiple_boards_available: multiple_boards_available?.to_s,
+ board_base_url: board_base_url
}
end
@@ -85,6 +90,11 @@ module BoardsHelper
current_board_parent.multiple_issue_boards_available?
end
+ # Boards are hidden when extra boards were created but the license does not allow multiple boards
+ def has_missing_boards?
+ !multiple_boards_available? && current_board_parent.boards.size > 1
+ end
+
def current_board_path(board)
@current_board_path ||= if board.group_board?
group_board_path(current_board_parent, board)
@@ -109,22 +119,12 @@ module BoardsHelper
can?(current_user, :admin_issue_board_list, current_board_parent)
end
- def can_admin_issue?
- can?(current_user, :admin_issue, current_board_parent)
+ def can_admin_board?
+ can?(current_user, :admin_issue_board, current_board_parent)
end
- def board_list_data
- include_descendant_groups = @group&.present?
-
- {
- toggle: "dropdown",
- list_labels_path: labels_filter_path_with_defaults(only_group_labels: true, include_ancestor_groups: true),
- labels: labels_filter_path_with_defaults(only_group_labels: true, include_descendant_groups: include_descendant_groups),
- labels_endpoint: @labels_endpoint,
- namespace_path: @namespace_path,
- project_path: @project&.path,
- group_path: @group&.path
- }
+ def can_admin_issue?
+ can?(current_user, :admin_issue, current_board_parent)
end
def serializer
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index dda834ee2c5..b138e9aeb0c 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -25,23 +25,7 @@ module BroadcastMessagesHelper
def broadcast_message(message, opts = {})
return unless message.present?
- render "shared/broadcast_message", { message: message, opts: opts }
- end
-
- def broadcast_message_style(broadcast_message)
- return '' if broadcast_message.notification?
-
- style = []
-
- if broadcast_message.color.present?
- style << "background-color: #{broadcast_message.color}"
- end
-
- if broadcast_message.font.present?
- style << "color: #{broadcast_message.font}"
- end
-
- style.join('; ')
+ render "shared/broadcast_message", { message: message, **opts }
end
def broadcast_message_status(broadcast_message)
@@ -70,6 +54,10 @@ module BroadcastMessagesHelper
BroadcastMessage.broadcast_types.keys.map { |w| [w.humanize, w] }
end
+ def broadcast_theme_options
+ BroadcastMessage.themes.keys
+ end
+
def target_access_level_options
BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS.map do |access_level|
[Gitlab::Access.human_access(access_level), access_level]
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 4ec95dc8bd7..c47aef24367 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -20,7 +20,7 @@ module ButtonHelper
#
# See http://clipboardjs.com/#usage
def clipboard_button(data = {})
- css_class = data[:class] || 'btn-clipboard btn-transparent'
+ css_class = data[:class] || 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm'
title = data[:title] || _('Copy')
button_text = data[:button_text] || nil
hide_tooltip = data[:hide_tooltip] || false
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index 14e52b120f3..6d63151769f 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -6,8 +6,8 @@ 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/deploy_to_cluster.html', anchor: 'troubleshooting'),
+ "artifact_help_url" => help_page_path('user/gitlab_com/index.md', anchor: 'gitlab-cicd'),
+ "deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.md', 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/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index 3f0379b1baa..18557afcb99 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -21,8 +21,8 @@ module Ci
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'),
"initial-branch-name" => initial_branch,
- "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
- "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'),
+ "lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
+ "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'),
"needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'),
"new-merge-request-path" => namespace_project_new_merge_request_path,
"pipeline_etag" => latest_commit ? graphql_etag_pipeline_sha_path(commit_sha) : '',
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index 8d2f83409be..70d2a4fafd1 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -106,6 +106,12 @@ module Ci
e.candidate { data[:any_runners_available] = project.active_runners.exists?.to_s }
end
+ experiment(:ios_specific_templates, actor: current_user, project: project, sticky_to: project) do |e|
+ e.candidate do
+ data[:registration_token] = project.runners_token if can?(current_user, :register_project_runners, project)
+ end
+ end
+
data
end
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index f84b42209da..0e8b6fa6d25 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -6,7 +6,7 @@ module Ci
def runner_status_icon(runner, size: 16, icon_class: '')
status = runner.status
- active = runner.active
+ contacted_at = runner.contacted_at
title = ''
icon = 'warning-solid'
@@ -14,22 +14,20 @@ module Ci
case status
when :online
- if active
- title = s_("Runners|Runner is online, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) }
- icon = 'status-active'
- span_class = 'gl-text-green-500'
- else
- title = s_("Runners|Runner is paused, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) }
- icon = 'status-paused'
- span_class = 'gl-text-gray-600'
- end
+ title = s_("Runners|Runner is online; last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(contacted_at) }
+ icon = 'status-active'
+ span_class = 'gl-text-green-500'
when :not_connected, :never_contacted
- title = s_("Runners|New runner, has not contacted yet")
+ title = s_("Runners|Runner has never contacted this instance")
icon = 'warning-solid'
when :offline
- title = s_("Runners|Runner is offline, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) }
+ title = s_("Runners|Runner is offline; last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(contacted_at) }
icon = 'status-failed'
span_class = 'gl-text-red-500'
+ when :stale
+ # runner may have contacted (or not) and be stale: consider both cases.
+ title = contacted_at ? s_("Runners|Runner is stale; last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(contacted_at) } : s_("Runners|Runner is stale; it has never contacted this instance")
+ icon = 'warning-solid'
end
content_tag(:span, class: span_class, title: title, data: { toggle: 'tooltip', container: 'body', testid: 'runner_status_icon', qa_selector: "runner_status_#{status}_content" }) do
@@ -65,7 +63,9 @@ module Ci
# Runner install help page is external, located at
# https://gitlab.com/gitlab-org/gitlab-runner
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
- registration_token: Gitlab::CurrentSettings.runners_registration_token
+ registration_token: Gitlab::CurrentSettings.runners_registration_token,
+ online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i,
+ stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i
}
end
@@ -85,7 +85,9 @@ module Ci
registration_token: group.runners_token,
group_id: group.id,
group_full_path: group.full_path,
- runner_install_help_page: 'https://docs.gitlab.com/runner/install/'
+ runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
+ online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i,
+ stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i
}
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 959dac1254e..fe057fb3412 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -19,6 +19,7 @@ module ClustersHelper
empty_state_help_text: clusterable.empty_state_help_text,
new_cluster_path: clusterable.new_path,
add_cluster_path: clusterable.connect_path,
+ new_cluster_docs_path: clusterable.new_cluster_docs_path,
can_add_cluster: clusterable.can_add_cluster?.to_s,
can_admin_cluster: clusterable.can_admin_cluster?.to_s,
display_cluster_agents: display_cluster_agents?(clusterable).to_s,
diff --git a/app/helpers/colors_helper.rb b/app/helpers/colors_helper.rb
new file mode 100644
index 00000000000..bc72122220a
--- /dev/null
+++ b/app/helpers/colors_helper.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module ColorsHelper
+ HEX_COLOR_PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze
+
+ def hex_color_to_rgb_array(hex_color)
+ raise ArgumentError, "invalid hex color `#{hex_color}`" unless hex_color =~ HEX_COLOR_PATTERN
+
+ hex_color.length == 7 ? hex_color[1, 7].scan(/.{2}/).map(&:hex) : hex_color[1, 4].scan(/./).map { |v| (v * 2).hex }
+ end
+
+ def rgb_array_to_hex_color(rgb_array)
+ raise ArgumentError, "invalid RGB array `#{rgb_array}`" unless rgb_array_valid?(rgb_array)
+
+ "##{rgb_array.map{ "%02x" % _1 }.join}"
+ end
+
+ private
+
+ def rgb_array_valid?(rgb_array)
+ rgb_array.is_a?(Array) && rgb_array.length == 3 && rgb_array.all?{ _1 >= 0 && _1 <= 255 }
+ end
+end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index c78e906e052..3c3179f6fbe 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -135,9 +135,9 @@ module CommitsHelper
%w(btn gpg-status-box) + Array(additional_classes)
end
- def conditionally_paginate_diff_files(diffs, paginate:, per:)
+ def conditionally_paginate_diff_files(diffs, paginate:, page:, per:)
if paginate
- Kaminari.paginate_array(diffs.diff_files.to_a).page(params[:page]).per(per)
+ Kaminari.paginate_array(diffs.diff_files.to_a).page(page).per(per)
else
diffs.diff_files
end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 100d5c0281c..522593dd487 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -60,6 +60,18 @@ module DiffHelper
html.join.html_safe
end
+ def diff_nomappinginraw_line(line, first_line_num_class, second_line_num_class, content_line_class)
+ css_class = ''
+ css_class = 'old' if line.type == 'old-nomappinginraw'
+ css_class = 'new' if line.type == 'new-nomappinginraw'
+
+ html = [content_tag(:td, '', class: [*first_line_num_class, css_class])]
+ html << content_tag(:td, '', class: [*second_line_num_class, css_class]) if second_line_num_class
+ html << content_tag(:td, diff_line_content(line.rich_text), class: [*content_line_class, 'nomappinginraw', css_class])
+
+ html.join.html_safe
+ end
+
def diff_line_content(line)
if line.blank?
"&nbsp;".html_safe
@@ -74,7 +86,7 @@ module DiffHelper
end
def diff_link_number(line_type, match, text)
- line_type == match || text == 0 ? " " : text
+ line_type == match ? " " : text
end
def parallel_diff_discussions(left, right, diff_file)
@@ -167,26 +179,20 @@ module DiffHelper
}
end
- def editable_diff?(diff_file)
- !diff_file.deleted_file? && @merge_request && @merge_request.source_project
- end
-
- def diff_file_changed_icon(diff_file)
- if diff_file.deleted_file?
- "file-deletion"
- elsif diff_file.new_file?
- "file-addition"
- else
- "file-modified"
- end
+ def diff_file_stats_data(diff_file)
+ old_blob = diff_file.old_blob
+ new_blob = diff_file.new_blob
+ {
+ old_size: old_blob&.size,
+ new_size: new_blob&.size,
+ added_lines: diff_file.added_lines,
+ removed_lines: diff_file.removed_lines,
+ viewer_name: diff_file.viewer.partial_name
+ }
end
- def diff_file_changed_icon_color(diff_file)
- if diff_file.deleted_file?
- "danger"
- elsif diff_file.new_file?
- "success"
- end
+ def editable_diff?(diff_file)
+ !diff_file.deleted_file? && @merge_request && @merge_request.source_project
end
def render_overflow_warning?(diffs_collection)
@@ -248,23 +254,6 @@ module DiffHelper
toggle_whitespace_link(url, options)
end
- def diff_files_data(diff_files)
- diffs_map = diff_files.map do |f|
- {
- href: "##{hexdigest(f.file_path)}",
- title: f.new_path,
- name: f.file_path,
- path: diff_file_path_text(f),
- icon: diff_file_changed_icon(f),
- iconColor: "#{diff_file_changed_icon_color(f)}",
- added: f.added_lines,
- removed: f.removed_lines
- }
- end
-
- diffs_map.to_json
- end
-
def hide_whitespace?
params[:w] == '1'
end
@@ -278,14 +267,6 @@ module DiffHelper
link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class]
end
- def diff_file_path_text(diff_file, max: 60)
- path = diff_file.new_path
-
- return path unless path.size > max && max > 3
-
- "...#{path[-(max - 3)..]}"
- end
-
def code_navigation_path(diffs)
Gitlab::CodeNavigationPath.new(merge_request.project, merge_request.diff_head_sha)
end
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index b804efb9561..79b04ae0e2b 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -206,6 +206,23 @@ module EmailsHelper
end
end
+ def new_email_address_added_text(email)
+ _('A new email address has been added to your GitLab account: %{email}') % { email: email }
+ end
+
+ def remove_email_address_text(format: nil)
+ url = profile_emails_url
+
+ case format
+ when :html
+ settings_link_to = generate_link(_('email address settings'), url).html_safe
+ _("If you want to remove this email address, visit the %{settings_link_to} page.").html_safe % { settings_link_to: settings_link_to }
+ else
+ _('If you want to remove this email address, visit %{profile_link}') %
+ { profile_link: url }
+ end
+ end
+
def admin_changed_password_text(format: nil)
url = Gitlab.config.gitlab.url
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index 1f0bf46097d..b6997b6fb70 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -77,7 +77,7 @@ module EnvironmentHelper
can_destroy_environment: can_destroy_environment?(environment),
can_stop_environment: can?(current_user, :stop_environment, environment),
can_admin_environment: can?(current_user, :admin_environment, project),
- environment_metrics_path: environment_metrics_path(environment),
+ environment_metrics_path: project_metrics_dashboard_path(project, environment: environment),
environments_fetch_path: project_environments_path(project, format: :json),
environment_edit_path: edit_project_environment_path(project, environment),
environment_stop_path: stop_project_environment_path(project, environment),
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 1894aba7dc0..3b60bda8605 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -92,7 +92,7 @@ module EnvironmentsHelper
return path if request.path.include?(path)
end
- environment_metrics_path(environment)
+ project_metrics_dashboard_path(project, environment: environment)
end
def project_and_environment_metrics_data(project, environment)
diff --git a/app/helpers/external_link_helper.rb b/app/helpers/external_link_helper.rb
index c951d0daf96..53dacfe0566 100644
--- a/app/helpers/external_link_helper.rb
+++ b/app/helpers/external_link_helper.rb
@@ -5,7 +5,7 @@ module ExternalLinkHelper
def external_link(body, url, options = {})
link = link_to url, { target: '_blank', rel: 'noopener noreferrer' }.merge(options) do
- "#{body}#{sprite_icon('external-link', css_class: 'gl-ml-1')}".html_safe
+ "#{body}#{sprite_icon('external-link', css_class: 'gl-ml-2')}".html_safe
end
sanitize(link, tags: %w(a svg use), attributes: %w(target rel data-testid class href).concat(options.stringify_keys.keys))
end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index a719d80a1a1..80ab303357b 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -9,10 +9,10 @@ module Groups::GroupMembersHelper
{ multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true }
end
- def group_members_app_data(group, members:, invited:, access_requests:)
+ def group_members_app_data(group, members:, invited:, access_requests:, include_relations:, search:)
{
user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }),
- group: group_group_links_list_data(group),
+ group: group_group_links_list_data(group, include_relations, search),
invite: group_members_list_data(group, invited.nil? ? [] : invited, { param_name: :invited_members_page, params: { page: nil } }),
access_request: group_members_list_data(group, access_requests.nil? ? [] : access_requests),
source_id: group.id,
@@ -26,8 +26,8 @@ module Groups::GroupMembersHelper
MemberSerializer.new.represent(members, { current_user: current_user, group: group, source: group })
end
- def group_group_links_serialized(group_links)
- GroupLink::GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user })
+ def group_group_links_serialized(group, group_links)
+ GroupLink::GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user, source: group })
end
# Overridden in `ee/app/helpers/ee/groups/group_members_helper.rb`
@@ -39,11 +39,29 @@ module Groups::GroupMembersHelper
}
end
- def group_group_links_list_data(group)
- group_links = group.shared_with_group_links
+ def group_group_links(group, include_relations)
+ group_links = case include_relations
+ when [:direct]
+ group.shared_with_group_links
+ when [:inherited]
+ group.shared_with_group_links.of_ancestors
+ else
+ group.shared_with_group_links.of_ancestors_and_self
+ end
+
+ group_links.distinct_on_shared_with_group_id_with_group_access
+ end
+
+ def group_group_links_list_data(group, include_relations, search)
+ if ::Feature.enabled?(:group_member_inherited_group, group, default_enabled: :yaml)
+ group_links = group_group_links(group, include_relations)
+ group_links = group_links.search(search) if search
+ else
+ group_links = group.shared_with_group_links
+ end
{
- members: group_group_links_serialized(group_links),
+ members: group_group_links_serialized(group, group_links),
pagination: members_pagination_data(group_links),
member_path: group_group_link_path(group, ':id')
}
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index bd1571f3956..4b463b9971d 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -20,7 +20,11 @@ module IdeHelper
'fork-info' => @fork_info&.to_json,
'project' => convert_to_project_entity_json(@project),
'enable-environments-guidance' => enable_environments_guidance?.to_s,
- 'preview-markdown-path' => @project && preview_markdown_path(@project)
+ 'preview-markdown-path' => @project && preview_markdown_path(@project),
+ 'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'),
+ 'web-terminal-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-web-terminals-for-the-web-ide'),
+ 'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'),
+ 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration')
}
end
@@ -44,5 +48,3 @@ module IdeHelper
current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance')
end
end
-
-::IdeHelper.prepend_mod_with('IdeHelper')
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index a2dde29e25d..a682d2712be 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -36,6 +36,7 @@ module InviteMembersHelper
def common_invite_group_modal_data(source, member_class, is_project)
{
id: source.id,
+ root_id: source.root_ancestor&.id,
name: source.name,
default_access_level: Gitlab::Access::GUEST,
invalid_groups: source.related_group_ids,
@@ -45,9 +46,11 @@ module InviteMembersHelper
}
end
+ # Overridden in EE
def common_invite_modal_dataset(source)
dataset = {
id: source.id,
+ root_id: source.root_ancestor&.id,
name: source.name,
default_access_level: Gitlab::Access::GUEST
}
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index ec1f8ca5f00..98eca3785e7 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -233,7 +233,7 @@ module IssuablesHelper
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,
- markdownPreviewPath: preview_markdown_path(parent),
+ markdownPreviewPath: preview_markdown_path(parent, target_type: issuable.model_name, target_id: issuable.iid),
markdownDocsPath: help_page_path('user/markdown'),
lockVersion: issuable.lock_version,
issuableTemplateNamesPath: template_names_path(parent, issuable),
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 298162fe970..c8c9ea32184 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -91,7 +91,7 @@ module IssuesHelper
if !can?(current_user, :award_emoji, awardable)
"disabled"
elsif current_user && awards.find { |a| a.user_id == current_user.id }
- "active"
+ "selected"
else
""
end
@@ -239,15 +239,19 @@ module IssuesHelper
)
end
+ def issues_form_data(project)
+ {
+ new_issue_path: new_project_issue_path(project)
+ }
+ end
+
# Overridden in EE
def scoped_labels_available?(parent)
false
end
def award_emoji_issue_api_path(issue)
- if Feature.enabled?(:improved_emoji_picker, issue.project, default_enabled: :yaml)
- api_v4_projects_issues_award_emoji_path(id: issue.project.id, issue_iid: issue.iid)
- end
+ api_v4_projects_issues_award_emoji_path(id: issue.project.id, issue_iid: issue.iid)
end
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 84a3802c72c..2d93813d5ee 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -185,7 +185,7 @@ module MergeRequestsHelper
endpoint_metadata: @endpoint_metadata_url,
endpoint_batch: diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params),
endpoint_coverage: @coverage_path,
- help_page_path: help_page_path('user/discussions/index.md', anchor: 'suggest-changes'),
+ help_page_path: help_page_path('user/project/merge_requests/reviews/suggestions.md'),
current_user_data: @current_user_data,
update_current_user_path: @update_current_user_path,
project_path: project_path(merge_request.project),
@@ -203,9 +203,7 @@ module MergeRequestsHelper
end
def award_emoji_merge_request_api_path(merge_request)
- if Feature.enabled?(:improved_emoji_picker, merge_request.project, default_enabled: :yaml)
- api_v4_projects_merge_requests_award_emoji_path(id: merge_request.project.id, merge_request_iid: merge_request.iid)
- end
+ api_v4_projects_merge_requests_award_emoji_path(id: merge_request.project.id, merge_request_iid: merge_request.iid)
end
private
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 64b58d28fc9..cf386ee398a 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -88,6 +88,15 @@ module NamespacesHelper
}.to_json
end
+ def pipeline_usage_quota_app_data(namespace)
+ {
+ namespace_actual_plan_name: namespace.actual_plan_name,
+ namespace_path: namespace.full_path,
+ namespace_id: namespace.id,
+ page_size: page_size
+ }
+ end
+
private
# Many importers create a temporary Group, so use the real
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index 01075862618..20d40626449 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -46,7 +46,7 @@ module PackagesHelper
::Gitlab::Tracking.event(category, event_name.to_s, **args)
end
- def show_cleanup_policy_on_alert(project)
+ def show_cleanup_policy_link(project)
Gitlab.com? &&
Gitlab.config.registry.enabled &&
project.feature_available?(:container_registry, current_user) &&
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 6a8c39b5b15..39a57e786ed 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -82,6 +82,22 @@ module PreferencesHelper
Gitlab::TabWidth.css_class_for_user(current_user)
end
+ def user_diffs_colors
+ {
+ deletion: current_user&.diffs_deletion_color.presence,
+ addition: current_user&.diffs_addition_color.presence
+ }.compact
+ end
+
+ def custom_diff_color_classes
+ return if request.path == profile_preferences_path
+
+ classes = []
+ classes << 'diff-custom-addition-color' if current_user&.diffs_addition_color.presence
+ classes << 'diff-custom-deletion-color' if current_user&.diffs_deletion_color.presence
+ classes
+ end
+
def language_choices
options_for_select(
selectable_locales_with_translation_level.sort,
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
index e03f2ae78bf..f21538fd3fb 100644
--- a/app/helpers/projects/alert_management_helper.rb
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -15,13 +15,14 @@ module Projects::AlertManagementHelper
}
end
- def alert_management_detail_data(project, alert_id)
+ def alert_management_detail_data(current_user, project, alert_id)
{
'alert-id' => alert_id,
'project-path' => project.full_path,
'project-id' => project.id,
'project-issues-path' => project_issues_path(project),
- 'page' => 'OPERATIONS'
+ 'page' => 'OPERATIONS',
+ 'can-update' => can?(current_user, :update_alert_management_alert, project).to_s
}
end
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
new file mode 100644
index 00000000000..185632a49b5
--- /dev/null
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Projects
+ module PipelineHelper
+ def js_pipeline_tabs_data(project, pipeline)
+ {
+ can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json,
+ graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
+ metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json),
+ pipeline_project_path: project.full_path
+ }
+ end
+ end
+end
+
+Projects::PipelineHelper.prepend_mod
diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb
index 514737b1417..980c8ca6b80 100644
--- a/app/helpers/projects/project_members_helper.rb
+++ b/app/helpers/projects/project_members_helper.rb
@@ -18,8 +18,8 @@ module Projects::ProjectMembersHelper
MemberSerializer.new.represent(members, { current_user: current_user, group: project.group, source: project })
end
- def project_group_links_serialized(group_links)
- GroupLink::ProjectGroupLinkSerializer.new.represent(group_links, { current_user: current_user })
+ def project_group_links_serialized(project, group_links)
+ GroupLink::ProjectGroupLinkSerializer.new.represent(group_links, { current_user: current_user, source: project })
end
def project_members_list_data(project, members, pagination = {})
@@ -32,7 +32,7 @@ module Projects::ProjectMembersHelper
def project_group_links_list_data(project, group_links)
{
- members: project_group_links_serialized(group_links),
+ members: project_group_links_serialized(project, group_links),
pagination: members_pagination_data(group_links),
member_path: project_group_link_path(project, ':id')
}
diff --git a/app/helpers/projects/security/configuration_helper.rb b/app/helpers/projects/security/configuration_helper.rb
index 8281b1f8522..efc77550c90 100644
--- a/app/helpers/projects/security/configuration_helper.rb
+++ b/app/helpers/projects/security/configuration_helper.rb
@@ -6,6 +6,10 @@ module Projects
def security_upgrade_path
"https://#{ApplicationHelper.promo_host}/pricing/"
end
+
+ def vulnerability_training_docs_path
+ help_page_path('user/application_security/vulnerabilities/index', anchor: 'enable-security-training-for-vulnerabilities')
+ end
end
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 8a75f545a32..21c7a54670c 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -420,6 +420,14 @@ module ProjectsHelper
project.path_with_namespace
end
+ def able_to_see_issues?(project, user)
+ project.issues_enabled? && can?(user, :read_issue, project)
+ end
+
+ def able_to_see_merge_requests?(project, user)
+ project.merge_requests_enabled? && can?(user, :read_merge_request, project)
+ end
+
def fork_button_disabled_tooltip(project)
return unless current_user
@@ -627,7 +635,9 @@ module ProjectsHelper
end
def can_show_last_commit_in_list?(project)
- can?(current_user, :read_cross_project) && project.commit
+ can?(current_user, :read_cross_project) &&
+ can?(current_user, :read_commit_status, project) &&
+ project.commit
end
def pages_https_only_disabled?
@@ -640,14 +650,6 @@ module ProjectsHelper
"You must enable HTTPS for all your domains first"
end
- def pages_https_only_label_class
- if pages_https_only_disabled?
- "list-label disabled"
- else
- "list-label"
- end
- end
-
def filter_starrer_path(options = {})
options = params.slice(:sort).merge(options).permit!
"#{request.path}?#{options.to_param}"
diff --git a/app/helpers/routing/projects_helper.rb b/app/helpers/routing/projects_helper.rb
index fb000b29739..859070d59ec 100644
--- a/app/helpers/routing/projects_helper.rb
+++ b/app/helpers/routing/projects_helper.rb
@@ -18,10 +18,6 @@ module Routing
project_environment_path(environment.project, environment, *args)
end
- def environment_metrics_path(environment, *args)
- metrics_project_environment_path(environment.project, environment, *args)
- end
-
def environment_delete_path(environment, *args)
expose_path(api_v4_projects_environments_path(id: environment.project.id, environment_id: environment.id))
end
diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb
index f1fafd563ce..3e5d4ee21c0 100644
--- a/app/helpers/routing/pseudonymization_helper.rb
+++ b/app/helpers/routing/pseudonymization_helper.rb
@@ -5,7 +5,11 @@ module Routing
class MaskHelper
QUERY_PARAMS_TO_NOT_MASK = %w[
scope
+ severity
+ sortBy
+ sortDesc
state
+ tab
].freeze
def initialize(request_object, group, project)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 5b596c328d1..f8bfc74b344 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -260,6 +260,7 @@ module SearchHelper
{
category: "Groups",
id: group.id,
+ value: "#{search_result_sanitize(group.name)}",
label: "#{search_result_sanitize(group.full_name)}",
url: group_path(group),
avatar_url: group.avatar_url || ''
@@ -311,7 +312,9 @@ module SearchHelper
id: mr.id,
label: search_result_sanitize(mr.title),
url: merge_request_path(mr),
- avatar_url: mr.project.avatar_url || ''
+ avatar_url: mr.project.avatar_url || '',
+ project_id: mr.target_project_id,
+ project_name: mr.target_project.name
}
end
end
@@ -325,7 +328,9 @@ module SearchHelper
id: i.id,
label: search_result_sanitize(i.title),
url: issue_path(i),
- avatar_url: i.project.avatar_url || ''
+ avatar_url: i.project.avatar_url || '',
+ project_id: i.project_id,
+ project_name: i.project.name
}
end
end
@@ -436,11 +441,11 @@ module SearchHelper
end
def show_user_search_tab?
- if @project
- project_search_tabs?(:members)
- else
- can?(current_user, :read_users_list)
- end
+ return project_search_tabs?(:members) if @project
+ return false unless can?(current_user, :read_users_list)
+ return true if @group
+
+ Feature.enabled?(:global_search_users_tab, current_user, type: :ops, default_enabled: :yaml)
end
def issuable_state_to_badge_class(issuable)
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index ca90d1e13c1..78b204fefe9 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -77,8 +77,6 @@ module SnippetsHelper
end
def project_snippets_award_api_path(snippet)
- if Feature.enabled?(:improved_emoji_picker, snippet.project, default_enabled: :yaml)
- api_v4_projects_snippets_award_emoji_path(id: snippet.project.id, snippet_id: snippet.id)
- end
+ api_v4_projects_snippets_award_emoji_path(id: snippet.project.id, snippet_id: snippet.id)
end
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 4db14d5cc4d..b4ad9db815d 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -134,14 +134,14 @@ module SortingHelper
)
end
- def milestone_sort_options_hash
+ def milestones_sort_options_hash
{
- sort_value_name => sort_title_name_asc,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_due_date_later => sort_title_due_date_later,
sort_value_due_date_soon => sort_title_due_date_soon,
+ sort_value_due_date_later => sort_title_due_date_later,
+ sort_value_start_date_soon => sort_title_start_date_soon,
sort_value_start_date_later => sort_title_start_date_later,
- sort_value_start_date_soon => sort_title_start_date_soon
+ sort_value_name => sort_title_name_asc,
+ sort_value_name_desc => sort_title_name_desc
}
end
@@ -186,6 +186,13 @@ module SortingHelper
}
end
+ def runners_sort_options_hash
+ {
+ sort_value_created_date => sort_title_created_date,
+ sort_value_contacted_date => sort_title_contacted_date
+ }
+ end
+
def starrers_sort_options_hash
{
sort_value_name => sort_title_name,
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index d3af6a00181..2942765a108 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -30,7 +30,7 @@ module SubmoduleHelper
end
end
- namespace.sub!(%r{\A/}, '')
+ namespace.delete_prefix!('/')
project.rstrip!
project.delete_suffix!('.git')
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index eca40572735..c81fbcbfd11 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -63,21 +63,6 @@ module TimeboxesHelper
issues.size
end
- # Returns count of milestones for different states
- # Uses explicit hash keys as the 'opened' state URL params differs from the db value
- # and we need to add the total
- # rubocop: disable CodeReuse/ActiveRecord
- def milestone_counts(milestones)
- counts = milestones.reorder(nil).group(:state).count
-
- {
- opened: counts['active'] || 0,
- closed: counts['closed'] || 0,
- all: counts.values.sum || 0
- }
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def milestone_progress_tooltip_text(milestone)
has_issues = milestone.total_issues_count > 0
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
index 87c8bf5cb28..b8231b02ac1 100644
--- a/app/helpers/users/callouts_helper.rb
+++ b/app/helpers/users/callouts_helper.rb
@@ -9,6 +9,7 @@ module Users
FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version'
REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
+ MINUTE_LIMIT_BANNER = 'minute_limit_banner'
SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout'
REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze
@@ -60,6 +61,10 @@ module Users
!user_dismissed?(SECURITY_NEWSLETTER_CALLOUT)
end
+ def minute_limit_banner_dismissed?
+ user_dismissed?(MINUTE_LIMIT_BANNER)
+ end
+
private
def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
diff --git a/app/helpers/users/group_callouts_helper.rb b/app/helpers/users/group_callouts_helper.rb
index 0aa4eb89499..9a9fce4d7e3 100644
--- a/app/helpers/users/group_callouts_helper.rb
+++ b/app/helpers/users/group_callouts_helper.rb
@@ -31,3 +31,5 @@ module Users
end
end
end
+
+Users::GroupCalloutsHelper.prepend_mod
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index ba876f6cb65..02ea3c1b010 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -134,6 +134,20 @@ module WikiHelper
current_user&.can?(:admin_project, container) &&
!container.has_confluence?
end
+
+ def wiki_page_render_api_endpoint(page)
+ expose_path(api_v4_projects_wikis_path(wiki_page_render_api_endpoint_params(page)))
+ end
+
+ def wiki_markup_hash_by_name_id
+ Wiki::VALID_USER_MARKUPS.map { |key, value| { value[:name] => key } }.reduce({}, :merge)
+ end
+
+ private
+
+ def wiki_page_render_api_endpoint_params(page)
+ { id: page.container.id, slug: ERB::Util.url_encode(page.slug), params: { version: page.version.id } }
+ end
end
WikiHelper.prepend_mod_with('WikiHelper')
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index 2460c956bb6..f1ddc2e902e 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -35,9 +35,11 @@ module WorkhorseHelper
head :ok
end
- # Send an entry from artifacts through Workhorse
+ # Send an entry from artifacts through Workhorse and set safe content type
def send_artifacts_entry(file, entry)
headers.store(*Gitlab::Workhorse.send_artifacts_entry(file, entry))
+ headers.store(*Gitlab::Workhorse.detect_content_type)
+
head :ok
end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index d2e710cc329..341accaea32 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -14,10 +14,17 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
- def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits: [], existing_commits: [])
+ # existing_commits - an array containing the first and last commits
+ def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits: [], total_new_commits_count: nil, existing_commits: [], total_existing_commits_count: nil)
setup_merge_request_mail(merge_request_id, recipient_id)
+
@new_commits = new_commits
+ @total_new_commits_count = total_new_commits_count || @new_commits.length
+ @total_stripped_new_commits_count = @total_new_commits_count - @new_commits.length
+
@existing_commits = existing_commits
+ @total_existing_commits_count = total_existing_commits_count || @existing_commits.length
+
@updated_by_user = User.find(updated_by_user_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 28e51ba311b..31fcc7c15cb 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -141,6 +141,17 @@ module Emails
end
end
+ def new_email_address_added_email(user, email)
+ return unless user
+
+ @user = user
+ @email = email
+
+ Gitlab::I18n.with_locale(@user.preferred_language) do
+ mail(to: @user.notification_email_or_default, subject: subject(_("New email address added")))
+ end
+ end
+
private
def profile_email_with_layout(to:, subject:, layout: 'mailer')
diff --git a/app/mailers/emails/reviews.rb b/app/mailers/emails/reviews.rb
index ddb9e161a80..b98fa8aa6c9 100644
--- a/app/mailers/emails/reviews.rb
+++ b/app/mailers/emails/reviews.rb
@@ -22,6 +22,10 @@ module Emails
review = Review.find_by_id(review_id)
@notes = review.notes
+ @discussions = Discussion.build_discussions(review.discussion_ids, preload_note_diff_file: true)
+ @include_diff_discussion_stylesheet = @discussions.values.any? do |discussion|
+ discussion.diff_discussion? && discussion.on_text?
+ end
@author = review.author
@author_name = review.author_name
@project = review.project
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 8e30eeee73f..e7c8964a733 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -181,6 +181,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.unknown_sign_in_email(user, '127.0.0.1', Time.current).message
end
+ def new_email_address_added_email
+ Notify.new_email_address_added_email(user, 'someone@gitlab.com').message
+ end
+
def service_desk_new_note_email
cleanup do
note = create_note(noteable_type: 'Issue', noteable_id: issue.id, note: 'Issue note content')
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index a53fa39c58f..1ec3cb62c76 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -27,6 +27,7 @@ module AlertManagement
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :ordered_notes, -> { fresh }, as: :noteable, class_name: 'Note'
has_many :user_mentions, class_name: 'AlertManagement::AlertUserMention', foreign_key: :alert_management_alert_id
+ has_many :metric_images, class_name: '::AlertManagement::MetricImage'
has_internal_id :iid, scope: :project
@@ -142,6 +143,10 @@ module AlertManagement
reference.to_i > 0 && reference.to_i <= Gitlab::Database::MAX_INT_VALUE
end
+ def metric_images_available?
+ ::AlertManagement::MetricImage.available_for?(project)
+ end
+
def prometheus?
monitoring_tool == Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
end
diff --git a/app/models/alert_management/metric_image.rb b/app/models/alert_management/metric_image.rb
new file mode 100644
index 00000000000..8175a31be7a
--- /dev/null
+++ b/app/models/alert_management/metric_image.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class MetricImage < ApplicationRecord
+ include MetricImageUploading
+ self.table_name = 'alert_management_alert_metric_images'
+
+ belongs_to :alert, class_name: 'AlertManagement::Alert', foreign_key: 'alert_id', inverse_of: :metric_images
+
+ def self.available_for?(project)
+ true
+ end
+
+ private
+
+ def local_path
+ Gitlab::Routing.url_helpers.alert_metric_image_upload_path(
+ filename: file.filename,
+ id: file.upload.model_id,
+ model: model_name.param_key,
+ mounted_as: 'file'
+ )
+ end
+ end
+end
diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb
index 44d2dc369f7..2c04e67a04b 100644
--- a/app/models/analytics/cycle_analytics/aggregation.rb
+++ b/app/models/analytics/cycle_analytics/aggregation.rb
@@ -1,15 +1,53 @@
# frozen_string_literal: true
class Analytics::CycleAnalytics::Aggregation < ApplicationRecord
+ include IgnorableColumns
include FromUnion
belongs_to :group, optional: false
- validates :incremental_runtimes_in_seconds, :incremental_processed_records, :last_full_run_runtimes_in_seconds, :last_full_run_processed_records, presence: true, length: { maximum: 10 }, allow_blank: true
+ validates :incremental_runtimes_in_seconds, :incremental_processed_records, :full_runtimes_in_seconds, :full_processed_records, presence: true, length: { maximum: 10 }, allow_blank: true
scope :priority_order, -> (column_to_sort = :last_incremental_run_at) { order(arel_table[column_to_sort].asc.nulls_first) }
scope :enabled, -> { where('enabled IS TRUE') }
+ # These columns were added with wrong naming convention, the columns were never used.
+ ignore_column :last_full_run_processed_records, remove_with: '15.1', remove_after: '2022-05-22'
+ ignore_column :last_full_run_runtimes_in_seconds, remove_with: '15.1', remove_after: '2022-05-22'
+ ignore_column :last_full_run_issues_updated_at, remove_with: '15.1', remove_after: '2022-05-22'
+ ignore_column :last_full_run_mrs_updated_at, remove_with: '15.1', remove_after: '2022-05-22'
+ ignore_column :last_full_run_issues_id, remove_with: '15.1', remove_after: '2022-05-22'
+ ignore_column :last_full_run_merge_requests_id, remove_with: '15.1', remove_after: '2022-05-22'
+
+ def cursor_for(mode, model)
+ {
+ updated_at: self["last_#{mode}_#{model.table_name}_updated_at"],
+ id: self["last_#{mode}_#{model.table_name}_id"]
+ }.compact
+ end
+
+ def refresh_last_run(mode)
+ self["last_#{mode}_run_at"] = Time.current
+ end
+
+ def reset_full_run_cursors
+ self.last_full_issues_id = nil
+ self.last_full_issues_updated_at = nil
+ self.last_full_merge_requests_id = nil
+ self.last_full_merge_requests_updated_at = nil
+ end
+
+ def set_cursor(mode, model, cursor)
+ self["last_#{mode}_#{model.table_name}_id"] = cursor[:id]
+ self["last_#{mode}_#{model.table_name}_updated_at"] = cursor[:updated_at]
+ end
+
+ def set_stats(mode, runtime, processed_records)
+ # We only store the last 10 data points
+ self["#{mode}_runtimes_in_seconds"] = (self["#{mode}_runtimes_in_seconds"] + [runtime]).last(10)
+ self["#{mode}_processed_records"] = (self["#{mode}_processed_records"] + [processed_records]).last(10)
+ end
+
def estimated_next_run_at
return unless enabled
return if last_incremental_run_at.nil?
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index c7aad7ff861..7cd2fe705e3 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -387,7 +387,7 @@ class ApplicationSetting < ApplicationRecord
validates :invisible_captcha_enabled,
inclusion: { in: [true, false], message: _('must be a boolean value') }
- SUPPORTED_KEY_TYPES.each do |type|
+ Gitlab::SSHPublicKey.supported_types.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -576,6 +576,17 @@ class ApplicationSetting < ApplicationRecord
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
allow_nil: false
+ validates :public_runner_releases_url, addressable_url: true, presence: true
+
+ validates :inactive_projects_min_size_mb,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :inactive_projects_delete_after_months,
+ numericality: { only_integer: true, greater_than: 0 }
+
+ validates :inactive_projects_send_warning_email_after_months,
+ numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months }
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -609,6 +620,9 @@ class ApplicationSetting < ApplicationRecord
attr_encrypted :cloud_license_auth_token, encryption_options_base_32_aes_256_gcm
attr_encrypted :external_pipeline_validation_service_token, encryption_options_base_32_aes_256_gcm
attr_encrypted :mailgun_signing_key, encryption_options_base_32_aes_256_gcm.merge(encode: false)
+ attr_encrypted :database_grafana_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :arkose_labs_public_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :arkose_labs_private_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
validates :disable_feed_token,
inclusion: { in: [true, false], message: _('must be a boolean value') }
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 42049713883..194356acc51 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -14,7 +14,6 @@ module ApplicationSettingImplementation
# Setting a key restriction to `-1` means that all keys of this type are
# forbidden.
FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
- SUPPORTED_KEY_TYPES = Gitlab::SSHPublicKey.supported_types
VALID_RUNNER_REGISTRAR_TYPES = %w(project group).freeze
DEFAULT_PROTECTED_PATHS = [
@@ -67,11 +66,11 @@ module ApplicationSettingImplementation
disabled_oauth_sign_in_sources: [],
dns_rebinding_protection_enabled: true,
domain_allowlist: Settings.gitlab['domain_allowlist'],
- dsa_key_restriction: 0,
- ecdsa_key_restriction: 0,
- ecdsa_sk_key_restriction: 0,
- ed25519_key_restriction: 0,
- ed25519_sk_key_restriction: 0,
+ dsa_key_restriction: default_min_key_size(:dsa),
+ ecdsa_key_restriction: default_min_key_size(:ecdsa),
+ ecdsa_sk_key_restriction: default_min_key_size(:ecdsa_sk),
+ ed25519_key_restriction: default_min_key_size(:ed25519),
+ ed25519_sk_key_restriction: default_min_key_size(:ed25519_sk),
eks_access_key_id: nil,
eks_account_id: nil,
eks_integration_enabled: false,
@@ -96,7 +95,6 @@ module ApplicationSettingImplementation
help_page_text: nil,
help_page_documentation_base_url: nil,
hide_third_party_offers: false,
- housekeeping_bitmaps_enabled: true,
housekeeping_enabled: true,
housekeeping_full_repack_period: 50,
housekeeping_gc_period: 200,
@@ -143,7 +141,7 @@ module ApplicationSettingImplementation
require_admin_approval_after_user_signup: true,
require_two_factor_authentication: false,
restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'],
- rsa_key_restriction: 0,
+ rsa_key_restriction: default_min_key_size(:rsa),
send_user_confirmation_email: false,
session_expire_delay: Settings.gitlab['session_expire_delay'],
shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'],
@@ -244,6 +242,20 @@ module ApplicationSettingImplementation
"users.noreply.#{Gitlab.config.gitlab.host}"
end
+ # Return the default allowed minimum key size for a type.
+ # By default this is 0 (unrestricted), but in FIPS mode
+ # this will return the smallest allowed key size. If no
+ # size is available, this type is denied.
+ #
+ # @return [Integer]
+ def default_min_key_size(name)
+ if Gitlab::FIPS.enabled?
+ Gitlab::SSHPublicKey.supported_sizes(name).select(&:positive?).min || -1
+ else
+ 0
+ end
+ end
+
def create_from_defaults
build_from_defaults.tap(&:save)
end
@@ -442,7 +454,7 @@ module ApplicationSettingImplementation
alias_method :usage_ping_enabled?, :usage_ping_enabled
def allowed_key_types
- SUPPORTED_KEY_TYPES.select do |type|
+ Gitlab::SSHPublicKey.supported_types.select do |type|
key_restriction_for(type) != FORBIDDEN_KEY_VALUE
end
end
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index b665f3d5d8c..22e5436dc5c 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -19,6 +19,8 @@ class AwardEmoji < ApplicationRecord
participant :user
+ delegate :resource_parent, to: :awardable, allow_nil: true
+
scope :downvotes, -> { named(DOWNVOTE_NAME) }
scope :upvotes, -> { named(UPVOTE_NAME) }
scope :named, -> (names) { where(name: names) }
@@ -60,6 +62,12 @@ class AwardEmoji < ApplicationRecord
self.name == UPVOTE_NAME
end
+ def url
+ return if TanukiEmoji.find_by_alpha_code(name)
+
+ CustomEmoji.for_resource(resource_parent).by_name(name).select(:url).first&.url
+ end
+
def expire_cache
awardable.try(:bump_updated_at)
awardable.try(:expire_etag_cache)
diff --git a/app/models/blob.rb b/app/models/blob.rb
index cc7758d9674..a12d856dc36 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -8,6 +8,7 @@ class Blob < SimpleDelegator
include BlobActiveModel
MODE_SYMLINK = '120000' # The STRING 120000 is the git-reported octal filemode for a symlink
+ MODE_EXECUTABLE = '100755' # The STRING 100755 is the git-reported octal filemode for an executable file
CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute
CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour
@@ -35,7 +36,6 @@ class Blob < SimpleDelegator
BlobViewer::Image,
BlobViewer::Sketch,
- BlobViewer::Balsamiq,
BlobViewer::Video,
BlobViewer::Audio,
@@ -182,6 +182,10 @@ class Blob < SimpleDelegator
mode == MODE_SYMLINK
end
+ def executable?
+ mode == MODE_EXECUTABLE
+ end
+
def extension
@extension ||= extname.downcase.delete('.')
end
diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb
deleted file mode 100644
index 6ab73730222..00000000000
--- a/app/models/blob_viewer/balsamiq.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-module BlobViewer
- class Balsamiq < Base
- include Rich
- include ClientSide
-
- self.partial_name = 'balsamiq'
- self.extensions = %w(bmpr)
- self.binary = true
- self.switcher_icon = 'doc-image'
- self.switcher_title = 'preview'
- end
-end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 949902fbb77..b255c774347 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -32,6 +32,19 @@ class BroadcastMessage < ApplicationRecord
after_commit :flush_redis_cache
+ enum theme: {
+ indigo: 0,
+ 'light-indigo': 1,
+ blue: 2,
+ 'light-blue': 3,
+ green: 4,
+ 'light-green': 5,
+ red: 6,
+ 'light-red': 7,
+ dark: 8,
+ light: 9
+ }, _default: 0, _prefix: true
+
enum broadcast_type: {
banner: 1,
notification: 2
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
index 818ae04ba29..2200a66b3c2 100644
--- a/app/models/bulk_import.rb
+++ b/app/models/bulk_import.rb
@@ -16,10 +16,14 @@ class BulkImport < ApplicationRecord
enum source_type: { gitlab: 0 }
+ scope :stale, -> { where('created_at < ?', 8.hours.ago).where(status: [0, 1]) }
+ scope :order_by_created_at, -> (direction) { order(created_at: direction) }
+
state_machine :status, initial: :created do
state :created, value: 0
state :started, value: 1
state :finished, value: 2
+ state :timeout, value: 3
state :failed, value: -1
event :start do
@@ -30,6 +34,11 @@ class BulkImport < ApplicationRecord
transition started: :finished
end
+ event :cleanup_stale do
+ transition created: :timeout
+ transition started: :timeout
+ end
+
event :fail_op do
transition any => :failed
end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index a7e1384641c..dee533944e9 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -51,11 +51,15 @@ class BulkImports::Entity < ApplicationRecord
enum source_type: { group_entity: 0, project_entity: 1 }
scope :by_user_id, ->(user_id) { joins(:bulk_import).where(bulk_imports: { user_id: user_id }) }
+ scope :stale, -> { where('created_at < ?', 8.hours.ago).where(status: [0, 1]) }
+ scope :by_bulk_import_id, ->(bulk_import_id) { where(bulk_import_id: bulk_import_id)}
+ scope :order_by_created_at, -> (direction) { order(created_at: direction) }
state_machine :status, initial: :created do
state :created, value: 0
state :started, value: 1
state :finished, value: 2
+ state :timeout, value: 3
state :failed, value: -1
event :start do
@@ -70,6 +74,11 @@ class BulkImports::Entity < ApplicationRecord
event :fail_op do
transition any => :failed
end
+
+ event :cleanup_stale do
+ transition created: :timeout
+ transition started: :timeout
+ end
end
def self.all_human_statuses
@@ -83,9 +92,9 @@ class BulkImports::Entity < ApplicationRecord
def pipelines
@pipelines ||= case source_type
when 'group_entity'
- BulkImports::Groups::Stage.new(bulk_import).pipelines
+ BulkImports::Groups::Stage.new(self).pipelines
when 'project_entity'
- BulkImports::Projects::Stage.new(bulk_import).pipelines
+ BulkImports::Projects::Stage.new(self).pipelines
end
end
diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb
index cae6aad27da..a9750a76987 100644
--- a/app/models/bulk_imports/export_status.rb
+++ b/app/models/bulk_imports/export_status.rb
@@ -32,10 +32,12 @@ module BulkImports
strong_memoize(:export_status) do
status = fetch_export_status
+ relation_export_status = status&.find { |item| item['relation'] == relation }
+
# Consider empty response as failed export
- raise StandardError, 'Empty export status response' unless status&.present?
+ raise StandardError, 'Empty relation export status' unless relation_export_status&.present?
- status.find { |item| item['relation'] == relation }
+ relation_export_status
end
rescue StandardError => e
{ 'status' => Export::FAILED, 'error' => e.message }
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
index cfe33c013ba..a994cc3f8ce 100644
--- a/app/models/bulk_imports/tracker.rb
+++ b/app/models/bulk_imports/tracker.rb
@@ -46,6 +46,7 @@ class BulkImports::Tracker < ApplicationRecord
state :started, value: 1
state :finished, value: 2
state :enqueued, value: 3
+ state :timeout, value: 4
state :failed, value: -1
state :skipped, value: -2
@@ -76,5 +77,9 @@ class BulkImports::Tracker < ApplicationRecord
event :fail_op do
transition any => :failed
end
+
+ event :cleanup_stale do
+ transition [:created, :started] => :timeout
+ end
end
end
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 2ff777bfc89..ff444ddefa3 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -57,10 +57,6 @@ module Ci
end
end
- def self.retry(bridge, current_user)
- raise NotImplementedError
- end
-
def self.with_preloads
preload(
:metadata,
@@ -69,6 +65,10 @@ module Ci
)
end
+ def retryable?
+ false
+ end
+
def inherit_status_from_downstream!(pipeline)
case pipeline.status
when 'success'
@@ -274,7 +274,8 @@ module Ci
# The order of this list refers to the priority of the variables
downstream_yaml_variables(expand_variables) +
- downstream_pipeline_variables(expand_variables)
+ downstream_pipeline_variables(expand_variables) +
+ downstream_pipeline_schedule_variables(expand_variables)
end
def downstream_yaml_variables(expand_variables)
@@ -293,6 +294,15 @@ module Ci
end
end
+ def downstream_pipeline_schedule_variables(expand_variables)
+ return [] unless forward_pipeline_variables?
+ return [] unless pipeline.pipeline_schedule
+
+ pipeline.pipeline_schedule.variables.to_a.map do |variable|
+ { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
+ end
+ end
+
def forward_yaml_variables?
strong_memoize(:forward_yaml_variables) do
result = options&.dig(:trigger, :forward, :yaml_variables)
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 68ec196a9ee..16c9aa212d0 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -218,17 +218,21 @@ module Ci
pending.unstarted.order('created_at ASC').first
end
- def retry(build, current_user)
- # rubocop: disable CodeReuse/ServiceClass
- Ci::RetryBuildService
- .new(build.project, current_user)
- .execute(build)
- # rubocop: enable CodeReuse/ServiceClass
- end
-
def with_preloads
preload(:job_artifacts_archive, :job_artifacts, :tags, project: [:namespace])
end
+
+ def extra_accessors
+ []
+ end
+
+ def clone_accessors
+ %i[pipeline project ref tag options name
+ allow_failure stage stage_id stage_idx trigger_request
+ yaml_variables when environment coverage_regex
+ description tag_list protected needs_attributes
+ job_variables_attributes resource_group scheduling_type].freeze
+ end
end
state_machine :status do
@@ -351,7 +355,9 @@ module Ci
if build.auto_retry_allowed?
begin
- Ci::Build.retry(build, build.user)
+ # rubocop: disable CodeReuse/ServiceClass
+ Ci::RetryJobService.new(build.project, build.user).execute(build)
+ # rubocop: enable CodeReuse/ServiceClass
rescue Gitlab::Access::AccessDeniedError => ex
Gitlab::AppLogger.error "Unable to auto-retry job #{build.id}: #{ex}"
end
@@ -472,12 +478,6 @@ module Ci
active? || created?
end
- def retryable?
- return false if retried? || archived? || deployment_rejected?
-
- success? || failed? || canceled?
- end
-
def retries_count
pipeline.builds.retried.where(name: self.name).count
end
@@ -504,7 +504,11 @@ module Ci
if metadata&.expanded_environment_name.present?
metadata.expanded_environment_name
else
- ExpandVariables.expand(environment, -> { simple_variables })
+ if ::Feature.enabled?(:ci_expand_environment_name_and_url, project, default_enabled: :yaml)
+ ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all })
+ else
+ ExpandVariables.expand(environment, -> { simple_variables })
+ end
end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 3426c4d5248..dff8bb89021 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -186,6 +186,7 @@ module Ci
scope :downloadable, -> { where(file_type: DOWNLOADABLE_TYPES) }
scope :unlocked, -> { joins(job: :pipeline).merge(::Ci::Pipeline.unlocked) }
+ scope :order_expired_asc, -> { order(expire_at: :asc) }
scope :order_expired_desc, -> { order(expire_at: :desc) }
scope :with_destroy_preloads, -> { includes(project: [:route, :statistics]) }
@@ -273,6 +274,10 @@ module Ci
self.where(project: project).sum(:size)
end
+ def self.pluck_job_id
+ pluck(:job_id)
+ end
+
##
# FastDestroyAll concerns
# rubocop: disable CodeReuse/ServiceClass
diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb
index d5cbbb96134..e8f08db597f 100644
--- a/app/models/ci/namespace_mirror.rb
+++ b/app/models/ci/namespace_mirror.rb
@@ -4,6 +4,8 @@ module Ci
# This model represents a record in a shadow table of the main database's namespaces table.
# It allows us to navigate the namespace hierarchy on the ci database without resorting to a JOIN.
class NamespaceMirror < ApplicationRecord
+ include FromUnion
+
belongs_to :namespace
scope :by_group_and_descendants, -> (id) do
@@ -14,6 +16,24 @@ module Ci
where('traversal_ids && ARRAY[?]::int[]', ids)
end
+ scope :contains_traversal_ids, -> (traversal_ids) do
+ mirrors = []
+
+ traversal_ids.group_by(&:count).each do |columns_count, traversal_ids_group|
+ columns = Array.new(columns_count) { |i| "(traversal_ids[#{i + 1}])" }
+ pairs = traversal_ids_group.map do |ids|
+ ids = ids.map { |id| Arel::Nodes.build_quoted(id).to_sql }
+ "(#{ids.join(",")})"
+ end
+
+ # Create condition in format:
+ # ((traversal_ids[1]),(traversal_ids[2])) IN ((1,2),(2,3))
+ mirrors << Ci::NamespaceMirror.where("(#{columns.join(",")}) IN (#{pairs.join(",")})") # rubocop:disable GitlabSecurity/SqlInjection
+ end
+
+ self.from_union(mirrors)
+ end
+
scope :by_namespace_id, -> (namespace_id) { where(namespace_id: namespace_id) }
class << self
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index ae3ea7aa03f..2d0479e02a3 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -824,6 +824,8 @@ module Ci
variables.append(key: 'CI_OPEN_MERGE_REQUESTS', value: open_merge_requests_refs.join(','))
end
+ variables.append(key: 'CI_GITLAB_FIPS_MODE', value: 'true') if Gitlab::FIPS.enabled?
+
variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active?
variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true') if freeze_period?
@@ -836,6 +838,8 @@ module Ci
def predefined_commit_variables
strong_memoize(:predefined_commit_variables) do
Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ next variables unless sha.present?
+
variables.append(key: 'CI_COMMIT_SHA', value: sha)
variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha)
variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha)
@@ -955,7 +959,7 @@ module Ci
Ci::Build.latest.where(pipeline: self_and_descendants)
end
- def environments_in_self_and_descendants
+ def environments_in_self_and_descendants(deployment_status: nil)
# We limit to 100 unique environments for application safety.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
expanded_environment_names =
@@ -965,7 +969,7 @@ module Ci
.limit(100)
.pluck(:expanded_environment_name)
- Environment.where(project: project, name: expanded_environment_names).with_deployment(sha)
+ Environment.where(project: project, name: expanded_environment_names).with_deployment(sha, status: deployment_status)
end
# With multi-project and parent-child pipelines
@@ -1285,6 +1289,12 @@ module Ci
end
end
+ def has_expired_test_reports?
+ strong_memoize(:artifacts_expired) do
+ !has_reports?(::Ci::JobArtifact.test_reports.not_expired)
+ end
+ end
+
private
def add_message(severity, content)
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 4d119706a43..d79ff74753a 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -101,6 +101,12 @@ module Ci
:merge_train_pipeline?,
to: :pipeline
+ def retryable?
+ return false if retried? || archived? || deployment_rejected?
+
+ success? || failed? || canceled?
+ end
+
def aggregated_needs_names
read_attribute(:aggregated_needs_names)
end
diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb
index 18f0093ea41..6a26a5341aa 100644
--- a/app/models/ci/secure_file.rb
+++ b/app/models/ci/secure_file.rb
@@ -15,7 +15,9 @@ module Ci
validates :file, presence: true, file_size: { maximum: FILE_SIZE_LIMIT }
validates :checksum, :file_store, :name, :permissions, :project_id, presence: true
+ validates :name, uniqueness: { scope: :project }
+ after_initialize :generate_key_data
before_validation :assign_checksum
enum permissions: { read_only: 0, read_write: 1, execute: 2 }
@@ -33,5 +35,11 @@ module Ci
def assign_checksum
self.checksum = file.checksum if file.present? && file_changed?
end
+
+ def generate_key_data
+ return if key_data.present?
+
+ self.key_data = SecureRandom.hex(64)
+ end
end
end
diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb
index 691d628524f..1607d0b6d19 100644
--- a/app/models/clusters/agent_token.rb
+++ b/app/models/clusters/agent_token.rb
@@ -18,7 +18,7 @@ module Clusters
validates :description, length: { maximum: 1024 }
validates :name, presence: true, length: { maximum: 255 }
- scope :order_last_used_at_desc, -> { order(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) }
+ scope :order_last_used_at_desc, -> { order(arel_table[:last_used_at].desc.nulls_last) }
scope :with_status, -> (status) { where(status: status) }
enum status: {
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 07eaca87fad..e62b6fa5fc5 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.37.1'
+ VERSION = '0.39.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 21e2e21e9b3..08fed353755 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -33,7 +33,7 @@ class CommitStatus < Ci::ApplicationRecord
where(allow_failure: true, status: [:failed, :canceled])
end
- scope :order_id_desc, -> { order('ci_builds.id DESC') }
+ scope :order_id_desc, -> { order(id: :desc) }
scope :exclude_ignored, -> do
# We want to ignore failed but allowed to fail jobs.
diff --git a/app/models/concerns/batch_nullify_dependent_associations.rb b/app/models/concerns/batch_nullify_dependent_associations.rb
new file mode 100644
index 00000000000..c95b5b64a43
--- /dev/null
+++ b/app/models/concerns/batch_nullify_dependent_associations.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# Provides a way to execute nullify behaviour in batches
+# to avoid query timeouts for really big tables
+# Assumes that associations have `dependent: :nullify` statement
+module BatchNullifyDependentAssociations
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def dependent_associations_to_nullify
+ reflect_on_all_associations(:has_many).select { |assoc| assoc.options[:dependent] == :nullify }
+ end
+ end
+
+ def nullify_dependent_associations_in_batches(exclude: [], batch_size: 100)
+ self.class.dependent_associations_to_nullify.each do |association|
+ next if association.name.in?(exclude)
+
+ loop do
+ # rubocop:disable GitlabSecurity/PublicSend
+ update_count = public_send(association.name).limit(batch_size).update_all(association.foreign_key => nil)
+ # rubocop:enable GitlabSecurity/PublicSend
+ break if update_count < batch_size
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/bulk_users_by_email_load.rb b/app/models/concerns/bulk_users_by_email_load.rb
new file mode 100644
index 00000000000..edbd3e21458
--- /dev/null
+++ b/app/models/concerns/bulk_users_by_email_load.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module BulkUsersByEmailLoad
+ extend ActiveSupport::Concern
+
+ included do
+ def users_by_emails(emails)
+ Gitlab::SafeRequestLoader.execute(resource_key: user_by_email_resource_key, resource_ids: emails) do |emails|
+ # have to consider all emails - even secondary, so use all_emails here
+ grouped_users_by_email = User.by_any_email(emails).preload(:emails).group_by(&:all_emails)
+
+ grouped_users_by_email.each_with_object({}) do |(found_emails, users), h|
+ found_emails.each { |e| h[e] = users.first if emails.include?(e) } # don't include all emails for an account, only the ones we want
+ end
+ end
+ end
+
+ private
+
+ def user_by_email_resource_key
+ "user_by_email_for_#{User.name.underscore.pluralize}:#{self.class}:#{self.id}"
+ end
+ end
+end
diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb
index 70d67fc7559..08189d83534 100644
--- a/app/models/concerns/featurable.rb
+++ b/app/models/concerns/featurable.rb
@@ -50,7 +50,7 @@ module Featurable
end
def available_features
- @available_features
+ @available_features || []
end
def access_level_attribute(feature)
@@ -74,6 +74,12 @@ module Featurable
STRING_OPTIONS.key(level)
end
+ def required_minimum_access_level(feature)
+ ensure_feature!(feature)
+
+ Gitlab::Access::GUEST
+ end
+
def ensure_feature!(feature)
feature = feature.model_name.plural if feature.respond_to?(:model_name)
feature = feature.to_sym
@@ -91,8 +97,8 @@ module Featurable
public_send(self.class.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
end
- def feature_available?(feature, user)
- get_permission(user, feature)
+ def feature_available?(feature, user = nil)
+ has_permission?(user, feature)
end
def string_access_level(feature)
@@ -115,4 +121,30 @@ module Featurable
def feature_validation_exclusion
[]
end
+
+ def has_permission?(user, feature)
+ case access_level(feature)
+ when DISABLED
+ false
+ when PRIVATE
+ member?(user, feature)
+ when ENABLED
+ true
+ when PUBLIC
+ true
+ else
+ true
+ end
+ end
+
+ def member?(user, feature)
+ return false unless user
+ return true if user.can_read_all_resources?
+
+ resource_member?(user, feature)
+ end
+
+ def resource_member?(user, feature)
+ raise NotImplementedError
+ end
end
diff --git a/app/models/concerns/from_set_operator.rb b/app/models/concerns/from_set_operator.rb
index c6d63631c84..ce3a83e9fa1 100644
--- a/app/models/concerns/from_set_operator.rb
+++ b/app/models/concerns/from_set_operator.rb
@@ -11,7 +11,12 @@ module FromSetOperator
raise "Trying to redefine method '#{method(method_name)}'" if methods.include?(method_name)
define_method(method_name) do |members, remove_duplicates: true, remove_order: true, alias_as: table_name|
- operator_sql = operator.new(members, remove_duplicates: remove_duplicates, remove_order: remove_order).to_sql
+ operator_sql =
+ if members.any?
+ operator.new(members, remove_duplicates: remove_duplicates, remove_order: remove_order).to_sql
+ else
+ where("1=0").to_sql
+ end
from(Arel.sql("(#{operator_sql}) #{alias_as}"))
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 1eb30e88f16..dbd760a9c45 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -195,7 +195,7 @@ module Issuable
end
def supports_escalation?
- return false unless ::Feature.enabled?(:incident_escalations, project)
+ return false unless ::Feature.enabled?(:incident_escalations, project, default_enabled: :yaml)
incident?
end
@@ -318,12 +318,16 @@ module Issuable
# 2. We can't ORDER BY a column that isn't in the GROUP BY and doesn't
# have an aggregate function applied, so we do a useless MIN() instead.
#
- milestones_due_date = 'MIN(milestones.due_date)'
+ milestones_due_date = Milestone.arel_table[:due_date].minimum
+ milestones_due_date_with_direction = direction == 'ASC' ? milestones_due_date.asc : milestones_due_date.desc
+
+ highest_priority_arel = Arel.sql('highest_priority')
+ highest_priority_arel_with_direction = direction == 'ASC' ? highest_priority_arel.asc : highest_priority_arel.desc
order_milestone_due_asc
.order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date])
- .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, direction),
- Gitlab::Database.nulls_last_order('highest_priority', direction))
+ .reorder(milestones_due_date_with_direction.nulls_last,
+ highest_priority_arel_with_direction.nulls_last)
end
def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false)
@@ -341,12 +345,15 @@ module Issuable
extra_select_columns.unshift("highest_priorities.label_priority as highest_priority")
+ highest_priority_arel = Arel.sql('highest_priority')
+ highest_priority_arel_with_direction = direction == 'ASC' ? highest_priority_arel.asc : highest_priority_arel.desc
+
select(issuable_columns)
.select(extra_select_columns)
.from("#{table_name}")
.joins("JOIN LATERAL(#{highest_priority}) as highest_priorities ON TRUE")
.group(group_columns)
- .reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
+ .reorder(highest_priority_arel_with_direction.nulls_last)
end
def with_label(title, sort = nil)
@@ -524,6 +531,10 @@ module Issuable
labels.order('title ASC').pluck(:title)
end
+ def labels_hook_attrs
+ labels.map(&:hook_attrs)
+ end
+
# Convert this Issuable class name to a format usable by Ability definitions
#
# Examples:
diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb
index 3e14507bc70..c319d685362 100644
--- a/app/models/concerns/issuable_link.rb
+++ b/app/models/concerns/issuable_link.rb
@@ -29,6 +29,8 @@ module IssuableLink
validate :check_self_relation
validate :check_opposite_relation
+ scope :for_source_or_target, ->(issuable) { where(source: issuable).or(where(target: issuable)) }
+
enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 }
private
diff --git a/app/models/concerns/metric_image_uploading.rb b/app/models/concerns/metric_image_uploading.rb
new file mode 100644
index 00000000000..3f7797f56c5
--- /dev/null
+++ b/app/models/concerns/metric_image_uploading.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module MetricImageUploading
+ extend ActiveSupport::Concern
+
+ MAX_FILE_SIZE = 1.megabyte.freeze
+
+ included do
+ include Gitlab::FileTypeDetection
+ include FileStoreMounter
+ include WithUploads
+
+ validates :file, presence: true
+ validate :validate_file_is_image
+ validates :url, length: { maximum: 255 }, public_url: { allow_blank: true }
+ validates :url_text, length: { maximum: 128 }
+
+ scope :order_created_at_asc, -> { order(created_at: :asc) }
+
+ attribute :file_store, :integer, default: -> { MetricImageUploader.default_store }
+
+ mount_file_store_uploader MetricImageUploader
+ end
+
+ def filename
+ @filename ||= file&.filename
+ end
+
+ def file_path
+ @file_path ||= begin
+ return file&.url unless file&.upload
+
+ # If we're using a CDN, we need to use the full URL
+ asset_host = ActionController::Base.asset_host || Gitlab.config.gitlab.base_url
+
+ Gitlab::Utils.append_path(asset_host, local_path)
+ end
+ end
+
+ private
+
+ def valid_file_extensions
+ Gitlab::FileTypeDetection::SAFE_IMAGE_EXT
+ end
+
+ def validate_file_is_image
+ unless image?
+ message = _('does not have a supported extension. Only %{extension_list} are supported') % {
+ extension_list: valid_file_extensions.to_sentence
+ }
+ errors.add(:file, message)
+ end
+ end
+end
diff --git a/app/models/concerns/sensitive_serializable_hash.rb b/app/models/concerns/sensitive_serializable_hash.rb
index 725ec60e9b6..94451fcd2c2 100644
--- a/app/models/concerns/sensitive_serializable_hash.rb
+++ b/app/models/concerns/sensitive_serializable_hash.rb
@@ -19,7 +19,6 @@ module SensitiveSerializableHash
# In general, prefer NOT to use serializable_hash / to_json / as_json in favor
# of serializers / entities instead which has an allowlist of attributes
def serializable_hash(options = nil)
- return super unless prevent_sensitive_fields_from_serializable_hash?
return super if options && options[:unsafe_serialization_hash]
options = options.try(:dup) || {}
@@ -37,10 +36,4 @@ module SensitiveSerializableHash
super(options)
end
-
- private
-
- def prevent_sensitive_fields_from_serializable_hash?
- Feature.enabled?(:prevent_sensitive_fields_from_serializable_hash, default_enabled: :yaml)
- end
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index b475eb79aa3..d27b451892a 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -84,7 +84,8 @@ module Spammable
end
def unrecoverable_spam_error!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam and has been discarded.") \
+ % { spammable_entity_type: spammable_entity_type })
end
def spammable_entity_type
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index e41a0ca28f9..904c96b11b3 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -11,14 +11,16 @@ require 'task_list/filter'
module Taskable
COMPLETED = 'completed'
INCOMPLETE = 'incomplete'
- COMPLETE_PATTERN = /(\[[xX]\])/.freeze
- INCOMPLETE_PATTERN = /(\[\s\])/.freeze
+ COMPLETE_PATTERN = /\[[xX]\]/.freeze
+ INCOMPLETE_PATTERN = /\[[[:space:]]\]/.freeze
ITEM_PATTERN = %r{
^
(?:(?:>\s{0,4})*) # optional blockquote characters
((?:\s*(?:[-+*]|(?:\d+\.)))+) # list prefix (one or more) required - task item has to be always in a list
\s+ # whitespace prefix has to be always presented for a list item
- (\[\s\]|\[[xX]\]) # checkbox
+ ( # checkbox
+ #{COMPLETE_PATTERN}|#{INCOMPLETE_PATTERN}
+ )
(\s.+) # followed by whitespace and some text.
}x.freeze
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index fa03d73646d..78bd520d5d5 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -9,15 +9,17 @@ class ContainerRepository < ApplicationRecord
WAITING_CLEANUP_STATUSES = %i[cleanup_scheduled cleanup_unfinished].freeze
REQUIRING_CLEANUP_STATUSES = %i[cleanup_unscheduled cleanup_scheduled].freeze
+
IDLE_MIGRATION_STATES = %w[default pre_import_done import_done import_aborted import_skipped].freeze
ACTIVE_MIGRATION_STATES = %w[pre_importing importing].freeze
- ABORTABLE_MIGRATION_STATES = (ACTIVE_MIGRATION_STATES + %w[pre_import_done default]).freeze
MIGRATION_STATES = (IDLE_MIGRATION_STATES + ACTIVE_MIGRATION_STATES).freeze
+ ABORTABLE_MIGRATION_STATES = (ACTIVE_MIGRATION_STATES + %w[pre_import_done default]).freeze
+ SKIPPABLE_MIGRATION_STATES = (ABORTABLE_MIGRATION_STATES + %w[import_aborted]).freeze
MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze
+ MIGRATION_PHASE_1_ENDED_AT = Date.new(2022, 01, 23).freeze
TooManyImportsError = Class.new(StandardError)
- NativeImportError = Class.new(StandardError)
belongs_to :project
@@ -32,7 +34,17 @@ class ContainerRepository < ApplicationRecord
enum status: { delete_scheduled: 0, delete_failed: 1 }
enum expiration_policy_cleanup_status: { cleanup_unscheduled: 0, cleanup_scheduled: 1, cleanup_unfinished: 2, cleanup_ongoing: 3 }
- enum migration_skipped_reason: { not_in_plan: 0, too_many_retries: 1, too_many_tags: 2, root_namespace_in_deny_list: 3 }
+
+ enum migration_skipped_reason: {
+ not_in_plan: 0,
+ too_many_retries: 1,
+ too_many_tags: 2,
+ root_namespace_in_deny_list: 3,
+ migration_canceled: 4,
+ not_found: 5,
+ native_import: 6,
+ migration_forced_canceled: 7
+ }
delegate :client, :gitlab_api_client, to: :registry
@@ -57,8 +69,8 @@ class ContainerRepository < ApplicationRecord
scope :import_in_process, -> { where(migration_state: %w[pre_importing pre_import_done importing]) }
scope :recently_done_migration_step, -> do
- where(migration_state: %w[import_done pre_import_done import_aborted])
- .order(Arel.sql('GREATEST(migration_pre_import_done_at, migration_import_done_at, migration_aborted_at) DESC'))
+ where(migration_state: %w[import_done pre_import_done import_aborted import_skipped])
+ .order(Arel.sql('GREATEST(migration_pre_import_done_at, migration_import_done_at, migration_aborted_at, migration_skipped_at) DESC'))
end
scope :ready_for_import, -> do
@@ -110,19 +122,19 @@ class ContainerRepository < ApplicationRecord
end
event :start_pre_import do
- transition default: :pre_importing
+ transition %i[default pre_importing importing import_aborted] => :pre_importing
end
event :finish_pre_import do
- transition %i[pre_importing import_aborted] => :pre_import_done
+ transition %i[pre_importing importing import_aborted] => :pre_import_done
end
event :start_import do
- transition pre_import_done: :importing
+ transition %i[pre_import_done pre_importing importing import_aborted] => :importing
end
event :finish_import do
- transition %i[importing import_aborted] => :import_done
+ transition %i[default pre_importing importing import_aborted] => :import_done
end
event :already_migrated do
@@ -134,15 +146,15 @@ class ContainerRepository < ApplicationRecord
end
event :skip_import do
- transition ABORTABLE_MIGRATION_STATES.map(&:to_sym) => :import_skipped
+ transition SKIPPABLE_MIGRATION_STATES.map(&:to_sym) => :import_skipped
end
event :retry_pre_import do
- transition import_aborted: :pre_importing
+ transition %i[pre_importing importing import_aborted] => :pre_importing
end
event :retry_import do
- transition import_aborted: :importing
+ transition %i[pre_importing importing import_aborted] => :importing
end
before_transition any => :pre_importing do |container_repository|
@@ -150,13 +162,16 @@ class ContainerRepository < ApplicationRecord
container_repository.migration_pre_import_done_at = nil
end
- after_transition any => :pre_importing do |container_repository|
+ after_transition any => :pre_importing do |container_repository, transition|
+ forced = transition.args.first.try(:[], :forced)
+ next if forced
+
container_repository.try_import do
container_repository.migration_pre_import
end
end
- before_transition %i[pre_importing import_aborted] => :pre_import_done do |container_repository|
+ before_transition any => :pre_import_done do |container_repository|
container_repository.migration_pre_import_done_at = Time.zone.now
end
@@ -165,13 +180,16 @@ class ContainerRepository < ApplicationRecord
container_repository.migration_import_done_at = nil
end
- after_transition any => :importing do |container_repository|
+ after_transition any => :importing do |container_repository, transition|
+ forced = transition.args.first.try(:[], :forced)
+ next if forced
+
container_repository.try_import do
container_repository.migration_import
end
end
- before_transition %i[importing import_aborted] => :import_done do |container_repository|
+ before_transition any => :import_done do |container_repository|
container_repository.migration_import_done_at = Time.zone.now
end
@@ -181,6 +199,12 @@ class ContainerRepository < ApplicationRecord
container_repository.migration_retries_count += 1
end
+ after_transition any => :import_aborted do |container_repository|
+ if container_repository.retried_too_many_times?
+ container_repository.skip_import(reason: :too_many_retries)
+ end
+ end
+
before_transition import_aborted: any do |container_repository|
container_repository.migration_aborted_at = nil
container_repository.migration_aborted_in_state = nil
@@ -204,6 +228,13 @@ class ContainerRepository < ApplicationRecord
).exists?
end
+ def self.all_migrated?
+ # check that the set of non migrated repositories is empty
+ where(created_at: ...MIGRATION_PHASE_1_ENDED_AT)
+ .where.not(migration_state: 'import_done')
+ .empty?
+ end
+
def self.with_enabled_policy
joins('INNER JOIN container_expiration_policies ON container_repositories.project_id = container_expiration_policies.project_id')
.where(container_expiration_policies: { enabled: true })
@@ -250,10 +281,10 @@ class ContainerRepository < ApplicationRecord
super
end
- def start_pre_import
+ def start_pre_import(*args)
return false unless ContainerRegistry::Migration.enabled?
- super
+ super(*args)
end
def retry_pre_import
@@ -276,24 +307,38 @@ class ContainerRepository < ApplicationRecord
def retry_aborted_migration
return unless migration_state == 'import_aborted'
- case external_import_status
+ reconcile_import_status(external_import_status) do
+ # If the import_status request fails, use the timestamp to guess current state
+ migration_pre_import_done_at ? retry_import : retry_pre_import
+ end
+ end
+
+ def reconcile_import_status(status)
+ case status
when 'native'
- raise NativeImportError
+ finish_import_as(:native_import)
+ when 'pre_import_in_progress'
+ return if pre_importing?
+
+ start_pre_import(forced: true)
when 'import_in_progress'
- nil
+ return if importing?
+
+ start_import(forced: true)
+ when 'import_canceled', 'pre_import_canceled'
+ return if import_skipped?
+
+ skip_import(reason: :migration_canceled)
when 'import_complete'
finish_import
when 'import_failed'
retry_import
- when 'pre_import_in_progress'
- nil
when 'pre_import_complete'
finish_pre_import_and_start_import
when 'pre_import_failed'
retry_pre_import
else
- # If the import_status request fails, use the timestamp to guess current state
- migration_pre_import_done_at ? retry_import : retry_pre_import
+ yield
end
end
@@ -303,9 +348,18 @@ class ContainerRepository < ApplicationRecord
try_count = 0
begin
try_count += 1
- return true if yield == :ok
- abort_import
+ case yield
+ when :ok
+ return true
+ when :not_found
+ finish_import_as(:not_found)
+ when :already_imported
+ finish_import_as(:native_import)
+ else
+ abort_import
+ end
+
false
rescue TooManyImportsError
if try_count <= ::ContainerRegistry::Migration.start_max_retries
@@ -318,8 +372,12 @@ class ContainerRepository < ApplicationRecord
end
end
+ def retried_too_many_times?
+ migration_retries_count >= ContainerRegistry::Migration.max_retries
+ end
+
def last_import_step_done_at
- [migration_pre_import_done_at, migration_import_done_at, migration_aborted_at].compact.max
+ [migration_pre_import_done_at, migration_import_done_at, migration_aborted_at, migration_skipped_at].compact.max
end
def external_import_status
@@ -416,7 +474,7 @@ class ContainerRepository < ApplicationRecord
next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT)
next unless gitlab_api_client.supports_gitlab_api?
- gitlab_api_client.repository_details(self.path, with_size: true)['size_bytes']
+ gitlab_api_client.repository_details(self.path, sizing: :self)['size_bytes']
end
end
@@ -450,6 +508,25 @@ class ContainerRepository < ApplicationRecord
response
end
+ def migration_cancel
+ return :error unless gitlab_api_client.supports_gitlab_api?
+
+ gitlab_api_client.cancel_repository_import(self.path)
+ end
+
+ # This method is not meant for consumption by the code
+ # It is meant for manual use in the case that a migration needs to be
+ # cancelled by an admin or SRE
+ def force_migration_cancel
+ return :error unless gitlab_api_client.supports_gitlab_api?
+
+ response = gitlab_api_client.cancel_repository_import(self.path, force: true)
+
+ skip_import(reason: :migration_forced_canceled) if response[:status] == :ok
+
+ response
+ end
+
def self.build_from_path(path)
self.new(project: path.repository_project,
name: path.repository_name)
@@ -478,6 +555,13 @@ class ContainerRepository < ApplicationRecord
self.find_by(project: path.repository_project,
name: path.repository_name)
end
+
+ private
+
+ def finish_import_as(reason)
+ self.migration_skipped_reason = reason
+ finish_import
+ end
end
ContainerRepository.prepend_mod_with('ContainerRepository')
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 173b38b2c63..09fbb93525b 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -28,6 +28,19 @@ class CustomEmoji < ApplicationRecord
alias_attribute :url, :file # this might need a change in https://gitlab.com/gitlab-org/gitlab/-/issues/230467
+ # Find custom emoji for the given resource.
+ # A resource can be either a Project or a Group, or anything responding to #root_ancestor.
+ # Usually it's the return value of #resource_parent on any model.
+ scope :for_resource, -> (resource) do
+ return none if resource.nil?
+
+ namespace = resource.root_ancestor
+
+ return none if namespace.nil? || Feature.disabled?(:custom_emoji, namespace)
+
+ namespace.custom_emoji
+ end
+
private
def valid_emoji_name
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index 4fa2c3fb8cf..cdb449e00bf 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -23,7 +23,7 @@ class CustomerRelations::Contact < ApplicationRecord
validates :last_name, presence: true, length: { maximum: 255 }
validates :email, length: { maximum: 255 }
validates :description, length: { maximum: 1024 }
- validates :email, uniqueness: { scope: :group_id }
+ validates :email, uniqueness: { case_sensitive: false, scope: :group_id }
validate :validate_email_format
validate :validate_root_group
@@ -42,7 +42,7 @@ class CustomerRelations::Contact < ApplicationRecord
def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
- where(group: group, email: emails).pluck(:id)
+ where(group: group).where('lower(email) in (?)', emails.map(&:downcase)).pluck(:id)
end
def self.exists_for_group?(group)
@@ -51,6 +51,34 @@ class CustomerRelations::Contact < ApplicationRecord
exists?(group: group)
end
+ def self.move_to_root_group(group)
+ update_query = <<~SQL
+ UPDATE #{CustomerRelations::IssueContact.table_name}
+ SET contact_id = new_contacts.id
+ FROM #{table_name} AS existing_contacts
+ JOIN #{table_name} AS new_contacts ON new_contacts.group_id = :old_group_id AND LOWER(new_contacts.email) = LOWER(existing_contacts.email)
+ WHERE existing_contacts.group_id = :new_group_id AND contact_id = existing_contacts.id
+ SQL
+ connection.execute(sanitize_sql([
+ update_query,
+ old_group_id: group.root_ancestor.id,
+ new_group_id: group.id
+ ]))
+
+ dupes_query = <<~SQL
+ DELETE FROM #{table_name} AS existing_contacts
+ USING #{table_name} AS new_contacts
+ WHERE existing_contacts.group_id = :new_group_id AND new_contacts.group_id = :old_group_id AND LOWER(new_contacts.email) = LOWER(existing_contacts.email)
+ SQL
+ connection.execute(sanitize_sql([
+ dupes_query,
+ old_group_id: group.root_ancestor.id,
+ new_group_id: group.id
+ ]))
+
+ where(group: group).update_all(group_id: group.root_ancestor.id)
+ end
+
private
def validate_email_format
diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb
index dc7a3fd87bc..70a30e583d5 100644
--- a/app/models/customer_relations/issue_contact.rb
+++ b/app/models/customer_relations/issue_contact.rb
@@ -8,6 +8,8 @@ class CustomerRelations::IssueContact < ApplicationRecord
validate :contact_belongs_to_root_group
+ BATCH_DELETE_SIZE = 1_000
+
def self.find_contact_ids_by_emails(issue_id, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
@@ -17,9 +19,17 @@ class CustomerRelations::IssueContact < ApplicationRecord
end
def self.delete_for_project(project_id)
- joins(:issue)
- .where(issues: { project_id: project_id })
- .delete_all
+ loop do
+ deleted_records = joins(:issue).where(issues: { project_id: project_id }).limit(BATCH_DELETE_SIZE).delete_all
+ break if deleted_records == 0
+ end
+ end
+
+ def self.delete_for_group(group)
+ loop do
+ deleted_records = joins(issue: :project).where(projects: { namespace: group.self_and_descendants }).limit(BATCH_DELETE_SIZE).delete_all
+ break if deleted_records == 0
+ end
end
private
diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb
index a23b9d8fe28..32adcc7492b 100644
--- a/app/models/customer_relations/organization.rb
+++ b/app/models/customer_relations/organization.rb
@@ -26,6 +26,34 @@ class CustomerRelations::Organization < ApplicationRecord
.where('LOWER(name) = LOWER(?)', name)
end
+ def self.move_to_root_group(group)
+ update_query = <<~SQL
+ UPDATE #{CustomerRelations::Contact.table_name}
+ SET organization_id = new_organizations.id
+ FROM #{table_name} AS existing_organizations
+ JOIN #{table_name} AS new_organizations ON new_organizations.group_id = :old_group_id AND LOWER(new_organizations.name) = LOWER(existing_organizations.name)
+ WHERE existing_organizations.group_id = :new_group_id AND organization_id = existing_organizations.id
+ SQL
+ connection.execute(sanitize_sql([
+ update_query,
+ old_group_id: group.root_ancestor.id,
+ new_group_id: group.id
+ ]))
+
+ dupes_query = <<~SQL
+ DELETE FROM #{table_name} AS existing_organizations
+ USING #{table_name} AS new_organizations
+ WHERE existing_organizations.group_id = :new_group_id AND new_organizations.group_id = :old_group_id AND LOWER(new_organizations.name) = LOWER(existing_organizations.name)
+ SQL
+ connection.execute(sanitize_sql([
+ dupes_query,
+ old_group_id: group.root_ancestor.id,
+ new_group_id: group.id
+ ]))
+
+ where(group: group).update_all(group_id: group.root_ancestor.id)
+ end
+
private
def validate_root_group
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index 326d3fb8470..360a9ffbc53 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -14,6 +14,11 @@ class DeployToken < ApplicationRecord
default_value_for(:expires_at) { Forever.date }
+ # Do NOT use this `user` for the authentication/authorization of the deploy tokens.
+ # It's for the auditing purpose on Credential Inventory, only.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/353467#note_859774246 for more information.
+ belongs_to :user, foreign_key: :creator_id, optional: true
+
has_many :project_deploy_tokens, inverse_of: :deploy_token
has_many :projects, through: :project_deploy_tokens
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index c06c809538a..63d531d82c3 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -14,8 +14,8 @@ class Deployment < ApplicationRecord
ARCHIVABLE_OFFSET = 50_000
- belongs_to :project, required: true
- belongs_to :environment, required: true
+ belongs_to :project, optional: false
+ belongs_to :environment, optional: false
belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true
belongs_to :user
belongs_to :deployable, polymorphic: true, optional: true # rubocop:disable Cop/PolymorphicAssociations
@@ -46,7 +46,7 @@ class Deployment < ApplicationRecord
scope :for_project, -> (project_id) { where(project_id: project_id) }
scope :for_projects, -> (projects) { where(project: projects) }
- scope :visible, -> { where(status: %i[running success failed canceled blocked]) }
+ scope :visible, -> { where(status: VISIBLE_STATUSES) }
scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success }
scope :active, -> { where(status: %i[created running]) }
scope :upcoming, -> { where(status: %i[blocked running]) }
@@ -58,6 +58,7 @@ class Deployment < ApplicationRecord
scope :ordered, -> { order(finished_at: :desc) }
+ VISIBLE_STATUSES = %i[running success failed canceled blocked].freeze
FINISHED_STATUSES = %i[success failed canceled].freeze
state_machine :status, initial: :created do
@@ -380,6 +381,12 @@ class Deployment < ApplicationRecord
status == params[:status]
end
+ def tier_in_yaml
+ return unless deployable
+
+ deployable.environment_deployment_tier
+ end
+
private
def update_status!(status)
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 8a167034629..9eb3308b901 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -47,6 +47,14 @@ class Discussion
grouped_notes.values.map { |notes| build(notes, context_noteable) }
end
+ def self.build_discussions(discussion_ids, context_noteable = nil, preload_note_diff_file: false)
+ notes = Note.where(discussion_id: discussion_ids).fresh
+ notes = notes.inc_note_diff_file if preload_note_diff_file
+
+ grouped_notes = notes.group_by { |n| n.discussion_id }
+ grouped_notes.transform_values { |notes| Discussion.build(notes, context_noteable) }
+ end
+
def self.lazy_find(discussion_id)
BatchLoader.for(discussion_id).batch do |discussion_ids, loader|
results = Note.where(discussion_id: discussion_ids).fresh.to_a.group_by(&:discussion_id)
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 450ed6206d5..9e663b2ee74 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -12,7 +12,7 @@ class Environment < ApplicationRecord
self.reactive_cache_hard_limit = 10.megabytes
self.reactive_cache_work_type = :external_dependency
- belongs_to :project, required: true
+ belongs_to :project, optional: false
use_fast_destroy :all_deployments
nullify_if_blank :external_url
@@ -26,7 +26,7 @@ class Environment < ApplicationRecord
has_many :self_managed_prometheus_alert_events, inverse_of: :environment
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment
- has_one :last_deployment, -> { success.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
+ has_one :last_deployment, -> { Feature.enabled?(:env_last_deployment_by_finished_at, default_enabled: :yaml) ? success.ordered : 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: true
has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: true
@@ -59,17 +59,17 @@ class Environment < ApplicationRecord
allow_nil: true,
addressable_url: true
- delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true
+ delegate :manual_actions, to: :last_deployment, allow_nil: true
delegate :auto_rollback_enabled?, to: :project
scope :available, -> { with_state(:available) }
scope :stopped, -> { with_state(:stopped) }
scope :order_by_last_deployed_at, -> do
- order(Gitlab::Database.nulls_first_order("(#{max_deployment_id_sql})", 'ASC'))
+ order(Arel::Nodes::Grouping.new(max_deployment_id_query).asc.nulls_first)
end
scope :order_by_last_deployed_at_desc, -> do
- order(Gitlab::Database.nulls_last_order("(#{max_deployment_id_sql})", 'DESC'))
+ order(Arel::Nodes::Grouping.new(max_deployment_id_query).desc.nulls_last)
end
scope :order_by_name, -> { order('environments.name ASC') }
@@ -89,13 +89,19 @@ class Environment < ApplicationRecord
scope :for_project, -> (project) { where(project_id: project) }
scope :for_tier, -> (tier) { where(tier: tier).where.not(tier: nil) }
- scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) }
scope :unfoldered, -> { where(environment_type: nil) }
scope :with_rank, -> do
select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)')
end
scope :for_id, -> (id) { where(id: id) }
+ scope :with_deployment, -> (sha, status: nil) do
+ deployments = Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)
+ deployments = deployments.where(status: status) if status
+
+ where('EXISTS (?)', deployments)
+ end
+
scope :stopped_review_apps, -> (before, limit) do
stopped
.in_review_folder
@@ -145,10 +151,11 @@ class Environment < ApplicationRecord
find_by(id: id, slug: slug)
end
- def self.max_deployment_id_sql
- Deployment.select(Deployment.arel_table[:id].maximum)
- .where(Deployment.arel_table[:environment_id].eq(arel_table[:id]))
- .to_sql
+ def self.max_deployment_id_query
+ Arel.sql(
+ Deployment.select(Deployment.arel_table[:id].maximum)
+ .where(Deployment.arel_table[:environment_id].eq(arel_table[:id])).to_sql
+ )
end
def self.pluck_names
@@ -185,6 +192,23 @@ class Environment < ApplicationRecord
last_deployment&.deployable
end
+ def last_deployment_pipeline
+ last_deployable&.pipeline
+ end
+
+ # This method returns the deployment records of the last deployment pipeline, that successfully executed to this environment.
+ # e.g.
+ # A pipeline contains
+ # - deploy job A => production environment
+ # - deploy job B => production environment
+ # In this case, `last_deployment_group` returns both deployments, whereas `last_deployable` returns only B.
+ def last_deployment_group
+ return Deployment.none unless last_deployment_pipeline
+
+ successful_deployments.where(
+ deployable_id: last_deployment_pipeline.latest_builds.pluck(:id))
+ end
+
# NOTE: Below assocation overrides is a workaround for issue https://gitlab.com/gitlab-org/gitlab/-/issues/339908
# It helps to avoid cross joins with the CI database.
# Caveat: It also overrides and losses the default AR caching mechanism.
@@ -255,8 +279,8 @@ class Environment < ApplicationRecord
external_url.gsub(%r{\A.*?://}, '')
end
- def stop_action_available?
- available? && stop_action.present?
+ def stop_actions_available?
+ available? && stop_actions.present?
end
def cancel_deployment_jobs!
@@ -269,11 +293,35 @@ class Environment < ApplicationRecord
end
end
- def stop_with_action!(current_user)
+ def stop_with_actions!(current_user)
return unless available?
stop!
- stop_action&.play(current_user)
+
+ actions = []
+
+ stop_actions.each do |stop_action|
+ Gitlab::OptimisticLocking.retry_lock(
+ stop_action,
+ name: 'environment_stop_with_actions'
+ ) do |build|
+ actions << build.play(current_user)
+ end
+ end
+
+ actions
+ end
+
+ def stop_actions
+ strong_memoize(:stop_actions) do
+ if ::Feature.enabled?(:environment_multiple_stop_actions, project, default_enabled: :yaml)
+ # Fix N+1 queries it brings to the serializer.
+ # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780
+ last_deployment_group.map(&:stop_action).compact
+ else
+ [last_deployment&.stop_action].compact
+ end
+ end
end
def reset_auto_stop
diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb
index 07c0983f239..43b2c7899a1 100644
--- a/app/models/environment_status.rb
+++ b/app/models/environment_status.rb
@@ -51,7 +51,7 @@ class EnvironmentStatus
def deployment
strong_memoize(:deployment) do
- Deployment.where(environment: environment).find_by_sha(sha)
+ Deployment.where(environment: environment).ordered.find_by_sha(sha)
end
end
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 0a429bb7afd..3ecfb895dac 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -135,7 +135,7 @@ module ErrorTracking
end
end
- def update_issue(opts = {} )
+ def update_issue(opts = {})
handle_exceptions do
{ updated: sentry_client.update_issue(opts) }
end
diff --git a/app/models/event.rb b/app/models/event.rb
index a8cf2e2dfb0..e9a98c06b59 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -10,6 +10,7 @@ class Event < ApplicationRecord
include UsageStatistics
include ShaAttribute
+ # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/358088
default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope
ACTIONS = HashWithIndifferentAccess.new(
@@ -30,8 +31,9 @@ class Event < ApplicationRecord
private_constant :ACTIONS
WIKI_ACTIONS = [:created, :updated, :destroyed].freeze
-
DESIGN_ACTIONS = [:created, :updated, :destroyed].freeze
+ TEAM_ACTIONS = [:joined, :left, :expired].freeze
+ ISSUE_ACTIONS = [:created, :updated, :closed, :reopened].freeze
TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue,
diff --git a/app/models/group.rb b/app/models/group.rb
index 14d088dd38b..990c06fdc41 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -17,6 +17,7 @@ class Group < Namespace
include GroupAPICompatibility
include EachBatch
include BulkMemberAccessLoad
+ include BulkUsersByEmailLoad
include ChronicDurationAttribute
include RunnerTokenExpirationInterval
@@ -42,7 +43,28 @@ class Group < Namespace
has_many :milestones
has_many :integrations
has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink'
- has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink'
+ has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink' do
+ def of_ancestors
+ group = proxy_association.owner
+
+ return GroupGroupLink.none unless group.has_parent?
+
+ GroupGroupLink.where(shared_group_id: group.ancestors.reorder(nil).select(:id))
+ end
+
+ def of_ancestors_and_self
+ group = proxy_association.owner
+
+ source_ids =
+ if group.has_parent?
+ group.self_and_ancestors.reorder(nil).select(:id)
+ else
+ group.id
+ end
+
+ GroupGroupLink.where(shared_group_id: source_ids)
+ end
+ end
has_many :shared_groups, through: :shared_group_links, source: :shared_group
has_many :shared_with_groups, through: :shared_with_group_links, source: :shared_with_group
has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -60,8 +82,9 @@ class Group < Namespace
has_many :boards
has_many :badges, class_name: 'GroupBadge'
- has_many :organizations, class_name: 'CustomerRelations::Organization', inverse_of: :group
- has_many :contacts, class_name: 'CustomerRelations::Contact', inverse_of: :group
+ # AR defaults to nullify when trying to delete via has_many associations unless we set dependent: :delete_all
+ has_many :organizations, class_name: 'CustomerRelations::Organization', inverse_of: :group, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :contacts, class_name: 'CustomerRelations::Contact', inverse_of: :group, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :cluster_groups, class_name: 'Clusters::Group'
has_many :clusters, through: :cluster_groups, class_name: 'Clusters::Cluster'
@@ -94,6 +117,8 @@ class Group < Namespace
has_many :group_callouts, class_name: 'Users::GroupCallout', foreign_key: :group_id
+ has_one :group_feature, inverse_of: :group, class_name: 'Groups::FeatureSetting'
+
delegate :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, :setup_for_company, :jobs_to_be_done, to: :namespace_settings
delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :namespace_settings, allow_nil: true
delegate :subgroup_runner_token_expiration_interval, :subgroup_runner_token_expiration_interval=, :subgroup_runner_token_expiration_interval_human_readable, :subgroup_runner_token_expiration_interval_human_readable=, to: :namespace_settings, allow_nil: true
@@ -102,6 +127,7 @@ class Group < Namespace
has_one :crm_settings, class_name: 'Group::CrmSettings', inverse_of: :group
accepts_nested_attributes_for :variables, allow_destroy: true
+ accepts_nested_attributes_for :group_feature, update_only: true
validate :visibility_level_allowed_by_projects
validate :visibility_level_allowed_by_sub_groups
@@ -117,6 +143,8 @@ class Group < Namespace
message: Gitlab::Regex.group_name_regex_message },
if: :name_changed?
+ validates :group_feature, presence: true
+
add_authentication_token_field :runners_token,
encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required },
prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
@@ -125,6 +153,7 @@ class Group < Namespace
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
after_update :path_changed_hook, if: :saved_change_to_path?
+ after_create -> { create_or_load_association(:group_feature) }
scope :with_users, -> { includes(:users) }
@@ -344,14 +373,16 @@ class Group < Namespace
)
end
- def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false)
- Members::Groups::CreatorService.new(self, # rubocop:disable CodeReuse/ServiceClass
- user,
- access_level,
- current_user: current_user,
- expires_at: expires_at,
- ldap: ldap)
- .execute
+ def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true)
+ Members::Groups::CreatorService.new( # rubocop:disable CodeReuse/ServiceClass
+ self,
+ user,
+ access_level,
+ current_user: current_user,
+ expires_at: expires_at,
+ ldap: ldap,
+ blocking_refresh: blocking_refresh
+ ).execute
end
def add_guest(user, current_user = nil)
@@ -794,6 +825,10 @@ class Group < Namespace
super || build_dependency_proxy_setting
end
+ def group_feature
+ super || build_group_feature
+ end
+
def crm_enabled?
crm_settings&.enabled?
end
@@ -813,8 +848,32 @@ class Group < Namespace
].compact.min
end
+ def work_items_feature_flag_enabled?
+ feature_flag_enabled_for_self_or_ancestor?(:work_items)
+ end
+
+ # Check for enabled features, similar to `Project#feature_available?`
+ # NOTE: We still want to keep this after removing `Namespace#feature_available?`.
+ override :feature_available?
+ def feature_available?(feature, user = nil)
+ if ::Groups::FeatureSetting.available_features.include?(feature)
+ group_feature.feature_available?(feature, user) # rubocop:disable Gitlab/FeatureAvailableUsage
+ else
+ super
+ end
+ end
+
private
+ def feature_flag_enabled_for_self_or_ancestor?(feature_flag)
+ actors = [root_ancestor]
+ actors << self if root_ancestor != self
+
+ actors.any? do |actor|
+ ::Feature.enabled?(feature_flag, actor, default_enabled: :yaml)
+ end
+ end
+
def max_member_access(user_ids)
Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(User),
resource_ids: user_ids,
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index c4c3fc390e1..b0020f097b5 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -16,6 +16,19 @@ class GroupGroupLink < ApplicationRecord
scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
scope :preload_shared_with_groups, -> { preload(:shared_with_group) }
+ scope :distinct_on_shared_with_group_id_with_group_access, -> do
+ distinct_group_links = select('DISTINCT ON (shared_with_group_id) *')
+ .order('shared_with_group_id, group_access DESC, expires_at DESC, created_at ASC')
+
+ unscoped.from(distinct_group_links, :group_group_links)
+ end
+
+ alias_method :shared_from, :shared_group
+
+ def self.search(query)
+ joins(:shared_with_group).merge(Group.search(query))
+ end
+
def self.access_options
Gitlab::Access.options_with_owner
end
diff --git a/app/models/groups/feature_setting.rb b/app/models/groups/feature_setting.rb
new file mode 100644
index 00000000000..72d0851ea85
--- /dev/null
+++ b/app/models/groups/feature_setting.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Groups
+ class FeatureSetting < ApplicationRecord
+ include Featurable
+ extend ::Gitlab::Utils::Override
+
+ self.primary_key = :group_id
+ self.table_name = 'group_features'
+
+ belongs_to :group
+
+ validates :group, presence: true
+
+ private
+
+ override :resource_member?
+ def resource_member?(user, feature)
+ group.member?(user, ::Groups::FeatureSetting.required_minimum_access_level(feature))
+ end
+ end
+end
+
+::Groups::FeatureSetting.prepend_mod_with('Groups::FeatureSetting')
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 274c16507b7..c0e244e38b6 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -10,9 +10,11 @@ class Integration < ApplicationRecord
include FromUnion
include EachBatch
include IgnorableColumns
+ extend ::Gitlab::Utils::Override
ignore_column :template, remove_with: '15.0', remove_after: '2022-04-22'
ignore_column :type, remove_with: '15.0', remove_after: '2022-04-22'
+ ignore_column :properties, remove_with: '15.1', remove_after: '2022-05-22'
UnknownType = Class.new(StandardError)
@@ -47,10 +49,7 @@ class Integration < ApplicationRecord
SECTION_TYPE_CONNECTION = 'connection'
- serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
-
- attr_encrypted :encrypted_properties_tmp,
- attribute: :encrypted_properties,
+ attr_encrypted :properties,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_32,
algorithm: 'aes-256-gcm',
@@ -59,6 +58,15 @@ class Integration < ApplicationRecord
encode: false,
encode_iv: false
+ # Handle assignment of props with symbol keys.
+ # To do this correctly, we need to call the method generated by attr_encrypted.
+ alias_method :attr_encrypted_props=, :properties=
+ private :attr_encrypted_props=
+
+ def properties=(props)
+ self.attr_encrypted_props = props&.with_indifferent_access&.freeze
+ end
+
alias_attribute :type, :type_new
default_value_for :active, false
@@ -77,8 +85,6 @@ class Integration < ApplicationRecord
default_value_for :wiki_page_events, true
after_initialize :initialize_properties
- after_initialize :copy_properties_to_encrypted_properties
- before_save :copy_properties_to_encrypted_properties
after_commit :reset_updated_properties
@@ -96,6 +102,9 @@ class Integration < ApplicationRecord
validate :validate_belongs_to_project_or_group
scope :external_issue_trackers, -> { where(category: 'issue_tracker').active }
+ # TODO: Will be modified in 15.0
+ # Details: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74501#note_744393645
+ scope :third_party_wikis, -> { where(type: %w[Integrations::Confluence Integrations::Shimo]).active }
scope :by_name, ->(name) { by_type(integration_name_to_type(name)) }
scope :external_wikis, -> { by_name(:external_wiki).active }
scope :active, -> { where(active: true) }
@@ -162,16 +171,14 @@ class Integration < ApplicationRecord
class_eval <<~RUBY, __FILE__, __LINE__ + 1
unless method_defined?(arg)
def #{arg}
- properties['#{arg}']
+ properties['#{arg}'] if properties.present?
end
end
def #{arg}=(value)
self.properties ||= {}
- self.encrypted_properties_tmp = properties
updated_properties['#{arg}'] = #{arg} unless #{arg}_changed?
- self.properties['#{arg}'] = value
- self.encrypted_properties_tmp['#{arg}'] = value
+ self.properties = self.properties.merge('#{arg}' => value)
end
def #{arg}_changed?
@@ -192,11 +199,13 @@ class Integration < ApplicationRecord
# Provide convenient boolean accessor methods for each serialized property.
# Also keep track of updated properties in a similar way as ActiveModel::Dirty
def self.boolean_accessor(*args)
- self.prop_accessor(*args)
+ prop_accessor(*args)
args.each do |arg|
class_eval <<~RUBY, __FILE__, __LINE__ + 1
def #{arg}
+ return if properties.blank?
+
Gitlab::Utils.to_boolean(properties['#{arg}'])
end
@@ -315,18 +324,31 @@ class Integration < ApplicationRecord
def self.build_from_integration(integration, project_id: nil, group_id: nil)
new_integration = integration.dup
- if integration.supports_data_fields?
- data_fields = integration.data_fields.dup
- data_fields.integration = new_integration
- end
-
new_integration.instance = false
new_integration.project_id = project_id
new_integration.group_id = group_id
- new_integration.inherit_from_id = integration.id if integration.instance_level? || integration.group_level?
+ new_integration.inherit_from_id = integration.id if integration.inheritable?
new_integration
end
+ # Duplicating an integration also duplicates the data fields. Duped records have different ciphertexts.
+ override :dup
+ def dup
+ new_integration = super
+ new_integration.assign_attributes(reencrypt_properties)
+
+ if supports_data_fields?
+ fields = data_fields.dup
+ fields.integration = new_integration
+ end
+
+ new_integration
+ end
+
+ def inheritable?
+ instance_level? || group_level?
+ end
+
def self.instance_exists_for?(type)
exists?(instance: true, type: type)
end
@@ -350,16 +372,17 @@ class Integration < ApplicationRecord
end
private_class_method :instance_level_integration
- def self.create_from_active_default_integrations(scope, association)
- group_ids = sorted_ancestors(scope).select(:id)
+ # Returns the number of successfully saved integrations
+ # Duplicate integrations are excluded from this count by their validations.
+ def self.create_from_active_default_integrations(owner, association)
+ group_ids = sorted_ancestors(owner).select(:id)
array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
+ order = Arel.sql("type_new ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")
- from_union([
- active.where(instance: true),
- active.where(group_id: group_ids, inherit_from_id: nil)
- ]).order(Arel.sql("type_new ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records|
- build_from_integration(records.first, association => scope.id).save
- end
+ from_union([active.where(instance: true), active.where(group_id: group_ids, inherit_from_id: nil)])
+ .order(order)
+ .group_by(&:type)
+ .count { |type, parents| build_from_integration(parents.first, association => owner.id).save }
end
def self.inherited_descendants_from_self_or_ancestors_from(integration)
@@ -398,13 +421,7 @@ class Integration < ApplicationRecord
end
def initialize_properties
- self.properties = {} if has_attribute?(:properties) && properties.nil?
- end
-
- def copy_properties_to_encrypted_properties
- self.encrypted_properties_tmp = properties
- rescue ActiveModel::MissingAttributeError
- # ignore - in a record built from using a restricted select list
+ self.properties = {} if has_attribute?(:encrypted_properties) && encrypted_properties.nil?
end
def title
@@ -428,7 +445,9 @@ class Integration < ApplicationRecord
[]
end
- def password_fields
+ # TODO: Once all integrations use `Integrations::Field` we can
+ # use `#secret?` here.
+ def secret_fields
fields.select { |f| f[:type] == 'password' }.pluck(:name)
end
@@ -439,21 +458,26 @@ class Integration < ApplicationRecord
%w[active]
end
+ # properties is always nil - ignore it.
+ override :attributes
+ def attributes
+ super.except('properties')
+ end
+
# return a hash of columns => values suitable for passing to insert_all
def to_integration_hash
column = self.class.attribute_aliases.fetch('type', 'type')
- copy_properties_to_encrypted_properties
- as_json(except: %w[id instance project_id group_id encrypted_properties_tmp])
+ as_json(except: %w[id instance project_id group_id])
.merge(column => type)
.merge(reencrypt_properties)
end
def reencrypt_properties
unless properties.nil? || properties.empty?
- alg = self.class.encrypted_attributes[:encrypted_properties_tmp][:algorithm]
+ alg = self.class.encrypted_attributes[:properties][:algorithm]
iv = generate_iv(alg)
- ep = self.class.encrypt(:encrypted_properties_tmp, properties, { iv: iv })
+ ep = self.class.encrypt(:properties, properties, { iv: iv })
end
{ 'encrypted_properties' => ep, 'encrypted_properties_iv' => iv }
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index d5b6357cb66..54bd595892f 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -35,8 +35,9 @@ module Integrations
validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true
def initialize_properties
- if properties.nil?
- self.properties = {}
+ super
+
+ if properties.empty?
self.notify_only_broken_pipelines = true
self.branches_to_be_notified = "default"
self.labels_to_be_notified_behavior = MATCH_ANY_LABEL
diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb
index 458d0199e7a..bffe87c21ee 100644
--- a/app/models/integrations/base_issue_tracker.rb
+++ b/app/models/integrations/base_issue_tracker.rb
@@ -25,12 +25,15 @@ module Integrations
def handle_properties
# this has been moved from initialize_properties and should be improved
# as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
- return unless properties
+ return unless properties.present?
+
+ safe_keys = data_fields.attributes.keys.grep_v(/encrypted/) - %w[id service_id created_at]
@legacy_properties_data = properties.dup
- data_values = properties.slice!('title', 'description')
+
+ data_values = properties.slice(*safe_keys)
data_values.reject! { |key| data_fields.changed.include?(key) }
- data_values.slice!(*data_fields.attributes.keys)
+
data_fields.assign_attributes(data_values) if data_values.present?
self.properties = {}
@@ -68,10 +71,6 @@ module Integrations
issue_url(iid)
end
- def initialize_properties
- {}
- end
-
# Initialize with default properties values
def set_default_data
return unless issues_tracker.present?
diff --git a/app/models/integrations/base_third_party_wiki.rb b/app/models/integrations/base_third_party_wiki.rb
new file mode 100644
index 00000000000..24f5bec93cf
--- /dev/null
+++ b/app/models/integrations/base_third_party_wiki.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Integrations
+ class BaseThirdPartyWiki < Integration
+ default_value_for :category, 'third_party_wiki'
+
+ validate :only_one_third_party_wiki, if: :activated?, on: :manual_change
+
+ after_commit :cache_project_has_integration
+
+ def self.supported_events
+ %w()
+ end
+
+ private
+
+ def only_one_third_party_wiki
+ return unless project_level?
+
+ if project.integrations.third_party_wikis.id_not_in(id).any?
+ errors.add(:base, _('Another third-party wiki is already in use. '\
+ 'Only one third-party wiki integration can be active at a time'))
+ end
+ end
+
+ def cache_project_has_integration
+ return unless project && !project.destroyed?
+
+ project_setting = project.project_setting
+
+ project_setting.public_send("#{project_settings_cache_key}=", active?) # rubocop:disable GitlabSecurity/PublicSend
+ project_setting.save!
+ end
+
+ def project_settings_cache_key
+ "has_#{self.class.to_param}"
+ end
+ end
+end
diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb
index 90593d78a5d..b816f90ef52 100644
--- a/app/models/integrations/buildkite.rb
+++ b/app/models/integrations/buildkite.rb
@@ -27,12 +27,12 @@ module Integrations
end
# Since SSL verification will always be enabled for Buildkite,
- # we no longer needs to store the boolean.
+ # we no longer need to store the boolean.
# This is a stub method to work with deprecated API param.
# TODO: remove enable_ssl_verification after 14.0
# https://gitlab.com/gitlab-org/gitlab/-/issues/222808
def enable_ssl_verification=(_value)
- self.properties.delete('enable_ssl_verification') # Remove unused key
+ self.properties = properties.except('enable_ssl_verification') # Remove unused key
end
override :hook_url
diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb
index 65adce7a8d6..4e1d1993d02 100644
--- a/app/models/integrations/confluence.rb
+++ b/app/models/integrations/confluence.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Integrations
- class Confluence < Integration
+ class Confluence < BaseThirdPartyWiki
VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze
VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze
VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze
@@ -11,16 +11,10 @@ module Integrations
validates :confluence_url, presence: true, if: :activated?
validate :validate_confluence_url_is_cloud, if: :activated?
- after_commit :cache_project_has_confluence
-
def self.to_param
'confluence'
end
- def self.supported_events
- %w()
- end
-
def title
s_('ConfluenceService|Confluence Workspace')
end
@@ -80,12 +74,5 @@ module Integrations
rescue URI::InvalidURIError
false
end
-
- def cache_project_has_confluence
- return unless project && !project.destroyed?
-
- project.project_setting.save! unless project.project_setting.persisted?
- project.project_setting.update_column(:has_confluence, active?)
- end
end
end
diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb
index a9cd67550dc..ab458bb2c27 100644
--- a/app/models/integrations/emails_on_push.rb
+++ b/app/models/integrations/emails_on_push.rb
@@ -13,9 +13,7 @@ module Integrations
validate :number_of_recipients_within_limit, if: :validate_recipients?
def self.valid_recipients(recipients)
- recipients.split.select do |recipient|
- recipient.include?('@')
- end.uniq(&:downcase)
+ recipients.split.grep(Devise.email_regexp).uniq(&:downcase)
end
def title
diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb
index 49ab97677db..f00c4236a92 100644
--- a/app/models/integrations/field.rb
+++ b/app/models/integrations/field.rb
@@ -2,7 +2,7 @@
module Integrations
class Field
- SENSITIVE_NAME = %r/token|key|password|passphrase|secret/.freeze
+ SECRET_NAME = %r/token|key|password|passphrase|secret/.freeze
ATTRIBUTES = %i[
section type placeholder required choices value checkbox_label
@@ -17,7 +17,7 @@ module Integrations
def initialize(name:, type: 'text', api_only: false, **attributes)
@name = name.to_s.freeze
- attributes[:type] = SENSITIVE_NAME.match?(@name) ? 'password' : type
+ attributes[:type] = SECRET_NAME.match?(@name) ? 'password' : type
attributes[:api_only] = api_only
@attributes = attributes.freeze
end
@@ -31,7 +31,7 @@ module Integrations
value
end
- def sensitive?
+ def secret?
@attributes[:type] == 'password'
end
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 74ece57000f..a800b9e5baa 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -94,10 +94,6 @@ module Integrations
!!URI(url).hostname&.end_with?(JIRA_CLOUD_HOST)
end
- def initialize_properties
- {}
- end
-
def data_fields
jira_tracker_data || self.build_jira_tracker_data
end
@@ -106,7 +102,7 @@ module Integrations
return unless reset_password?
data_fields.password = nil
- properties.delete('password') if properties
+ self.properties = properties.except('password')
end
def set_default_data
@@ -143,7 +139,7 @@ module Integrations
end
def help
- jira_doc_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/index.html') }
+ jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('integration/jira/index') }
s_("JiraService|You must configure Jira before enabling this integration. %{jira_doc_link_start}Learn more.%{link_end}") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe }
end
@@ -160,8 +156,6 @@ module Integrations
end
def sections
- jira_issues_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/issues.html') }
-
sections = [
{
type: SECTION_TYPE_CONNECTION,
@@ -180,7 +174,7 @@ module Integrations
sections.push({
type: SECTION_TYPE_JIRA_ISSUES,
title: _('Issues'),
- description: s_('JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}') % { jira_issues_link_start: jira_issues_link_start, link_end: '</a>'.html_safe }
+ description: jira_issues_section_description
})
end
@@ -610,6 +604,19 @@ module Integrations
data_fields.deployment_server!
end
end
+
+ def jira_issues_section_description
+ jira_issues_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('integration/jira/issues') }
+ description = s_('JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}') % { jira_issues_link_start: jira_issues_link_start, link_end: '</a>'.html_safe }
+
+ if project&.issues_enabled?
+ gitlab_issues_link_start = '<a href="%{url}">'.html_safe % { url: edit_project_path(project, anchor: 'js-shared-permissions') }
+ description += '<br><br>'.html_safe
+ description += s_("JiraService|Displaying Jira issues while leaving GitLab issues also enabled might be confusing. Consider %{gitlab_issues_link_start}disabling GitLab issues%{link_end} if they won't otherwise be used.") % { gitlab_issues_link_start: gitlab_issues_link_start, link_end: '</a>'.html_safe }
+ end
+
+ description
+ end
end
end
diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb
index 6dc41958daa..f15482dc2e1 100644
--- a/app/models/integrations/pipelines_email.rb
+++ b/app/models/integrations/pipelines_email.rb
@@ -12,8 +12,9 @@ module Integrations
validate :number_of_recipients_within_limit, if: :validate_recipients?
def initialize_properties
- if properties.nil?
- self.properties = {}
+ super
+
+ if properties.blank?
self.notify_only_broken_pipelines = true
self.branches_to_be_notified = "default"
elsif !self.notify_only_default_branch.nil?
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index 2e275dab91b..d6aafe45ae9 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -32,12 +32,6 @@ module Integrations
scope :preload_project, -> { preload(:project) }
scope :with_clusters_with_cilium, -> { joins(project: [:clusters]).merge(Clusters::Cluster.with_available_cilium) }
- def initialize_properties
- if properties.nil?
- self.properties = {}
- end
- end
-
def show_active_box?
false
end
diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb
index 0e1023bb7a7..dd25a0bc558 100644
--- a/app/models/integrations/shimo.rb
+++ b/app/models/integrations/shimo.rb
@@ -1,12 +1,10 @@
# frozen_string_literal: true
module Integrations
- class Shimo < Integration
+ class Shimo < BaseThirdPartyWiki
prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
- after_commit :cache_project_has_shimo
-
def render?
return false unless Feature.enabled?(:shimo_integration, project)
@@ -33,10 +31,6 @@ module Integrations
nil
end
- def self.supported_events
- %w()
- end
-
def fields
[
{
@@ -47,14 +41,5 @@ module Integrations
}
]
end
-
- private
-
- def cache_project_has_shimo
- return unless project && !project.destroyed?
-
- project.project_setting.save! unless project.project_setting.persisted?
- project.project_setting.update_column(:has_shimo, activated?)
- end
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 75727fff2cd..c2b8b457049 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -118,13 +118,15 @@ class Issue < ApplicationRecord
scope :not_authored_by, ->(user) { where.not(author_id: user) }
- scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
- scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) }
+ scope :order_due_date_asc, -> { reorder(arel_table[:due_date].asc.nulls_last) }
+ scope :order_due_date_desc, -> { reorder(arel_table[:due_date].desc.nulls_last) }
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_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') }
scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') }
+ scope :order_escalation_status_asc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].asc.nulls_last).references(:incident_management_issuable_escalation_status) }
+ scope :order_escalation_status_desc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].desc.nulls_last).references(:incident_management_issuable_escalation_status) }
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
scope :with_web_entity_associations, -> { preload(:author, project: [:project_feature, :route, namespace: :route]) }
@@ -133,7 +135,7 @@ class Issue < ApplicationRecord
scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) }
scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) }
scope :with_api_entity_associations, -> {
- preload(:timelogs, :closed_by, :assignees, :author, :labels,
+ preload(:timelogs, :closed_by, :assignees, :author, :labels, :issuable_severity,
milestone: { project: [:route, { namespace: :route }] },
project: [:route, { namespace: :route }])
}
@@ -327,6 +329,8 @@ class Issue < ApplicationRecord
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
+ when 'escalation_status_asc' then order_escalation_status_asc.with_order_id_desc
+ when 'escalation_status_desc' then order_escalation_status_desc.with_order_id_desc
else
super
end
@@ -340,8 +344,8 @@ class Issue < ApplicationRecord
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'relative_position',
column_expression: arel_table[:relative_position],
- order_expression: Gitlab::Database.nulls_last_order('issues.relative_position', 'ASC'),
- reversed_order_expression: Gitlab::Database.nulls_last_order('issues.relative_position', 'DESC'),
+ order_expression: Issue.arel_table[:relative_position].asc.nulls_last,
+ reversed_order_expression: Issue.arel_table[:relative_position].desc.nulls_last,
order_direction: :asc,
nullable: :nulls_last,
distinct: false
@@ -382,10 +386,6 @@ class Issue < ApplicationRecord
resource_parent.root_namespace&.issue_repositioning_disabled?
end
- def hook_attrs
- Gitlab::HookData::IssueBuilder.new(self).build
- end
-
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -526,10 +526,6 @@ class Issue < ApplicationRecord
::MergeRequestsClosingIssues.count_for_issue(self.id, user)
end
- def labels_hook_attrs
- labels.map(&:hook_attrs)
- end
-
def previous_updated_at
previous_changes['updated_at']&.first || updated_at
end
diff --git a/app/models/key.rb b/app/models/key.rb
index 4a4e792c074..42ea0f29171 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -26,7 +26,13 @@ class Key < ApplicationRecord
validates :fingerprint,
uniqueness: true,
- presence: { message: 'cannot be generated' }
+ presence: { message: 'cannot be generated' },
+ unless: -> { Gitlab::FIPS.enabled? }
+
+ validates :fingerprint_sha256,
+ uniqueness: true,
+ presence: { message: 'cannot be generated' },
+ if: -> { Gitlab::FIPS.enabled? }
validate :key_meets_restrictions
@@ -43,7 +49,7 @@ class Key < ApplicationRecord
scope :preload_users, -> { preload(:user) }
scope :for_user, -> (user) { where(user: user) }
- scope :order_last_used_at_desc, -> { reorder(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) }
+ scope :order_last_used_at_desc, -> { reorder(arel_table[:last_used_at].desc.nulls_last) }
# Date is set specifically in this scope to improve query time.
scope :expired_today_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') = CURRENT_DATE AND expiry_notification_delivered_at IS NULL"]) }
@@ -129,7 +135,7 @@ class Key < ApplicationRecord
return unless public_key.valid?
- self.fingerprint_md5 = public_key.fingerprint
+ self.fingerprint_md5 = public_key.fingerprint unless Gitlab::FIPS.enabled?
self.fingerprint_sha256 = public_key.fingerprint_sha256.gsub("SHA256:", "")
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 528c6855d9c..18ad2785d6e 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -22,6 +22,7 @@ class Member < ApplicationRecord
STATE_AWAITING = 1
attr_accessor :raw_invite_token
+ attr_writer :blocking_refresh
belongs_to :created_by, class_name: "User"
belongs_to :user
@@ -65,10 +66,10 @@ class Member < ApplicationRecord
scope :in_hierarchy, ->(source) do
groups = source.root_ancestor.self_and_descendants
- group_members = Member.default_scoped.where(source: groups)
+ group_members = Member.default_scoped.where(source: groups).select(*Member.cached_column_list)
projects = source.root_ancestor.all_projects
- project_members = Member.default_scoped.where(source: projects)
+ project_members = Member.default_scoped.where(source: projects).select(*Member.cached_column_list)
Member.default_scoped.from_union([
group_members,
@@ -177,10 +178,14 @@ class Member < ApplicationRecord
unscoped.from(distinct_members, :members)
end
- scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
- scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
- scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
- scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) }
+ scope :order_name_asc, -> { left_join_users.reorder(User.arel_table[:name].asc.nulls_last) }
+ scope :order_name_desc, -> { left_join_users.reorder(User.arel_table[:name].desc.nulls_last) }
+ scope :order_recent_sign_in, -> { left_join_users.reorder(User.arel_table[:last_sign_in_at].desc.nulls_last) }
+ scope :order_oldest_sign_in, -> { left_join_users.reorder(User.arel_table[:last_sign_in_at].asc.nulls_last) }
+ scope :order_recent_last_activity, -> { left_join_users.reorder(User.arel_table[:last_activity_on].desc.nulls_last) }
+ scope :order_oldest_last_activity, -> { left_join_users.reorder(User.arel_table[:last_activity_on].asc.nulls_first) }
+ scope :order_recent_created_user, -> { left_join_users.reorder(User.arel_table[:created_at].desc.nulls_last) }
+ scope :order_oldest_created_user, -> { left_join_users.reorder(User.arel_table[:created_at].asc.nulls_first) }
scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }
@@ -197,7 +202,7 @@ class Member < ApplicationRecord
after_save :log_invitation_token_cleanup
after_commit on: [:create, :update], unless: :importing? do
- refresh_member_authorized_projects(blocking: true)
+ refresh_member_authorized_projects(blocking: blocking_refresh)
end
after_commit on: [:destroy], unless: :importing? do
@@ -232,6 +237,10 @@ class Member < ApplicationRecord
when 'access_level_desc' then reorder(access_level: :desc)
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
+ when 'recent_created_user' then order_recent_created_user
+ when 'oldest_created_user' then order_oldest_created_user
+ when 'recent_last_activity' then order_recent_last_activity
+ when 'oldest_last_activity' then order_oldest_last_activity
when 'last_joined' then order_created_desc
when 'oldest_joined' then order_created_asc
else
@@ -505,6 +514,13 @@ class Member < ApplicationRecord
error = StandardError.new("Invitation token is present but invite was already accepted!")
Gitlab::ErrorTracking.track_exception(error, attributes.slice(%w["invite_accepted_at created_at source_type source_id user_id id"]))
end
+
+ def blocking_refresh
+ return true unless Feature.enabled?(:allow_non_blocking_member_refresh, default_enabled: :yaml)
+ return true if @blocking_refresh.nil?
+
+ @blocking_refresh
+ end
end
Member.prepend_mod_with('Member')
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 3e19f294253..995c26d7221 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -82,10 +82,6 @@ class ProjectMember < Member
source
end
- def owner?
- project.owner == user
- end
-
def notifiable_options
{ project: project }
end
@@ -132,7 +128,10 @@ class ProjectMember < Member
end
def post_create_hook
- unless owner?
+ # The creator of a personal project gets added as a `ProjectMember`
+ # with `OWNER` access during creation of a personal project,
+ # but we do not want to trigger notifications to the same person who created the personal project.
+ unless project.personal_namespace_holder?(user)
event_service.join_project(self.project, self.user)
run_after_commit_or_now { notification_service.new_project_member(self) }
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 854325e1fcd..4c6ed399bf9 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -329,15 +329,15 @@ class MergeRequest < ApplicationRecord
end
scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) }
scope :order_by_metric, ->(metric, direction) do
- reverse_direction = { 'ASC' => 'DESC', 'DESC' => 'ASC' }
- reversed_direction = reverse_direction[direction] || raise("Unknown sort direction was given: #{direction}")
+ column_expression = MergeRequest::Metrics.arel_table[metric]
+ column_expression_with_direction = direction == 'ASC' ? column_expression.asc : column_expression.desc
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: "merge_request_metrics_#{metric}",
- column_expression: MergeRequest::Metrics.arel_table[metric],
- order_expression: Gitlab::Database.nulls_last_order("merge_request_metrics.#{metric}", direction),
- reversed_order_expression: Gitlab::Database.nulls_first_order("merge_request_metrics.#{metric}", reversed_direction),
+ column_expression: column_expression,
+ order_expression: column_expression_with_direction.nulls_last,
+ reversed_order_expression: column_expression_with_direction.reverse.nulls_first,
order_direction: direction,
nullable: :nulls_last,
distinct: false,
@@ -1409,9 +1409,7 @@ class MergeRequest < ApplicationRecord
def has_ci?
return false if has_no_commits?
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do
- !!(head_pipeline_id || all_pipelines.any? || source_project&.ci_integration)
- end
+ !!(head_pipeline_id || all_pipelines.any? || source_project&.ci_integration)
end
def branch_missing?
@@ -1444,7 +1442,7 @@ class MergeRequest < ApplicationRecord
# This method is for looking for active environments which created via pipelines for merge requests.
# Since deployments run on a merge request ref (e.g. `refs/merge-requests/:iid/head`),
# we cannot look up environments with source branch name.
- def environments
+ def legacy_environments
return Environment.none unless actual_head_pipeline&.merge_request?
build_for_actual_head_pipeline = Ci::Build.latest.where(pipeline: actual_head_pipeline)
@@ -1458,6 +1456,14 @@ class MergeRequest < ApplicationRecord
Environment.where(project: project, name: environments)
end
+ def environments_in_head_pipeline(deployment_status: nil)
+ if ::Feature.enabled?(:fix_related_environments_for_merge_requests, target_project, default_enabled: :yaml)
+ actual_head_pipeline&.environments_in_self_and_descendants(deployment_status: deployment_status) || Environment.none
+ else
+ legacy_environments
+ end
+ end
+
def fetch_ref!
target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path)
end
@@ -1904,9 +1910,7 @@ class MergeRequest < ApplicationRecord
end
def find_actual_head_pipeline
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do
- all_pipelines.for_sha_or_source_sha(diff_head_sha).first
- end
+ all_pipelines.for_sha_or_source_sha(diff_head_sha).first
end
def etag_caching_enabled?
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 86da29dd27a..ff4fadb0f13 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -31,7 +31,7 @@ class Milestone < ApplicationRecord
end
scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) }
- scope :reorder_by_due_date_asc, -> { reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
+ scope :reorder_by_due_date_asc, -> { reorder(arel_table[:due_date].asc.nulls_last) }
scope :with_api_entity_associations, -> { preload(project: [:project_feature, :route, namespace: :route]) }
scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) }
@@ -116,15 +116,15 @@ class Milestone < ApplicationRecord
when 'due_date_asc'
reorder_by_due_date_asc
when 'due_date_desc'
- reorder(Gitlab::Database.nulls_last_order('due_date', 'DESC'))
+ reorder(arel_table[:due_date].desc.nulls_last)
when 'name_asc'
reorder(Arel::Nodes::Ascending.new(arel_table[:title].lower))
when 'name_desc'
reorder(Arel::Nodes::Descending.new(arel_table[:title].lower))
when 'start_date_asc'
- reorder(Gitlab::Database.nulls_last_order('start_date', 'ASC'))
+ reorder(arel_table[:start_date].asc.nulls_last)
when 'start_date_desc'
- reorder(Gitlab::Database.nulls_last_order('start_date', 'DESC'))
+ reorder(arel_table[:start_date].desc.nulls_last)
else
order_by(method)
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index ffaeb2071f6..3b75b6d163a 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -15,6 +15,7 @@ class Namespace < ApplicationRecord
include Namespaces::Traversal::Recursive
include Namespaces::Traversal::Linear
include EachBatch
+ include BlocksUnsafeSerialization
# Temporary column used for back-filling project namespaces.
# Remove it once the back-filling of all project namespaces is done.
@@ -131,7 +132,7 @@ class Namespace < ApplicationRecord
scope :user_namespaces, -> { where(type: Namespaces::UserNamespace.sti_name) }
scope :without_project_namespaces, -> { where(Namespace.arel_table[:type].not_eq(Namespaces::ProjectNamespace.sti_name)) }
- scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) }
+ scope :sort_by_type, -> { order(arel_table[:type].asc.nulls_first) }
scope :include_route, -> { includes(:route) }
scope :by_parent, -> (parent) { where(parent_id: parent) }
scope :filter_by_path, -> (query) { where('lower(path) = :query', query: query.downcase) }
@@ -372,7 +373,7 @@ class Namespace < ApplicationRecord
end
# Deprecated, use #licensed_feature_available? instead. Remove once Namespace#feature_available? isn't used anymore.
- def feature_available?(feature)
+ def feature_available?(feature, _user = nil)
licensed_feature_available?(feature)
end
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index ee04ec39b1e..96715863892 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -23,6 +23,14 @@ class Namespace::RootStorageStatistics < ApplicationRecord
delegate :all_projects, to: :namespace
+ enum notification_level: {
+ storage_remaining: 100,
+ caution: 30,
+ warning: 15,
+ danger: 5,
+ exceeded: 0
+ }, _prefix: true
+
def recalculate!
update!(merged_attributes)
end
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 1963745cf4d..6320e0bc39d 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -49,6 +49,33 @@ module Namespaces
before_commit :sync_traversal_ids, on: [:create], if: -> { sync_traversal_ids? }
end
+ class_methods do
+ # This method looks into a list of namespaces trying to optimise a returned traversal_ids
+ # into a list of shortest prefixes, due to fact that the shortest prefixes include all childrens.
+ # Example:
+ # INPUT: [[4909902], [4909902,51065789], [4909902,51065793], [7135830], [15599674, 1], [15599674, 1, 3], [15599674, 2]]
+ # RESULT: [[4909902], [7135830], [15599674, 1], [15599674, 2]]
+ def shortest_traversal_ids_prefixes
+ raise ArgumentError, 'Feature not supported since the `:use_traversal_ids` is disabled' unless use_traversal_ids?
+
+ prefixes = []
+
+ # The array needs to be sorted (O(nlogn)) to ensure shortest elements are always first
+ # This allows to do O(n) search of shortest prefixes
+ all_traversal_ids = all.order('namespaces.traversal_ids').pluck('namespaces.traversal_ids')
+ last_prefix = [nil]
+
+ all_traversal_ids.each do |traversal_ids|
+ next if last_prefix == traversal_ids[0..(last_prefix.count - 1)]
+
+ last_prefix = traversal_ids
+ prefixes << traversal_ids
+ end
+
+ prefixes
+ end
+ end
+
def sync_traversal_ids?
Feature.enabled?(:sync_traversal_ids, root_ancestor, default_enabled: :yaml)
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 4f2e7ebe2c5..3d2ac69a2ab 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -35,6 +35,8 @@ class Note < ApplicationRecord
contact: :read_crm_contact
}.freeze
+ NON_DIFF_NOTE_TYPES = ['Note', 'DiscussionNote', nil].freeze
+
# Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes.
# See https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/10392/diffs#note_28719102
alias_attribute :last_edited_by, :updated_by
@@ -97,6 +99,11 @@ class Note < ApplicationRecord
validates :author, presence: true
validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
+ validate :ensure_confidentiality_discussion_compliance
+ validate :ensure_noteable_can_have_confidential_note
+ validate :ensure_note_type_can_be_confidential
+ validate :ensure_confidentiality_not_changed, on: :update
+
validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note|
unless note.noteable.try(:project) == note.project
errors.add(:project, 'does not match noteable project')
@@ -121,6 +128,7 @@ class Note < ApplicationRecord
scope :with_discussion_ids, ->(discussion_ids) { where(discussion_id: discussion_ids) }
scope :with_suggestions, -> { joins(:suggestions) }
scope :inc_author, -> { includes(:author) }
+ scope :inc_note_diff_file, -> { includes(:note_diff_file) }
scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
scope :inc_relations_for_view, -> do
includes({ project: :group }, { author: :status }, :updated_by, :resolved_by, :award_emoji,
@@ -140,7 +148,7 @@ class Note < ApplicationRecord
scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) }
scope :new_diff_notes, -> { where(type: 'DiffNote') }
- scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) }
+ scope :non_diff_notes, -> { where(type: NON_DIFF_NOTE_TYPES) }
scope :with_associations, -> do
# FYI noteable cannot be loaded for LegacyDiffNote for commits
@@ -457,7 +465,7 @@ class Note < ApplicationRecord
# and all its notes and if we don't care about the discussion's resolvability status.
def discussion
strong_memoize(:discussion) do
- full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
+ full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if self.noteable && part_of_discussion?
full_discussion || to_discussion
end
end
@@ -501,7 +509,15 @@ class Note < ApplicationRecord
# Instead of calling touch which is throttled via ThrottledTouch concern,
# we bump the updated_at column directly. This also prevents executing
# after_commit callbacks that we don't need.
- update_column(:updated_at, Time.current)
+ attributes_to_update = { updated_at: Time.current }
+
+ # Notes that were edited before the `last_edited_at` column was added, fall back to `updated_at` for the edit time.
+ # We copy this over to the correct column so we don't erroneously change the edit timestamp.
+ if updated_by_id.present? && read_attribute(:last_edited_at).blank?
+ attributes_to_update[:last_edited_at] = updated_at
+ end
+
+ update_columns(attributes_to_update)
end
def expire_etag_cache
@@ -717,6 +733,42 @@ class Note < ApplicationRecord
def noteable_label_url_method
for_merge_request? ? :project_merge_requests_url : :project_issues_url
end
+
+ def ensure_confidentiality_not_changed
+ return unless will_save_change_to_attribute?(:confidential)
+ return unless attribute_change_to_be_saved(:confidential).include?(true)
+
+ errors.add(:confidential, _('can not be changed for existing notes'))
+ end
+
+ def ensure_confidentiality_discussion_compliance
+ return if start_of_discussion?
+
+ if discussion.first_note.confidential? != confidential?
+ errors.add(:confidential, _('reply should have same confidentiality as top-level note'))
+ end
+
+ ensure
+ clear_memoization(:discussion)
+ end
+
+ def ensure_noteable_can_have_confidential_note
+ return unless confidential?
+ return if noteable_can_have_confidential_note?
+
+ errors.add(:confidential, _('can not be set for this resource'))
+ end
+
+ def ensure_note_type_can_be_confidential
+ return unless confidential?
+ return if NON_DIFF_NOTE_TYPES.include?(type)
+
+ errors.add(:confidential, _('can not be set for this type of note'))
+ end
+
+ def noteable_can_have_confidential_note?
+ for_issue?
+ end
end
Note.prepend_mod_with('Note')
diff --git a/app/models/onboarding_progress.rb b/app/models/onboarding_progress.rb
index 58b7848f7e2..e5851c5cfc5 100644
--- a/app/models/onboarding_progress.rb
+++ b/app/models/onboarding_progress.rb
@@ -27,7 +27,8 @@ class OnboardingProgress < ApplicationRecord
:secure_secret_detection_run,
:secure_coverage_fuzzing_run,
:secure_api_fuzzing_run,
- :secure_cluster_image_scanning_run
+ :secure_cluster_image_scanning_run,
+ :license_scanning_run
].freeze
scope :incomplete_actions, -> (actions) do
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index c76473c9438..7744e578df5 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -228,8 +228,8 @@ class Packages::Package < ApplicationRecord
def self.keyset_pagination_order(join_class:, column_name:, direction: :asc)
join_table = join_class.table_name
- asc_order_expression = Gitlab::Database.nulls_last_order("#{join_table}.#{column_name}", :asc)
- desc_order_expression = Gitlab::Database.nulls_first_order("#{join_table}.#{column_name}", :desc)
+ asc_order_expression = join_class.arel_table[column_name].asc.nulls_last
+ desc_order_expression = join_class.arel_table[column_name].desc.nulls_first
order_direction = direction == :asc ? asc_order_expression : desc_order_expression
reverse_order_direction = direction == :asc ? desc_order_expression : asc_order_expression
arel_order_classes = ::Gitlab::Pagination::Keyset::ColumnOrderDefinition::AREL_ORDER_CLASSES.invert
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index ad8140ac684..b49e04f481c 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -34,7 +34,7 @@ class Packages::PackageFile < ApplicationRecord
validates :file, presence: true
validates :file_name, presence: true
- validates :file_name, uniqueness: { scope: :package }, if: -> { package&.pypi? }
+ validates :file_name, uniqueness: { scope: :package }, if: -> { !pending_destruction? && package&.pypi? }
scope :recent, -> { order(id: :desc) }
scope :limit_recent, ->(limit) { recent.limit(limit) }
diff --git a/app/models/preloaders/group_root_ancestor_preloader.rb b/app/models/preloaders/group_root_ancestor_preloader.rb
new file mode 100644
index 00000000000..3ca713d9635
--- /dev/null
+++ b/app/models/preloaders/group_root_ancestor_preloader.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Preloaders
+ class GroupRootAncestorPreloader
+ def initialize(groups, root_ancestor_preloads = [])
+ @groups = groups
+ @root_ancestor_preloads = root_ancestor_preloads
+ end
+
+ def execute
+ return unless ::Feature.enabled?(:use_traversal_ids, default_enabled: :yaml)
+
+ # type == 'Group' condition located on subquery to prevent a filter in the query
+ root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id")
+ .select('namespaces.*, root_query.id as source_id')
+
+ root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any?
+
+ root_ancestors_by_id = root_query.group_by(&:source_id)
+
+ @groups.each do |group|
+ group.root_ancestor = root_ancestors_by_id[group.id].first
+ end
+ end
+
+ private
+
+ def join_sql
+ Group.select('id, traversal_ids[1] as root_id').where(id: @groups.map(&:id)).to_sql
+ end
+ end
+end
diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb
index 375fbe9b5a9..06e3034e56a 100644
--- a/app/models/programming_language.rb
+++ b/app/models/programming_language.rb
@@ -4,9 +4,10 @@ class ProgrammingLanguage < ApplicationRecord
validates :name, presence: true
validates :color, allow_blank: false, color: true
- # Returns all programming languages which match the given name (case
+ # Returns all programming languages which match any of the given names (case
# insensitively).
- scope :with_name_case_insensitive, ->(name) do
- where(arel_table[:name].matches(sanitize_sql_like(name)))
+ scope :with_name_case_insensitive, ->(*names) do
+ sanitized_names = names.map(&method(:sanitize_sql_like))
+ where(arel_table[:name].matches_any(sanitized_names))
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 155ebe88d33..f7182d1645c 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -37,6 +37,7 @@ class Project < ApplicationRecord
include EachBatch
include GitlabRoutingHelper
include BulkMemberAccessLoad
+ include BulkUsersByEmailLoad
include RunnerTokenExpirationInterval
include BlocksUnsafeSerialization
@@ -382,7 +383,7 @@ class Project < ApplicationRecord
has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id
has_many :import_failures, inverse_of: :project
- has_many :jira_imports, -> { order 'jira_imports.created_at' }, class_name: 'JiraImportState', inverse_of: :project
+ has_many :jira_imports, -> { order(JiraImportState.arel_table[:created_at].asc) }, class_name: 'JiraImportState', inverse_of: :project
has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult'
has_many :ci_feature_usages, class_name: 'Projects::CiFeatureUsage'
@@ -545,8 +546,8 @@ class Project < ApplicationRecord
.or(arel_table[:storage_version].eq(nil)))
end
- # last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push
- scope :sorted_by_activity, -> { reorder(Arel.sql("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC")) }
+ scope :sorted_by_updated_asc, -> { reorder(self.arel_table['updated_at'].asc) }
+ scope :sorted_by_updated_desc, -> { reorder(self.arel_table['updated_at'].desc) }
scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) }
scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) }
# Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name
@@ -655,7 +656,9 @@ class Project < ApplicationRecord
preload(:project_feature, :route, namespace: [:route, :owner])
}
+ scope :created_by, -> (user) { where(creator: user) }
scope :imported_from, -> (type) { where(import_type: type) }
+ scope :imported, -> { where.not(import_type: nil) }
scope :with_tracing_enabled, -> { joins(:tracing_setting) }
scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) }
@@ -780,9 +783,9 @@ class Project < ApplicationRecord
# pass a string to avoid AR adding the table name
reorder('project_statistics.storage_size DESC, projects.id DESC')
when 'latest_activity_desc'
- reorder(self.arel_table['last_activity_at'].desc)
+ sorted_by_updated_desc
when 'latest_activity_asc'
- reorder(self.arel_table['last_activity_at'].asc)
+ sorted_by_updated_asc
when 'stars_desc'
sorted_by_stars_desc
when 'stars_asc'
@@ -896,6 +899,18 @@ class Project < ApplicationRecord
association(:namespace).loaded?
end
+ def personal_namespace_holder?(user)
+ return false unless personal?
+ return false unless user
+
+ # We do not want to use a check like `project.team.owner?(user)`
+ # here because that would depend upon the state of the `project_authorizations` cache,
+ # and also perform the check across multiple `owners` of the project, but our intention
+ # is to check if the user is the "holder" of the personal namespace, so need to make this
+ # check against only a single user (ie, namespace.owner).
+ namespace.owner == user
+ end
+
def project_setting
super.presence || build_project_setting
end
@@ -1048,6 +1063,17 @@ class Project < ApplicationRecord
end
end
+ def container_repositories_size
+ strong_memoize(:container_repositories_size) do
+ next unless Gitlab.com?
+ next 0 if container_repositories.empty?
+ next unless container_repositories.all_migrated?
+ next unless ContainerRegistry::GitlabApiClient.supports_gitlab_api?
+
+ ContainerRegistry::GitlabApiClient.deduplicated_size(full_path)
+ end
+ end
+
def has_container_registry_tags?
return @images if defined?(@images)
@@ -1401,7 +1427,7 @@ class Project < ApplicationRecord
end
def last_activity_date
- [last_activity_at, last_repository_updated_at, updated_at].compact.max
+ updated_at
end
def project_id
@@ -1469,7 +1495,7 @@ class Project < ApplicationRecord
end
def find_or_initialize_integration(name)
- return if disabled_integrations.include?(name)
+ return if disabled_integrations.include?(name) || Integration.available_integration_names.exclude?(name)
find_integration(integrations, name) || build_from_instance(name) || build_integration(name)
end
@@ -1920,6 +1946,10 @@ class Project < ApplicationRecord
Gitlab.config.pages.enabled
end
+ def pages_show_onboarding?
+ !(pages_metadatum&.onboarding_complete || pages_metadatum&.deployed)
+ end
+
def remove_private_deploy_keys
exclude_keys_linked_to_other_projects = <<-SQL
NOT EXISTS (
@@ -1935,6 +1965,10 @@ class Project < ApplicationRecord
.delete_all
end
+ def mark_pages_onboarding_complete
+ ensure_pages_metadatum.update!(onboarding_complete: true)
+ end
+
def mark_pages_as_deployed
ensure_pages_metadatum.update!(deployed: true)
end
@@ -1974,13 +2008,15 @@ class Project < ApplicationRecord
ProjectCacheWorker.perform_async(self.id, [], [:repository_size])
AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(id)
+ enqueue_record_project_target_platforms
+
# The import assigns iid values on its own, e.g. by re-using GitHub ids.
# Flush existing InternalId records for this project for consistency reasons.
# Those records are going to be recreated with the next normal creation
# of a model instance (e.g. an Issue).
InternalId.flush_records!(project: self)
- import_state.finish
+ import_state&.finish
update_project_counter_caches
after_create_default_branch
join_pool_repository
@@ -2829,6 +2865,22 @@ class Project < ApplicationRecord
pending_delete? || hidden?
end
+ def work_items_feature_flag_enabled?
+ group&.work_items_feature_flag_enabled? || Feature.enabled?(:work_items, self, default_enabled: :yaml)
+ end
+
+ def enqueue_record_project_target_platforms
+ return unless Gitlab.com?
+ return unless Feature.enabled?(:record_projects_target_platforms, self, default_enabled: :yaml)
+
+ Projects::RecordTargetPlatformsWorker.perform_async(id)
+ end
+
+ def inactive?
+ (statistics || build_statistics).storage_size > ::Gitlab::CurrentSettings.inactive_projects_min_size_mb.megabytes &&
+ last_activity_at < ::Gitlab::CurrentSettings.inactive_projects_send_warning_email_after_months.months.ago
+ end
+
private
# overridden in EE
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 0d3e50837ab..33783d31355 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -3,6 +3,7 @@
class ProjectFeature < ApplicationRecord
include Featurable
extend Gitlab::ConfigHelper
+ extend ::Gitlab::Utils::Override
# When updating this array, make sure to update rubocop/cop/gitlab/feature_available_usage.rb as well.
FEATURES = %i[
@@ -155,31 +156,14 @@ class ProjectFeature < ApplicationRecord
%i(merge_requests_access_level builds_access_level).each(&validator)
end
- def get_permission(user, feature)
- case access_level(feature)
- when DISABLED
- false
- when PRIVATE
- team_access?(user, feature)
- when ENABLED
- true
- when PUBLIC
- true
- else
- true
- end
+ def feature_validation_exclusion
+ %i(pages)
end
- def team_access?(user, feature)
- return unless user
- return true if user.can_read_all_resources?
-
+ override :resource_member?
+ def resource_member?(user, feature)
project.team.member?(user, ProjectFeature.required_minimum_access_level(feature))
end
-
- def feature_validation_exclusion
- %i(pages)
- end
end
ProjectFeature.prepend_mod_with('ProjectFeature')
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index 8394ebe1df4..2ba3c74df5b 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -18,6 +18,7 @@ class ProjectGroupLink < ApplicationRecord
scope :in_group, -> (group_ids) { where(group_id: group_ids) }
alias_method :shared_with_group, :group
+ alias_method :shared_from, :project
def self.access_options
Gitlab::Access.options
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index 0f04eb7d4af..fabbd5b49cb 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -6,6 +6,8 @@ class ProjectImportState < ApplicationRecord
self.table_name = "project_mirror_data"
+ after_commit :expire_etag_cache
+
belongs_to :project, inverse_of: :import_state
validates :project, presence: true
@@ -58,9 +60,7 @@ class ProjectImportState < ApplicationRecord
end
after_transition any => :failed do |state, _|
- if Feature.enabled?(:remove_import_data_on_failure, state.project, default_enabled: :yaml)
- state.project.remove_import_data
- end
+ state.project.remove_import_data
end
after_transition started: :finished do |state, _|
@@ -78,6 +78,23 @@ class ProjectImportState < ApplicationRecord
end
end
+ def expire_etag_cache
+ if realtime_changes_path
+ Gitlab::EtagCaching::Store.new.tap do |store|
+ store.touch(realtime_changes_path)
+ rescue Gitlab::EtagCaching::Store::InvalidKeyError
+ # no-op: not every realtime changes endpoint is using etag caching
+ end
+ end
+ end
+
+ def realtime_changes_path
+ Gitlab::Routing.url_helpers.polymorphic_path([:realtime_changes_import, project.import_type.to_sym], format: :json)
+ rescue NoMethodError
+ # polymorphic_path throws NoMethodError when no such path exists
+ nil
+ end
+
def relation_hard_failures(limit:)
project.import_failures.hard_failures_by_correlation_id(correlation_id).limit(limit)
end
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index ae3d7038a88..6cd6eee2616 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
class ProjectSetting < ApplicationRecord
- include IgnorableColumns
-
- ignore_column :show_diff_preview_in_email, remove_with: '14.10', remove_after: '2022-03-22'
+ ALLOWED_TARGET_PLATFORMS = %w(ios osx tvos watchos).freeze
belongs_to :project, inverse_of: :project_setting
@@ -18,6 +16,9 @@ class ProjectSetting < ApplicationRecord
validates :merge_commit_template, length: { maximum: Project::MAX_COMMIT_TEMPLATE_LENGTH }
validates :squash_commit_template, length: { maximum: Project::MAX_COMMIT_TEMPLATE_LENGTH }
+ validates :target_platforms, inclusion: { in: ALLOWED_TARGET_PLATFORMS }
+
+ validate :validates_mr_default_target_self
default_value_for(:legacy_open_source_license_available) do
Feature.enabled?(:legacy_open_source_license_available, default_enabled: :yaml, type: :ops)
@@ -31,7 +32,9 @@ class ProjectSetting < ApplicationRecord
%w[always never].include?(squash_option)
end
- validate :validates_mr_default_target_self
+ def target_platforms=(val)
+ super(val&.map(&:to_s)&.sort)
+ end
private
diff --git a/app/models/projects/build_artifacts_size_refresh.rb b/app/models/projects/build_artifacts_size_refresh.rb
index afb67b79f0d..959f486a50a 100644
--- a/app/models/projects/build_artifacts_size_refresh.rb
+++ b/app/models/projects/build_artifacts_size_refresh.rb
@@ -4,7 +4,7 @@ module Projects
class BuildArtifactsSizeRefresh < ApplicationRecord
include BulkInsertSafe
- STALE_WINDOW = 3.days
+ STALE_WINDOW = 2.hours
self.table_name = 'project_build_artifacts_size_refreshes'
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index b42b03f0618..9214a23e259 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -23,6 +23,10 @@ module Projects
end
class << self
+ def find_by_name_case_insensitive(name)
+ find_by('LOWER(name) = ?', name.downcase)
+ end
+
def search(query)
fuzzy_search(query, [:name])
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 346478b6689..dc0b5b54fb0 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -2,6 +2,12 @@
require 'securerandom'
+# Explicitly require licensee/license file in order to use Licensee::InvalidLicense class defined in
+# https://github.com/licensee/licensee/blob/v9.14.1/lib/licensee/license.rb#L6
+# The problem is that nested classes are not automatically preloaded which may lead to
+# uninitialized constant exception being raised: https://gitlab.com/gitlab-org/gitlab/-/issues/356658
+require 'licensee/license'
+
class Repository
REF_MERGE_REQUEST = 'merge-requests'
REF_KEEP_AROUND = 'keep-around'
@@ -789,6 +795,12 @@ class Repository
def create_file(user, path, content, **options)
options[:actions] = [{ action: :create, file_path: path, content: content }]
+ execute_filemode = options.delete(:execute_filemode)
+
+ unless execute_filemode.nil?
+ options[:actions].push({ action: :chmod, file_path: path, execute_filemode: execute_filemode })
+ end
+
multi_action(user, **options)
end
@@ -798,6 +810,12 @@ class Repository
options[:actions] = [{ action: action, file_path: path, previous_path: previous_path, content: content }]
+ execute_filemode = options.delete(:execute_filemode)
+
+ unless execute_filemode.nil?
+ options[:actions].push({ action: :chmod, file_path: path, execute_filemode: execute_filemode })
+ end
+
multi_action(user, **options)
end
@@ -941,6 +959,10 @@ class Repository
end
end
+ def clone_as_mirror(url, http_authorization_header: "")
+ import_repository(url, http_authorization_header: http_authorization_header, mirror: true)
+ end
+
def fetch_as_mirror(url, forced: false, refmap: :all_refs, prune: true, http_authorization_header: "")
fetch_remote(url, refmap: refmap, forced: forced, prune: prune, http_authorization_header: http_authorization_header)
end
diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb
index 2816aa4cc5b..60aaa1f932a 100644
--- a/app/models/repository_language.rb
+++ b/app/models/repository_language.rb
@@ -8,8 +8,8 @@ class RepositoryLanguage < ApplicationRecord
default_scope { includes(:programming_language) } # rubocop:disable Cop/DefaultScope
- scope :with_programming_language, ->(name) do
- joins(:programming_language).merge(ProgrammingLanguage.with_name_case_insensitive(name))
+ scope :with_programming_language, ->(*names) do
+ joins(:programming_language).merge(ProgrammingLanguage.with_name_case_insensitive(*names))
end
validates :project, presence: true
diff --git a/app/models/review.rb b/app/models/review.rb
index 5a30e2963c8..c621da3b03c 100644
--- a/app/models/review.rb
+++ b/app/models/review.rb
@@ -14,6 +14,10 @@ class Review < ApplicationRecord
participant :author
+ def discussion_ids
+ notes.select(:discussion_id)
+ end
+
def all_references(current_user = nil, extractor: nil)
ext = super
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 38aaeff5c9a..cf4b83d44c2 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -40,6 +40,7 @@ class Snippet < ApplicationRecord
belongs_to :author, class_name: 'User'
belongs_to :project
+ alias_method :resource_parent, :project
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
index f1ca5c23997..ca2ad8bf88c 100644
--- a/app/models/suggestion.rb
+++ b/app/models/suggestion.rb
@@ -16,10 +16,14 @@ class Suggestion < ApplicationRecord
note.latest_diff_file
end
- def project
+ def source_project
noteable.source_project
end
+ def target_project
+ noteable.target_project
+ end
+
def branch
noteable.source_branch
end
diff --git a/app/models/todo.rb b/app/models/todo.rb
index eb5d9965955..45ab770a0f6 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -148,10 +148,10 @@ class Todo < ApplicationRecord
target_type_column: "todos.target_type",
target_column: "todos.target_id",
project_column: "todos.project_id"
- ).to_sql
+ ).arel.as('highest_priority')
- select("#{table_name}.*, (#{highest_priority}) AS highest_priority")
- .order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
+ select(arel_table[Arel.star], highest_priority)
+ .order(Arel.sql('highest_priority').asc.nulls_last)
.order('todos.created_at')
end
diff --git a/app/models/user.rb b/app/models/user.rb
index bc02f0ba55e..26d47de4f00 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -21,6 +21,7 @@ class User < ApplicationRecord
include OptionallySearch
include FromUnion
include BatchDestroyDependentAssociations
+ include BatchNullifyDependentAssociations
include HasUniqueInternalUsers
include IgnorableColumns
include UpdateHighestRole
@@ -37,6 +38,9 @@ class User < ApplicationRecord
COUNT_CACHE_VALIDITY_PERIOD = 24.hours
+ OTP_SECRET_LENGTH = 32
+ OTP_SECRET_TTL = 2.minutes
+
MAX_USERNAME_LENGTH = 255
MIN_USERNAME_LENGTH = 2
@@ -46,6 +50,8 @@ class User < ApplicationRecord
:public_email
].freeze
+ FORBIDDEN_SEARCH_STATES = %w(blocked banned ldap_blocked).freeze
+
add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) }
add_authentication_token_field :feed_token
add_authentication_token_field :static_object_token, encrypted: :optional
@@ -184,6 +190,8 @@ class User < ApplicationRecord
has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :updated_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :closed_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :closed_by_id # rubocop:disable Cop/ActiveRecordDependent
has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :events, dependent: :delete_all, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
@@ -277,7 +285,7 @@ class User < ApplicationRecord
after_update :username_changed_hook, if: :saved_change_to_username?
after_destroy :post_destroy_hook
after_destroy :remove_key_cache
- after_save if: -> { saved_change_to_email? && confirmed? } do
+ after_save if: -> { (saved_change_to_email? || saved_change_to_confirmed_at?) && confirmed? } do
email_to_confirm = self.emails.find_by(email: self.email)
if email_to_confirm.present?
@@ -322,6 +330,8 @@ class User < ApplicationRecord
:setup_for_company, :setup_for_company=,
:render_whitespace_in_code, :render_whitespace_in_code=,
:markdown_surround_selection, :markdown_surround_selection=,
+ :diffs_deletion_color, :diffs_deletion_color=,
+ :diffs_addition_color, :diffs_addition_color=,
to: :user_preference
delegate :path, to: :namespace, allow_nil: true, prefix: true
@@ -460,15 +470,16 @@ class User < ApplicationRecord
.where('keys.user_id = users.id')
.expiring_soon_and_not_notified)
end
- scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
- scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
- scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
- scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) }
+ scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) }
+ scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) }
+ scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last) }
+ scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first) }
scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) }
scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) }
scope :with_no_activity, -> { with_state(:active).human_or_service_user.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 :by_ids_or_usernames, -> (ids, usernames) { where(username: usernames).or(where(id: ids)) }
+ scope :without_forbidden_states, -> { where.not(state: FORBIDDEN_SEARCH_STATES) }
strip_attributes! :name
@@ -660,9 +671,9 @@ class User < ApplicationRecord
order = <<~SQL
CASE
- WHEN users.name = :query THEN 0
- WHEN users.username = :query THEN 1
- WHEN users.public_email = :query THEN 2
+ WHEN LOWER(users.name) = :query THEN 0
+ WHEN LOWER(users.username) = :query THEN 1
+ WHEN LOWER(users.public_email) = :query THEN 2
ELSE 3
END
SQL
@@ -949,6 +960,21 @@ class User < ApplicationRecord
(webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?)
end
+ def needs_new_otp_secret?
+ !two_factor_enabled? && otp_secret_expired?
+ end
+
+ def otp_secret_expired?
+ return true unless otp_secret_expires_at
+
+ otp_secret_expires_at < Time.current
+ end
+
+ def update_otp_secret!
+ self.otp_secret = User.generate_otp_secret(OTP_SECRET_LENGTH)
+ self.otp_secret_expires_at = Time.current + OTP_SECRET_TTL
+ end
+
def namespace_move_dir_allowed
if namespace&.any_project_has_container_registry_tags?
errors.add(:username, _('cannot be changed if a personal project has container registry tags.'))
@@ -1709,8 +1735,12 @@ class User < ApplicationRecord
end
def attention_requested_open_merge_requests_count(force: false)
- Rails.cache.fetch(attention_request_cache_key, force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
+ if Feature.enabled?(:uncached_mr_attention_requests_count, self, default_enabled: :yaml)
MergeRequestsFinder.new(self, attention: self.username, state: 'opened', non_archived: true).execute.count
+ else
+ Rails.cache.fetch(attention_request_cache_key, force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
+ MergeRequestsFinder.new(self, attention: self.username, state: 'opened', non_archived: true).execute.count
+ end
end
end
@@ -2121,8 +2151,8 @@ class User < ApplicationRecord
def authorized_groups_without_shared_membership
Group.from_union([
- groups.select(Namespace.arel_table[Arel.star]),
- authorized_projects.joins(:namespace).select(Namespace.arel_table[Arel.star])
+ groups.select(*Namespace.cached_column_list),
+ authorized_projects.joins(:namespace).select(*Namespace.cached_column_list)
])
end
@@ -2237,33 +2267,66 @@ class User < ApplicationRecord
end
def ci_owned_project_runners_from_project_members
- Ci::RunnerProject
- .select('ci_runners.*')
- .joins(:runner)
- .where(project: project_members.where('access_level >= ?', Gitlab::Access::MAINTAINER).pluck(:source_id))
+ project_ids = project_members.where('access_level >= ?', Gitlab::Access::MAINTAINER).pluck(:source_id)
+
+ Ci::Runner
+ .joins(:runner_projects)
+ .where(runner_projects: { project: project_ids })
end
def ci_owned_project_runners_from_group_members
- Ci::RunnerProject
- .select('ci_runners.*')
- .joins(:runner)
- .joins('JOIN ci_project_mirrors ON ci_project_mirrors.project_id = ci_runner_projects.project_id')
- .joins('JOIN ci_namespace_mirrors ON ci_namespace_mirrors.namespace_id = ci_project_mirrors.namespace_id')
- .merge(ci_namespace_mirrors_for_group_members(Gitlab::Access::MAINTAINER))
+ cte_namespace_ids = Gitlab::SQL::CTE.new(
+ :cte_namespace_ids,
+ ci_namespace_mirrors_for_group_members(Gitlab::Access::MAINTAINER).select(:namespace_id)
+ )
+
+ cte_project_ids = Gitlab::SQL::CTE.new(
+ :cte_project_ids,
+ Ci::ProjectMirror
+ .select(:project_id)
+ .where('ci_project_mirrors.namespace_id IN (SELECT namespace_id FROM cte_namespace_ids)')
+ )
+
+ Ci::Runner
+ .with(cte_namespace_ids.to_arel)
+ .with(cte_project_ids.to_arel)
+ .joins(:runner_projects)
+ .where('ci_runner_projects.project_id IN (SELECT project_id FROM cte_project_ids)')
end
def ci_owned_group_runners
- Ci::RunnerNamespace
- .select('ci_runners.*')
- .joins(:runner)
- .joins('JOIN ci_namespace_mirrors ON ci_namespace_mirrors.namespace_id = ci_runner_namespaces.namespace_id')
- .merge(ci_namespace_mirrors_for_group_members(Gitlab::Access::OWNER))
+ cte_namespace_ids = Gitlab::SQL::CTE.new(
+ :cte_namespace_ids,
+ ci_namespace_mirrors_for_group_members(Gitlab::Access::OWNER).select(:namespace_id)
+ )
+
+ Ci::Runner
+ .with(cte_namespace_ids.to_arel)
+ .joins(:runner_namespaces)
+ .where('ci_runner_namespaces.namespace_id IN (SELECT namespace_id FROM cte_namespace_ids)')
end
def ci_namespace_mirrors_for_group_members(level)
- Ci::NamespaceMirror.contains_any_of_namespaces(
- group_members.where('access_level >= ?', level).pluck(:source_id)
- )
+ search_members = group_members.where('access_level >= ?', level)
+
+ # This reduces searched prefixes to only shortest ones
+ # to avoid querying descendants since they are already covered
+ # by ancestor namespaces. If the FF is not available fallback to
+ # inefficient search: https://gitlab.com/gitlab-org/gitlab/-/issues/336436
+ unless Feature.enabled?(:use_traversal_ids, default_enabled: :yaml)
+ return Ci::NamespaceMirror.contains_any_of_namespaces(search_members.pluck(:source_id))
+ end
+
+ traversal_ids = Group.joins(:all_group_members)
+ .merge(search_members)
+ .shortest_traversal_ids_prefixes
+
+ # Use efficient btree index to perform search
+ if Feature.enabled?(:ci_owned_runners_unnest_index, self, default_enabled: :yaml)
+ Ci::NamespaceMirror.contains_traversal_ids(traversal_ids)
+ else
+ Ci::NamespaceMirror.contains_any_of_namespaces(traversal_ids.map(&:last))
+ end
end
end
diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb
index 727975c3f6e..62614a851c1 100644
--- a/app/models/user_custom_attribute.rb
+++ b/app/models/user_custom_attribute.rb
@@ -5,4 +5,14 @@ class UserCustomAttribute < ApplicationRecord
validates :user_id, :key, :value, presence: true
validates :key, uniqueness: { scope: [:user_id] }
+
+ def self.upsert_custom_attributes(custom_attributes)
+ created_at = DateTime.now
+ updated_at = DateTime.now
+
+ custom_attributes.map! do |custom_attribute|
+ custom_attribute.merge({ created_at: created_at, updated_at: updated_at })
+ end
+ upsert_all(custom_attributes, unique_by: [:user_id, :key])
+ end
end
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 7687430cfd1..9b4c0a2527a 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -19,6 +19,9 @@ class UserPreference < ApplicationRecord
greater_than_or_equal_to: Gitlab::TabWidth::MIN,
less_than_or_equal_to: Gitlab::TabWidth::MAX
}
+ validates :diffs_deletion_color, :diffs_addition_color,
+ format: { with: ColorsHelper::HEX_COLOR_PATTERN },
+ allow_blank: true
ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22'
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 0922323e12b..a91a3406b22 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -48,7 +48,8 @@ module Users
storage_enforcement_banner_third_enforcement_threshold: 45,
storage_enforcement_banner_fourth_enforcement_threshold: 46,
attention_requests_top_nav: 47,
- attention_requests_side_nav: 48
+ attention_requests_side_nav: 48,
+ minute_limit_banner: 49
}
validates :feature_name,
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 839be8d2a48..373bc30889f 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -14,7 +14,9 @@ module Users
storage_enforcement_banner_first_enforcement_threshold: 3,
storage_enforcement_banner_second_enforcement_threshold: 4,
storage_enforcement_banner_third_enforcement_threshold: 5,
- storage_enforcement_banner_fourth_enforcement_threshold: 6
+ storage_enforcement_banner_fourth_enforcement_threshold: 6,
+ preview_user_over_limit_free_plan_alert: 7, # EE-only
+ user_reached_limit_free_plan_alert: 8 # EE-only
}
validates :group, presence: true
diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb
index 1f1eaacfe5c..f2f1d18339e 100644
--- a/app/models/users/in_product_marketing_email.rb
+++ b/app/models/users/in_product_marketing_email.rb
@@ -26,12 +26,17 @@ module Users
invite_team: 8
}, _suffix: true
+ # Tracks we don't send emails for (e.g. unsuccessful experiment). These
+ # are kept since we already have DB records that use the enum value.
+ INACTIVE_TRACK_NAMES = %w(invite_team).freeze
+ ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES)
+
scope :without_track_and_series, -> (track, series) do
users = User.arel_table
product_emails = arel_table
join_condition = users[:id].eq(product_emails[:user_id])
- .and(product_emails[:track]).eq(tracks[track])
+ .and(product_emails[:track]).eq(ACTIVE_TRACKS[track])
.and(product_emails[:series]).eq(series)
arel_join = users.join(product_emails, Arel::Nodes::OuterJoin).on(join_condition)
diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb
index a5881e80e88..8bb598ee316 100644
--- a/app/models/vulnerability.rb
+++ b/app/models/vulnerability.rb
@@ -5,6 +5,8 @@ class Vulnerability < ApplicationRecord
include EachBatch
include IgnorableColumns
+ alias_attribute :vulnerability_id, :id
+
def self.link_reference_pattern
nil
end
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index 622070abd88..b3f09b20463 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -10,12 +10,46 @@ class Wiki
extend ActiveModel::Naming
MARKUPS = { # rubocop:disable Style/MultilineIfModifier
- 'Markdown' => :markdown,
- 'RDoc' => :rdoc,
- 'AsciiDoc' => :asciidoc,
- 'Org' => :org
+ markdown: {
+ name: 'Markdown',
+ default_extension: :md,
+ created_by_user: true
+ },
+ rdoc: {
+ name: 'RDoc',
+ default_extension: :rdoc,
+ created_by_user: true
+ },
+ asciidoc: {
+ name: 'AsciiDoc',
+ default_extension: :asciidoc,
+ created_by_user: true
+ },
+ org: {
+ name: 'Org',
+ default_extension: :org,
+ created_by_user: true
+ },
+ textile: {
+ name: 'Textile',
+ default_extension: :textile
+ },
+ creole: {
+ name: 'Creole',
+ default_extension: :creole
+ },
+ rest: {
+ name: 'reStructuredText',
+ default_extension: :rst
+ },
+ mediawiki: {
+ name: 'MediaWiki',
+ default_extension: :mediawiki
+ }
}.freeze unless defined?(MARKUPS)
+ VALID_USER_MARKUPS = MARKUPS.select { |_, v| v[:created_by_user] }.freeze unless defined?(VALID_USER_MARKUPS)
+
CouldNotCreateWikiError = Class.new(StandardError)
HOMEPAGE = 'home'
@@ -184,12 +218,37 @@ class Wiki
end
def update_page(page, content:, title: nil, format: :markdown, message: nil)
- commit = commit_details(:updated, message, page.title)
+ if Feature.enabled?(:gitaly_replace_wiki_update_page, container, default_enabled: :yaml)
+ with_valid_format(format) do |default_extension|
+ title = title.presence || Pathname(page.path).sub_ext('').to_s
- wiki.update_page(page.path, title || page.name, format.to_sym, content, commit)
- after_wiki_activity
+ # If the format is the same we keep the former extension. This check is for formats
+ # that can have more than one extension like Markdown (.md, .markdown)
+ # If we don't do this we will override the existing extension.
+ extension = page.format != format.to_sym ? default_extension : File.extname(page.path).downcase[1..]
- true
+ capture_git_error(:updated) do
+ repository.update_file(
+ user,
+ sluggified_full_path(title, extension),
+ content,
+ previous_path: page.path,
+ **multi_commit_options(:updated, message, title))
+
+ after_wiki_activity
+
+ true
+ end
+ end
+ else
+ commit = commit_details(:updated, message, page.title)
+
+ wiki.update_page(page.path, title || page.name, format.to_sym, content, commit)
+
+ after_wiki_activity
+
+ true
+ end
end
def delete_page(page, message = nil)
@@ -296,7 +355,7 @@ class Wiki
git_user = Gitlab::Git::User.from_gitlab(user)
{
- branch_name: repository.root_ref,
+ branch_name: repository.root_ref || default_branch,
message: commit_message,
author_email: git_user.email,
author_name: git_user.name
@@ -321,6 +380,26 @@ class Wiki
def default_message(action, title)
"#{user.username} #{action} page: #{title}"
end
+
+ def with_valid_format(format, &block)
+ default_extension = Wiki::VALID_USER_MARKUPS.dig(format.to_sym, :default_extension).to_s
+
+ if default_extension.blank?
+ @error_message = _('Invalid format selected')
+
+ return false
+ end
+
+ yield default_extension
+ end
+
+ def sluggified_full_path(title, extension)
+ sluggified_title(title) + '.' + extension
+ end
+
+ def sluggified_title(title)
+ Gitlab::EncodingHelper.encode_utf8_no_detect(title).tr(' ', '-')
+ end
end
Wiki.prepend_mod_with('Wiki')
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 803b9781ac4..647b4e787c6 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -185,7 +185,7 @@ class WikiPage
# :content - The raw markup content.
# :format - Optional symbol representing the
# content format. Can be any type
- # listed in the Wiki::MARKUPS
+ # listed in the Wiki::VALID_USER_MARKUPS
# Hash.
# :message - Optional commit message to set on
# the new page.
@@ -205,7 +205,7 @@ class WikiPage
# attrs - Hash of attributes to be updated on the page.
# :content - The raw markup content to replace the existing.
# :format - Optional symbol representing the content format.
- # See Wiki::MARKUPS Hash for available formats.
+ # See Wiki::VALID_USER_MARKUPS Hash for available formats.
# :message - Optional commit message to set on the new version.
# :last_commit_sha - Optional last commit sha to validate the page unchanged.
# :title - The Title (optionally including dir) to replace existing title
@@ -222,7 +222,7 @@ class WikiPage
update_attributes(attrs)
- if title.present? && title_changed? && wiki.find_page(title).present?
+ if title.present? && title_changed? && wiki.find_page(title, load_content: false).present?
attributes[:title] = page.title
raise PageRenameError, s_('WikiEdit|There is already a page with the same title in that path.')
end
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index 080513b28e9..e2d38dc9903 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -37,7 +37,7 @@ module WorkItems
validates :icon_name, length: { maximum: 255 }
scope :default, -> { where(namespace: nil) }
- scope :order_by_name_asc, -> { order('LOWER(name)') }
+ scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) }
scope :by_type, ->(base_type) { where(base_type: base_type) }
def self.default_by_type(type)
diff --git a/app/policies/alert_management/alert_policy.rb b/app/policies/alert_management/alert_policy.rb
index e2383921c82..9b6ce72851c 100644
--- a/app/policies/alert_management/alert_policy.rb
+++ b/app/policies/alert_management/alert_policy.rb
@@ -3,7 +3,15 @@
module AlertManagement
class AlertPolicy < ::BasePolicy
delegate { @subject.project }
+
+ rule { can?(:read_alert_management_alert) }.policy do
+ enable :read_alert_management_metric_image
+ end
+
+ rule { can?(:update_alert_management_alert) }.policy do
+ enable :upload_alert_management_metric_image
+ enable :update_alert_management_metric_image
+ enable :destroy_alert_management_metric_image
+ end
end
end
-
-AlertManagement::AlertPolicy.prepend_mod
diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb
index e9e3517b3da..72db6d31764 100644
--- a/app/policies/environment_policy.rb
+++ b/app/policies/environment_policy.rb
@@ -4,12 +4,12 @@ class EnvironmentPolicy < BasePolicy
delegate { @subject.project }
condition(:stop_with_deployment_allowed) do
- @subject.stop_action_available? &&
- can?(:create_deployment) && can?(:update_build, @subject.stop_action)
+ @subject.stop_actions_available? &&
+ can?(:create_deployment) && can?(:update_build, @subject.stop_actions.last)
end
condition(:stop_with_update_allowed) do
- !@subject.stop_action_available? && can?(:update_environment, @subject)
+ !@subject.stop_actions_available? && can?(:update_environment, @subject)
end
condition(:stopped) do
diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb
index 91f1eb35506..40ba30fce5e 100644
--- a/app/policies/project_member_policy.rb
+++ b/app/policies/project_member_policy.rb
@@ -3,13 +3,16 @@
class ProjectMemberPolicy < BasePolicy
delegate { @subject.project }
- condition(:target_is_owner, scope: :subject) { @subject.user == @subject.project.owner }
+ condition(:target_is_holder_of_the_personal_namespace, scope: :subject) do
+ @subject.project.personal_namespace_holder?(@subject.user)
+ end
+
condition(:target_is_self) { @user && @subject.user == @user }
condition(:project_bot) { @subject.user&.project_bot? }
rule { anonymous }.prevent_all
- rule { target_is_owner }.policy do
+ rule { target_is_holder_of_the_personal_namespace }.policy do
prevent :update_project_member
prevent :destroy_project_member
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 2ffafb79134..a417ea35673 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -728,6 +728,10 @@ class ProjectPolicy < BasePolicy
enable :create_resource_access_tokens
end
+ rule { can?(:admin_project) }.policy do
+ enable :read_usage_quotas
+ end
+
rule { can?(:project_bot_access) }.policy do
prevent :create_resource_access_tokens
end
diff --git a/app/policies/suggestion_policy.rb b/app/policies/suggestion_policy.rb
index 4c84c8ba690..3c273dc6d39 100644
--- a/app/policies/suggestion_policy.rb
+++ b/app/policies/suggestion_policy.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
class SuggestionPolicy < BasePolicy
- delegate { @subject.project }
+ delegate { @subject.source_project }
condition(:can_push_to_branch) do
- Gitlab::UserAccess.new(@user, container: @subject.project).can_push_to_branch?(@subject.branch)
+ Gitlab::UserAccess.new(@user, container: @subject.source_project).can_push_to_branch?(@subject.branch)
end
rule { can_push_to_branch }.enable :apply_suggestion
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index de99cbffb6f..f62ccef826c 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -25,6 +25,7 @@ class UserPolicy < BasePolicy
enable :update_user_status
enable :create_saved_replies
enable :update_saved_replies
+ enable :destroy_saved_replies
enable :read_user_personal_access_tokens
enable :read_group_count
enable :read_user_groups
diff --git a/app/presenters/README.md b/app/presenters/README.md
index 31e5c971a88..e2461580107 100644
--- a/app/presenters/README.md
+++ b/app/presenters/README.md
@@ -68,9 +68,11 @@ we gain the following benefits:
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`.
+
+It provides a `.presents` class method which allows you to define the class the presenter is wrapping,
+and specify an accessor for the presented object using the `as:` keyword.
+
+It also includes common helpers like `Gitlab::Routing` and `Gitlab::Allowable`.
```ruby
class LabelPresenter < Gitlab::View::Presenter::Simple
diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb
index a62d7cdbbd4..ded3844ac99 100644
--- a/app/presenters/ci/bridge_presenter.rb
+++ b/app/presenters/ci/bridge_presenter.rb
@@ -2,11 +2,11 @@
module Ci
class BridgePresenter < ProcessablePresenter
- presents ::Ci::Bridge
+ presents ::Ci::Bridge, as: :bridge
delegator_override :detailed_status
def detailed_status
- @detailed_status ||= subject.detailed_status(user)
+ @detailed_status ||= bridge.detailed_status(user)
end
end
end
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index 65e1c80085f..0be684901d5 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -2,7 +2,7 @@
module Ci
class BuildPresenter < ProcessablePresenter
- presents ::Ci::Build
+ presents ::Ci::Build, as: :build
def erased_by_user?
# Build can be erased through API, therefore it does not have
@@ -34,7 +34,7 @@ module Ci
end
def tooltip_message
- "#{subject.name} - #{detailed_status.status_tooltip}"
+ "#{build.name} - #{detailed_status.status_tooltip}"
end
def execute_in
@@ -48,7 +48,7 @@ module Ci
end
def detailed_status
- @detailed_status ||= subject.detailed_status(user)
+ @detailed_status ||= build.detailed_status(user)
end
end
end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 082993130a1..015dfc16df0 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -64,35 +64,50 @@ module Ci
def create_archive(artifacts)
return unless artifacts[:untracked] || artifacts[:paths]
- archive = {
- artifact_type: :archive,
- artifact_format: :zip,
- name: artifacts[:name],
- untracked: artifacts[:untracked],
- paths: artifacts[:paths],
- when: artifacts[:when],
- expire_in: artifacts[:expire_in]
- }
-
- if artifacts.dig(:exclude).present?
- archive.merge(exclude: artifacts[:exclude])
- else
- archive
+ BuildArtifact.for_archive(artifacts).to_h.tap do |artifact|
+ artifact.delete(:exclude) unless artifact[:exclude].present?
end
end
def create_reports(reports, expire_in:)
return unless reports&.any?
- reports.map do |report_type, report_paths|
- {
- artifact_type: report_type.to_sym,
- artifact_format: ::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(report_type.to_sym),
- name: ::Ci::JobArtifact::DEFAULT_FILE_NAMES.fetch(report_type.to_sym),
- paths: report_paths,
+ reports.map { |report| BuildArtifact.for_report(report, expire_in).to_h.compact }
+ end
+
+ BuildArtifact = Struct.new(:name, :untracked, :paths, :exclude, :when, :expire_in, :artifact_type, :artifact_format, keyword_init: true) do
+ def self.for_archive(artifacts)
+ self.new(
+ artifact_type: :archive,
+ artifact_format: :zip,
+ name: artifacts[:name],
+ untracked: artifacts[:untracked],
+ paths: artifacts[:paths],
+ when: artifacts[:when],
+ expire_in: artifacts[:expire_in],
+ exclude: artifacts[:exclude]
+ )
+ end
+
+ def self.for_report(report, expire_in)
+ type, params = report
+
+ if type == :coverage_report
+ artifact_type = params[:coverage_format].to_sym
+ paths = [params[:path]]
+ else
+ artifact_type = type
+ paths = params
+ end
+
+ self.new(
+ artifact_type: artifact_type,
+ artifact_format: ::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(artifact_type),
+ name: ::Ci::JobArtifact::DEFAULT_FILE_NAMES.fetch(artifact_type),
+ paths: paths,
when: 'always',
expire_in: expire_in
- }
+ )
end
end
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index 82152ce42ae..c2ed40d8b0c 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -36,6 +36,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
polymorphic_path([clusterable, :clusters], action: :connect)
end
+ def new_cluster_docs_path
+ polymorphic_path([clusterable, :clusters], action: :new_cluster_docs)
+ end
+
def authorize_aws_role_path
polymorphic_path([clusterable, :clusters], action: :authorize_aws_role)
end
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 250715d7c9c..fdfcc896bf8 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -39,7 +39,7 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
private_constant :CALLOUT_FAILURE_MESSAGES
- presents ::CommitStatus, as: :build
+ presents ::CommitStatus
def self.callout_failure_messages
CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/dev_ops_report/metric_presenter.rb b/app/presenters/dev_ops_report/metric_presenter.rb
index 55326f8f678..ec85c5d3809 100644
--- a/app/presenters/dev_ops_report/metric_presenter.rb
+++ b/app/presenters/dev_ops_report/metric_presenter.rb
@@ -2,28 +2,28 @@
module DevOpsReport
class MetricPresenter < Gitlab::View::Presenter::Simple
- presents ::DevOpsReport::Metric
+ presents ::DevOpsReport::Metric, as: :metric
- delegate :created_at, to: :subject
+ delegate :created_at, to: :metric
def cards
[
Card.new(
- metric: subject,
+ metric: metric,
title: 'Issues',
description: 'created per active user',
feature: 'issues',
blog: 'https://www2.deloitte.com/content/dam/Deloitte/se/Documents/technology-media-telecommunications/deloitte-digital-collaboration.pdf'
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Comments',
description: 'created per active user',
feature: 'notes',
blog: 'http://conversationaldevelopment.com/why/'
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Milestones',
description: 'created per active user',
feature: 'milestones',
@@ -31,7 +31,7 @@ module DevOpsReport
docs: help_page_path('user/project/milestones/index')
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Boards',
description: 'created per active user',
feature: 'boards',
@@ -39,7 +39,7 @@ module DevOpsReport
docs: help_page_path('user/project/issue_board')
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Merge requests',
description: 'per active user',
feature: 'merge_requests',
@@ -47,7 +47,7 @@ module DevOpsReport
docs: help_page_path('user/project/merge_requests/index')
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Pipelines',
description: 'created per active user',
feature: 'ci_pipelines',
@@ -55,7 +55,7 @@ module DevOpsReport
docs: help_page_path('ci/index')
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Environments',
description: 'created per active user',
feature: 'environments',
@@ -63,14 +63,14 @@ module DevOpsReport
docs: help_page_path('ci/environments')
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Deployments',
description: 'created per active user',
feature: 'deployments',
blog: 'https://puppet.com/blog/continuous-delivery-vs-continuous-deployment-what-s-diff'
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Monitoring',
description: 'fraction of all projects',
feature: 'projects_prometheus_active',
@@ -78,7 +78,7 @@ module DevOpsReport
docs: help_page_path('user/project/integrations/prometheus')
),
Card.new(
- metric: subject,
+ metric: metric,
title: 'Service Desk',
description: 'issues created per active user',
feature: 'service_desk_issues',
@@ -91,52 +91,52 @@ module DevOpsReport
def idea_to_production_steps
[
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Idea',
features: %w(issues)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Issue',
features: %w(issues notes)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Plan',
features: %w(milestones boards)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Code',
features: %w(merge_requests)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Commit',
features: %w(merge_requests)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Test',
features: %w(ci_pipelines)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Review',
features: %w(ci_pipelines environments)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Staging',
features: %w(environments deployments)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Production',
features: %w(deployments)
),
IdeaToProductionStep.new(
- metric: subject,
+ metric: metric,
title: 'Feedback',
features: %w(projects_prometheus_active service_desk_issues)
)
diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb
index 4c787b88e20..7fa87d33c0d 100644
--- a/app/presenters/event_presenter.rb
+++ b/app/presenters/event_presenter.rb
@@ -3,7 +3,7 @@
class EventPresenter < Gitlab::View::Presenter::Delegated
presents ::Event, as: :event
- def initialize(subject, **attributes)
+ def initialize(event, **attributes)
super
@visible_to_user_cache = ActiveSupport::Cache::MemoryStore.new
diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb
index 5dd2f3adda5..81a954761ea 100644
--- a/app/presenters/gitlab/blame_presenter.rb
+++ b/app/presenters/gitlab/blame_presenter.rb
@@ -21,19 +21,23 @@ module Gitlab
:project_blame_link,
:time_ago_tooltip)
- def initialize(subject, **attributes)
+ def initialize(blame, **attributes)
super
@commits = {}
precalculate_data_by_commit!
end
+ def first_line
+ blame.first_line
+ end
+
def groups
@groups ||= blame.groups
end
- def commit_data(commit)
- @commits[commit.id] ||= get_commit_data(commit)
+ def commit_data(commit, previous_path = nil)
+ @commits[commit.id] ||= get_commit_data(commit, previous_path)
end
private
@@ -44,25 +48,25 @@ module Gitlab
# to avoid recalculating it multiple times.
# For such files, it could significantly improve the performance of the Blame.
def precalculate_data_by_commit!
- groups.each { |group| commit_data(group[:commit]) }
+ groups.each { |group| commit_data(group[:commit], group[:previous_path]) }
end
- def get_commit_data(commit)
+ def get_commit_data(commit, previous_path = nil)
CommitData.new.tap do |data|
data.author_avatar = author_avatar(commit, size: 36, has_tooltip: false, lazy: true)
data.age_map_class = age_map_class(commit.committed_date, project_duration)
data.commit_link = link_to commit.title, project_commit_path(project, commit.id), class: "cdark", title: commit.title
data.commit_author_link = commit_author_link(commit, avatar: false)
- data.project_blame_link = project_blame_link(commit)
+ data.project_blame_link = project_blame_link(commit, previous_path)
data.time_ago_tooltip = time_ago_with_tooltip(commit.committed_date)
end
end
- def project_blame_link(commit)
+ def project_blame_link(commit, previous_path = nil)
previous_commit_id = commit.parent_id
- return unless previous_commit_id
+ return unless previous_commit_id && !previous_path.nil?
- link_to project_blame_path(project, tree_join(previous_commit_id, path)),
+ link_to project_blame_path(project, tree_join(previous_commit_id, previous_path)),
title: _('View blame prior to this change'),
aria: { label: _('View blame prior to this change') },
class: 'version-link',
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
index 9e4a3b403ea..001c9cbb4e9 100644
--- a/app/presenters/instance_clusterable_presenter.rb
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -43,6 +43,11 @@ class InstanceClusterablePresenter < ClusterablePresenter
connect_admin_clusters_path
end
+ override :new_cluster_docs_path
+ def new_cluster_docs_path
+ nil
+ end
+
override :create_user_clusters_path
def create_user_clusters_path
create_user_admin_clusters_path
diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb
index 72fe14d224d..75f6d749acb 100644
--- a/app/presenters/issue_presenter.rb
+++ b/app/presenters/issue_presenter.rb
@@ -4,7 +4,9 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated
presents ::Issue, as: :issue
def issue_path
- url_builder.build(issue, only_path: true)
+ return url_builder.build(issue, only_path: true) unless use_work_items_path?
+
+ project_work_items_path(issue.project, work_items_path: issue.id)
end
delegator_override :subscribed?
@@ -15,6 +17,18 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated
def project_emails_disabled?
issue.project.emails_disabled?
end
+
+ def web_url
+ return super unless use_work_items_path?
+
+ project_work_items_url(issue.project, work_items_path: issue.id)
+ end
+
+ private
+
+ def use_work_items_path?
+ issue.issue_type == 'task' && issue.project.work_items_feature_flag_enabled?
+ end
end
IssuePresenter.prepend_mod_with('IssuePresenter')
diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb
index 6929bf79fdf..e60cdf4088c 100644
--- a/app/presenters/label_presenter.rb
+++ b/app/presenters/label_presenter.rb
@@ -4,8 +4,6 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated
presents ::Label, as: :label
delegate :name, :full_name, to: :label_subject, prefix: :subject, allow_nil: true
- 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/pages_domain_presenter.rb b/app/presenters/pages_domain_presenter.rb
index 0523f702416..d730608cc27 100644
--- a/app/presenters/pages_domain_presenter.rb
+++ b/app/presenters/pages_domain_presenter.rb
@@ -3,8 +3,6 @@
class PagesDomainPresenter < Gitlab::View::Presenter::Delegated
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?
end
diff --git a/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb
index 1798d4b780f..77f4d57ae09 100644
--- a/app/presenters/projects/security/configuration_presenter.rb
+++ b/app/presenters/projects/security/configuration_presenter.rb
@@ -81,7 +81,8 @@ module Projects
configured: scan.configured?,
configuration_path: scan.configuration_path,
available: scan.available?,
- can_enable_by_merge_request: scan.can_enable_by_merge_request?
+ can_enable_by_merge_request: scan.can_enable_by_merge_request?,
+ meta_info_path: scan.meta_info_path
}
end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index 020c66af777..f63a1bf094a 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -23,6 +23,7 @@ class DeploymentEntity < Grape::Entity
expose :tag
expose :last?
expose :last?, as: :is_last
+ expose :tier_in_yaml
expose :deployed_by, as: :user, using: UserEntity
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index d484f60ed8f..634be365a9d 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -18,7 +18,7 @@ class EnvironmentEntity < Grape::Entity
expose :environment_type
expose :name_without_type
expose :last_deployment, using: DeploymentEntity
- expose :stop_action_available?, as: :has_stop_action
+ expose :stop_actions_available?, as: :has_stop_action
expose :rollout_status, if: -> (*) { can_read_deploy_board? }, using: RolloutStatusEntity
expose :tier
diff --git a/app/serializers/environment_status_entity.rb b/app/serializers/environment_status_entity.rb
index 40db23c143e..e8cf7980f5e 100644
--- a/app/serializers/environment_status_entity.rb
+++ b/app/serializers/environment_status_entity.rb
@@ -16,7 +16,7 @@ class EnvironmentStatusEntity < Grape::Entity
end
expose :metrics_monitoring_url, if: ->(*) { can_read_environment? } do |es|
- environment_metrics_path(es.environment)
+ project_metrics_dashboard_path(es.project, environment: es.environment)
end
expose :stop_url, if: ->(*) { can_stop_environment? } do |es|
diff --git a/app/serializers/group_link/group_group_link_entity.rb b/app/serializers/group_link/group_group_link_entity.rb
index cedc8bd8582..563a75ccdaa 100644
--- a/app/serializers/group_link/group_group_link_entity.rb
+++ b/app/serializers/group_link/group_group_link_entity.rb
@@ -4,22 +4,14 @@ module GroupLink
class GroupGroupLinkEntity < GroupLink::GroupLinkEntity
include RequestAwareEntity
- expose :can_update do |group_link|
- can_manage?(group_link)
- end
-
- expose :can_remove do |group_link|
- can_manage?(group_link)
+ expose :source do |group_link|
+ GroupEntity.represent(group_link.shared_from, only: [:id, :full_name, :web_url])
end
private
- def current_user
- options[:current_user]
- end
-
- def can_manage?(group_link)
- can?(current_user, :admin_group_member, group_link.shared_group)
+ def admin_permission_name
+ :admin_group_member
end
end
end
diff --git a/app/serializers/group_link/group_link_entity.rb b/app/serializers/group_link/group_link_entity.rb
index 12349320b6f..73c9931fc70 100644
--- a/app/serializers/group_link/group_link_entity.rb
+++ b/app/serializers/group_link/group_link_entity.rb
@@ -30,5 +30,32 @@ module GroupLink
expose :shared_with_group, merge: true, using: GroupBasicEntity
end
+
+ expose :can_update do |group_link, options|
+ can_admin_shared_from?(group_link, options)
+ end
+
+ expose :can_remove do |group_link, options|
+ can_admin_shared_from?(group_link, options)
+ end
+
+ expose :is_direct_member do |group_link, options|
+ direct_member?(group_link, options)
+ end
+
+ private
+
+ def current_user
+ options[:current_user]
+ end
+
+ def direct_member?(group_link, options)
+ group_link.shared_from == options[:source]
+ end
+
+ def can_admin_shared_from?(group_link, options)
+ direct_member?(group_link, options) &&
+ can?(current_user, admin_permission_name, group_link.shared_from)
+ end
end
end
diff --git a/app/serializers/group_link/project_group_link_entity.rb b/app/serializers/group_link/project_group_link_entity.rb
index bcdafd8d685..c667bcd5bf4 100644
--- a/app/serializers/group_link/project_group_link_entity.rb
+++ b/app/serializers/group_link/project_group_link_entity.rb
@@ -4,18 +4,14 @@ module GroupLink
class ProjectGroupLinkEntity < GroupLink::GroupLinkEntity
include RequestAwareEntity
- expose :can_update do |group_link|
- can?(current_user, :admin_project_member, group_link.project)
- end
-
- expose :can_remove do |group_link|
- can?(current_user, :admin_project_member, group_link.project)
+ expose :source do |group_link|
+ ProjectEntity.represent(group_link.shared_from, only: [:id, :full_name])
end
private
- def current_user
- options[:current_user]
+ def admin_permission_name
+ :admin_project_member
end
end
end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index 773bbf268eb..fbcfcf84d9b 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -61,7 +61,7 @@ class IssueEntity < IssuableEntity
end
expose :locked_discussion_docs_path, if: -> (issue) { issue.discussion_locked? } do |issue|
- help_page_path('user/discussions/index.md', anchor: 'lock-discussions')
+ help_page_path('user/discussions/index.md', anchor: 'prevent-comments-by-locking-an-issue')
end
expose :is_project_archived do |issue|
diff --git a/app/serializers/member_user_entity.rb b/app/serializers/member_user_entity.rb
index fde3282ad25..b3d8efc9143 100644
--- a/app/serializers/member_user_entity.rb
+++ b/app/serializers/member_user_entity.rb
@@ -5,6 +5,9 @@ class MemberUserEntity < UserEntity
unexpose :state
unexpose :status_tooltip_html
+ expose :created_at
+ expose :last_activity_on
+
expose :avatar_url do |user|
user.avatar_url(size: Member::AVATAR_SIZE, only_path: false)
end
diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb
index a356b5b5cd4..f8c8e3538da 100644
--- a/app/serializers/merge_request_noteable_entity.rb
+++ b/app/serializers/merge_request_noteable_entity.rb
@@ -43,7 +43,7 @@ class MergeRequestNoteableEntity < IssuableEntity
end
expose :locked_discussion_docs_path, if: -> (merge_request) { merge_request.discussion_locked? } do |merge_request|
- help_page_path('user/discussions/index.md', anchor: 'lock-discussions')
+ help_page_path('user/discussions/index.md', anchor: 'prevent-comments-by-locking-an-issue')
end
expose :is_project_archived do |merge_request|
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index e8fc18e6cf3..9fd50c8c51d 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -27,5 +27,3 @@ class MergeRequestSerializer < BaseSerializer
super(merge_request, opts, entity)
end
end
-
-MergeRequestSerializer.prepend_mod_with('MergeRequestSerializer')
diff --git a/app/services/alert_management/metric_images/upload_service.rb b/app/services/alert_management/metric_images/upload_service.rb
new file mode 100644
index 00000000000..e9db10594df
--- /dev/null
+++ b/app/services/alert_management/metric_images/upload_service.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ module MetricImages
+ class UploadService < BaseService
+ attr_reader :alert, :file, :url, :url_text, :metric
+
+ def initialize(alert, current_user, params = {})
+ super
+
+ @alert = alert
+ @file = params.fetch(:file)
+ @url = params.fetch(:url, nil)
+ @url_text = params.fetch(:url_text, nil)
+ end
+
+ def execute
+ unless can_upload_metrics?
+ return ServiceResponse.error(
+ message: _("You are not authorized to upload metric images"),
+ http_status: :forbidden
+ )
+ end
+
+ metric = AlertManagement::MetricImage.new(
+ alert: alert,
+ file: file,
+ url: url,
+ url_text: url_text
+ )
+
+ if metric.save
+ ServiceResponse.success(payload: { metric: metric, alert: alert })
+ else
+ ServiceResponse.error(message: metric.errors.full_messages.join(', '), http_status: :bad_request)
+ end
+ end
+
+ private
+
+ def can_upload_metrics?
+ alert.metric_images_available? && current_user&.can?(:upload_alert_management_metric_image, alert)
+ end
+ end
+ end
+end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index ad733c455a9..97debccfb18 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -14,14 +14,16 @@ class AuditEventService
# @param [Hash] details extra data of audit event
# @param [Symbol] save_type the type to save the event
# Can be selected from the following, :database, :stream, :database_and_stream .
+ # @params [DateTime] created_at the time the action occured
#
# @return [AuditEventService]
- def initialize(author, entity, details = {}, save_type = :database_and_stream)
+ def initialize(author, entity, details = {}, save_type = :database_and_stream, created_at = DateTime.current)
@author = build_author(author)
@entity = entity
@details = details
@ip_address = resolve_ip_address(@author)
@save_type = save_type
+ @created_at = created_at
end
# Builds the @details attribute for authentication
@@ -79,7 +81,8 @@ class AuditEventService
author_id: @author.id,
author_name: @author.name,
entity_id: @entity.id,
- entity_type: @entity.class.name
+ entity_type: @entity.class.name,
+ created_at: @created_at
}
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index bb6a52eb2f4..6d6d8641d9d 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -50,6 +50,12 @@ module Auth
access_token(['pull'], names)
end
+ def self.pull_nested_repositories_access_token(name)
+ name = name.chomp('/') if name.end_with?('/')
+ paths = [name, "#{name}/*"]
+ access_token(['pull'], paths)
+ end
+
def self.access_token(actions, names, type = 'repository')
names = names.flatten
registry = Gitlab.config.registry
diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb
index 190d159e7f1..86df0236a7f 100644
--- a/app/services/base_container_service.rb
+++ b/app/services/base_container_service.rb
@@ -26,4 +26,8 @@ class BaseContainerService
def group_container?
container.is_a?(::Group)
end
+
+ def namespace_container?
+ container.is_a?(::Namespace)
+ end
end
diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb
index 14f073120c5..c43f0d8cb4f 100644
--- a/app/services/bulk_imports/relation_export_service.rb
+++ b/app/services/bulk_imports/relation_export_service.rb
@@ -4,6 +4,8 @@ module BulkImports
class RelationExportService
include Gitlab::ImportExport::CommandLineUtil
+ EXISTING_EXPORT_TTL = 3.minutes
+
def initialize(user, portable, relation, jid)
@user = user
@portable = portable
@@ -31,6 +33,9 @@ module BulkImports
validate_user_permissions!
export = portable.bulk_import_exports.safe_find_or_create_by!(relation: relation)
+
+ return export if export.finished? && export.updated_at > EXISTING_EXPORT_TTL.ago
+
export.update!(status_event: 'start', jid: jid)
yield export
diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb
index bc70dd3bea4..1ae4639751b 100644
--- a/app/services/ci/after_requeue_job_service.rb
+++ b/app/services/ci/after_requeue_job_service.rb
@@ -22,15 +22,9 @@ module Ci
end
def dependent_jobs
- dependent_jobs = stage_dependent_jobs
- .or(needs_dependent_jobs)
- .ordered_by_stage
-
- if ::Feature.enabled?(:ci_fix_order_of_subsequent_jobs, @processable.pipeline.project, default_enabled: :yaml)
- dependent_jobs = ordered_by_dag(dependent_jobs)
- end
-
- dependent_jobs
+ ordered_by_dag(
+ stage_dependent_jobs.or(needs_dependent_jobs).ordered_by_stage
+ )
end
def process(job)
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index 034bab93108..0a0c614bb87 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -9,7 +9,7 @@ module Ci
DuplicateDownstreamPipelineError = Class.new(StandardError)
- MAX_DESCENDANTS_DEPTH = 2
+ MAX_NESTED_CHILDREN = 2
def execute(bridge)
@bridge = bridge
@@ -77,7 +77,8 @@ module Ci
# TODO: Remove this condition if favour of model validation
# https://gitlab.com/gitlab-org/gitlab/issues/38338
- if has_max_descendants_depth?
+ # only applies to parent-child pipelines not multi-project
+ if has_max_nested_children?
@bridge.drop!(:reached_max_descendant_pipelines_depth)
return false
end
@@ -129,11 +130,12 @@ module Ci
pipeline_checksums.tally.any? { |_checksum, occurrences| occurrences > 2 }
end
- def has_max_descendants_depth?
+ def has_max_nested_children?
return false unless @bridge.triggers_child_pipeline?
+ # only applies to parent-child pipelines not multi-project
ancestors_of_new_child = @bridge.pipeline.self_and_ancestors
- ancestors_of_new_child.count > MAX_DESCENDANTS_DEPTH
+ ancestors_of_new_child.count > MAX_NESTED_CHILDREN
end
def config_checksum(pipeline)
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index d53e136effb..02f25a82307 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -14,6 +14,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Build::Associations,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
Gitlab::Ci::Pipeline::Chain::Validate::Repository,
+ Gitlab::Ci::Pipeline::Chain::Limit::RateLimit,
Gitlab::Ci::Pipeline::Chain::Validate::SecurityOrchestrationPolicy,
Gitlab::Ci::Pipeline::Chain::Skip,
Gitlab::Ci::Pipeline::Chain::Config::Content,
diff --git a/app/services/ci/job_artifacts/destroy_all_expired_service.rb b/app/services/ci/job_artifacts/destroy_all_expired_service.rb
index c089567ec14..4070875ffe1 100644
--- a/app/services/ci/job_artifacts/destroy_all_expired_service.rb
+++ b/app/services/ci/job_artifacts/destroy_all_expired_service.rb
@@ -7,16 +7,14 @@ module Ci
include ::Gitlab::LoopHelpers
BATCH_SIZE = 100
+ LOOP_LIMIT = 500
LOOP_TIMEOUT = 5.minutes
- SMALL_LOOP_LIMIT = 100
- LARGE_LOOP_LIMIT = 500
- EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock'
LOCK_TIMEOUT = 6.minutes
+ EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock'
def initialize
@removed_artifacts_count = 0
@start_at = Time.current
- @loop_limit = ::Feature.enabled?(:ci_artifact_fast_removal_large_loop_limit, default_enabled: :yaml) ? LARGE_LOOP_LIMIT : SMALL_LOOP_LIMIT
end
##
@@ -26,8 +24,6 @@ module Ci
# preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently,
# which is scheduled every 7 minutes.
def execute
- return 0 unless ::Feature.enabled?(:ci_destroy_all_expired_service, default_enabled: :yaml)
-
in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
if ::Feature.enabled?(:ci_destroy_unlocked_job_artifacts)
destroy_unlocked_job_artifacts
@@ -42,7 +38,7 @@ module Ci
private
def destroy_unlocked_job_artifacts
- loop_until(timeout: LOOP_TIMEOUT, limit: @loop_limit) do
+ loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do
artifacts = Ci::JobArtifact.expired_before(@start_at).artifact_unlocked.limit(BATCH_SIZE)
service_response = destroy_batch(artifacts)
@removed_artifacts_count += service_response[:destroyed_artifacts_count]
@@ -59,7 +55,7 @@ module Ci
@removed_artifacts_count += service_response[:destroyed_artifacts_count]
break if loop_timeout?
- break if index >= @loop_limit
+ break if index >= LOOP_LIMIT
end
end
diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb
index d5a0a2dd885..90d157373c3 100644
--- a/app/services/ci/job_artifacts/destroy_batch_service.rb
+++ b/app/services/ci/job_artifacts/destroy_batch_service.rb
@@ -117,7 +117,7 @@ module Ci
wrongly_expired_artifacts, @job_artifacts = @job_artifacts.partition { |artifact| wrongly_expired?(artifact) }
- remove_expire_at(wrongly_expired_artifacts)
+ remove_expire_at(wrongly_expired_artifacts) if wrongly_expired_artifacts.any?
end
def fix_expire_at?
@@ -127,7 +127,9 @@ module Ci
def wrongly_expired?(artifact)
return false unless artifact.expire_at.present?
- match_date?(artifact.expire_at) && match_time?(artifact.expire_at)
+ # Although traces should never have expiration dates that don't match time & date here.
+ # we can explicitly exclude them by type since they should never be destroyed.
+ artifact.trace? || (match_date?(artifact.expire_at) && match_time?(artifact.expire_at))
end
def match_date?(expire_at)
diff --git a/app/services/ci/job_artifacts/update_unknown_locked_status_service.rb b/app/services/ci/job_artifacts/update_unknown_locked_status_service.rb
new file mode 100644
index 00000000000..0d35a90ed04
--- /dev/null
+++ b/app/services/ci/job_artifacts/update_unknown_locked_status_service.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobArtifacts
+ class UpdateUnknownLockedStatusService
+ include ::Gitlab::ExclusiveLeaseHelpers
+ include ::Gitlab::LoopHelpers
+
+ BATCH_SIZE = 100
+ LOOP_TIMEOUT = 5.minutes
+ LOOP_LIMIT = 100
+ LARGE_LOOP_LIMIT = 500
+ EXCLUSIVE_LOCK_KEY = 'unknown_status_job_artifacts:update:lock'
+ LOCK_TIMEOUT = 6.minutes
+
+ def initialize
+ @removed_count = 0
+ @locked_count = 0
+ @start_at = Time.current
+ @loop_limit = Feature.enabled?(:ci_job_artifacts_backlog_large_loop_limit) ? LARGE_LOOP_LIMIT : LOOP_LIMIT
+ end
+
+ def execute
+ in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
+ update_locked_status_on_unknown_artifacts
+ end
+
+ { removed: @removed_count, locked: @locked_count }
+ end
+
+ private
+
+ def update_locked_status_on_unknown_artifacts
+ loop_until(timeout: LOOP_TIMEOUT, limit: @loop_limit) do
+ unknown_status_build_ids = safely_ordered_ci_job_artifacts_locked_unknown_relation.pluck_job_id.uniq
+
+ locked_pipe_build_ids = ::Ci::Build
+ .with_pipeline_locked_artifacts
+ .id_in(unknown_status_build_ids)
+ .pluck_primary_key
+
+ @locked_count += update_unknown_artifacts(locked_pipe_build_ids, Ci::JobArtifact.lockeds[:artifacts_locked])
+
+ unlocked_pipe_build_ids = unknown_status_build_ids - locked_pipe_build_ids
+ service_response = batch_destroy_artifacts(unlocked_pipe_build_ids)
+ @removed_count += service_response[:destroyed_artifacts_count]
+ end
+ end
+
+ def update_unknown_artifacts(build_ids, locked_value)
+ return 0 unless build_ids.any?
+
+ expired_locked_unknown_artifacts.for_job_ids(build_ids).update_all(locked: locked_value)
+ end
+
+ def batch_destroy_artifacts(build_ids)
+ deleteable_artifacts_relation =
+ if build_ids.any?
+ expired_locked_unknown_artifacts.for_job_ids(build_ids)
+ else
+ Ci::JobArtifact.none
+ end
+
+ Ci::JobArtifacts::DestroyBatchService.new(deleteable_artifacts_relation).execute
+ end
+
+ def expired_locked_unknown_artifacts
+ # UPDATE queries perform better without the specific order and limit
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76509#note_891260455
+ Ci::JobArtifact.expired_before(@start_at).artifact_unknown
+ end
+
+ def safely_ordered_ci_job_artifacts_locked_unknown_relation
+ # Adding the ORDER and LIMIT improves performance when we don't have build_id
+ expired_locked_unknown_artifacts.limit(BATCH_SIZE).order_expired_asc
+ end
+ end
+ end
+end
diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb
index 2d6b6aeee14..fbf2aad1991 100644
--- a/app/services/ci/play_build_service.rb
+++ b/app/services/ci/play_build_service.rb
@@ -14,10 +14,7 @@ module Ci
AfterRequeueJobService.new(project, current_user).execute(build)
end
else
- # Retrying in Ci::PlayBuildService is a legacy process that should be removed.
- # Instead, callers should explicitly execute Ci::RetryBuildService.
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/347493.
- build.retryable? ? Ci::Build.retry(build, current_user) : build
+ Ci::RetryJobService.new(project, current_user).execute(build)[:job]
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index c8b475f6c48..6c9044b5089 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -283,7 +283,8 @@ module Ci
runner_unsupported: -> (build, params) { !build.supported_runner?(params.dig(:info, :features)) },
archived_failure: -> (build, _) { build.archived? },
project_deleted: -> (build, _) { build.project.pending_delete? },
- builds_disabled: -> (build, _) { !build.project.builds_enabled? }
+ builds_disabled: -> (build, _) { !build.project.builds_enabled? },
+ user_blocked: -> (build, _) { build.user&.blocked? }
}
end
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
deleted file mode 100644
index 906e5cec4f3..00000000000
--- a/app/services/ci/retry_build_service.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class RetryBuildService < ::BaseService
- include Gitlab::Utils::StrongMemoize
-
- def self.clone_accessors
- %i[pipeline project ref tag options name
- allow_failure stage stage_id stage_idx trigger_request
- yaml_variables when environment coverage_regex
- description tag_list protected needs_attributes
- job_variables_attributes resource_group scheduling_type].freeze
- end
-
- def self.extra_accessors
- []
- end
-
- def execute(build)
- build.ensure_scheduling_type!
-
- clone!(build).tap do |new_build|
- check_assignable_runners!(new_build)
- next if new_build.failed?
-
- Gitlab::OptimisticLocking.retry_lock(new_build, name: 'retry_build', &:enqueue)
- AfterRequeueJobService.new(project, current_user).execute(build)
- end
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- 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)
-
- new_build.run_after_commit do
- ::Ci::CopyCrossDatabaseAssociationsService.new.execute(build, new_build)
-
- ::Deployments::CreateForBuildService.new.execute(new_build)
-
- ::MergeRequests::AddTodoWhenBuildFailsService
- .new(project: project)
- .close(new_build)
- end
-
- ::Ci::Pipelines::AddJobService.new(build.pipeline).execute!(new_build) do |job|
- BulkInsertableAssociations.with_bulk_insert do
- job.save!
- end
- end
-
- build.reset # refresh the data to get new values of `retried` and `processed`.
-
- new_build
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- def check_access!(build)
- unless can?(current_user, :update_build, build)
- raise Gitlab::Access::AccessDeniedError, '403 Forbidden'
- end
- end
-
- def check_assignable_runners!(build); end
-
- def clone_build(build)
- project.builds.new(build_attributes(build))
- end
-
- def build_attributes(build)
- attributes = self.class.clone_accessors.to_h do |attribute|
- [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
- end
-
- if build.persisted_environment.present?
- attributes[:metadata_attributes] ||= {}
- attributes[:metadata_attributes][:expanded_environment_name] = build.expanded_environment_name
- end
-
- attributes[:user] = current_user
- attributes
- end
- end
-end
-
-Ci::RetryBuildService.prepend_mod_with('Ci::RetryBuildService')
diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb
new file mode 100644
index 00000000000..af7e7fa16e9
--- /dev/null
+++ b/app/services/ci/retry_job_service.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module Ci
+ class RetryJobService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute(job)
+ if job.retryable?
+ job.ensure_scheduling_type!
+ new_job = retry_job(job)
+
+ ServiceResponse.success(payload: { job: new_job })
+ else
+ ServiceResponse.error(
+ message: 'Job cannot be retried',
+ payload: { job: job, reason: :not_retryable }
+ )
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def clone!(job)
+ # Cloning a job 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 job.instance_of?(Ci::Build)
+
+ check_access!(job)
+
+ new_job = clone_job(job)
+
+ new_job.run_after_commit do
+ ::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job)
+
+ ::Deployments::CreateForBuildService.new.execute(new_job)
+
+ ::MergeRequests::AddTodoWhenBuildFailsService
+ .new(project: project)
+ .close(new_job)
+ end
+
+ ::Ci::Pipelines::AddJobService.new(job.pipeline).execute!(new_job) do |processable|
+ BulkInsertableAssociations.with_bulk_insert do
+ processable.save!
+ end
+ end
+
+ job.reset # refresh the data to get new values of `retried` and `processed`.
+
+ new_job
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def retry_job(job)
+ clone!(job).tap do |new_job|
+ check_assignable_runners!(new_job)
+ next if new_job.failed?
+
+ Gitlab::OptimisticLocking.retry_lock(new_job, name: 'retry_build', &:enqueue)
+ AfterRequeueJobService.new(project, current_user).execute(job)
+ end
+ end
+
+ def check_access!(job)
+ unless can?(current_user, :update_build, job)
+ raise Gitlab::Access::AccessDeniedError, '403 Forbidden'
+ end
+ end
+
+ def check_assignable_runners!(job); end
+
+ def clone_job(job)
+ project.builds.new(job_attributes(job))
+ end
+
+ def job_attributes(job)
+ attributes = job.class.clone_accessors.to_h do |attribute|
+ [attribute, job.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ if job.persisted_environment.present?
+ attributes[:metadata_attributes] ||= {}
+ attributes[:metadata_attributes][:expanded_environment_name] = job.expanded_environment_name
+ end
+
+ attributes[:user] = current_user
+ attributes
+ end
+ end
+end
+
+Ci::RetryJobService.prepend_mod_with('Ci::RetryJobService')
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index d40643e1513..85f910d05d7 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -13,7 +13,7 @@ module Ci
builds_relation(pipeline).find_each do |build|
next unless can_be_retried?(build)
- Ci::RetryBuildService.new(project, current_user).clone!(build)
+ Ci::RetryJobService.new(project, current_user).clone!(build)
end
pipeline.processables.latest.skipped.find_each do |skipped|
diff --git a/app/services/concerns/deploy_token_methods.rb b/app/services/concerns/deploy_token_methods.rb
index f59a50d6878..578be53f82c 100644
--- a/app/services/concerns/deploy_token_methods.rb
+++ b/app/services/concerns/deploy_token_methods.rb
@@ -1,16 +1,17 @@
# frozen_string_literal: true
module DeployTokenMethods
- def create_deploy_token_for(entity, params)
+ def create_deploy_token_for(entity, current_user, params)
params[:deploy_token_type] = DeployToken.deploy_token_types["#{entity.class.name.downcase}_type".to_sym]
entity.deploy_tokens.create(params) do |deploy_token|
deploy_token.username = params[:username].presence
+ deploy_token.creator_id = current_user.id
end
end
def destroy_deploy_token(entity, params)
- deploy_token = entity.deploy_tokens.find_by_id!(params[:token_id])
+ deploy_token = entity.deploy_tokens.find(params[:token_id])
deploy_token.destroy
end
diff --git a/app/services/concerns/incident_management/usage_data.rb b/app/services/concerns/incident_management/usage_data.rb
index b91aa59099d..27e60029ea3 100644
--- a/app/services/concerns/incident_management/usage_data.rb
+++ b/app/services/concerns/incident_management/usage_data.rb
@@ -9,10 +9,5 @@ module IncidentManagement
track_usage_event(:"incident_management_#{action}", current_user.id)
end
-
- # No-op as optionally overridden in implementing classes.
- # For use to provide checks before calling #track_incident_action.
- def track_event
- end
end
end
diff --git a/app/services/concerns/members/bulk_create_users.rb b/app/services/concerns/members/bulk_create_users.rb
index 3f8971dde74..e60c84af89e 100644
--- a/app/services/concerns/members/bulk_create_users.rb
+++ b/app/services/concerns/members/bulk_create_users.rb
@@ -51,12 +51,20 @@ module Members
users.concat(User.id_in(user_ids)) if user_ids.present?
users.uniq! # de-duplicate just in case as there is no controlling if user records and ids are sent multiple times
+ users_by_emails = source.users_by_emails(emails) # preloads our request store for all emails
+ # in case emails belong to a user that is being invited by user or user_id, remove them from
+ # emails and let users/user_ids handle it.
+ parsed_emails = emails.select do |email|
+ user = users_by_emails[email]
+ !user || (users.exclude?(user) && user_ids.exclude?(user.id))
+ end
+
if users.present?
# helps not have to perform another query per user id to see if the member exists later on when fetching
- existing_members = source.members_and_requesters.where(user_id: users).index_by(&:user_id) # rubocop:disable CodeReuse/ActiveRecord
+ existing_members = source.members_and_requesters.with_user(users).index_by(&:user_id)
end
- [emails, users, existing_members]
+ [parsed_emails, users, existing_members]
end
end
end
diff --git a/app/services/database/consistency_check_service.rb b/app/services/database/consistency_check_service.rb
new file mode 100644
index 00000000000..e39bc8f25b8
--- /dev/null
+++ b/app/services/database/consistency_check_service.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+module Database
+ class ConsistencyCheckService
+ CURSOR_REDIS_KEY_TTL = 7.days
+ EMPTY_RESULT = { matches: 0, mismatches: 0, batches: 0, mismatches_details: [] }.freeze
+
+ def initialize(source_model:, target_model:, source_columns:, target_columns:)
+ @source_model = source_model
+ @target_model = target_model
+ @source_columns = source_columns
+ @target_columns = target_columns
+ @source_sort_column = source_columns.first
+ @target_sort_column = target_columns.first
+ end
+
+ # This class takes two ActiveRecord models, and compares the selected columns
+ # of the two models tables, for the purposes of checking the consistency of
+ # mirroring of tables. For example Namespace and Ci::NamepaceMirror
+ #
+ # It compares up to 25 batches (1000 records / batch), or up to 30 seconds
+ # for all the batches in total.
+ #
+ # It saves the cursor of the next start_id (cusror) in Redis. If the start_id
+ # wasn't saved in Redis, for example, in the first run, it will choose some random start_id
+ #
+ # Example:
+ # service = Database::ConsistencyCheckService.new(
+ # source_model: Namespace,
+ # target_model: Ci::NamespaceMirror,
+ # source_columns: %w[id traversal_ids],
+ # target_columns: %w[namespace_id traversal_ids],
+ # )
+ # result = service.execute
+ #
+ # result is a hash that has the following fields:
+ # - batches: Number of batches checked
+ # - matches: The number of matched records
+ # - mismatches: The number of mismatched records
+ # - mismatches_details: It's an array that contains details about the mismatched records.
+ # each record in this array is a hash of format {id: ID, source_table: [...], target_table: [...]}
+ # Each record represents the attributes of the records in the two tables.
+ # - start_id: The start id cursor of the current batch. <nil> means no records.
+ # - next_start_id: The ID that can be used for the next batch iteration check. <nil> means no records
+ def execute
+ start_id = next_start_id
+
+ return EMPTY_RESULT if start_id.nil?
+
+ result = consistency_checker.execute(start_id: start_id)
+ result[:start_id] = start_id
+
+ save_next_start_id(result[:next_start_id])
+
+ result
+ end
+
+ private
+
+ attr_reader :source_model, :target_model, :source_columns, :target_columns, :source_sort_column, :target_sort_column
+
+ def consistency_checker
+ @consistency_checker ||= Gitlab::Database::ConsistencyChecker.new(
+ source_model: source_model,
+ target_model: target_model,
+ source_columns: source_columns,
+ target_columns: target_columns
+ )
+ end
+
+ def next_start_id
+ return if min_id.nil?
+
+ fetch_next_start_id || random_start_id
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def min_id
+ @min_id ||= source_model.minimum(source_sort_column)
+ end
+
+ def max_id
+ @max_id ||= source_model.minimum(source_sort_column)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def fetch_next_start_id
+ Gitlab::Redis::SharedState.with { |redis| redis.get(cursor_redis_shared_state_key)&.to_i }
+ end
+
+ # This returns some random start_id, so that we don't always start checking
+ # from the start of the table, in case we lose the cursor in Redis.
+ def random_start_id
+ range_start = min_id
+ range_end = [min_id, max_id - Gitlab::Database::ConsistencyChecker::BATCH_SIZE].max
+ rand(range_start..range_end)
+ end
+
+ def save_next_start_id(start_id)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(cursor_redis_shared_state_key, start_id, ex: CURSOR_REDIS_KEY_TTL)
+ end
+ end
+
+ def cursor_redis_shared_state_key
+ "consistency_check_cursor:#{source_model.table_name}:#{target_model.table_name}"
+ end
+ end
+end
diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb
index 19b950044d0..b0ba8ecaa47 100644
--- a/app/services/deployments/update_environment_service.rb
+++ b/app/services/deployments/update_environment_service.rb
@@ -56,7 +56,13 @@ module Deployments
end
def expanded_environment_url
- ExpandVariables.expand(environment_url, -> { variables }) if environment_url
+ return unless environment_url
+
+ if ::Feature.enabled?(:ci_expand_environment_name_and_url, deployment.project, default_enabled: :yaml)
+ ExpandVariables.expand(environment_url, -> { variables.sort_and_expand_all })
+ else
+ ExpandVariables.expand(environment_url, -> { variables })
+ end
end
def environment_url
diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb
index 58fc9799673..6f2b1018a6a 100644
--- a/app/services/emails/base_service.rb
+++ b/app/services/emails/base_service.rb
@@ -9,6 +9,10 @@ module Emails
@params = params.dup
@user = params.delete(:user)
end
+
+ def notification_service
+ NotificationService.new
+ end
end
end
diff --git a/app/services/emails/create_service.rb b/app/services/emails/create_service.rb
index 011978ba76a..d2d8b69559a 100644
--- a/app/services/emails/create_service.rb
+++ b/app/services/emails/create_service.rb
@@ -7,6 +7,7 @@ module Emails
user.emails.create(params.merge(extra_params)).tap do |email|
email&.confirm if skip_confirmation && current_user.admin?
+ notification_service.new_email_address_added(user, email.email) if email.persisted? && !email.user_primary_email?
end
end
end
diff --git a/app/services/environments/stop_service.rb b/app/services/environments/stop_service.rb
index d9c66bd13fe..24ae658d3d6 100644
--- a/app/services/environments/stop_service.rb
+++ b/app/services/environments/stop_service.rb
@@ -7,7 +7,7 @@ module Environments
def execute(environment)
return unless can?(current_user, :stop_environment, environment)
- environment.stop_with_action!(current_user)
+ environment.stop_with_actions!(current_user)
end
def execute_for_branch(branch_name)
@@ -19,7 +19,9 @@ module Environments
end
def execute_for_merge_request(merge_request)
- merge_request.environments.each { |environment| execute(environment) }
+ merge_request.environments_in_head_pipeline(deployment_status: :success).each do |environment|
+ execute(environment)
+ end
end
private
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 01a40fc6473..417680e37cf 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -184,6 +184,11 @@ class EventCreateService
track_event(event_action: :pushed, event_target: Project, author_id: current_user.id)
+ namespace = project.namespace
+ if Feature.enabled?(:route_hll_to_snowplow, namespace, default_enabled: :yaml)
+ Gitlab::Tracking.event(self.class.to_s, 'action_active_users_project_repo', namespace: namespace, user: current_user, project: project)
+ end
+
Users::LastPushEventService.new(current_user)
.cache_last_push_event(event)
diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb
index d42f718a272..1055f5ff088 100644
--- a/app/services/files/base_service.rb
+++ b/app/services/files/base_service.rb
@@ -19,6 +19,8 @@ module Files
@file_content = params[:file_content]
@file_content = Base64.decode64(@file_content) if params[:file_content_encoding] == 'base64'
+
+ @execute_filemode = params[:execute_filemode]
end
def file_has_changed?(path, commit_id)
diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb
index f2cd51ef4d0..f9ced112896 100644
--- a/app/services/files/create_service.rb
+++ b/app/services/files/create_service.rb
@@ -22,7 +22,8 @@ module Files
author_email: @author_email,
author_name: @author_name,
start_project: @start_project,
- start_branch_name: @start_branch)
+ start_branch_name: @start_branch,
+ execute_filemode: @execute_filemode)
end
end
end
diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb
index 54ab07da680..9fa966bb8a8 100644
--- a/app/services/files/update_service.rb
+++ b/app/services/files/update_service.rb
@@ -10,7 +10,8 @@ module Files
author_email: @author_email,
author_name: @author_name,
start_project: @start_project,
- start_branch_name: @start_branch)
+ start_branch_name: @start_branch,
+ execute_filemode: @execute_filemode)
end
private
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index 13223872e4f..3c27ad56ebb 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -24,6 +24,7 @@ module Git
enqueue_update_mrs
enqueue_detect_repository_languages
+ enqueue_record_project_target_platforms
execute_related_hooks
@@ -53,6 +54,12 @@ module Git
DetectRepositoryLanguagesWorker.perform_async(project.id)
end
+ def enqueue_record_project_target_platforms
+ return unless default_branch?
+
+ project.enqueue_record_project_target_platforms
+ end
+
# Only stop environments if the ref is a branch that is being deleted
def stop_environments
return unless removing_branch?
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 67cbbaf84f6..639f7c68c40 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -57,11 +57,6 @@ module Groups
end
def after_create_hook
- if group.persisted? && group.root?
- delay = Namespaces::InviteTeamEmailService::DELIVERY_DELAY_IN_MINUTES
- Namespaces::InviteTeamEmailWorker.perform_in(delay, group.id, current_user.id)
- end
-
track_experiment_event
end
diff --git a/app/services/groups/deploy_tokens/create_service.rb b/app/services/groups/deploy_tokens/create_service.rb
index 4b0541e78a1..e6189df0472 100644
--- a/app/services/groups/deploy_tokens/create_service.rb
+++ b/app/services/groups/deploy_tokens/create_service.rb
@@ -6,7 +6,7 @@ module Groups
include DeployTokenMethods
def execute
- deploy_token = create_deploy_token_for(@group, params)
+ deploy_token = create_deploy_token_for(@group, current_user, params)
create_deploy_token_payload_for(deploy_token)
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 10ff4961faf..f2e959396bc 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -25,10 +25,15 @@ module Groups
private
def proceed_to_transfer
+ old_root_ancestor_id = @group.root_ancestor.id
+ was_root_group = @group.root?
+
Group.transaction do
update_group_attributes
ensure_ownership
update_integrations
+ remove_issue_contacts(old_root_ancestor_id, was_root_group)
+ update_crm_objects(was_root_group)
end
post_update_hooks(@updated_project_ids)
@@ -53,6 +58,17 @@ module Groups
raise_transfer_error(:group_contains_images) if group_projects_contain_registry_images?
raise_transfer_error(:cannot_transfer_to_subgroup) if transfer_to_subgroup?
raise_transfer_error(:group_contains_npm_packages) if group_with_npm_packages?
+ raise_transfer_error(:no_permissions_to_migrate_crm) if no_permissions_to_migrate_crm?
+ end
+
+ def no_permissions_to_migrate_crm?
+ return false unless group && @new_parent_group
+ return false if group.root_ancestor == @new_parent_group.root_ancestor
+
+ return true if group.contacts.exists? && !current_user.can?(:admin_crm_contact, @new_parent_group.root_ancestor)
+ return true if group.organizations.exists? && !current_user.can?(:admin_crm_organization, @new_parent_group.root_ancestor)
+
+ false
end
def group_with_npm_packages?
@@ -202,7 +218,8 @@ module Groups
invalid_policies: s_("TransferGroup|You don't have enough permissions."),
group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.'),
cannot_transfer_to_subgroup: s_('TransferGroup|Cannot transfer group to one of its subgroup.'),
- group_contains_npm_packages: s_('TransferGroup|Group contains projects with NPM packages.')
+ group_contains_npm_packages: s_('TransferGroup|Group contains projects with NPM packages.'),
+ no_permissions_to_migrate_crm: s_("TransferGroup|Group contains contacts/organizations and you don't have enough permissions to move them to the new root group.")
}.freeze
end
@@ -238,6 +255,20 @@ module Groups
namespace_id: group.id
}
end
+
+ def update_crm_objects(was_root_group)
+ return unless was_root_group
+
+ CustomerRelations::Contact.move_to_root_group(group)
+ CustomerRelations::Organization.move_to_root_group(group)
+ end
+
+ def remove_issue_contacts(old_root_ancestor_id, was_root_group)
+ return if was_root_group
+ return if old_root_ancestor_id == @group.root_ancestor.id
+
+ CustomerRelations::IssueContact.delete_for_group(@group)
+ end
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index 061543b5885..a891dcc11e3 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -117,7 +117,7 @@ module Import
error: exception.response_body
)
- error(_('Import failed due to a GitHub error: %{original}') % { original: exception.response_body }, :unprocessable_entity)
+ error(_('Import failed due to a GitHub error: %{original} (HTTP %{code})') % { original: exception.response_body, code: exception.response_status }, :unprocessable_entity)
end
def log_and_return_error(message, translated_message, http_status)
diff --git a/app/services/incident_management/issuable_escalation_statuses/build_service.rb b/app/services/incident_management/issuable_escalation_statuses/build_service.rb
new file mode 100644
index 00000000000..9ebcf72a0c9
--- /dev/null
+++ b/app/services/incident_management/issuable_escalation_statuses/build_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module IssuableEscalationStatuses
+ class BuildService < ::BaseProjectService
+ def initialize(issue)
+ @issue = issue
+ @alert = issue.alert_management_alert
+
+ super(project: issue.project)
+ end
+
+ def execute
+ return issue.escalation_status if issue.escalation_status
+
+ issue.build_incident_management_issuable_escalation_status(alert_params)
+ end
+
+ private
+
+ attr_reader :issue, :alert
+
+ def alert_params
+ return {} unless alert
+
+ {
+ status_event: alert.status_event_for(alert.status_name)
+ }
+ end
+ end
+ end
+end
+
+IncidentManagement::IssuableEscalationStatuses::BuildService.prepend_mod
diff --git a/app/services/incident_management/issuable_escalation_statuses/create_service.rb b/app/services/incident_management/issuable_escalation_statuses/create_service.rb
index e28debf0fa3..9b22fb97e0d 100644
--- a/app/services/incident_management/issuable_escalation_statuses/create_service.rb
+++ b/app/services/incident_management/issuable_escalation_statuses/create_service.rb
@@ -2,14 +2,15 @@
module IncidentManagement
module IssuableEscalationStatuses
- class CreateService < BaseService
+ class CreateService < ::BaseProjectService
def initialize(issue)
@issue = issue
- @alert = issue.alert_management_alert
+
+ super(project: issue.project)
end
def execute
- escalation_status = ::IncidentManagement::IssuableEscalationStatus.new(issue: issue, **alert_params)
+ escalation_status = BuildService.new(issue).execute
if escalation_status.save
ServiceResponse.success(payload: { escalation_status: escalation_status })
@@ -20,17 +21,7 @@ module IncidentManagement
private
- attr_reader :issue, :alert
-
- def alert_params
- return {} unless alert
-
- {
- status_event: alert.status_event_for(alert.status_name)
- }
- end
+ attr_reader :issue
end
end
end
-
-IncidentManagement::IssuableEscalationStatuses::CreateService.prepend_mod
diff --git a/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb b/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb
index 8f591b375ee..1d0504a6e80 100644
--- a/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb
+++ b/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb
@@ -31,9 +31,7 @@ module IncidentManagement
attr_reader :issuable, :param_errors
def available?
- issuable.supports_escalation? &&
- user_has_permissions? &&
- escalation_status.present?
+ issuable.supports_escalation? && user_has_permissions?
end
def user_has_permissions?
@@ -42,7 +40,7 @@ module IncidentManagement
def escalation_status
strong_memoize(:escalation_status) do
- issuable.escalation_status
+ issuable.escalation_status || BuildService.new(issuable).execute
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index a63c54df4a6..03115416607 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -525,6 +525,10 @@ class IssuableBaseService < ::BaseProjectService
attrs_changed || labels_changed || assignees_changed || reviewers_changed
end
+ def has_label_changes?(issuable, old_labels)
+ Set.new(issuable.labels) != Set.new(old_labels)
+ end
+
def invalidate_cache_counts(issuable, users: [])
users.each do |user|
user.public_send("invalidate_#{issuable.noteable_target_type_name}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
@@ -532,6 +536,16 @@ class IssuableBaseService < ::BaseProjectService
end
# override if needed
+ def handle_label_changes(issuable, old_labels)
+ return unless has_label_changes?(issuable, old_labels)
+
+ # reset to preserve the label sort order (title ASC)
+ issuable.labels.reset
+
+ GraphqlTriggers.issuable_labels_updated(issuable)
+ end
+
+ # override if needed
def handle_changes(issuable, options)
end
diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index 802260c8fae..0887f04760c 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -2,8 +2,6 @@
module IssuableLinks
class CreateService < BaseService
- include IncidentManagement::UsageData
-
attr_reader :issuable, :current_user, :params
def initialize(issuable, user, params)
@@ -25,7 +23,7 @@ module IssuableLinks
end
@errors = []
- create_links
+ references = create_links
if @errors.present?
return error(@errors.join('. '), 422)
@@ -33,7 +31,7 @@ module IssuableLinks
track_event
- success
+ success(created_references: references)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -66,15 +64,19 @@ module IssuableLinks
end
def link_issuables(target_issuables)
- target_issuables.each do |referenced_object|
+ target_issuables.map do |referenced_object|
link = relate_issuables(referenced_object)
- unless link.valid?
+ if link.valid?
+ after_create_for(link)
+ else
@errors << _("%{ref} cannot be added: %{error}") % {
ref: referenced_object.to_reference,
error: link.errors.messages.values.flatten.to_sentence
}
end
+
+ link
end
end
@@ -142,6 +144,18 @@ module IssuableLinks
def set_link_type(_link)
# no-op
end
+
+ # Override on child classes to perform
+ # actions when the service is executed.
+ def track_event
+ # no-op
+ end
+
+ # Override on child classes to
+ # perform actions for each object created.
+ def after_create_for(_link)
+ # no-op
+ end
end
end
diff --git a/app/services/issuable_links/destroy_service.rb b/app/services/issuable_links/destroy_service.rb
index 19edd008b0a..204cf7ce966 100644
--- a/app/services/issuable_links/destroy_service.rb
+++ b/app/services/issuable_links/destroy_service.rb
@@ -2,8 +2,6 @@
module IssuableLinks
class DestroyService < BaseService
- include IncidentManagement::UsageData
-
attr_reader :link, :current_user, :source, :target
def initialize(link, user)
@@ -41,5 +39,9 @@ module IssuableLinks
def not_found_message
'No Issue Link found'
end
+
+ def track_event
+ # no op
+ end
end
end
diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb
index 1c6621ce0a1..7f509f3b3e0 100644
--- a/app/services/issue_links/create_service.rb
+++ b/app/services/issue_links/create_service.rb
@@ -2,6 +2,8 @@
module IssueLinks
class CreateService < IssuableLinks::CreateService
+ include IncidentManagement::UsageData
+
def linkable_issuables(issues)
@linkable_issuables ||= begin
issues.select { |issue| can?(current_user, :admin_issue_link, issue) }
diff --git a/app/services/issue_links/destroy_service.rb b/app/services/issue_links/destroy_service.rb
index e2422ecaca9..9116e9fb703 100644
--- a/app/services/issue_links/destroy_service.rb
+++ b/app/services/issue_links/destroy_service.rb
@@ -2,6 +2,8 @@
module IssueLinks
class DestroyService < IssuableLinks::DestroyService
+ include IncidentManagement::UsageData
+
private
def permission_to_remove_relation?
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 88c4ff1a8bb..d9210169005 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -63,6 +63,7 @@ module Issues
handle_assignee_changes(issue, old_assignees)
handle_confidential_change(issue)
+ handle_label_changes(issue, old_labels)
handle_added_labels(issue, old_labels)
handle_milestone_change(issue)
handle_added_mentions(issue, old_mentioned_users)
diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb
index a16f8bbd367..3e15d47e8af 100644
--- a/app/services/jira/requests/base.rb
+++ b/app/services/jira/requests/base.rb
@@ -68,7 +68,7 @@ module Jira
end
def auth_docs_link_start
- auth_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira', anchor: 'authentication-in-jira')
+ auth_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/index', anchor: 'authentication-in-jira')
'<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auth_docs_link_url }
end
diff --git a/app/services/loose_foreign_keys/process_deleted_records_service.rb b/app/services/loose_foreign_keys/process_deleted_records_service.rb
index 2826bdb4c3c..54f54d99afb 100644
--- a/app/services/loose_foreign_keys/process_deleted_records_service.rb
+++ b/app/services/loose_foreign_keys/process_deleted_records_service.rb
@@ -52,7 +52,7 @@ module LooseForeignKeys
end
def tracked_tables
- @tracked_tables ||= Gitlab::Database::LooseForeignKeys.definitions_by_table.keys
+ @tracked_tables ||= Gitlab::Database::LooseForeignKeys.definitions_by_table.keys.shuffle
end
end
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 758fa2e67f1..8f7b63c32c8 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -14,8 +14,9 @@ module Members
super
@errors = []
- @invites = invites_from_params&.split(',')&.uniq&.flatten
+ @invites = invites_from_params
@source = params[:source]
+ @tasks_to_be_done_members = []
end
def execute
@@ -25,6 +26,7 @@ module Members
validate_invitable!
add_members
+ create_tasks_to_be_done
enqueue_onboarding_progress_action
publish_event!
@@ -40,10 +42,13 @@ module Members
private
- attr_reader :source, :errors, :invites, :member_created_namespace_id, :members
+ attr_reader :source, :errors, :invites, :member_created_namespace_id, :members,
+ :tasks_to_be_done_members, :member_created_member_task_id
def invites_from_params
- params[:user_ids]
+ return params[:user_ids] if params[:user_ids].is_a?(Array)
+
+ params[:user_ids]&.to_s&.split(',')&.uniq&.flatten || []
end
def validate_invite_source!
@@ -74,33 +79,45 @@ module Members
)
members.each { |member| process_result(member) }
-
- create_tasks_to_be_done
end
def process_result(member)
- if member.invalid?
- add_error_for_member(member)
+ existing_errors = member.errors.full_messages
+
+ # calling invalid? clears any errors that were added outside of the
+ # rails validation process
+ if member.invalid? || existing_errors.present?
+ add_error_for_member(member, existing_errors)
else
after_execute(member: member)
@member_created_namespace_id ||= member.namespace_id
end
end
- def add_error_for_member(member)
+ # overridden
+ def add_error_for_member(member, existing_errors)
prefix = "#{member.user.username}: " if member.user.present?
- errors << "#{prefix}#{member.errors.full_messages.to_sentence}"
+ errors << "#{prefix}#{all_member_errors(member, existing_errors).to_sentence}"
+ end
+
+ def all_member_errors(member, existing_errors)
+ existing_errors.concat(member.errors.full_messages).uniq
end
def after_execute(member:)
super
+ build_tasks_to_be_done_members(member)
track_invite_source(member)
end
def track_invite_source(member)
- Gitlab::Tracking.event(self.class.name, 'create_member', label: invite_source, property: tracking_property(member), user: current_user)
+ Gitlab::Tracking.event(self.class.name,
+ 'create_member',
+ label: invite_source,
+ property: tracking_property(member),
+ user: current_user)
end
def invite_source
@@ -114,16 +131,28 @@ module Members
member.invite? ? 'net_new_user' : 'existing_user'
end
- def create_tasks_to_be_done
- return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank?
-
- valid_members = members.select { |member| member.valid? && member.member_task.valid? }
- return unless valid_members.present?
+ def build_tasks_to_be_done_members(member)
+ return unless tasks_to_be_done?(member)
+ @tasks_to_be_done_members << member
# We can take the first `member_task` here, since all tasks will have the same attributes needed
# for the `TasksToBeDone::CreateWorker`, ie. `project` and `tasks_to_be_done`.
- member_task = valid_members[0].member_task
- TasksToBeDone::CreateWorker.perform_async(member_task.id, current_user.id, valid_members.map(&:user_id))
+ @member_created_member_task_id ||= member.member_task.id
+ end
+
+ def tasks_to_be_done?(member)
+ return false if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank?
+
+ # Only create task issues for existing users. Tasks for new users are created when they signup.
+ member.member_task&.valid? && member.user.present?
+ end
+
+ def create_tasks_to_be_done
+ return unless member_created_member_task_id # signal if there is any work to be done here
+
+ TasksToBeDone::CreateWorker.perform_async(member_created_member_task_id,
+ current_user.id,
+ tasks_to_be_done_members.map(&:user_id))
end
def user_limit
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index fcce32ead94..321658ac9c5 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -4,15 +4,13 @@ module Members
# This class serves as more of an app-wide way we add/create members
# All roads to add members should take this path.
class CreatorService
- include Gitlab::Experiment::Dsl
-
class << self
def parsed_access_level(access_level)
access_levels.fetch(access_level) { access_level.to_i }
end
def access_levels
- raise NotImplementedError
+ Gitlab::Access.sym_options_with_owner
end
end
@@ -25,7 +23,7 @@ module Members
def execute
find_or_build_member
- update_member
+ commit_member
create_member_task
member
@@ -33,23 +31,39 @@ module Members
private
+ delegate :new_record?, to: :member
attr_reader :source, :user, :access_level, :member, :args
- def update_member
- return unless can_update_member?
-
+ def assign_member_attributes
member.attributes = member_attributes
+ end
- if member.request?
- approve_request
+ def commit_member
+ if can_commit_member?
+ assign_member_attributes
+ commit_changes
else
- member.save
+ add_commit_error
end
end
- def can_update_member?
+ def can_commit_member?
# There is no current user for bulk actions, in which case anything is allowed
- !current_user # inheriting classes will add more logic
+ return true if skip_authorization?
+
+ if new_record?
+ can_create_new_member?
+ else
+ can_update_existing_member?
+ end
+ end
+
+ def can_create_new_member?
+ raise NotImplementedError
+ end
+
+ def can_update_existing_member?
+ raise NotImplementedError
end
# Populates the attributes of a member.
@@ -64,6 +78,14 @@ module Members
}
end
+ def commit_changes
+ if member.request?
+ approve_request
+ else
+ member.save
+ end
+ end
+
def create_member_task
return unless member.persisted?
return if member_task_attributes.value?(nil)
@@ -93,6 +115,20 @@ module Members
args[:current_user]
end
+ def skip_authorization?
+ !current_user
+ end
+
+ def add_commit_error
+ msg = if new_record?
+ _('not authorized to create member')
+ else
+ _('not authorized to update member')
+ end
+
+ member.errors.add(:base, msg)
+ end
+
def find_or_build_member
@user = parse_user_param
@@ -101,6 +137,8 @@ module Members
else
source.members.build(invite_email: user)
end
+
+ @member.blocking_refresh = args[:blocking_refresh]
end
# This method is used to find users that have been entered into the "Add members" field.
@@ -114,7 +152,7 @@ module Members
User.find_by(id: user) # rubocop:todo CodeReuse/ActiveRecord
else
# must be an email or at least we'll consider it one
- User.find_by_any_email(user) || user
+ source.users_by_emails([user])[user] || user
end
end
diff --git a/app/services/members/groups/creator_service.rb b/app/services/members/groups/creator_service.rb
index df4d3f59d3b..a6f0daa99aa 100644
--- a/app/services/members/groups/creator_service.rb
+++ b/app/services/members/groups/creator_service.rb
@@ -3,14 +3,14 @@
module Members
module Groups
class CreatorService < Members::CreatorService
- def self.access_levels
- Gitlab::Access.sym_options_with_owner
- end
-
private
- def can_update_member?
- super || current_user.can?(:update_group_member, member)
+ def can_create_new_member?
+ current_user.can?(:admin_group_member, member.group)
+ end
+
+ def can_update_existing_member?
+ current_user.can?(:update_group_member, member)
end
end
end
diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb
index 85acb720f0f..1bf209ab79d 100644
--- a/app/services/members/invite_service.rb
+++ b/app/services/members/invite_service.rb
@@ -7,6 +7,8 @@ module Members
def initialize(*args)
super
+ @invites += parsed_emails
+
@errors = {}
end
@@ -14,38 +16,63 @@ module Members
alias_method :formatted_errors, :errors
- def invites_from_params
- params[:email]
+ def parsed_emails
+ # can't put this in the initializer since `invites_from_params` is called in super class
+ # and needs it
+ @parsed_emails ||= (formatted_param(params[:email]) || [])
+ end
+
+ def formatted_param(parameter)
+ parameter&.split(',')&.uniq&.flatten
end
def validate_invitable!
super
+ return if params[:email].blank?
+
# we need the below due to add_users hitting Members::CreatorService.parse_users_list and ignoring invalid emails
# ideally we wouldn't need this, but we can't really change the add_users method
- valid, invalid = invites.partition { |email| Member.valid_email?(email) }
- @invites = valid
+ invalid_emails.each { |email| errors[email] = s_('AddMember|Invite email is invalid') }
+ end
+
+ def invalid_emails
+ parsed_emails.each_with_object([]) do |email, invalid|
+ next if Member.valid_email?(email)
- invalid.each { |email| errors[email] = s_('AddMember|Invite email is invalid') }
+ invalid << email
+ @invites.delete(email)
+ end
end
override :blank_invites_message
def blank_invites_message
- s_('AddMember|Emails cannot be blank')
+ s_('AddMember|Invites cannot be blank')
end
override :add_error_for_member
- def add_error_for_member(member)
- errors[invite_email(member)] = member.errors.full_messages.to_sentence
+ def add_error_for_member(member, existing_errors)
+ errors[invited_object(member)] = all_member_errors(member, existing_errors).to_sentence
end
- override :create_tasks_to_be_done
- def create_tasks_to_be_done
- # Only create task issues for existing users. Tasks for new users are created when they signup.
- end
+ def invited_object(member)
+ return member.invite_email if member.invite_email
- def invite_email(member)
- member.invite_email || member.user.email
+ # There is a case where someone was invited by email, but the `user` record exists.
+ # The member record returned will not have an invite_email attribute defined since
+ # the CreatorService finds `user` record sometimes by email.
+ # At that point we lose the info of whether this invite was done by `user` or by email.
+ # Here we will give preference to check invites by user_id first.
+ # There is also a case where a user could be invited by their email and
+ # at the same time via the API in the same request.
+ # This would would mean the same user is invited as user_id and email.
+ # However, that isn't as likely from the UI at least since the token generator checks
+ # for that case and doesn't allow email being used if the user exists as a record already.
+ if member.user_id.to_s.in?(invites)
+ member.user.username
+ else
+ member.user.all_emails.detect { |email| email.in?(invites) }
+ end
end
end
end
diff --git a/app/services/members/projects/creator_service.rb b/app/services/members/projects/creator_service.rb
index 4dba81acf73..d92fe60c54a 100644
--- a/app/services/members/projects/creator_service.rb
+++ b/app/services/members/projects/creator_service.rb
@@ -3,19 +3,28 @@
module Members
module Projects
class CreatorService < Members::CreatorService
- def self.access_levels
- Gitlab::Access.sym_options_with_owner
- end
-
private
- def can_update_member?
- super || current_user.can?(:update_project_member, member) || adding_a_new_owner?
+ def can_create_new_member?
+ # order is important here!
+ # The `admin_project_member` check has side-effects that causes projects not be created if this area is hit
+ # during project creation.
+ # Call that triggers is current_user.can?(:admin_project_member, member.project)
+ # I tracked back to base_policy.rb admin check and specifically in
+ # Gitlab::Auth::CurrentUserMode.new(@user).admin_mode? call.
+ # This calls user.admin? and that specific call causes issues with project creation in
+ # spec/requests/api/projects_spec.rb specs and others, mostly around project creation.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/358931 for investigation
+ adding_the_creator_as_owner_in_a_personal_project? || current_user.can?(:admin_project_member, member.project)
+ end
+
+ def can_update_existing_member?
+ current_user.can?(:update_project_member, member)
end
- def adding_a_new_owner?
+ def adding_the_creator_as_owner_in_a_personal_project?
# this condition is reached during testing setup a lot due to use of `.add_user`
- member.owner? && member.new_record?
+ member.project.personal_namespace_holder?(member.user)
end
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 2ab623bacf8..d197c13378a 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -72,7 +72,7 @@ module MergeRequests
end
def cancel_review_app_jobs!(merge_request)
- environments = merge_request.environments.in_review_folder.available
+ environments = merge_request.environments_in_head_pipeline.in_review_folder.available
environments.each { |environment| environment.cancel_deployment_jobs! }
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index c5395138902..391079223ca 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -34,6 +34,7 @@ module MergeRequests
handle_target_branch_change(merge_request)
handle_milestone_change(merge_request)
handle_draft_status_change(merge_request, changed_fields)
+ handle_label_changes(merge_request, old_labels)
track_title_and_desc_edits(changed_fields)
track_discussion_lock_toggle(merge_request, changed_fields)
diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb
index 90900698e1a..e42c3498c21 100644
--- a/app/services/namespaces/in_product_marketing_emails_service.rb
+++ b/app/services/namespaces/in_product_marketing_emails_service.rb
@@ -45,6 +45,11 @@ module Namespaces
}
}.freeze
+ def self.email_count_for_track(track)
+ interval_days = TRACKS.dig(track.to_sym, :interval_days)
+ interval_days&.count || 0
+ end
+
def self.send_for_all_tracks_and_intervals
TRACKS.each_key do |track|
TRACKS[track][:interval_days].each do |interval|
diff --git a/app/services/namespaces/invite_team_email_service.rb b/app/services/namespaces/invite_team_email_service.rb
deleted file mode 100644
index 78edc205990..00000000000
--- a/app/services/namespaces/invite_team_email_service.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-module Namespaces
- class InviteTeamEmailService
- include Gitlab::Experiment::Dsl
-
- TRACK = :invite_team
- DELIVERY_DELAY_IN_MINUTES = 20.minutes
-
- def self.send_email(user, group)
- new(user, group).execute
- end
-
- def initialize(user, group)
- @group = group
- @user = user
- @sent_email_records = InProductMarketingEmailRecords.new
- end
-
- def execute
- return unless user.email_opted_in?
- return unless group.root?
- return unless group.setup_for_company
-
- # Exclude group if users other than the creator have already been
- # added/invited
- return unless group.member_count == 1
-
- return if email_for_track_sent_to_user?
-
- experiment(:invite_team_email, group: group) do |e|
- e.publish_to_database
- e.candidate do
- send_email(user, group)
- sent_email_records.add(user, track, series)
- sent_email_records.save!
- end
- end
- end
-
- private
-
- attr_reader :user, :group, :sent_email_records
-
- def send_email(user, group)
- NotificationService.new.in_product_marketing(user.id, group.id, track, series)
- end
-
- def track
- TRACK
- end
-
- def series
- 0
- end
-
- def email_for_track_sent_to_user?
- Users::InProductMarketingEmail.for_user_with_track_and_series(user, track, series).present?
- end
- end
-end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 9a0db3bb9aa..d32d1c8ca12 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -111,7 +111,7 @@ module Notes
def track_event(note, user)
track_note_creation_usage_for_issues(note) if note.for_issue?
track_note_creation_usage_for_merge_requests(note) if note.for_merge_request?
- track_usage_event(:incident_management_incident_comment, user.id) if note.for_issue? && note.noteable.incident?
+ track_incident_action(user, note.noteable, 'incident_comment') if note.for_issue?
if Feature.enabled?(:notes_create_service_tracking, project)
Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note))
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 1cbb5916107..04fc4c7c944 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -27,10 +27,7 @@ module Notes
note.assign_attributes(last_edited_at: Time.current, updated_by: current_user)
end
- note.with_transaction_returning_status do
- update_confidentiality(note)
- note.save
- end
+ note.save
unless only_commands || note.for_personal_snippet?
note.create_new_cross_references!(current_user)
@@ -88,15 +85,6 @@ module Notes
TodoService.new.update_note(note, current_user, old_mentioned_users)
end
- # This method updates confidentiality of all discussion notes at once
- def update_confidentiality(note)
- return unless params.key?(:confidential)
- return unless note.is_a?(DiscussionNote) # we don't need to do bulk update for single notes
- return unless note.start_of_discussion? # don't update all notes if a response is being updated
-
- Note.id_in(note.discussion.notes.map(&:id)).update_all(confidential: params[:confidential])
- end
-
def track_note_edit_usage_for_issues(note)
Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_edited_action(author: note.author)
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index aa7e636b8a4..a3f250bb235 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -109,6 +109,13 @@ class NotificationService
mailer.unknown_sign_in_email(user, ip, time).deliver_later
end
+ # Notify a user when a new email address is added to the their account
+ def new_email_address_added(user, email)
+ return unless user.can?(:receive_notifications)
+
+ mailer.new_email_address_added_email(user, email).deliver_later
+ end
+
# When create an issue we should send an email to:
#
# * issue assignee if their notification level is not Disabled
@@ -201,13 +208,30 @@ class NotificationService
new_resource_email(merge_request, current_user, :new_merge_request_email)
end
+ NEW_COMMIT_EMAIL_DISPLAY_LIMIT = 20
def push_to_merge_request(merge_request, current_user, new_commits: [], existing_commits: [])
- new_commits = new_commits.map { |c| { short_id: c.short_id, title: c.title } }
- existing_commits = existing_commits.map { |c| { short_id: c.short_id, title: c.title } }
+ total_new_commits_count = new_commits.count
+ truncated_new_commits = new_commits.first(NEW_COMMIT_EMAIL_DISPLAY_LIMIT).map do |commit|
+ { short_id: commit.short_id, title: commit.title }
+ end
+
+ # We don't need the list of all existing commits. We need the first, the
+ # last, and the total number of existing commits only.
+ total_existing_commits_count = existing_commits.count
+ existing_commits = [existing_commits.first, existing_commits.last] if total_existing_commits_count > 2
+ existing_commits = existing_commits.map do |commit|
+ { short_id: commit.short_id, title: commit.title }
+ end
+
recipients = NotificationRecipients::BuildService.build_recipients(merge_request, current_user, action: "push_to")
recipients.each do |recipient|
- mailer.send(:push_to_merge_request_email, recipient.user.id, merge_request.id, current_user.id, recipient.reason, new_commits: new_commits, existing_commits: existing_commits).deliver_later
+ mailer.send(
+ :push_to_merge_request_email,
+ recipient.user.id, merge_request.id, current_user.id, recipient.reason,
+ new_commits: truncated_new_commits, total_new_commits_count: total_new_commits_count,
+ existing_commits: existing_commits, total_existing_commits_count: total_existing_commits_count
+ ).deliver_later
end
end
diff --git a/app/services/packages/rubygems/metadata_extraction_service.rb b/app/services/packages/rubygems/metadata_extraction_service.rb
index b3bac1854d7..872d68e1dbd 100644
--- a/app/services/packages/rubygems/metadata_extraction_service.rb
+++ b/app/services/packages/rubygems/metadata_extraction_service.rb
@@ -49,7 +49,11 @@ module Packages
# rubocop:enable Metrics/CyclomaticComplexity
def metadatum
- Packages::Rubygems::Metadatum.safe_find_or_create_by!(package: package)
+ # safe_find_or_create_by! was originally called here.
+ # We merely switched to `find_or_create_by!`
+ # rubocop: disable CodeReuse/ActiveRecord
+ Packages::Rubygems::Metadatum.find_or_create_by!(package: package)
+ # rubocop: enable CodeReuse/ActiveRecord
end
end
end
diff --git a/app/services/projects/apple_target_platform_detector_service.rb b/app/services/projects/apple_target_platform_detector_service.rb
new file mode 100644
index 00000000000..ec4c16a1416
--- /dev/null
+++ b/app/services/projects/apple_target_platform_detector_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Projects
+ # Service class to detect target platforms of a project made for the Apple
+ # Ecosystem.
+ #
+ # This service searches project.pbxproj and *.xcconfig files (contains build
+ # settings) for the string "SDKROOT = <SDK_name>" where SDK_name can be
+ # 'iphoneos', 'macosx', 'appletvos' or 'watchos'. Currently, the service is
+ # intentionally limited (for performance reasons) to detect if a project
+ # targets iOS.
+ #
+ # Ref: https://developer.apple.com/documentation/xcode/build-settings-reference/
+ #
+ # Example usage:
+ # > AppleTargetPlatformDetectorService.new(a_project).execute
+ # => []
+ # > AppleTargetPlatformDetectorService.new(an_ios_project).execute
+ # => [:ios]
+ # > AppleTargetPlatformDetectorService.new(multiplatform_project).execute
+ # => [:ios, :osx, :tvos, :watchos]
+ class AppleTargetPlatformDetectorService < BaseService
+ BUILD_CONFIG_FILENAMES = %w(project.pbxproj *.xcconfig).freeze
+
+ # For the current iteration, we only want to detect when the project targets
+ # iOS. In the future, we can use the same logic to detect projects that
+ # target OSX, TvOS, and WatchOS platforms with SDK names 'macosx', 'appletvos',
+ # and 'watchos', respectively.
+ PLATFORM_SDK_NAMES = { ios: 'iphoneos' }.freeze
+
+ def execute
+ detect_platforms
+ end
+
+ private
+
+ def file_finder
+ @file_finder ||= ::Gitlab::FileFinder.new(project, project.default_branch)
+ end
+
+ def detect_platforms
+ # Return array of SDK names for which "SDKROOT = <sdk_name>" setting
+ # definition can be found in either project.pbxproj or *.xcconfig files.
+ PLATFORM_SDK_NAMES.select do |_, sdk|
+ config_files_containing_sdk_setting(sdk).present?
+ end.keys
+ end
+
+ # Return array of project.pbxproj and/or *.xcconfig files
+ # (Gitlab::Search::FoundBlob) that contain the setting definition string
+ # "SDKROOT = <sdk_name>"
+ def config_files_containing_sdk_setting(sdk)
+ BUILD_CONFIG_FILENAMES.map do |filename|
+ file_finder.find("SDKROOT = #{sdk} filename:#{filename}")
+ end.flatten
+ end
+ end
+end
diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb
index 4184c676fc3..942df177bea 100644
--- a/app/services/projects/container_repository/third_party/delete_tags_service.rb
+++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb
@@ -33,7 +33,7 @@ module Projects
if deleted_tags.any? && @container_repository.delete_tag_by_digest(deleted_tags.each_value.first)
success(deleted: deleted_tags.keys)
else
- error('could not delete tags')
+ error("could not delete tags: #{@tag_names.join(', ')}".truncate(1000))
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 252e1d76bef..3e26c8c35b2 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -105,7 +105,8 @@ module Projects
end
@project.track_project_repository
- @project.create_project_setting unless @project.project_setting
+
+ create_project_settings
yield if block_given?
@@ -122,6 +123,14 @@ module Projects
create_sast_commit if @initialize_with_sast
end
+ def create_project_settings
+ if Feature.enabled?(:create_project_settings, default_enabled: :yaml)
+ @project.project_setting.save if @project.project_setting.changed?
+ else
+ @project.create_project_setting unless @project.project_setting
+ end
+ end
+
# Add an authorization for the current user authorizations inline
# (so they can access the project immediately after this request
# completes), and any other affected users in the background
@@ -243,7 +252,7 @@ module Projects
def import_schedule
if @project.errors.empty?
- @project.import_state.schedule if @project.import? && !@project.bare_repository_import?
+ @project.import_state.schedule if @project.import? && !@project.bare_repository_import? && !@project.gitlab_project_migration?
else
fail(error: @project.errors.full_messages.join(', '))
end
diff --git a/app/services/projects/deploy_tokens/create_service.rb b/app/services/projects/deploy_tokens/create_service.rb
index 2486544b150..c44a7686c04 100644
--- a/app/services/projects/deploy_tokens/create_service.rb
+++ b/app/services/projects/deploy_tokens/create_service.rb
@@ -6,7 +6,7 @@ module Projects
include DeployTokenMethods
def execute
- deploy_token = create_deploy_token_for(@project, params)
+ deploy_token = create_deploy_token_for(@project, current_user, params)
create_deploy_token_payload_for(deploy_token)
end
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index b91b7f34d42..72492b6f5a5 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -23,6 +23,13 @@ module Projects
cleanup
end
+ def exporters
+ [
+ version_saver, avatar_saver, project_tree_saver, uploads_saver,
+ repo_saver, wiki_repo_saver, lfs_saver, snippets_repo_saver, design_repo_saver
+ ]
+ end
+
protected
def extra_attributes_for_measurement
@@ -59,30 +66,23 @@ module Projects
end
def save_export_archive
- Gitlab::ImportExport::Saver.save(exportable: project, shared: shared)
- end
-
- def exporters
- [
- version_saver, avatar_saver, project_tree_saver, uploads_saver,
- repo_saver, wiki_repo_saver, lfs_saver, snippets_repo_saver, design_repo_saver
- ]
+ @export_saver ||= Gitlab::ImportExport::Saver.save(exportable: project, shared: shared)
end
def version_saver
- Gitlab::ImportExport::VersionSaver.new(shared: shared)
+ @version_saver ||= Gitlab::ImportExport::VersionSaver.new(shared: shared)
end
def avatar_saver
- Gitlab::ImportExport::AvatarSaver.new(project: project, shared: shared)
+ @avatar_saver ||= Gitlab::ImportExport::AvatarSaver.new(project: project, shared: shared)
end
def project_tree_saver
- tree_saver_class.new(project: project,
- current_user: current_user,
- shared: shared,
- params: params,
- logger: logger)
+ @project_tree_saver ||= tree_saver_class.new(project: project,
+ current_user: current_user,
+ shared: shared,
+ params: params,
+ logger: logger)
end
def tree_saver_class
@@ -90,27 +90,31 @@ module Projects
end
def uploads_saver
- Gitlab::ImportExport::UploadsSaver.new(project: project, shared: shared)
+ @uploads_saver ||= Gitlab::ImportExport::UploadsSaver.new(project: project, shared: shared)
end
def repo_saver
- Gitlab::ImportExport::RepoSaver.new(exportable: project, shared: shared)
+ @repo_saver ||= Gitlab::ImportExport::RepoSaver.new(exportable: project, shared: shared)
end
def wiki_repo_saver
- Gitlab::ImportExport::WikiRepoSaver.new(exportable: project, shared: shared)
+ @wiki_repo_saver ||= Gitlab::ImportExport::WikiRepoSaver.new(exportable: project, shared: shared)
end
def lfs_saver
- Gitlab::ImportExport::LfsSaver.new(project: project, shared: shared)
+ @lfs_saver ||= Gitlab::ImportExport::LfsSaver.new(project: project, shared: shared)
end
def snippets_repo_saver
- Gitlab::ImportExport::SnippetsRepoSaver.new(current_user: current_user, project: project, shared: shared)
+ @snippets_repo_saver ||= Gitlab::ImportExport::SnippetsRepoSaver.new(
+ current_user: current_user,
+ project: project,
+ shared: shared
+ )
end
def design_repo_saver
- Gitlab::ImportExport::DesignRepoSaver.new(exportable: project, shared: shared)
+ @design_repo_saver ||= Gitlab::ImportExport::DesignRepoSaver.new(exportable: project, shared: shared)
end
def cleanup
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index ef74f3e6e7a..b66435d013b 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -112,8 +112,9 @@ module Projects
integration = project.find_or_initialize_integration(::Integrations::Prometheus.to_param)
integration.assign_attributes(attrs)
+ attrs = integration.to_integration_hash.except('created_at', 'updated_at')
- { prometheus_integration_attributes: integration.attributes.except(*%w[id project_id created_at updated_at]) }
+ { prometheus_integration_attributes: attrs }
end
def incident_management_setting_params
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index 152590fffff..c7a34afffb3 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -39,6 +39,7 @@ module Projects
GroupMember
.active_without_invites_and_requests
.with_source_id(visible_groups.self_and_ancestors.pluck_primary_key)
+ .select(*GroupMember.cached_column_list)
end
def visible_groups
@@ -52,11 +53,12 @@ module Projects
end
def project_members_through_ancestral_groups
- project.group.present? ? project.group.members_with_parents : Member.none
+ members = project.group.present? ? project.group.members_with_parents : Member.none
+ members.select(*GroupMember.cached_column_list)
end
def individual_project_members
- project.project_members
+ project.project_members.select(*GroupMember.cached_column_list)
end
def project_owner?
diff --git a/app/services/projects/record_target_platforms_service.rb b/app/services/projects/record_target_platforms_service.rb
new file mode 100644
index 00000000000..224e16f53b3
--- /dev/null
+++ b/app/services/projects/record_target_platforms_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Projects
+ class RecordTargetPlatformsService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute
+ record_target_platforms
+ end
+
+ private
+
+ def target_platforms
+ strong_memoize(:target_platforms) do
+ AppleTargetPlatformDetectorService.new(project).execute
+ end
+ end
+
+ def record_target_platforms
+ return unless target_platforms.present?
+
+ setting = ::ProjectSetting.find_or_initialize_by(project: project) # rubocop:disable CodeReuse/ActiveRecord
+ setting.target_platforms = target_platforms
+ setting.save
+
+ setting.target_platforms
+ end
+ end
+end
diff --git a/app/services/projects/refresh_build_artifacts_size_statistics_service.rb b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
index 794c042ea39..1f86e5f4ba9 100644
--- a/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
+++ b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
@@ -12,7 +12,7 @@ module Projects
if batch.any?
# We are doing the sum in ruby because the query takes too long when done in SQL
- total_artifacts_size = batch.sum(&:size)
+ total_artifacts_size = batch.sum { |artifact| artifact.size.to_i }
Projects::BuildArtifactsSizeRefresh.transaction do
# Mark the refresh ready for another worker to pick up and process the next batch
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 51c0989ee55..2ad5c303be2 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -121,6 +121,7 @@ module Projects
# Overridden in EE
def post_update_hooks(project)
move_pages(project)
+ ensure_personal_project_owner_membership(project)
end
# Overridden in EE
@@ -152,6 +153,19 @@ module Projects
project.track_project_repository
end
+ def ensure_personal_project_owner_membership(project)
+ # In case of personal projects, we want to make sure that
+ # a membership record with `OWNER` access level exists for the owner of the namespace.
+ return unless project.personal?
+
+ namespace_owner = project.namespace.owner
+ existing_membership_record = project.member(namespace_owner)
+
+ return if existing_membership_record.present? && existing_membership_record.access_level == Gitlab::Access::OWNER
+
+ project.add_owner(namespace_owner)
+ end
+
def refresh_permissions
# This ensures we only schedule 1 job for every user that has access to
# the namespaces.
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index 1baa4ddf0eb..47f4b9c6898 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -77,7 +77,10 @@ module QuickActions
# want to also handle bare usernames. The ReferenceExtractor also has
# different behaviour, and will return all group members for groups named
# using a user-style reference, which is not in scope here.
+ #
+ # nb: underscores may be passed in escaped to protect them from markdown rendering
args = params.split(/\s|,/).select(&:present?).uniq - ['and']
+ args.map! { _1.gsub(/\\_/, '_') }
usernames = (args - ['me']).map { _1.delete_prefix('@') }
found = User.by_username(usernames).to_a.select { can?(:read_user, _1) }
found_names = found.map(&:username).to_set
@@ -168,7 +171,7 @@ module QuickActions
next unless definition
definition.execute(self, arg)
- usage_ping_tracking(name, arg)
+ usage_ping_tracking(definition.name, arg)
end
end
@@ -186,7 +189,7 @@ module QuickActions
def usage_ping_tracking(quick_action_name, arg)
Gitlab::UsageDataCounters::QuickActionActivityUniqueCounter.track_unique_action(
- quick_action_name,
+ quick_action_name.to_s,
args: arg&.strip,
user: current_user
)
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index 28ea1ac8296..f7ffe288d57 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -75,7 +75,6 @@ module ResourceAccessTokens
end
def generate_email
- # Default emaildomain need to be reworked. See gitlab-org/gitlab#260305
email_pattern = "#{resource_type}#{resource.id}_bot%s@noreply.#{Gitlab.config.gitlab.host}"
uniquify.string(-> (n) { Kernel.sprintf(email_pattern, n) }) do |s|
diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
index a0d26e08341..a20eb6b79c5 100644
--- a/app/services/suggestions/apply_service.rb
+++ b/app/services/suggestions/apply_service.rb
@@ -54,7 +54,7 @@ module Suggestions
author_email: author&.email
}
- ::Files::MultiService.new(suggestion_set.project, current_user, params)
+ ::Files::MultiService.new(suggestion_set.source_project, current_user, params)
end
def commit_message
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 46eec082125..1ea65049dc2 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -64,6 +64,10 @@ module Users
# This ensures we delete records in batches.
user.destroy_dependent_associations_in_batches(exclude: [:snippets])
+ if Feature.enabled?(:nullify_in_batches_on_user_deletion, default_enabled: :yaml)
+ user.nullify_dependent_associations_in_batches
+ end
+
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
user_data = user.destroy
namespace.destroy
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index 604b83f621f..3eb220c0e40 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -100,9 +100,9 @@ module Users
end
# rubocop:disable CodeReuse/ActiveRecord
- def batched_migrate(base_scope, column)
+ def batched_migrate(base_scope, column, batch_size: 50)
loop do
- update_count = base_scope.where(column => user.id).limit(100).update_all(column => ghost_user.id)
+ update_count = base_scope.where(column => user.id).limit(batch_size).update_all(column => ghost_user.id)
break if update_count == 0
end
end
diff --git a/app/services/users/registrations_build_service.rb b/app/services/users/registrations_build_service.rb
index 2d367e7b185..0065b49cc00 100644
--- a/app/services/users/registrations_build_service.rb
+++ b/app/services/users/registrations_build_service.rb
@@ -16,3 +16,5 @@ module Users
end
end
end
+
+Users::RegistrationsBuildService.prepend_mod
diff --git a/app/services/users/saved_replies/destroy_service.rb b/app/services/users/saved_replies/destroy_service.rb
new file mode 100644
index 00000000000..ac08cddad0c
--- /dev/null
+++ b/app/services/users/saved_replies/destroy_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Users
+ module SavedReplies
+ class DestroyService
+ def initialize(saved_reply:)
+ @saved_reply = saved_reply
+ end
+
+ def execute
+ if saved_reply.destroy
+ ServiceResponse.success(payload: { saved_reply: saved_reply })
+ else
+ ServiceResponse.error(message: saved_reply.errors.full_messages)
+ end
+ end
+
+ private
+
+ attr_reader :saved_reply
+ end
+ end
+end
diff --git a/app/services/users/saved_replies/update_service.rb b/app/services/users/saved_replies/update_service.rb
index ab0a3eaf87d..80d3da8a0a3 100644
--- a/app/services/users/saved_replies/update_service.rb
+++ b/app/services/users/saved_replies/update_service.rb
@@ -3,8 +3,7 @@
module Users
module SavedReplies
class UpdateService
- def initialize(current_user:, saved_reply:, name:, content:)
- @current_user = current_user
+ def initialize(saved_reply:, name:, content:)
@saved_reply = saved_reply
@name = name
@content = content
@@ -20,7 +19,7 @@ module Users
private
- attr_reader :current_user, :saved_reply, :name, :content
+ attr_reader :saved_reply, :name, :content
end
end
end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index b1d8872aa5e..c0727e52cc3 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -36,7 +36,7 @@ class WebHookService
def initialize(hook, data, hook_name, uniqueness_token = nil, force: false)
@hook = hook
- @data = data
+ @data = data.to_h
@hook_name = hook_name.to_s
@uniqueness_token = uniqueness_token
@force = force
@@ -70,9 +70,6 @@ class WebHookService
end
log_execution(
- trigger: hook_name,
- url: hook.url,
- request_data: data,
response: response,
execution_duration: Gitlab::Metrics::System.monotonic_time - start_time
)
@@ -86,9 +83,6 @@ class WebHookService
Gitlab::Json::LimitedEncoder::LimitExceeded, URI::InvalidURIError => e
execution_duration = Gitlab::Metrics::System.monotonic_time - start_time
log_execution(
- trigger: hook_name,
- url: hook.url,
- request_data: data,
response: InternalErrorResponse.new,
execution_duration: execution_duration,
error_message: e.to_s
@@ -139,14 +133,14 @@ class WebHookService
make_request(post_url, basic_auth)
end
- def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil)
+ def log_execution(response:, execution_duration:, error_message: nil)
category = response_category(response)
log_data = {
- trigger: trigger,
- url: url,
+ trigger: hook_name,
+ url: hook.url,
execution_duration: execution_duration,
request_headers: build_headers,
- request_data: request_data,
+ request_data: data,
response_headers: format_response_headers(response),
response_body: safe_response_body(response),
response_status: response.code,
diff --git a/app/uploaders/ci/secure_file_uploader.rb b/app/uploaders/ci/secure_file_uploader.rb
index 514d88dd177..8aa624d6b30 100644
--- a/app/uploaders/ci/secure_file_uploader.rb
+++ b/app/uploaders/ci/secure_file_uploader.rb
@@ -10,7 +10,7 @@ module Ci
encrypt(key: :key)
def key
- OpenSSL::HMAC.digest('SHA256', Gitlab::Application.secrets.db_key_base, model.project_id.to_s)
+ Digest::SHA256.digest model.key_data
end
def checksum
diff --git a/app/uploaders/metric_image_uploader.rb b/app/uploaders/metric_image_uploader.rb
new file mode 100644
index 00000000000..0826bb251e4
--- /dev/null
+++ b/app/uploaders/metric_image_uploader.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class MetricImageUploader < GitlabUploader # rubocop:disable Gitlab/NamespacedClass
+ include RecordsUploads::Concern
+ include ObjectStorage::Concern
+ prepend ObjectStorage::Extension::RecordsUploads
+ include UploaderHelper
+
+ private
+
+ def dynamic_segment
+ File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
+ end
+
+ class << self
+ def default_store
+ object_store_enabled? ? ObjectStorage::Store::REMOTE : ObjectStorage::Store::LOCAL
+ end
+ end
+end
diff --git a/app/validators/gitlab/emoji_name_validator.rb b/app/validators/gitlab/emoji_name_validator.rb
index a9092d0194f..c034a79214b 100644
--- a/app/validators/gitlab/emoji_name_validator.rb
+++ b/app/validators/gitlab/emoji_name_validator.rb
@@ -11,9 +11,22 @@
module Gitlab
class EmojiNameValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
- unless TanukiEmoji.find_by_alpha_code(value.to_s)
- record.errors.add(attribute, (options[:message] || 'is not a valid emoji name'))
- end
+ return if valid_tanuki_emoji?(value)
+ return if valid_custom_emoji?(record, value)
+
+ record.errors.add(attribute, (options[:message] || 'is not a valid emoji name'))
+ end
+
+ private
+
+ def valid_tanuki_emoji?(value)
+ TanukiEmoji.find_by_alpha_code(value.to_s)
+ end
+
+ def valid_custom_emoji?(record, value)
+ resource = record.try(:resource_parent)
+
+ CustomEmoji.for_resource(resource).by_name(value.to_s).any?
end
end
end
diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb
index 9809047ae83..0094d6156a3 100644
--- a/app/validators/key_restriction_validator.rb
+++ b/app/validators/key_restriction_validator.rb
@@ -2,25 +2,34 @@
class KeyRestrictionValidator < ActiveModel::EachValidator
FORBIDDEN = -1
+ ALLOWED = 0
def self.supported_sizes(type)
Gitlab::SSHPublicKey.supported_sizes(type)
end
def self.supported_key_restrictions(type)
- [0, *supported_sizes(type), FORBIDDEN]
+ if Gitlab::FIPS.enabled?
+ [*supported_sizes(type), FORBIDDEN]
+ else
+ [ALLOWED, *supported_sizes(type), FORBIDDEN]
+ end
end
def validate_each(record, attribute, value)
unless valid_restriction?(value)
- record.errors.add(attribute, "must be forbidden, allowed, or one of these sizes: #{supported_sizes_message}")
+ record.errors.add(attribute, "must be #{supported_sizes_message}")
end
end
private
def supported_sizes_message
- sizes = self.class.supported_sizes(options[:type])
+ sizes = []
+
+ sizes << "forbidden" if valid_restriction?(FORBIDDEN)
+ sizes << "allowed" if valid_restriction?(ALLOWED)
+ sizes += self.class.supported_sizes(options[:type])
Gitlab::Utils.to_exclusive_sentence(sizes)
end
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 189986b3dec..a0fa69c54c5 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -1,12 +1,9 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-account-settings'), html: { class: 'fieldset-form', id: 'account-settings' } do |f|
+= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-account-settings'), html: { class: 'fieldset-form', id: 'account-settings' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :gravatar_enabled, class: 'form-check-input'
- = f.label :gravatar_enabled, class: 'form-check-label' do
- = _('Gravatar enabled')
+ = f.gitlab_ui_checkbox_component :gravatar_enabled, _('Gravatar enabled')
.form-group
= f.label :default_projects_limit, _('Default projects limit'), class: 'label-bold'
@@ -38,16 +35,10 @@
.form-group
= f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold'
- .form-check
- = f.check_box :user_oauth_applications, class: 'form-check-input'
- = f.label :user_oauth_applications, class: 'form-check-label' do
- = _('Allow users to register any application to use GitLab as an OAuth provider')
+ = f.gitlab_ui_checkbox_component :user_oauth_applications, _('Allow users to register any application to use GitLab as an OAuth provider')
.form-group
= f.label :user_default_external, _('New users set to external'), class: 'label-bold'
- .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 are external by default')
+ = f.gitlab_ui_checkbox_component :user_default_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'
@@ -57,22 +48,15 @@
- unless Gitlab.com?
.form-group
= f.label :deactivate_dormant_users, _('Dormant users'), class: 'label-bold'
- .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')
- .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', rel: 'noopener noreferrer'
+ - dormant_users_help_link = help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users')
+ - dormant_users_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: dormant_users_help_link }
+ = f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after 90 days of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe }
.form-group
= f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light'
= 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
- = f.check_box :user_show_add_ssh_key_message, class: 'form-check-input'
- = f.label :user_show_add_ssh_key_message, class: 'form-check-label' do
- = _("Inform users without uploaded SSH keys that they can't push over SSH until one is added")
+ = f.gitlab_ui_checkbox_component :user_show_add_ssh_key_message, _("Inform users without uploaded SSH keys that they can't push over SSH until one is added")
= render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f
= render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 41698f9720b..201ca830ba4 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -1,80 +1,102 @@
-= form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+.settings-content
+ = gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
- %fieldset
- .form-group
- .form-check
- = f.check_box :auto_devops_enabled, class: 'form-check-input'
- = f.label :auto_devops_enabled, class: 'form-check-label' do
- = s_('CICD|Default to Auto DevOps pipeline for all projects')
+ %fieldset
+ .form-group
+ - devops_help_link_url = help_page_path('topics/autodevops/index.md')
+ - devops_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: devops_help_link_url }
+ = f.gitlab_ui_checkbox_component :auto_devops_enabled, s_('CICD|Default to Auto DevOps pipeline for all projects'), help_text: s_('CICD|The Auto DevOps pipeline runs by default in all projects with no CI/CD configuration file. %{link_start}What is Auto DevOps?%{link_end}').html_safe % { link_start: devops_help_link_start, link_end: '</a>'.html_safe }
+ .form-group
+ = f.label :auto_devops_domain, s_('AdminSettings|Auto DevOps domain'), class: 'label-bold'
+ = f.text_field :auto_devops_domain, class: 'form-control gl-form-input', placeholder: 'example.com'
.form-text.text-muted
- = s_('CICD|The Auto DevOps pipeline runs by default in all projects with no CI/CD configuration file.')
- = link_to _('What is Auto DevOps?'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
- .form-group
- = f.label :auto_devops_domain, s_('AdminSettings|Auto DevOps domain'), class: 'label-bold'
- = f.text_field :auto_devops_domain, class: 'form-control gl-form-input', placeholder: 'example.com'
- .form-text.text-muted
- = s_("AdminSettings|The default domain to use for Auto Review Apps and Auto Deploy stages in all projects.")
- = link_to _('Learn more.'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-review-apps'), target: '_blank', rel: 'noopener noreferrer'
+ = s_("AdminSettings|The default domain to use for Auto Review Apps and Auto Deploy stages in all projects.")
+ = link_to _('Learn more.'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-review-apps'), target: '_blank', rel: 'noopener noreferrer'
- .form-group
- .form-check
- = f.check_box :shared_runners_enabled, class: 'form-check-input'
- = f.label :shared_runners_enabled, class: 'form-check-label' do
- = s_("AdminSettings|Enable shared runners for new projects")
- .form-text.text-muted
- = s_("AdminSettings|All new projects can use the instance's shared runners by default.")
+ .form-group
+ = f.gitlab_ui_checkbox_component :shared_runners_enabled, s_("AdminSettings|Enable shared runners for new projects"), help_text: s_("AdminSettings|All new projects can use the instance's shared runners by default.")
- = render_if_exists 'admin/application_settings/shared_runners_minutes_setting', form: f
+ = render_if_exists 'admin/application_settings/shared_runners_minutes_setting', form: f
- .form-group
- = f.label :shared_runners_text, _('Shared runners details'), class: 'label-bold'
- = f.text_area :shared_runners_text, class: 'form-control gl-form-input', rows: 4
- .form-text.text-muted= _("Add a custom message with details about the instance's shared runners. The message is visible in group and project CI/CD settings, in the Runners section. Markdown is supported.")
- .form-group
- = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold'
- = f.number_field :max_artifacts_size, class: 'form-control gl-form-input'
- .form-text.text-muted
- = _("The maximum file size for job artifacts.")
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size')
- .form-group
- = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold'
- = f.text_field :default_artifacts_expire_in, class: 'form-control gl-form-input'
- .form-text.text-muted
- = html_escape(_("Set the default expiration time for job artifacts in all projects. Set to %{code_open}0%{code_close} to never expire artifacts by default. If no unit is written, it defaults to seconds. For example, these are all equivalent: %{code_open}3600%{code_close}, %{code_open}60 minutes%{code_close}, or %{code_open}one hour%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
- .form-group
- .form-check
- = f.check_box :keep_latest_artifact, class: 'form-check-input'
- = f.label :keep_latest_artifact, class: 'form-check-label' do
- = s_('AdminSettings|Keep the latest artifacts for all jobs in the latest successful pipelines')
+ .form-group
+ = f.label :shared_runners_text, _('Shared runners details'), class: 'label-bold'
+ = f.text_area :shared_runners_text, class: 'form-control gl-form-input', rows: 4
+ .form-text.text-muted= _("Add a custom message with details about the instance's shared runners. The message is visible in group and project CI/CD settings, in the Runners section. Markdown is supported.")
+ .form-group
+ = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold'
+ = f.number_field :max_artifacts_size, class: 'form-control gl-form-input'
.form-text.text-muted
- = s_('AdminSettings|The latest artifacts for all jobs in the most recent successful pipelines in each project are stored and do not expire.')
- .form-group
- = f.label :archive_builds_in_human_readable, _('Archive jobs'), class: 'label-bold'
- = f.text_field :archive_builds_in_human_readable, class: 'form-control gl-form-input'
- .form-text.text-muted
- = html_escape(_("Jobs older than the configured time are considered expired and are archived. Archived jobs can no longer be retried. Leave empty to never archive jobs automatically. The default unit is in days, but you can use other units, for example %{code_open}15 days%{code_close}, %{code_open}1 month%{code_close}, %{code_open}2 years%{code_close}. Minimum value is 1 day.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'archive-jobs')
- .form-group
- .form-check
- = f.check_box :protected_ci_variables, class: 'form-check-input'
- = f.label :protected_ci_variables, class: 'form-check-label' do
- = s_('AdminSettings|Protect CI/CD variables by default')
+ = _("The maximum file size for job artifacts.")
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size')
+ .form-group
+ = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold'
+ = f.text_field :default_artifacts_expire_in, class: 'form-control gl-form-input'
.form-text.text-muted
- = s_('AdminSettings|New CI/CD variables in projects and groups default to protected.')
- .form-group
- = f.label :ci_config_path, _('Default CI/CD configuration file'), class: 'label-bold'
- = f.text_field :default_ci_config_path, class: 'form-control gl-form-input', placeholder: '.gitlab-ci.yml'
- %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', rel: 'noopener noreferrer'
- .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')
+ = html_escape(_("Set the default expiration time for job artifacts in all projects. Set to %{code_open}0%{code_close} to never expire artifacts by default. If no unit is written, it defaults to seconds. For example, these are all equivalent: %{code_open}3600%{code_close}, %{code_open}60 minutes%{code_close}, or %{code_open}one hour%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
+ .form-group
+ = f.gitlab_ui_checkbox_component :keep_latest_artifact, s_('AdminSettings|Keep the latest artifacts for all jobs in the latest successful pipelines'), help_text: s_('AdminSettings|The latest artifacts for all jobs in the most recent successful pipelines in each project are stored and do not expire.')
+ .form-group
+ = f.label :archive_builds_in_human_readable, _('Archive jobs'), class: 'label-bold'
+ = f.text_field :archive_builds_in_human_readable, class: 'form-control gl-form-input'
.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.')
+ = html_escape(_("Jobs older than the configured time are considered expired and are archived. Archived jobs can no longer be retried. Leave empty to never archive jobs automatically. The default unit is in days, but you can use other units, for example %{code_open}15 days%{code_close}, %{code_open}1 month%{code_close}, %{code_open}2 years%{code_close}. Minimum value is 1 day.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'archive-jobs')
+ .form-group
+ = f.gitlab_ui_checkbox_component :protected_ci_variables, s_('AdminSettings|Protect CI/CD variables by default'), help_text: s_('AdminSettings|New CI/CD variables in projects and groups default to protected.')
+ .form-group
+ = f.label :ci_config_path, _('Default CI/CD configuration file'), class: 'label-bold'
+ = f.text_field :default_ci_config_path, class: 'form-control gl-form-input', placeholder: '.gitlab-ci.yml'
+ %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', rel: 'noopener noreferrer'
+ .form-group
+ = f.gitlab_ui_checkbox_component :suggest_pipeline_enabled, s_('AdminSettings|Enable pipeline suggestion banner'), help_text: 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"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
+
+.settings-content
+ %h4
+ = s_('AdminSettings|CI/CD limits')
+ %p
+ = s_('AdminSettings|Set limit to 0 to disable it.')
+ .scrolling-tabs-container.inner-page-scroll-tabs
+ - if @plans.size > 1
+ %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.gl-mb-5
+ - @plans.each_with_index do |plan, index|
+ %li
+ = link_to admin_plan_limits_path(anchor: 'js-ci-cd-settings'), data: { target: "div#plan#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do
+ = plan.name.capitalize
+ .tab-content.gl-tab-content
+ - @plans.each_with_index do |plan, index|
+ .tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' }
+ = form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' }, method: :post do |f|
+ = form_errors(plan)
+ %fieldset
+ = f.hidden_field(:plan_id, value: plan.id)
+ .form-group
+ = f.label :ci_pipeline_size, s_('AdminSettings|Maximum number of jobs in a single pipeline')
+ = f.number_field :ci_pipeline_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_active_jobs, s_('AdminSettings|Total number of jobs in currently active pipelines')
+ = f.number_field :ci_active_jobs, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_active_pipelines, s_('AdminSettings|Maximum number of active pipelines per project')
+ = f.number_field :ci_active_pipelines, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_project_subscriptions, s_('AdminSettings|Maximum number of pipeline subscriptions to and from a project')
+ = f.number_field :ci_project_subscriptions, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_pipeline_schedules, s_('AdminSettings|Maximum number of pipeline schedules')
+ = f.number_field :ci_pipeline_schedules, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_needs_size_limit, s_('AdminSettings|Maximum number of DAG dependencies that a job can have')
+ = f.number_field :ci_needs_size_limit, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_registered_group_runners, s_('AdminSettings|Maximum number of runners registered per group')
+ = f.number_field :ci_registered_group_runners, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_registered_project_runners, s_('AdminSettings|Maximum number of runners registered per project')
+ = f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input'
+ = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, class: 'btn gl-button btn-confirm'
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index d9c0a01beb0..bd6ff9b426f 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -9,15 +9,13 @@
= _('Amazon EKS integration allows you to provision EKS clusters from GitLab.')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form', id: 'eks-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form', id: 'eks-settings' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :eks_integration_enabled, class: 'form-check-input'
- = f.label :eks_integration_enabled, class: 'form-check-label' do
- = _('Enable Amazon EKS integration')
+ = f.gitlab_ui_checkbox_component :eks_integration_enabled,
+ _('Enable Amazon EKS integration')
.form-group
= f.label :eks_account_id, _('Account ID'), class: 'label-bold'
= f.text_field :eks_account_id, class: 'form-control gl-form-input'
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 0ab462a3fa8..fd65d4029f5 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -1,21 +1,11 @@
-= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-email-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-email-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :email_author_in_body, class: 'form-check-input'
- = f.label :email_author_in_body, class: 'form-check-label' do
- = _('Include author name in notification email body')
- .form-text.text-muted
- = _("Include the name of the author of the issue, merge request or comment in the email body. By default, GitLab overrides the email sender's name. Some email servers don't support that option.")
+ = f.gitlab_ui_checkbox_component :email_author_in_body, _('Include author name in notification email body'), help_text: _("Include the name of the author of the issue, merge request or comment in the email body. By default, GitLab overrides the email sender's name. Some email servers don't support that option.")
.form-group
- .form-check
- = f.check_box :html_emails_enabled, class: 'form-check-input'
- = f.label :html_emails_enabled, class: 'form-check-label' do
- = _('Enable multipart emails')
- .form-text.text-muted
- = _('Send email in multipart format (HTML and plain text). Uncheck to send email messages in plain text only.')
+ = f.gitlab_ui_checkbox_component :html_emails_enabled, _('Enable multipart emails'), help_text: _('Send email in multipart format (HTML and plain text). Uncheck to send email messages in plain text only.')
.form-group
= f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold'
= f.text_field :commit_email_hostname, class: 'form-control gl-form-input'
@@ -26,19 +16,9 @@
= render_if_exists 'admin/application_settings/email_additional_text_setting', form: f
.form-group
- .form-check
- = f.check_box :in_product_marketing_emails_enabled, class: 'form-check-input'
- = f.label :in_product_marketing_emails_enabled, class: 'form-check-label' do
- = _('Enable in-product marketing emails')
- .form-text.text-muted
- = _('Send emails to help guide new users through the onboarding process.')
+ = f.gitlab_ui_checkbox_component :in_product_marketing_emails_enabled, _('Enable in-product marketing emails'), help_text: _('Send emails to help guide new users through the onboarding process.')
.form-group
- .form-check
- = f.check_box :user_deactivation_emails_enabled, class: 'form-check-input'
- = f.label :user_deactivation_emails_enabled, class: 'form-check-label' do
- = _('Enable user deactivation emails')
- .form-text.text-muted
- = _('Send emails to users upon account deactivation.')
+ = f.gitlab_ui_checkbox_component :user_deactivation_emails_enabled, _('Enable user deactivation emails'), help_text: _('Send emails to users upon account deactivation.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
index 4fb10d48540..1abf8f78060 100644
--- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -9,17 +9,14 @@
= link_to _('Learn more.'), help_page_path('user/admin_area/settings/external_authorization'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form', id: 'external-auth-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form', id: 'external-auth-settings' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :external_authorization_service_enabled, class: 'form-check-input'
- = f.label :external_authorization_service_enabled, class: 'form-check-label' do
- = s_('ExternalAuthorization|Enable classification control using an external service')
- %span.form-text.text-muted
- = external_authorization_description
+ = f.gitlab_ui_checkbox_component :external_authorization_service_enabled,
+ s_('ExternalAuthorization|Enable classification control using an external service'),
+ help_text: external_authorization_description
.form-group
= f.label :external_authorization_service_url, s_('ExternalAuthorization|Service URL'), class: 'label-bold'
= f.text_field :external_authorization_service_url, class: 'form-control gl-form-input'
diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml
index 66259926064..14b1a58c1ad 100644
--- a/app/views/admin/application_settings/_floc.html.haml
+++ b/app/views/admin/application_settings/_floc.html.haml
@@ -11,12 +11,11 @@
= link_to sprite_icon('question-o'), 'https://github.com/WICG/floc', target: '_blank', rel: 'noopener noreferrer', class: 'has-tooltip', title: _('More information')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-floc-settings'), html: { class: 'fieldset-form', id: 'floc-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-floc-settings'), html: { class: 'fieldset-form', id: 'floc-settings' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :floc_enabled, class: 'form-check-input'
- = f.label :floc_enabled, s_('FloC|Enable FloC (Federated Learning of Cohorts)'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :floc_enabled,
+ s_('FloC|Enable FloC (Federated Learning of Cohorts)')
= f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_git_lfs_limits.html.haml b/app/views/admin/application_settings/_git_lfs_limits.html.haml
index de5a2ceaa3d..b8970a5bcf1 100644
--- a/app/views/admin/application_settings/_git_lfs_limits.html.haml
+++ b/app/views/admin/application_settings/_git_lfs_limits.html.haml
@@ -1,16 +1,13 @@
-= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-git-lfs-limits-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-git-lfs-limits-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
%h5
= _('Authenticated Git LFS request rate limit')
.form-group
- .form-check
- = f.check_box :throttle_authenticated_git_lfs_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_git_lfs_checkbox' }
- = f.label :throttle_authenticated_git_lfs_enabled, class: 'form-check-label gl-font-weight-bold' do
- = _('Enable authenticated Git LFS request rate limit')
- %span.form-text.gl-text-gray-600
- = _('Helps reduce request volume (for example, from crawlers or abusive bots)')
+ = f.gitlab_ui_checkbox_component :throttle_authenticated_git_lfs_enabled,
+ _('Enable authenticated Git LFS request rate limit'),
+ help_text: _('Helps reduce request volume (for example, from crawlers or abusive bots)')
.form-group
= f.label :throttle_authenticated_git_lfs_requests_per_period, _('Max authenticated Git LFS requests per period per user'), class: 'gl-font-weight-bold'
= f.number_field :throttle_authenticated_git_lfs_requests_per_period, class: 'form-control gl-form-input'
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index 8f99a07b87c..515b3691324 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -12,14 +12,13 @@
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form', id: 'gitpod-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form', id: 'gitpod-settings' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :gitpod_enabled, class: 'form-check-input'
- = f.label :gitpod_enabled, s_('Gitpod|Enable Gitpod integration'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :gitpod_enabled,
+ s_('Gitpod|Enable Gitpod integration')
.form-group
= f.label :gitpod_url, s_('Gitpod|Gitpod URL'), class: 'label-bold'
= f.text_field :gitpod_url, class: 'form-control gl-form-input', placeholder: s_('Gitpod|https://gitpod.example.com')
diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml
index 70c1e3ce3c1..7f305b9ad9c 100644
--- a/app/views/admin/application_settings/_grafana.html.haml
+++ b/app/views/admin/application_settings/_grafana.html.haml
@@ -1,14 +1,11 @@
-= form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :grafana_enabled, class: 'form-check-input'
- = f.label :grafana_enabled, class: 'form-check-label' do
- = _("Add a link to Grafana")
- .form-text.text-muted
- = _("A Metrics Dashboard menu item appears in the Monitoring section of the Admin Area.")
+ = f.gitlab_ui_checkbox_component :grafana_enabled,
+ s_('ApplicationSettings|Add a link to Grafana'),
+ help_text: s_('ApplicationSettings|A Metrics Dashboard menu item appears in the Monitoring section of the Admin Area.')
.form-group
= f.label :grafana_url, _('Grafana URL'), class: 'label-bold'
= f.text_field :grafana_url, class: 'form-control gl-form-input', placeholder: '/-/grafana'
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index cd7eaa1896a..21eb4caf579 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-help-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-help-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
@@ -9,10 +9,7 @@
= f.text_area :help_page_text, class: 'form-control gl-form-input', rows: 4
.form-text.text-muted= _('Markdown enabled.')
.form-group
- .form-check
- = f.check_box :help_page_hide_commercial_content, class: 'form-check-input'
- = f.label :help_page_hide_commercial_content, class: 'form-check-label' do
- = _('Hide marketing-related entries from the Help page')
+ = f.gitlab_ui_checkbox_component :help_page_hide_commercial_content, _('Hide marketing-related entries from the Help page')
.form-group
= f.label :help_page_support_url, _('Support page URL'), class: 'label-bold'
= 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'
diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml
index b22eef83876..61469d87656 100644
--- a/app/views/admin/application_settings/_kroki.html.haml
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -9,14 +9,13 @@
= _('Users can render diagrams in AsciiDoc, Markdown, reStructuredText, and Textile documents using Kroki.')
= link_to _('Learn more.'), help_page_path('administration/integration/kroki.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form', id: 'kroki-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form', id: 'kroki-settings' } do |f|
= form_errors(@application_setting) if expanded
%fieldset
.form-group
- .form-check
- = f.check_box :kroki_enabled, class: 'form-check-input'
- = f.label :kroki_enabled, _('Enable Kroki'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :kroki_enabled,
+ _('Enable Kroki')
.form-group
= f.label :kroki_url, 'Kroki URL', class: 'label-bold'
= f.text_field :kroki_url, class: 'form-control gl-form-input', placeholder: 'http://your-kroki-instance:8000'
@@ -30,9 +29,7 @@
- container_link_url = 'https://docs.kroki.io/kroki/setup/install/#images'
- container_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: container_link_url }
= html_escape(_('To use the additional formats, you must start the required %{container_link_start}companion containers%{container_link_end}.')) % { container_link_start: container_link_start, container_link_end: '</a>'.html_safe }
- - kroki_available_formats.each do |format|
- .form-check
- = f.check_box format[:name], class: 'form-check-input'
- = f.label format[:name], format[:label], class: 'form-check-label'
+ - kroki_available_formats.each do |format|
+ = f.gitlab_ui_checkbox_component format[:name], format[:label]
= f.submit _('Save changes'), class: "btn gl-button btn-confirm"
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index d0bb6a78ed6..a6ed48ef4fe 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
@@ -11,13 +11,9 @@
.form-group
= f.label :time_tracking, _('Time tracking'), class: 'label-bold'
- .form-check
- = f.check_box :time_tracking_limit_to_hours, class: 'form-check-input'
- = f.label :time_tracking_limit_to_hours, class: 'form-check-label' do
- = _('Limit display of time tracking units to hours.')
- .form-text.text-muted
- = _('Display time tracking in issues in total hours only.')
- = link_to _('What is time tracking?'), help_page_path('user/project/time_tracking.md'), target: '_blank', rel: 'noopener noreferrer'
+ - time_tracking_help_link = help_page_path('user/project/time_tracking.md')
+ - time_tracking_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: time_tracking_help_link }
+ = f.gitlab_ui_checkbox_component :time_tracking_limit_to_hours, _('Limit display of time tracking units to hours.'), help_text: _('Display time tracking in issues in total hours only. %{link_start}What is time tracking?%{link_end}').html_safe % { link_start: time_tracking_help_link_start, 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 ad9e84ffdab..7afb35bc9cb 100644
--- a/app/views/admin/application_settings/_mailgun.html.haml
+++ b/app/views/admin/application_settings/_mailgun.html.haml
@@ -8,14 +8,13 @@
%p
= _('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|
+ = gitlab_ui_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
%fieldset
.form-group
- .form-check
- = f.check_box :mailgun_events_enabled, class: 'form-check-input'
- = f.label :mailgun_events_enabled, _('Enable Mailgun event receiver'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :mailgun_events_enabled,
+ _('Enable Mailgun event receiver')
.form-group
= f.label :mailgun_signing_key, _('Mailgun HTTP webhook signing key'), class: 'label-light'
= f.text_field :mailgun_signing_key, class: 'form-control gl-form-input'
diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml
index 38a5d6a1010..d4ae0d3944c 100644
--- a/app/views/admin/application_settings/_note_limits.html.haml
+++ b/app/views/admin/application_settings/_note_limits.html.haml
@@ -7,8 +7,8 @@
= f.number_field :notes_create_limit, class: 'form-control gl-form-input'
.form-group
= f.label :notes_create_limit_allowlist, _('Users to exclude from the rate limit'), class: 'label-bold'
- = f.text_area :notes_create_limit_allowlist_raw, placeholder: 'username1, username2', class: 'form-control gl-form-input', rows: 5
- .form-text.text-muted
+ = f.text_area :notes_create_limit_allowlist_raw, class: 'form-control gl-form-input', rows: 5, aria: { describedBy: 'note-create-limits-allowlist-field-description' }
+ .form-text.text-muted{ id: 'note-create-limits-allowlist-field-description' }
= _('List of users allowed to exceed the rate limit.')
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index 9a31fdd7fdf..503e7d8afa6 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -1,16 +1,13 @@
-= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-outbound-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-outbound-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :allow_local_requests_from_web_hooks_and_services, class: 'form-check-input', data: { qa_selector: 'allow_requests_from_services_checkbox' }
- = f.label :allow_local_requests_from_web_hooks_and_services, class: 'form-check-label' do
- = s_('OutboundRequests|Allow requests to the local network from web hooks and services')
- .form-check
- = f.check_box :allow_local_requests_from_system_hooks, class: 'form-check-input'
- = f.label :allow_local_requests_from_system_hooks, class: 'form-check-label' do
- = s_('OutboundRequests|Allow requests to the local network from system hooks')
+ = f.gitlab_ui_checkbox_component :allow_local_requests_from_web_hooks_and_services,
+ s_('OutboundRequests|Allow requests to the local network from web hooks and services'),
+ checkbox_options: { data: { qa_selector: 'allow_requests_from_services_checkbox' } }
+ = f.gitlab_ui_checkbox_component :allow_local_requests_from_system_hooks,
+ s_('OutboundRequests|Allow requests to the local network from system hooks')
.form-group
= f.label :outbound_local_requests_allowlist_raw, class: 'label-bold' do
@@ -21,11 +18,8 @@
= link_to _('Learn more.'), help_page_path('security/webhooks.md', anchor: 'allowlist-for-local-requests'), target: '_blank', rel: 'noopener noreferrer'
.form-group
- .form-check
- = f.check_box :dns_rebinding_protection_enabled, class: 'form-check-input'
- = f.label :dns_rebinding_protection_enabled, class: 'form-check-label' do
- = s_('OutboundRequests|Enforce DNS rebinding attack protection')
- %span.form-text.text-muted
- = s_('OutboundRequests|Resolve IP addresses once and uses them to submit requests.')
+ = f.gitlab_ui_checkbox_component :dns_rebinding_protection_enabled,
+ s_('OutboundRequests|Enforce DNS rebinding attack protection'),
+ help_text: _('OutboundRequests|Resolve IP addresses once and uses them to submit requests.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index d14c8cffcc7..74903d52f25 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -1,26 +1,20 @@
-= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-pages-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-pages-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :pages_domain_verification_enabled, class: 'form-check-input'
- = f.label :pages_domain_verification_enabled, class: 'form-check-label' do
- = s_("AdminSettings|Require users to prove ownership of custom domains")
- .form-text.text-muted
- - pages_link_url = help_page_path('administration/pages/index', anchor: 'custom-domain-verification')
- - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url }
- = s_('AdminSettings|Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled. %{link_start}Learn more.%{link_end}').html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe }
+ - pages_link_url = help_page_path('administration/pages/index', anchor: 'custom-domain-verification')
+ - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url }
+ = f.gitlab_ui_checkbox_component :pages_domain_verification_enabled,
+ s_("AdminSettings|Require users to prove ownership of custom domains"),
+ help_text: s_('AdminSettings|Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled. %{link_start}Learn more.%{link_end}').html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe }
- if Gitlab.config.pages.access_control
.form-group
- .form-check
- = f.check_box :force_pages_access_control, class: 'form-check-input'
- = f.label :force_pages_access_control, class: 'form-check-label' do
- = s_("AdminSettings|Disable public access to Pages sites")
- .form-text.text-muted
- - pages_link_url = help_page_path('administration/pages/index', anchor: 'disable-public-access-to-all-pages-sites')
- - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url }
- = s_("AdminSettings|Select to disable public access for Pages sites, which requires users to sign in for access to the Pages sites in your instance. %{link_start}Learn more.%{link_end}").html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe }
+ - pages_link_url = help_page_path('administration/pages/index', anchor: 'disable-public-access-to-all-pages-sites')
+ - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url }
+ = f.gitlab_ui_checkbox_component :force_pages_access_control,
+ s_("AdminSettings|Disable public access to Pages sites"),
+ help_text: s_("AdminSettings|Select to disable public access for Pages sites, which requires users to sign in for access to the Pages sites in your instance. %{link_start}Learn more.%{link_end}").html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe }
.form-group
= f.label :max_pages_size, _('Maximum size of pages (MB)'), class: 'label-bold'
= f.number_field :max_pages_size, class: 'form-control gl-form-input'
@@ -41,10 +35,8 @@
- pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url }
= s_("AdminSettings|A Let's Encrypt account will be configured for this GitLab instance using this email address. You will receive emails to warn of expiring certificates. %{link_start}Learn more.%{link_end}").html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe }
.form-group
- .form-check
- = f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input'
- = f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do
- - terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path }
- = s_("AdminSettings|I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end} (PDF).").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
+ - terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path }
+ = f.gitlab_ui_checkbox_component :lets_encrypt_terms_of_service_accepted,
+ s_("AdminSettings|I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end} (PDF).").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
index 82e56cf8b81..e0ba8d93fbd 100644
--- a/app/views/admin/application_settings/_performance.html.haml
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -1,15 +1,12 @@
-= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-performance-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-performance-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :authorized_keys_enabled, class: 'form-check-input'
- = f.label :authorized_keys_enabled, class: 'form-check-label' do
- = _('Use authorized_keys file to authenticate SSH keys')
- .form-text.text-muted
- = _('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'
+ - help_link = help_page_path('administration/operations/fast_ssh_key_lookup')
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_link }
+ = f.gitlab_ui_checkbox_component :authorized_keys_enabled, _('Use authorized_keys file to authenticate SSH keys'),
+ help_text: _('Authenticate user SSH keys without requiring additional configuration. Performance of GitLab can be improved by using the GitLab database instead. %{link_start}How do I configure authentication using the GitLab database? %{link_end}').html_safe % { link_start: help_link_start, link_end: '</a>'.html_safe}
.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'
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
index 58ea2be8b61..4e37c4c3c98 100644
--- a/app/views/admin/application_settings/_performance_bar.html.haml
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -1,12 +1,11 @@
-= form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-performance-bar-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-performance-bar-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :performance_bar_enabled, class: 'form-check-input', data: { qa_selector: 'enable_performance_bar_checkbox'}
- = f.label :performance_bar_enabled, class: 'form-check-label' do
- = _("Allow non-administrators access to the performance bar")
+ = f.gitlab_ui_checkbox_component :performance_bar_enabled,
+ s_("Allow non-administrators access to the performance bar"),
+ checkbox_options: { data: { qa_selector: 'enable_performance_bar_checkbox' } }
.form-group
= f.label :performance_bar_allowed_group_path, _('Allow access to members of the following group'), class: 'label-bold'
= f.text_field :performance_bar_allowed_group_path, class: 'form-control gl-form-input', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index 39de15dc38d..42914652655 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -9,14 +9,13 @@
= _('Render diagrams in your documents using PlantUML.')
= link_to _('Learn more.'), help_page_path('administration/integration/plantuml.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form', id: 'plantuml-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form', id: 'plantuml-settings' } do |f|
= form_errors(@application_setting) if expanded
%fieldset
.form-group
- .form-check
- = f.check_box :plantuml_enabled, class: 'form-check-input'
- = f.label :plantuml_enabled, _('Enable PlantUML'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :plantuml_enabled,
+ _('Enable PlantUML')
.form-group
= f.label :plantuml_url, _('PlantUML URL'), class: 'label-bold'
= f.text_field :plantuml_url, class: 'form-control gl-form-input', placeholder: 'http://your-plantuml-instance:8080'
diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml
index d273c81f51d..1f3f67c71c7 100644
--- a/app/views/admin/application_settings/_protected_paths.html.haml
+++ b/app/views/admin/application_settings/_protected_paths.html.haml
@@ -1,14 +1,11 @@
-= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-protected-paths-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-protected-paths-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :throttle_protected_paths_enabled, class: 'form-check-input'
- = f.label :throttle_protected_paths_enabled, class: 'form-check-label' do
- = _('Enable rate limiting for POST requests to the specified paths')
- %span.form-text.text-muted
- = _('Helps reduce request volume for protected paths.')
+ = f.gitlab_ui_checkbox_component :throttle_protected_paths_enabled,
+ _('Enable rate limiting for POST requests to the specified paths'),
+ help_text: _('Helps reduce request volume for protected paths.')
.form-group
= f.label :throttle_protected_paths_requests_per_period, 'Maximum requests per period per user', class: 'label-bold'
= f.number_field :throttle_protected_paths_requests_per_period, class: 'form-control gl-form-input'
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index 364a7cf5a8e..eb1f94a2f04 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
@@ -6,14 +6,13 @@
= f.label :container_registry_token_expire_delay, _('Authorization token duration (minutes)'), class: 'label-bold'
= f.number_field :container_registry_token_expire_delay, class: 'form-control gl-form-input'
.form-group
- .form-check
- = f.check_box :container_expiration_policies_enable_historic_entries, class: 'form-check-input'
- = f.label :container_expiration_policies_enable_historic_entries, class: 'form-check-label' do
- = _("Enable container expiration and retention policies for projects created earlier than GitLab 12.7.")
- = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy')
- .form-text.text-muted
- = _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
- = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'use-with-external-container-registries')
+ - label = _("Enable container expiration and retention policies for projects created earlier than GitLab 12.7.")
+ - label_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy')
+ - help_text = _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
+ - help_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'use-with-external-container-registries')
+ = f.gitlab_ui_checkbox_component :container_expiration_policies_enable_historic_entries,
+ '%{label} %{label_link}'.html_safe % { label: label, label_link: label_link },
+ help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
- if container_registry_expiration_policies_throttling?
.form-group
= f.label :container_registry_delete_tags_service_timeout, _('Cleanup policy maximum processing time (seconds)'), class: 'label-bold'
@@ -31,12 +30,9 @@
.form-text.text-muted
= _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.")
.form-group
- .form-check
- = f.check_box :container_registry_expiration_policies_caching, class: 'form-check-input'
- = f.label :container_registry_expiration_policies_caching, class: 'form-check-label' do
- = _("Enable container expiration caching.")
- .form-text.text-muted
- = _("When enabled, cleanup polices execute faster but put more load on Redis.")
- = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'set-cleanup-limits-to-conserve-resources')
+ - help_text = _("When enabled, cleanup polices execute faster but put more load on Redis.")
+ - help_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'set-cleanup-limits-to-conserve-resources')
+ = f.gitlab_ui_checkbox_component :container_registry_expiration_policies_caching, _("Enable container expiration caching."),
+ help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index ce81f81c125..c2087efa650 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -1,16 +1,13 @@
-= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-check-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-check-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.sub-section
%h4= _("Repository checks")
.form-group
- .form-check
- = f.check_box :repository_checks_enabled, class: 'form-check-input'
- = f.label :repository_checks_enabled, class: 'form-check-label' do
- = _("Enable repository checks")
- .form-text.text-muted
- = html_escape(s_('Run %{code_start}git fsck%{code_end} periodically in all project and wiki repositories to look for silent disk corruption issues.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
+ = f.gitlab_ui_checkbox_component :repository_checks_enabled,
+ _("Enable repository checks"),
+ help_text: html_escape(s_('Run %{code_start}git fsck%{code_end} periodically in all project and wiki repositories to look for silent disk corruption issues.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
.form-group
.form-text.text-muted
= _("If you get a lot of false alarms from repository checks, you can clear all repository check information from the database.")
@@ -21,20 +18,11 @@
.sub-section
%h4= _("Housekeeping")
.form-group
- .form-check
- = f.check_box :housekeeping_enabled, class: 'form-check-input'
- = f.label :housekeeping_enabled, class: 'form-check-label' do
- = _("Enable automatic repository housekeeping")
- .form-text.text-muted
- = _("Leaving this setting enabled is recommended.")
- = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'housekeeping-options'), target: '_blank', rel: 'noopener noreferrer'
- .form-check
- = f.check_box :housekeeping_bitmaps_enabled, class: 'form-check-input'
- = f.label :housekeeping_bitmaps_enabled, class: 'form-check-label' do
- = _("Enable Git pack file bitmap creation")
- .form-text.text-muted
- = _("Improves Git cloning performance.")
- = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'housekeeping-options'), target: '_blank', rel: 'noopener noreferrer'
+ - help_text = _("Leaving this setting enabled is recommended.")
+ - help_link = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'housekeeping-options'), target: '_blank', rel: 'noopener noreferrer'
+ = f.gitlab_ui_checkbox_component :housekeeping_enabled,
+ _("Enable automatic repository housekeeping"),
+ help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
.form-group
= f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'label-bold'
= f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input'
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index 0c9b04c02d1..dad8d5f3fae 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -1,15 +1,11 @@
-= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-mirror-settings') do |f|
+= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-mirror-settings') do |f|
= form_errors(@application_setting)
%fieldset
.form-group
= f.label :mirror_available, _('Repository mirroring configuration'), class: 'label-bold'
- .form-check
- = f.check_box :mirror_available, class: 'form-check-input'
- = f.label :mirror_available, class: 'form-check-label' do
- = _('Allow project maintainers to configure repository mirroring')
- %span.form-text.text-muted
- = _('If disabled, only administrators can configure repository mirroring.')
+ = f.gitlab_ui_checkbox_component :mirror_available, _('Allow project maintainers to configure repository mirroring'),
+ help_text: _('If disabled, only administrators can configure repository mirroring.')
= render_if_exists 'admin/application_settings/mirror_settings', form: f
diff --git a/app/views/admin/application_settings/_sentry.html.haml b/app/views/admin/application_settings/_sentry.html.haml
index 5fd373d59e9..cfd34f6ca15 100644
--- a/app/views/admin/application_settings/_sentry.html.haml
+++ b/app/views/admin/application_settings/_sentry.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-sentry-settings'), html: { class: 'fieldset-form', id: 'sentry-settings' } do |f|
+= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-sentry-settings'), html: { class: 'fieldset-form', id: 'sentry-settings' } do |f|
= form_errors(@application_setting)
%span.text-muted
@@ -6,9 +6,7 @@
%fieldset
.form-group
- .form-check
- = f.check_box :sentry_enabled, class: 'form-check-input'
- = f.label :sentry_enabled, _('Enable Sentry error tracking'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :sentry_enabled, _('Enable Sentry error tracking')
.form-group
= f.label :sentry_dsn, _('DSN'), class: 'label-light'
= f.text_field :sentry_dsn, class: 'form-control gl-form-input', placeholder: 'https://public@sentry.example.com/1'
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index a658ba63939..85cf43ba5c2 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -16,6 +16,6 @@
domain_denylist_raw: @application_setting.domain_denylist_raw,
email_restrictions_enabled: @application_setting[:email_restrictions_enabled].to_s,
supported_syntax_link_url: 'https://github.com/google/re2/wiki/Syntax',
- email_restrictions: @application_setting.email_restrictions,
- after_sign_up_text: @application_setting[:after_sign_up_text],
+ email_restrictions: @application_setting.email_restrictions.to_s,
+ after_sign_up_text: @application_setting[:after_sign_up_text].to_s,
pending_user_count: pending_user_count } }
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index f7a6a26c645..378c1712ae0 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -9,14 +9,12 @@
- 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', 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|
+ = gitlab_ui_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
%fieldset
.form-group
- .form-check
- = f.check_box :snowplow_enabled, class: 'form-check-input', data: { qa_selector: 'snowplow_enabled_checkbox' }
- = f.label :snowplow_enabled, _('Enable Snowplow tracking'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :snowplow_enabled, _('Enable Snowplow tracking'), checkbox_options: { data: { qa_selector: 'snowplow_enabled_checkbox' } }
.form-group
= f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light'
= f.text_field :snowplow_collector_hostname, class: 'form-control gl-form-input', placeholder: 'snowplow.example.com'
diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml
index 65b2a95bcc1..391f79e431b 100644
--- a/app/views/admin/application_settings/_sourcegraph.html.haml
+++ b/app/views/admin/application_settings/_sourcegraph.html.haml
@@ -16,20 +16,14 @@
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form', id: 'sourcegraph-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form', id: 'sourcegraph-settings' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :sourcegraph_enabled, class: 'form-check-input'
- = f.label :sourcegraph_enabled, s_('SourcegraphAdmin|Enable Sourcegraph'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :sourcegraph_enabled, s_('SourcegraphAdmin|Enable Sourcegraph')
.form-group
- .form-check
- = f.check_box :sourcegraph_public_only, class: 'form-check-input'
- = f.label :sourcegraph_public_only, s_('SourcegraphAdmin|Block on private and internal projects'), class: 'form-check-label'
- .form-text.text-muted
- = s_('SourcegraphAdmin|Only public projects have code intelligence enabled and communicate with Sourcegraph.')
+ = f.gitlab_ui_checkbox_component :sourcegraph_public_only, s_('SourcegraphAdmin|Block on private and internal projects'), help_text: s_('SourcegraphAdmin|Only public projects have code intelligence enabled and communicate with Sourcegraph.')
.form-group
= f.label :sourcegraph_url, s_('SourcegraphAdmin|Sourcegraph URL'), class: 'label-bold'
= f.text_field :sourcegraph_url, class: 'form-control gl-form-input', placeholder: s_('SourcegraphAdmin|https://sourcegraph.example.com')
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
index 27113fddb27..bb512940be2 100644
--- a/app/views/admin/application_settings/_spam.html.haml
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-spam-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-spam-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
@@ -8,20 +8,13 @@
= _('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'
- = f.label :recaptcha_enabled, class: 'form-check-label' do
- = _("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'
+ - spam_help_link_url = help_page_path('integration/recaptcha.md')
+ - spam_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: spam_help_link_url }
+ = f.gitlab_ui_checkbox_component :recaptcha_enabled, _("Enable reCAPTCHA"),
+ help_text: _('Helps prevent bots from creating accounts. %{link_start}How do I configure it?%{link_end}').html_safe % { link_start: spam_help_link_start, link_end: '</a>'.html_safe }
.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.')
- %span.form-text.text-muted#recaptcha_help_block
- = _('Helps prevent bots from brute-force attacks.')
+ = f.gitlab_ui_checkbox_component :login_recaptcha_protection_enabled, _('Enable reCAPTCHA for login.'),
+ help_text: _('Helps prevent bots from brute-force attacks.')
.form-group
= f.label :recaptcha_site_key, _('reCAPTCHA site key'), class: 'label-bold'
= f.text_field :recaptcha_site_key, class: 'form-control gl-form-input'
@@ -40,12 +33,8 @@
= 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'
- = f.label :invisible_captcha_enabled, class: 'form-check-label' do
- = _('Enable Invisible Captcha during sign up')
- %span.form-text.text-muted
- = _('Helps prevent bots from creating accounts.')
+ = f.gitlab_ui_checkbox_component :invisible_captcha_enabled, _('Enable Invisible Captcha during sign up'),
+ help_text: _('Helps prevent bots from creating accounts.')
%h5
= _('Akismet')
@@ -54,11 +43,8 @@
= 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.")
+ = f.gitlab_ui_checkbox_component :akismet_enabled, _('Enable Akismet'),
+ help_text: _("Helps prevent bots from creating issues.")
.form-group
= f.label :akismet_api_key, _('Akismet API Key'), class: 'label-bold'
@@ -71,12 +57,8 @@
= _('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 IP addresses")
- %span.form-text.text-muted#unique_ip_help_block
- = _("Helps prevent malicious users hide their activity.")
+ = f.gitlab_ui_checkbox_component :unique_ips_limit_enabled, _("Limit sign in from multiple IP addresses"),
+ help_text: _("Helps prevent malicious users hide their activity.")
.form-group
= f.label :unique_ips_limit_per_user, _('IP addresses per user'), class: 'label-bold'
@@ -94,10 +76,8 @@
= _('Spam Check')
.form-group
- .form-check
- = f.check_box :spam_check_endpoint_enabled, class: 'form-check-input'
- = f.label :spam_check_endpoint_enabled, _('Enable Spam Check via external API endpoint'), class: 'form-check-label'
- .form-text.text-muted= _('Define custom rules for what constitutes spam, independent of Akismet')
+ = f.gitlab_ui_checkbox_component :spam_check_endpoint_enabled, _('Enable Spam Check via external API endpoint'),
+ help_text: _('Define custom rules for what constitutes spam, independent of Akismet')
.form-group
= f.label :spam_check_endpoint_url, _('URL of the external Spam Check endpoint'), class: 'label-bold'
= f.text_field :spam_check_endpoint_url, class: 'form-control gl-form-input'
diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml
index fdf79004c45..a4b6e061c43 100644
--- a/app/views/admin/application_settings/_terms.html.haml
+++ b/app/views/admin/application_settings/_terms.html.haml
@@ -1,13 +1,9 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terms-settings'), html: { class: 'fieldset-form', id: 'terms-settings' } do |f|
+= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terms-settings'), html: { class: 'fieldset-form', id: 'terms-settings' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :enforce_terms, class: 'form-check-input'
- = f.label :enforce_terms, class: 'form-check-label' do
- = _("All users must accept the Terms of Service and Privacy Policy to access GitLab")
- .form-text.text-muted
+ = f.gitlab_ui_checkbox_component :enforce_terms, _("All users must accept the Terms of Service and Privacy Policy to access GitLab")
.form-group
= f.label :terms do
= _("Terms of Service Agreement and Privacy Policy")
diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml
index 231c45ec46c..a62e730ee89 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -8,13 +8,12 @@
%p
= _('Control whether to display customer experience improvement content and third-party offers in GitLab.')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f|
= form_errors(@application_setting) if expanded
%fieldset
.form-group
- .form-check
- = f.check_box :hide_third_party_offers, class: 'form-check-input'
- = f.label :hide_third_party_offers, _('Do not display content for customer experience improvement and offers from third parties'), class: 'form-check-label'
+ = f.gitlab_ui_checkbox_component :hide_third_party_offers,
+ _('Do not display content for customer experience improvement and offers from third parties')
= 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 02031880fab..a1285a3f467 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -1,68 +1,53 @@
- payload_class = 'js-service-ping-payload'
+- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
+- link_end = '</a>'.html_safe
-= form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
- .form-group.mb-2
- .form-check
- = f.check_box :version_check_enabled, class: 'form-check-input'
- = f.label :version_check_enabled, class: 'form-check-label' do
- = _("Enable version check")
- .form-text.text-muted
- = _("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
+ - help_link_start = link_start % { url: help_page_path('user/admin_area/settings/usage_statistics', anchor: 'version-check') }
+ = f.gitlab_ui_checkbox_component :version_check_enabled, _('Enable version check'),
+ help_text: _("GitLab informs you if a new version is available. %{link_start}What information does GitLab Inc. collect?%{link_end}").html_safe % { link_start: help_link_start, link_end: link_end }
.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', data: { qa_selector: 'enable_usage_data_checkbox' }
- = f.label :usage_ping_enabled, class: 'form-check-label' do
- = _('Enable Service Ping')
- .form-text.text-muted
- - if can_be_configured
- %p.mb-2= _('To help improve GitLab and its user experience, GitLab periodically collects usage information.')
-
- - 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}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_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2')
- .js-text.gl-display-inline= _('Preview payload')
- %pre.service-data-payload-container.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
- - else
- = _('Service ping is disabled in your configuration file, and cannot be enabled through this form.')
- - deactivating_service_ping_path = help_page_path('development/service_ping/index.md', anchor: 'disable-service-ping-using-the-configuration-file')
- - deactivating_service_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_service_ping_path }
- = s_('For more information, see the documentation on %{deactivating_service_ping_link_start}deactivating service ping%{deactivating_service_ping_link_end}.').html_safe % { deactivating_service_ping_link_start: deactivating_service_ping_link_start, deactivating_service_ping_link_end: '</a>'.html_safe }
+ - service_ping_link_start = link_start % { url: help_page_path('development/service_ping/index') }
+ - deactivating_service_ping_link_start = link_start % { url: help_page_path('development/service_ping/index', anchor: 'disable-service-ping-using-the-configuration-file') }
+ - usage_ping_help_text = s_('AdminSettings|To help improve GitLab and its user experience, GitLab periodically collects usage information. %{link_start}What information is shared with GitLab Inc.?%{link_end}').html_safe % { link_start: service_ping_link_start, link_end: link_end }
+ - disabled_help_text = s_('AdminSettings|Service ping is disabled in your configuration file, and cannot be enabled through this form. For more information, see the documentation on %{link_start}deactivating service ping%{link_end}.').html_safe % { link_start: deactivating_service_ping_link_start, link_end: link_end }
+ = f.gitlab_ui_checkbox_component :usage_ping_enabled, s_('AdminSettings|Enable Service Ping'),
+ help_text: can_be_configured ? usage_ping_help_text : disabled_help_text,
+ checkbox_options: { disabled: !can_be_configured, data: { qa_selector: 'enable_usage_data_checkbox' } }
+ .form-text.gl-pl-6
+ - if can_be_configured
+ %button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
+ = gl_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2')
+ .js-text.gl-display-inline= s_('AdminSettings|Preview payload')
+ %pre.service-data-payload-container.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
.form-group
- usage_ping_enabled = @application_setting.usage_ping_enabled?
- .form-check
- = f.check_box :usage_ping_features_enabled?, disabled: !usage_ping_enabled, class: 'form-check-input'
- = f.label :usage_ping_features_enabled?, class: 'form-check-label gl-cursor-not-allowed', id: 'service_ping_features_label' do
- = _('Enable Registration Features')
- = link_to sprite_icon('question-o'), help_page_path('development/service_ping/index.md', anchor: 'registration-features-program')
- .form-text.text-muted
- - 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, 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 }
+ - label = s_('AdminSettings|Enable Registration Features')
+ - label_link = link_to sprite_icon('question-o'), help_page_path('development/service_ping/index', anchor: 'registration-features-program')
+ - help_text = usage_ping_enabled ? s_('AdminSettings|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.') : s_('AdminSettings|To enable Registration Features, first enable Service Ping.')
+ = f.gitlab_ui_checkbox_component :usage_ping_features_enabled?, '%{label} %{label_link}'.html_safe % { label: label, label_link: label_link },
+ help_text: '<span id="service_ping_features_helper_text">%{help_text}</span>'.html_safe % { help_text: help_text },
+ checkbox_options: { id: 'application_setting_usage_ping_features_enabled' },
+ label_options: { id: 'service_ping_features_label' }
+ .form-text.gl-text-gray-500.gl-pl-6
+ %p.gl-mb-3= s_('AdminSettings|Registration Features include:')
+ - email_from_gitlab_path = help_page_path('user/admin_area/email_from_gitlab')
+ - repo_size_limit_path = help_page_path('user/admin_area/settings/account_and_limit_settings', anchor: 'repository-size-limit')
+ - restrict_ip_path = help_page_path('user/group/index', anchor: 'restrict-group-access-by-ip-address')
+ - email_from_gitlab_link = link_start % { url: email_from_gitlab_path }
+ - repo_size_limit_link = link_start % { url: repo_size_limit_path }
+ - restrict_ip_link = link_start % { url: restrict_ip_path }
+ %ul
+ %li
+ = s_('AdminSettings|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
+ = s_('AdminSettings|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
+ = s_('AdminSettings|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/_users_api_limits.html.haml b/app/views/admin/application_settings/_users_api_limits.html.haml
index e9b657f8942..9b3502b3cfd 100644
--- a/app/views/admin/application_settings/_users_api_limits.html.haml
+++ b/app/views/admin/application_settings/_users_api_limits.html.haml
@@ -7,8 +7,8 @@
= f.number_field :users_get_by_id_limit, class: 'form-control gl-form-input'
.form-group
= f.label :users_get_by_id_limit_allowlist_raw, _('Users to exclude from the rate limit'), class: 'label-bold'
- = f.text_area :users_get_by_id_limit_allowlist_raw, placeholder: 'username1, username2', class: 'form-control gl-form-input', rows: 5
- .form-text.text-muted
+ = f.text_area :users_get_by_id_limit_allowlist_raw, class: 'form-control gl-form-input', rows: 5, aria: { describedBy: 'users-api-limit-users-allowlist-field-description' }
+ .form-text.text-muted{ id: 'users-api-limit-users-allowlist-field-description' }
= _('List of users allowed to exceed the rate limit.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index b0810d3d48a..23649bc2d54 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -17,18 +17,16 @@
= render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new)
.form-group
= f.label :restricted_visibility_levels, class: 'label-bold'
- - checkbox_name = 'application_setting[restricted_visibility_levels][]'
- = hidden_field_tag(checkbox_name)
- - restricted_level_checkboxes('restricted-visibility-help', checkbox_name, class: 'form-check-input').each do |level|
- .form-check
- = level
+ = hidden_field_tag 'application_setting[restricted_visibility_levels][]'
+ - restricted_level_checkboxes(f).each do |level|
+ = level
%span.form-text.text-muted#restricted-visibility-help
= _('Selected levels cannot be used by non-admin users for groups, projects or snippets. If the public level is restricted, user profiles are only visible to logged in users.')
.form-group
= f.label :import_sources, class: 'label-bold'
= hidden_field_tag 'application_setting[import_sources][]'
- - import_sources_checkboxes('import-sources-help', class: 'form-check-input').each do |source|
- .form-check= source
+ - import_sources_checkboxes(f).each do |source|
+ = source
%span.form-text.text-muted#import-sources-help
= _('Enabled sources for code import during project creation. OmniAuth must be configured for GitHub')
= link_to sprite_icon('question-o'), help_page_path("integration/github")
@@ -40,10 +38,7 @@
= render_if_exists 'admin/application_settings/ldap_access_setting', form: f
.form-group
- .form-check
- = f.check_box :project_export_enabled, class: 'form-check-input'
- = f.label :project_export_enabled, class: 'form-check-label' do
- = _('Project export enabled')
+ = f.gitlab_ui_checkbox_component :project_export_enabled, s_('AdminSettings|Project export enabled')
.form-group
%label.label-bold= _('Enabled Git access protocols')
@@ -57,7 +52,7 @@
%span.form-text.text-muted#custom_http_clone_url_root_help_block
= _('Replaces the clone URL root.')
- - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
+ - Gitlab::SSHPublicKey.supported_types.each do |type|
- field_name = :"#{type}_key_restriction"
.form-group
= f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold'
@@ -65,9 +60,6 @@
.form-group
%label.label-bold= s_('AdminSettings|Feed token')
- .form-check
- = f.check_box :disable_feed_token, class: 'form-check-input'
- = f.label :disable_feed_token, class: 'form-check-label' do
- = s_('AdminSettings|Disable feed token')
+ = f.gitlab_ui_checkbox_component :disable_feed_token, s_('AdminSettings|Disable feed token')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index 84c26da8772..5816bd42a83 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -1,6 +1,6 @@
- parsed_with_gfm = (_("Content parsed with %{link}.") % { link: link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank') }).html_safe
-= form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f|
+= gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f|
= form_errors(@appearance)
@@ -21,7 +21,7 @@
= f.hidden_field :header_logo_cache
= f.file_field :header_logo, class: "", accept: 'image/*'
.form-text.text-muted
- = _('Maximum file size is 1MB. Pages are optimized for a 28px tall header logo')
+ = _('Maximum file size is 1MB. Pages are optimized for a 24px tall header logo')
%hr
.row
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
index 1ce79e61ac6..415606c055d 100644
--- a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
@@ -14,13 +14,10 @@
= form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold'
= form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize"
.form-group
- .form-check
- = form.check_box :email_header_and_footer_enabled, class: 'form-check-input'
- = form.label :email_header_and_footer_enabled, class: 'label-bold' do
- = _('Enable header and footer in emails')
-
- .form-text.text-muted
- = _('Add header and footer to emails. Please note that color settings will only be applied within the application interface')
+ = form.gitlab_ui_checkbox_component :email_header_and_footer_enabled,
+ _('Enable header and footer in emails'),
+ help_text: _('Add header and footer to emails. Please note that color settings will only be applied within the application interface'),
+ label_options: { class: 'gl-font-weight-bold!' }
.form-group.js-toggle-colors-container
%button.btn.gl-button.btn-link.js-toggle-colors-link{ type: 'button' }
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index 762dba69e6a..aab4f44d4d7 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -20,8 +20,7 @@
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Customize CI/CD settings, including Auto DevOps, shared runners, and job artifacts.')
- .settings-content
- = render 'ci_cd'
+ = render 'ci_cd'
= render_if_exists 'admin/application_settings/required_instance_ci_setting', expanded: expanded_by_default?
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 9eef4bc2a37..bc2fedec69c 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -93,18 +93,15 @@
%p
= _('Manage Web IDE features.')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f|
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
- .form-check
- = f.check_box :web_ide_clientside_preview_enabled, class: 'form-check-input'
- = f.label :web_ide_clientside_preview_enabled, class: 'form-check-label' do
- = s_('IDE|Live Preview')
- %span.form-text.text-muted
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/project/web_ide/index', anchor: 'enable-live-preview') }
- = s_('Preview JavaScript projects in the Web IDE with CodeSandbox Live Preview. %{link_start}Learn more.%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/project/web_ide/index', anchor: 'enable-live-preview') }
+ = f.gitlab_ui_checkbox_component :web_ide_clientside_preview_enabled,
+ s_('IDE|Live Preview'),
+ help_text: s_('Preview JavaScript projects in the Web IDE with CodeSandbox Live Preview. %{link_start}Learn more.%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
= render_if_exists 'admin/application_settings/maintenance_mode_settings_form'
diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml
index c3a39ddf86d..ce7972827d3 100644
--- a/app/views/admin/application_settings/repository.html.haml
+++ b/app/views/admin/application_settings/repository.html.haml
@@ -21,7 +21,7 @@
= expanded_by_default? ? 'Collapse' : 'Expand'
%p
= _('Configure repository mirroring.')
- = link_to s_('Learn more.'), help_page_path('user/project/repository/repository_mirroring.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('Learn more.'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render partial: 'repository_mirrors_form'
diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml
index d9825183d88..ec084c05cf7 100644
--- a/app/views/admin/application_settings/service_usage_data.html.haml
+++ b/app/views/admin/application_settings/service_usage_data.html.haml
@@ -7,10 +7,23 @@
%h3= name
-%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
- .js-text.gl-display-inline= _('Preview payload')
-%button.gl-button.btn.btn-default.js-payload-download-trigger{ type: 'button', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } }
- .gl-spinner.js-spinner.gl-display-none.gl-mr-2
- .js-text.d-inline= _('Download payload')
-%pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
+- if @service_ping_data_present
+ %button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
+ = gl_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2')
+ .js-text.gl-display-inline= _('Preview payload')
+ %button.gl-button.btn.btn-default.js-payload-download-trigger{ type: 'button', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } }
+ = gl_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2')
+ .js-text.d-inline= _('Download payload')
+ %pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
+- else
+ = render Pajamas::AlertComponent.new(variant: :warning,
+ dismissible: false,
+ title: _('Service Ping payload not found in the application cache')) do
+
+ .gl-alert-body
+ - enable_service_ping_link_url = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics')
+ - enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url }
+ - generate_manually_link_url = help_page_path('administration/troubleshooting/gitlab_rails_cheat_sheet', anchor: 'generate-service-ping')
+ - generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url }
+
+ = html_escape(s_('%{enable_service_ping_link_start}Enable%{link_end} or %{generate_manually_link_start}generate%{link_end} Service Ping to preview and download service usage data payload.')) % { enable_service_ping_link_start: enable_service_ping_link, generate_manually_link_start: generate_manually_link, link_end: '</a>'.html_safe }
diff --git a/app/views/admin/applications/_delete_form.html.haml b/app/views/admin/applications/_delete_form.html.haml
index 16ec8014c5e..f9fd5864176 100644
--- a/app/views/admin/applications/_delete_form.html.haml
+++ b/app/views/admin/applications/_delete_form.html.haml
@@ -1,5 +1,4 @@
+- submit_btn_css ||= 'gl-button btn btn-danger btn-danger-secondary btn-sm js-application-delete-button'
-- submit_btn_css ||= 'gl-button btn btn-danger btn-sm js-application-delete-button'
%button{ class: submit_btn_css, data: { path: admin_application_path(application), name: application.name } }
= _('Destroy')
-
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index a1990ad5750..925b3681298 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [:admin, @application], url: @url, html: {role: 'form'} do |f|
+= gitlab_ui_form_for [:admin, @application], url: @url, html: {role: 'form'} do |f|
= form_errors(application)
= content_tag :div, class: 'form-group row' do
@@ -45,7 +45,7 @@
.col-sm-2.col-form-label.pt-0
= f.label :scopes
.col-sm-10
- = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
+ = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes, f: f
.form-actions
= f.submit _('Save application'), class: "gl-button btn btn-confirm wide"
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index 86a4ab00ba3..890155ee604 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -1,36 +1,51 @@
-- page_title _("Applications")
+- page_title s_('AdminArea|Instance OAuth applications')
+
%h3.page-title
- = _('System OAuth applications')
+ = s_('AdminArea|Instance OAuth applications')
%p.light
- = _('System OAuth applications don\'t belong to any user and can only be managed by admins')
-%hr
-%p= link_to _('New application'), new_admin_application_path, class: 'gl-button btn btn-confirm'
-.table-responsive
- %table.table
- %thead
- %tr
- %th
- = _('Name')
- %th
- = _('Callback URL')
- %th
- = _('Clients')
- %th
- = _('Trusted')
- %th
- = _('Confidential')
- %th
- %th
- %tbody.oauth-applications
- - @applications.each do |application|
- %tr{ :id => "application_#{application.id}" }
- %td= link_to application.name, admin_application_path(application)
- %td= application.redirect_uri
- %td= @application_counts[application.id].to_i
- %td= application.trusted? ? _('Yes'): _('No')
- %td= application.confidential? ? _('Yes'): _('No')
- %td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link'
- %td= render 'delete_form', application: application
+ - docs_link_path = help_page_path('integration/oauth_provider')
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: docs_link_path }
+ = s_('AdminArea|Manage applications for your instance that can use GitLab as an %{docs_link_start}OAuth provider%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
+
+- if @applications.empty?
+ %section.empty-state.gl-text-center.gl-display-flex.gl-flex-direction-column
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-admin-apps.svg', class: 'gl-max-w-full'
+
+ .gl-max-w-full.gl-m-auto
+ %h1.h4.gl-font-size-h-display= s_('AdminArea|No applications found')
+ = link_to _('New application'), new_admin_application_path, class: 'btn gl-button btn-confirm'
+
+- else
+ %hr
+ %p= link_to _('New application'), new_admin_application_path, class: 'gl-button btn btn-confirm'
+
+ .table-responsive
+ %table.b-table.gl-table.gl-w-full{ role: 'table' }
+ %thead
+ %tr
+ %th
+ = _('Name')
+ %th
+ = _('Callback URL')
+ %th
+ = _('Clients')
+ %th
+ = _('Trusted')
+ %th
+ = _('Confidential')
+ %th
+ %th
+ %tbody.oauth-applications
+ - @applications.each do |application|
+ %tr{ id: "application_#{application.id}" }
+ %td= link_to application.name, admin_application_path(application)
+ %td= application.redirect_uri
+ %td= @application_counts[application.id].to_i
+ %td= application.trusted? ? _('Yes'): _('No')
+ %td= application.confidential? ? _('Yes'): _('No')
+ %td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link'
+ %td= render 'delete_form', application: application
= paginate @applications, theme: 'gitlab'
diff --git a/app/views/admin/background_migrations/_migration.html.haml b/app/views/admin/background_migrations/_migration.html.haml
index b6077bc54d6..9cef8332259 100644
--- a/app/views/admin/background_migrations/_migration.html.haml
+++ b/app/views/admin/background_migrations/_migration.html.haml
@@ -7,7 +7,7 @@
- else
= _('Unknown')
%td{ role: 'cell', data: { label: _('Status') } }
- = gl_badge_tag migration.status.humanize, { size: :sm, variant: batched_migration_status_badge_variant(migration) }
+ = gl_badge_tag migration.status_name.to_s.humanize, { size: :sm, variant: batched_migration_status_badge_variant(migration) }
%td{ role: 'cell', data: { label: _('Action') } }
- if migration.active?
= button_to pause_admin_background_migration_path(migration),
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 3e698f0508c..4102918931f 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -1,20 +1,5 @@
-.broadcast-message.broadcast-banner-message.gl-alert-warning.js-broadcast-banner-message-preview.gl-mt-3{ style: broadcast_message_style(@broadcast_message), class: ('gl-display-none' unless @broadcast_message.banner? ) }
- .gl-alert-container
- = sprite_icon('bullhorn', css_class: 'vertical-align-text-top')
- .js-broadcast-message-preview
- .gl-alert-content
- - if @broadcast_message.message.present?
- = render_broadcast_message(@broadcast_message)
- - else
- = _('Your message here')
-.d-flex.justify-content-center
- .broadcast-message.broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) }
- = sprite_icon('bullhorn', css_class: 'vertical-align-text-top')
- .js-broadcast-message-preview
- - if @broadcast_message.message.present?
- = render_broadcast_message(@broadcast_message)
- - else
- = _('Your message here')
+#broadcast-message-preview
+ = render 'preview'
= gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
= form_errors(@broadcast_message)
@@ -34,19 +19,10 @@
= f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type'
.form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner? ) }
.col-sm-2.col-form-label
- = f.label :color, _("Background color")
+ = f.label :theme, _("Theme")
.col-sm-10
.input-group
- .input-group-prepend
- .input-group-text.label-color-preview{ :style => 'background-color: ' + @broadcast_message.color + '; color: ' + @broadcast_message.font }
- = '&nbsp;'.html_safe
- = f.text_field :color, class: "form-control gl-form-input js-broadcast-message-color"
- .form-text.text-muted
- = _('Choose any color.')
- %br
- = _("Or you can choose one of the suggested colors below")
-
- = render_suggested_colors
+ = f.select :theme, broadcast_theme_options, {}, class: 'form-control js-broadcast-message-theme'
.form-group.row.js-broadcast-message-dismissable-form-group{ class: ('hidden' unless @broadcast_message.banner? ) }
.col-sm-2.col-form-label.pt-0
diff --git a/app/views/admin/broadcast_messages/_preview.html.haml b/app/views/admin/broadcast_messages/_preview.html.haml
new file mode 100644
index 00000000000..56168926a6e
--- /dev/null
+++ b/app/views/admin/broadcast_messages/_preview.html.haml
@@ -0,0 +1,3 @@
+.js-broadcast-banner-message-preview
+ = render "shared/broadcast_message", { message: @broadcast_message, preview: true } do
+ = _('Your message here')
diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
index aced997bada..9b994b757f9 100644
--- a/app/views/admin/dashboard/_security_newsletter_callout.html.haml
+++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
@@ -1,11 +1,12 @@
- 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',
- alert_data: { feature_id: Users::CalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' },
- close_button_data: { testid: 'close-security-newsletter-callout' } do
+= render Pajamas::AlertComponent.new(variant: :tip,
+ title: s_('AdminArea|Get security updates from GitLab and stay up to date'),
+ alert_class: 'js-security-newsletter-callout',
+ alert_data: { feature_id: Users::CalloutsHelper::SECURITY_NEWSLETTER_CALLOUT,
+ dismiss_endpoint: 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
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 0c3ce1f3fa4..8ac6f63cdfb 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -18,7 +18,7 @@
.form-group.row
.offset-sm-2.col-sm-10
- = render 'shared/allow_request_access', form: f, bold_label: true
+ = render 'shared/allow_request_access', form: f
= render 'groups/group_admin_settings', f: f
@@ -27,7 +27,7 @@
- if @group.new_record?
.form-group.row
.offset-sm-2.col-sm-10
- = render 'shared/global_alert', dismissible: false do
+ = render Pajamas::AlertComponent.new(dismissible: false) do
.gl-alert-body
= render 'shared/group_tips'
.form-actions
diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml
index 17dccae44b5..2ea5890be2c 100644
--- a/app/views/admin/groups/index.html.haml
+++ b/app/views/admin/groups/index.html.haml
@@ -5,9 +5,8 @@
= form_tag admin_groups_path, method: :get, class: 'js-search-form' do |f|
= hidden_field_tag :sort, @sort
.search-holder
- - project_name = params[:name].present? ? params[:name] : nil
.search-field-holder
- = search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' }
+ = search_field_tag :name, params[:name].presence, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' }
= sprite_icon('search', css_class: 'search-icon')
= render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash
= link_to new_admin_group_path, class: "gl-button btn btn-confirm" do
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index bd63172a0ee..a309e874317 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -11,24 +11,18 @@
.form-group
= form.label :url, _('Trigger'), class: 'label-bold'
.form-text.text-secondary.gl-mb-5= _('System hooks are triggered on sets of events like creating a project or adding an SSH key. You can also enable extra triggers, such as push events.')
- %fieldset.form-group.form-check
- = form.check_box :repository_update_events, class: 'form-check-input'
- = form.label :repository_update_events, _('Repository update events'), class: 'label-bold form-check-label'
- .text-secondary= _('URL is triggered when repository is updated')
- %fieldset.form-group.form-check
- = form.check_box :push_events, class: 'form-check-input'
- = form.label :push_events, _('Push events'), class: 'label-bold form-check-label'
- .text-secondary= _('URL is triggered for each branch updated to the repository')
- %fieldset.form-group.form-check
- = form.check_box :tag_push_events, class: 'form-check-input'
- = form.label :tag_push_events, _('Tag push events'), class: 'label-bold form-check-label'
- .text-secondary= _('URL is triggered when a new tag is pushed to the repository')
- %fieldset.form-group.form-check
- = form.check_box :merge_requests_events, class: 'form-check-input'
- = form.label :merge_requests_events, _('Merge request events'), class: 'label-bold form-check-label'
- .text-secondary= _('URL is triggered when a merge request is created, updated, or merged')
+ %fieldset.form-group
+ = form.gitlab_ui_checkbox_component :repository_update_events, _('Repository update events'),
+ help_text: _('URL is triggered when repository is updated')
+ %fieldset.form-group
+ = form.gitlab_ui_checkbox_component :push_events, _('Push events'),
+ help_text: _('URL is triggered for each branch updated to the repository')
+ %fieldset.form-group
+ = form.gitlab_ui_checkbox_component :tag_push_events, _('Tag push events'),
+ help_text: _('URL is triggered when a new tag is pushed to the repository')
+ %fieldset.form-group
+ = form.gitlab_ui_checkbox_component :merge_requests_events, _('Merge request events'),
+ help_text: _('URL is triggered when a merge request is created, updated, or merged')
.form-group
= form.label :enable_ssl_verification, _('SSL verification'), class: 'label-bold checkbox'
- .form-check
- = form.check_box :enable_ssl_verification, class: 'form-check-input'
- = form.label :enable_ssl_verification, _('Enable SSL verification'), class: 'label-bold form-check-label'
+ = form.gitlab_ui_checkbox_component :enable_ssl_verification, _('Enable SSL verification')
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 566d8a99ac6..e8176e9f8bb 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -8,7 +8,7 @@
= render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-9.gl-mb-3
- = form_for @hook, as: :hook, url: admin_hook_path do |f|
+ = gitlab_ui_form_for @hook, as: :hook, url: admin_hook_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
%span>= f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3'
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index 3e2cd126e1a..f23d77c8da5 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -5,7 +5,7 @@
= render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-8.gl-mb-3
- = form_for @hook, as: :hook, url: admin_hooks_path do |f|
+ = gitlab_ui_form_for @hook, as: :hook, url: admin_hooks_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
= f.submit _('Add system hook'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml
index 3b3042b5506..a4f1ce4afc0 100644
--- a/app/views/admin/identities/index.html.haml
+++ b/app/views/admin/identities/index.html.haml
@@ -15,5 +15,3 @@
= render @identities
- else
%h4= _('This user has no identities')
-
-= render partial: 'admin/users/modals'
diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml
index 26fbba83a32..2c526bb38d8 100644
--- a/app/views/admin/impersonation_tokens/index.html.haml
+++ b/app/views/admin/impersonation_tokens/index.html.haml
@@ -28,5 +28,3 @@
impersonation: true,
active_tokens: @active_impersonation_tokens,
revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) }
-
-= render partial: 'admin/users/modals'
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index e8bcf479d70..be7055e6f7b 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -14,11 +14,9 @@
- if @project.last_repository_check_failed?
.row
.col-md-12
- = render 'shared/global_alert',
- variant: :danger,
+ = render Pajamas::AlertComponent.new(variant: :danger,
alert_class: 'gl-mb-5',
- alert_data: { testid: 'last-repository-check-failed-alert' },
- is_container: true do
+ alert_data: { testid: 'last-repository-check-failed-alert' }) do
.gl-alert-body
- last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.")
- last_check_message = last_check_message % { last_check_timestamp: time_ago_with_tooltip(@project.last_repository_check_at) }
diff --git a/app/views/admin/runners/edit.html.haml b/app/views/admin/runners/edit.html.haml
index 55fd09ac203..5570c46c17f 100644
--- a/app/views/admin/runners/edit.html.haml
+++ b/app/views/admin/runners/edit.html.haml
@@ -25,10 +25,9 @@
- if project
%tr
%td
- = render 'shared/global_alert',
- variant: :danger,
+ = render Pajamas::AlertComponent.new(variant: :danger,
dismissible: false,
- title: project.full_name do
+ title: project.full_name) do
.gl-alert-actions
= link_to _('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-confirm btn-md gl-button'
diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml
index c40484ea494..50ef375dd35 100644
--- a/app/views/admin/topics/_form.html.haml
+++ b/app/views/admin/topics/_form.html.haml
@@ -27,7 +27,7 @@
= topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s90')
= render 'shared/choose_avatar_button', f: f
- if @topic.avatar?
- .js-remove-topic-avatar{ data: { path: admin_topic_avatar_path(@topic) } }
+ .js-remove-topic-avatar{ data: { path: admin_topic_avatar_path(@topic), name: @topic.name } }
- if @topic.new_record?
.form-actions
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index 1e4c3f3bb62..51e6af56377 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -11,7 +11,7 @@
.col-sm-2.col-form-label.gl-pt-0
= f.label :can_create_group
.col-sm-10
- = f.check_box :can_create_group
+ = f.gitlab_ui_checkbox_component :can_create_group, ''
.form-group.row
.col-sm-2.col-form-label.gl-pt-0
@@ -39,10 +39,7 @@
= f.label :external
.hidden{ data: user_internal_regex_data }
.col-sm-10.gl-display-flex.gl-align-items-baseline
- = f.check_box :external do
- = s_('AdminUsers|External')
- %p.light.gl-pl-2
- = s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.')
+ = f.gitlab_ui_checkbox_component :external, s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.')
%row.hidden#warning_external_automatically_set
= gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning
@@ -50,12 +47,9 @@
- @user.credit_card_validation || @user.build_credit_card_validation
= f.fields_for :credit_card_validation do |ff|
.col-sm-2.col-form-label.gl-pt-0
- = ff.label s_("AdminUsers|Validate user account")
+ = ff.label s_('AdminUsers|Validate user account')
.col-sm-10.gl-display-flex.gl-align-items-baseline
- = ff.check_box :credit_card_validated_at, checked: @user.credit_card_validated_at.present?
- .gl-pl-2
- .light
- = s_('AdminUsers|User is validated and can use free CI minutes on shared runners.')
- .gl-text-gray-600
- = s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user.')
-
+ = ff.gitlab_ui_checkbox_component :credit_card_validated_at,
+ s_('AdminUsers|User is validated and can use free CI minutes on shared runners.'),
+ help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user.'),
+ checkbox_options: { checked: @user.credit_card_validated_at.present? }
diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml
deleted file mode 100644
index 0890990f476..00000000000
--- a/app/views/admin/users/_modals.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
-#js-delete-user-modal
-#js-modal-texts.hidden{ "hidden": true, "aria-hidden": "true" }
- %div{ data: { modal: "delete",
- title: s_("AdminUsers|Delete User %{username}?"),
- action: s_('AdminUsers|Delete user'),
- 'secondary-action': s_('AdminUsers|Block user') } }
- = s_('AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests,
- and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss,
- consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
- it cannot be undone or recovered.')
-
- %div{ data: { modal: "delete-with-contributions",
- title: s_("AdminUsers|Delete User %{username} and contributions?"),
- action: s_('AdminUsers|Delete user and contributions') ,
- 'secondary-action': s_('AdminUsers|Block user') } }
- = s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
- merge requests, and groups linked to them. To avoid data loss,
- consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
- it cannot be undone or recovered.')
-
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index ad7ce57ebda..86391b980c0 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -1,8 +1,7 @@
- if registration_features_can_be_prompted?
- = render 'shared/global_alert',
- variant: :tip,
+ = render Pajamas::AlertComponent.new(variant: :tip,
alert_class: 'gl-my-5',
- dismissible: false do
+ dismissible: false) do
.gl-alert-body
= render 'shared/registration_features_discovery_message', feature_title: s_('RegistrationFeatures|send emails to users')
@@ -68,5 +67,3 @@
= gl_loading_icon(size: 'lg', css_class: 'gl-my-7')
= paginate_collection @users
-
-= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/keys.html.haml b/app/views/admin/users/keys.html.haml
index 28024ae084f..5f9d11af7c1 100644
--- a/app/views/admin/users/keys.html.haml
+++ b/app/views/admin/users/keys.html.haml
@@ -3,4 +3,3 @@
- page_title _("SSH Keys"), @user.name, _("Users")
= render 'admin/users/head'
= render 'profiles/keys/key_table', admin: true
-= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index 580cfe9f956..2f6c08f123e 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -48,5 +48,3 @@
- if member.respond_to? :project
= link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do
= sprite_icon('remove', size: 16, css_class: 'gl-icon')
-
-= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 94542af3b96..9197d6684e0 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -40,7 +40,7 @@
%span.light= _('Secondary email:')
%strong
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
- = link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email } }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do
+ = link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email }, 'confirm-btn-variant': 'danger' }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do
= sprite_icon('close', size: 16, css_class: 'gl-icon')
%li
%span.light ID:
@@ -56,7 +56,7 @@
%strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' }
- if @user.two_factor_enabled?
= _('Enabled')
- = link_to _('Disable'), disable_two_factor_admin_user_path(@user), data: { confirm: _('Are you sure?') }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication')
+ = link_to _('Disable'), disable_two_factor_admin_user_path(@user), aria: { label: _('Disable') }, data: { confirm: _('Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication')
- else
= _('Disabled')
@@ -146,4 +146,3 @@
.col-md-6.gl-display-none.gl-md-display-block
= render 'admin/users/profile', user: @user
= render 'admin/users/user_detail_note'
-= render partial: 'admin/users/modals'
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 6fb3f26ff4f..b7d1aa6f944 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -6,17 +6,13 @@
%span.gl-spinner.gl-spinner-dark{ 'aria-label': 'Loading' }
%span.gl-ml-2= s_('ClusterIntegration|Kubernetes cluster is being created...')
-= render 'shared/global_alert',
- variant: :warning,
- alert_class: 'hidden js-cluster-api-unreachable',
- close_button_class: 'js-close' do
+= render Pajamas::AlertComponent.new(variant: :warning,
+ alert_class: 'hidden js-cluster-api-unreachable') do
.gl-alert-body
= s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.')
-= render 'shared/global_alert',
- variant: :warning,
- alert_class: 'hidden js-cluster-authentication-failure js-cluster-api-unreachable',
- close_button_class: 'js-close' do
+= render Pajamas::AlertComponent.new(variant: :warning,
+ alert_class: 'hidden js-cluster-authentication-failure js-cluster-api-unreachable') do
.gl-alert-body
= s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.')
diff --git a/app/views/clusters/clusters/_deprecation_alert.html.haml b/app/views/clusters/clusters/_deprecation_alert.html.haml
new file mode 100644
index 00000000000..202e2c14d3f
--- /dev/null
+++ b/app/views/clusters/clusters/_deprecation_alert.html.haml
@@ -0,0 +1,7 @@
+= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_class: 'gl-mt-6 gl-mb-3') do
+ .gl-alert-body
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
+ - issue_link_start = link_start % { url: 'https://gitlab.com/gitlab-org/configure/general/-/issues/199' }
+ - docs_link_start = link_start % { url: help_page_path('user/clusters/agent/index.md') }
+ - link_end = '</a>'.html_safe
+ = s_('ClusterIntegration|This process is %{issue_link_start}deprecated%{issue_link_end}. Use the %{docs_link_start}the GitLab agent for Kubernetes%{docs_link_end} instead.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end, issue_link_start: issue_link_start, issue_link_end: link_end }
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 3a4632affdc..ffd910b1b9d 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,8 +1,8 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-= render 'shared/global_alert',
- title: s_('ClusterIntegration|Did you know?'),
+
+= render Pajamas::AlertComponent.new(title: s_('ClusterIntegration|Did you know?'),
alert_class: 'gcp-signup-offer',
- alert_data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } do
+ alert_data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path }) do
.gl-alert-body
= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
.gl-alert-actions
diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml
index bda774ee780..045c03df4fa 100644
--- a/app/views/clusters/clusters/_sidebar.html.haml
+++ b/app/views/clusters/clusters/_sidebar.html.haml
@@ -1,8 +1,14 @@
+- is_connect_page = local_assigns.fetch(:is_connect_page, false)
+- docs_mode = local_assigns.fetch(:docs_mode, false)
+- title = is_connect_page ? s_('ClusterIntegration|Connect a Kubernetes cluster') : s_('ClusterIntegration|Create a Kubernetes cluster')
+
%h3
- = s_('ClusterIntegration|Connect a Kubernetes cluster')
+ = title
%p
= clusterable.sidebar_text
-%p
- = clusterable.learn_more_link
-= render 'clusters/clusters/multiple_clusters_message'
+- if !docs_mode
+ %p
+ = clusterable.learn_more_link
+
+ = render 'clusters/clusters/multiple_clusters_message'
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
index 826dc749dad..807f98b7b0a 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
@@ -1,12 +1,15 @@
- provider = local_assigns.fetch(:provider)
- is_current_provider = provider == params[:provider]
- logo_path = local_assigns.fetch(:logo_path)
+- help_path = local_assigns.fetch(:help_path)
- label = local_assigns.fetch(:label)
- last = local_assigns.fetch(:last, false)
-- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-w-half js-create-#{provider}-cluster-button"]
-- conditional_classes = [('gl-mr-5' unless last), ('active' if is_current_provider)]
+- docs_mode = local_assigns.fetch(:docs_mode, false)
+- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-w-half"]
+- conditional_classes = [("gl-mr-5" unless last), ("active" if is_current_provider && !docs_mode), ("js-create-#{provider}-cluster-button" if !docs_mode)]
+- link = docs_mode ? help_path : clusterable.new_path(provider: provider)
-= link_to clusterable.new_path(provider: provider), class: classes + conditional_classes do
- .svg-content.gl-p-3= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64'
+= link_to link, class: classes + conditional_classes do
+ .svg-content.gl-p-3= image_tag logo_path, alt: label, class: "gl-w-64 gl-h-64"
%span
= label
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
index 321fb854e0d..69250141816 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
@@ -1,11 +1,15 @@
- gke_label = s_('ClusterIntegration|Google GKE')
- eks_label = s_('ClusterIntegration|Amazon EKS')
- create_cluster_label = s_('ClusterIntegration|Where do you want to create a cluster?')
+- eks_help_path = help_page_path('user/infrastructure/clusters/connect/new_eks_cluster')
+- gke_help_path = help_page_path('user/infrastructure/clusters/connect/new_gke_cluster')
+- docs_mode = local_assigns.fetch(:docs_mode, false)
+
.gl-p-5
%h4.gl-mb-5
= create_cluster_label
.gl-display-flex
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
- locals: { provider: 'aws', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' }
+ locals: { provider: 'aws', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg', help_path: eks_help_path, docs_mode: docs_mode }
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
- locals: { provider: 'gcp', label: gke_label, logo_path: 'illustrations/logos/google_gke.svg', last: true }
+ locals: { provider: 'gcp', label: gke_label, logo_path: 'illustrations/logos/google_gke.svg', help_path: gke_help_path, docs_mode: docs_mode, last: true }
diff --git a/app/views/clusters/clusters/connect.html.haml b/app/views/clusters/clusters/connect.html.haml
index 1043f78bd3c..ec00a9c345a 100644
--- a/app/views/clusters/clusters/connect.html.haml
+++ b/app/views/clusters/clusters/connect.html.haml
@@ -3,9 +3,11 @@
- breadcrumb_title _('Connect a cluster')
- page_title _('Connect a Kubernetes Cluster')
-.row.gl-mt-3
- .col-md-3
- = render 'sidebar'
- .col-md-9
+= render 'deprecation_alert'
+
+.gl-md-display-flex.gl-mt-3
+ .gl-w-quarter.gl-xs-w-full.gl-flex-shrink-0.gl-md-mr-5
+ = render 'sidebar', is_connect_page: true
+ .gl-w-full
#js-cluster-new{ data: js_cluster_new }
= render 'clusters/clusters/user/form'
diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml
index a184f412565..53d521fface 100644
--- a/app/views/clusters/clusters/new.html.haml
+++ b/app/views/clusters/clusters/new.html.haml
@@ -4,12 +4,14 @@
- page_title _('Create a Kubernetes cluster')
- provider = params[:provider]
+= render 'deprecation_alert'
+
= render_gcp_signup_offer
-.row.gl-mt-3
- .col-md-3
- = render 'sidebar'
- .col-md-9
+.gl-md-display-flex.gl-mt-3
+ .gl-w-quarter.gl-xs-w-full.gl-flex-shrink-0.gl-md-mr-5
+ = render 'sidebar', is_connect_page: false
+ .gl-w-full
= render 'clusters/clusters/cloud_providers/cloud_provider_selector'
- if ['aws', 'gcp'].include?(provider)
diff --git a/app/views/clusters/clusters/new_cluster_docs.html.haml b/app/views/clusters/clusters/new_cluster_docs.html.haml
new file mode 100644
index 00000000000..9b99aef0e72
--- /dev/null
+++ b/app/views/clusters/clusters/new_cluster_docs.html.haml
@@ -0,0 +1,13 @@
+- @content_class = 'limit-container-width' unless fluid_layout
+- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path
+- breadcrumb_title _('Create a cluster')
+- page_title _('Create a Kubernetes cluster')
+- docs_mode = true
+
+= render_gcp_signup_offer
+
+.gl-md-display-flex.gl-mt-3
+ .gl-w-quarter.gl-xs-w-full.gl-flex-shrink-0.gl-md-mr-5
+ = render 'sidebar', docs_mode: docs_mode, is_connect_page: false
+ .gl-w-full
+ = render 'clusters/clusters/cloud_providers/cloud_provider_selector', docs_mode: docs_mode
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index 872099f98fc..2bbca851dcc 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -12,16 +12,29 @@
path: '-/milestones/new', label: 'New milestone',
include_groups: true, type: :milestones
-.top-area
- = render 'shared/milestones_filter', counts: @milestone_states
- .nav-controls
- = render 'shared/milestones/search_form'
+- if @milestone_states.any? { |name, count| count > 0 }
+ .top-area
+ = render 'shared/milestones_filter', counts: @milestone_states
+ .nav-controls
+ = render 'shared/milestones/search_form'
-- if @milestones.blank?
- = render 'shared/empty_states/milestones'
+ - if @milestones.blank?
+ = render 'shared/empty_states/milestones_tab', active_tab: params[:state] do
+ - if current_user
+ .page-title-controls
+ = render 'shared/new_project_item_select',
+ path: '-/milestones/new', label: 'New milestone',
+ include_groups: true, type: :milestones
+ - else
+ .milestones
+ %ul.content-list
+ - @milestones.each do |milestone|
+ = render 'milestone', milestone: milestone
+ = paginate @milestones, theme: 'gitlab'
- else
- .milestones
- %ul.content-list
- - @milestones.each do |milestone|
- = render 'milestone', milestone: milestone
- = paginate @milestones, theme: 'gitlab'
+ = render 'shared/empty_states/milestones' do
+ - if current_user
+ .page-title-controls
+ = render 'shared/new_project_item_select',
+ path: '-/milestones/new', label: 'New milestone',
+ include_groups: true, type: :milestones
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index b40373ecc37..83e3fd85511 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -1,23 +1,24 @@
-= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f|
+= gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-sign-in-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f|
.form-group
= f.label _('Username or email'), for: 'user_login', class: 'label-bold'
- = 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' }
+ = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' }
.form-group
= f.label :password, class: 'label-bold'
= 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?
%div
- %label{ for: 'user_remember_me' }
- = f.check_box :remember_me
- %span= _('Remember me')
- .float-right
+ .gl-display-inline-block
+ = f.gitlab_ui_checkbox_component :remember_me, _('Remember me')
+ .gl-float-right
- if unconfirmed_email?
= link_to _('Resend confirmation email'), new_user_confirmation_path
- else
= link_to _('Forgot your password?'), new_password_path(:user)
%div
- - if captcha_enabled? || captcha_on_login_required?
+ - if Feature.enabled?(:arkose_labs_login_challenge)
+ = render_if_exists 'devise/sessions/arkose_labs'
+ - elsif captcha_enabled? || captcha_on_login_required?
= recaptcha_tags nonce: content_security_policy_nonce
.submit-container.move-submit-down
- = f.submit _('Sign in'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'sign_in_button' }
+ = f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' }
diff --git a/app/views/devise/shared/_email_opted_in.html.haml b/app/views/devise/shared/_email_opted_in.html.haml
index 898b8f31f1d..d8ed0028222 100644
--- a/app/views/devise/shared/_email_opted_in.html.haml
+++ b/app/views/devise/shared/_email_opted_in.html.haml
@@ -3,5 +3,4 @@
.gl-mb-3.js-email-opt-in.hidden
.gl-font-weight-bold.gl-mb-3
= _('Email updates (optional)')
- = f.check_box :email_opted_in
- = f.label :email_opted_in, _("I'd like to receive updates about GitLab via email"), class: 'gl-font-weight-normal'
+ = f.gitlab_ui_checkbox_component :email_opted_in, _("I'd like to receive updates about GitLab via email")
diff --git a/app/views/errors/_footer.html.haml b/app/views/errors/_footer.html.haml
index 62bac62c70c..3adde3ef544 100644
--- a/app/views/errors/_footer.html.haml
+++ b/app/views/errors/_footer.html.haml
@@ -4,8 +4,8 @@
= link_to s_('Nav|Home'), root_path
%li
- if current_user
- = link_to s_('Nav|Sign out and sign in with a different account'), '#', id: 'sign_out_link'
- %form{ action: destroy_user_session_path, method: :post, id: 'sign_out_form' }
+ = link_to s_('Nav|Sign out and sign in with a different account'), '#', class: 'js-sign-out-link'
+ %form.js-sign-out-form{ action: destroy_user_session_path, method: :post }
- else
= link_to s_('Nav|Sign In / Register'), new_session_path(:user, redirect_to_referer: 'yes')
%li
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index ab6861b5f24..785ca71b371 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -2,14 +2,12 @@
.col-sm-2.col-form-label.pt-0
= f.label :lfs_enabled, _('Large File Storage')
.col-sm-10
- .form-check
- = f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input'
- = f.label :lfs_enabled, class: 'form-check-label' do
- %strong
- = _('Allow projects within this group to use Git LFS')
- = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index')
- %br/
- %span= _('This setting can be overridden in each project.')
+ - label = _('Allow projects within this group to use Git LFS')
+ - help_link = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index'), class: 'gl-ml-2'
+ = f.gitlab_ui_checkbox_component :lfs_enabled,
+ '%{label}%{help_link}'.html_safe % { label: label, help_link: help_link },
+ help_text: _('This setting can be overridden in each project.'),
+ checkbox_options: { checked: @group.lfs_enabled? }
.form-group.row
.col-sm-2.col-form-label
= f.label s_('ProjectCreationLevel|Allowed to create projects')
@@ -26,12 +24,9 @@
.col-sm-2.col-form-label.pt-0
= f.label :require_two_factor_authentication, _('Two-factor authentication')
.col-sm-10
- .form-check
- = f.check_box :require_two_factor_authentication, class: 'form-check-input'
- = f.label :require_two_factor_authentication, class: 'form-check-label' do
- %strong
- = _("Require all users in this group to set up two-factor authentication")
- = link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforce-2fa-for-all-users-in-a-group')
+ - label = _("Require all users in this group to set up two-factor authentication")
+ - help_link = link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforce-2fa-for-all-users-in-a-group'), class: 'gl-ml-2'
+ = f.gitlab_ui_checkbox_component :require_two_factor_authentication, '%{label}%{help_link}'.html_safe % { label: label, help_link: help_link }
.form-group.row
.offset-sm-2.col-sm-10
.form-check
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 5b9cd80799c..95b3ad26e99 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
@@ -4,8 +4,8 @@
%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')
+ = render Pajamas::AlertComponent.new(dismissible: false,
+ variant: :warning) do
.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') }
- docs_link_end = '</a>'.html_safe
diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml
index ee0967f708a..ddd7481e0bd 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -6,8 +6,8 @@
.gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
%h4
= _('Import group from file')
- .gl-alert.gl-alert-warning{ role: 'alert' }
- = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
+ = render Pajamas::AlertComponent.new(variant: :warning,
+ dismissible: false) do
.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') }
- link_end = '</a>'.html_safe
diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml
index 651d182b9cc..427a36aaec4 100644
--- a/app/views/groups/_subgroups_and_projects.html.haml
+++ b/app/views/groups/_subgroups_and_projects.html.haml
@@ -4,5 +4,4 @@
%section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder{ data: { show_schema_markup: 'true'} }
- .loading-container.text-center.prepend-top-20
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md', css_class: 'gl-mt-6')
diff --git a/app/views/groups/crm/contacts/index.html.haml b/app/views/groups/crm/contacts/index.html.haml
index 81293937f77..8a971e451a4 100644
--- a/app/views/groups/crm/contacts/index.html.haml
+++ b/app/views/groups/crm/contacts/index.html.haml
@@ -1,4 +1,8 @@
-- breadcrumb_title _('Customer Relations Contacts')
-- page_title _('Customer Relations Contacts')
+- breadcrumb_title _('Customer relations contacts')
+- page_title _('Customer relations contacts')
+- @content_wrapper_class = "gl-relative"
+
+= content_for :after_content do
+ #js-crm-form-portal
#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group), group_id: @group.id, can_admin_crm_contact: can?(current_user, :admin_crm_contact, @group).to_s, base_path: group_crm_contacts_path(@group) } }
diff --git a/app/views/groups/crm/organizations/index.html.haml b/app/views/groups/crm/organizations/index.html.haml
index 1647805b976..ff1ba678de0 100644
--- a/app/views/groups/crm/organizations/index.html.haml
+++ b/app/views/groups/crm/organizations/index.html.haml
@@ -1,4 +1,8 @@
-- breadcrumb_title _('Customer Relations Organizations')
-- page_title _('Customer Relations Organizations')
+- breadcrumb_title _('Customer relations organizations')
+- page_title _('Customer relations organizations')
+- @content_wrapper_class = "gl-relative"
+
+= content_for :after_content do
+ #js-crm-form-portal
#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group) } }
diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml
index 47caec717af..940a504438d 100644
--- a/app/views/groups/dependency_proxies/show.html.haml
+++ b/app/views/groups/dependency_proxies/show.html.haml
@@ -4,4 +4,4 @@
#js-dependency-proxy{ data: { group_path: @group.full_path,
dependency_proxy_available: dependency_proxy_available.to_s,
- no_manifests_illustration: image_path('illustrations/docker-empty-state.svg') } }
+ no_manifests_illustration: image_path('illustrations/docker-empty-state.svg'), group_id: @group.id } }
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index f3494149087..3dcc75ce8f4 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -4,6 +4,7 @@
- expanded = expanded_by_default?
= render 'shared/namespaces/cascading_settings/lock_popovers'
+= render_if_exists 'shared/minute_limit_banner', namespace: @group
%section.settings.gs-general.no-animate.expanded#js-general-settings
.settings-header
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 5c579cf6488..9aa626724a8 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -1,6 +1,8 @@
- add_page_specific_style 'page_bundles/members'
- page_title _('Group members')
+= render_if_exists 'shared/user_over_limit_free_plan_alert', source: @group
+
.row.gl-mt-3
.col-lg-12
.gl-display-flex.gl-flex-wrap
@@ -25,5 +27,7 @@
.js-group-members-list-app{ data: { members_data: group_members_app_data(@group,
members: @members,
invited: @invited_members,
- access_requests: @requesters).to_json } }
+ access_requests: @requesters,
+ include_relations: @include_relations,
+ search: params[:search_groups]).to_json } }
= gl_loading_icon(css_class: 'gl-my-5', size: 'md')
diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml
index 1ee15557e21..6a1e66520b5 100644
--- a/app/views/groups/harbor/repositories/index.html.haml
+++ b/app/views/groups/harbor/repositories/index.html.haml
@@ -4,6 +4,8 @@
#js-harbor-registry-list-group{ data: { endpoint: group_harbor_registries_path(@group),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
- "help_page_path" => help_page_path('user/packages/container_registry/index'),
+ "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ "registry_host_url_with_port" => 'demo.harbor.com',
connection_error: (!!@connection_error).to_s,
- invalid_path_error: (!!@invalid_path_error).to_s, } }
+ invalid_path_error: (!!@invalid_path_error).to_s,
+ is_group_page: true.to_s } }
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index 1c7427fef87..5c0487db0fc 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -1,23 +1,32 @@
- page_title _("Milestones")
- add_page_specific_style 'page_bundles/milestone'
-.top-area
- = render 'shared/milestones_filter', counts: @milestone_states
+- if @milestone_states.any? { |name, count| count > 0 }
+ .top-area
+ = render 'shared/milestones_filter', counts: @milestone_states
- .nav-controls
- = render 'shared/milestones/search_form'
- = render 'shared/milestones_sort_dropdown'
- - if can?(current_user, :admin_milestone, @group)
- = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-confirm", data: { qa_selector: "new_group_milestone_link" }
+ .nav-controls
+ = render 'shared/milestones/search_form'
+ = render 'shared/milestones_sort_dropdown'
+ - if can?(current_user, :admin_milestone, @group)
+ = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-confirm", data: { qa_selector: "new_group_milestone_link" }
-- if @milestones.blank?
- = render 'shared/empty_states/milestones'
+ - if @milestones.blank?
+ = render 'shared/empty_states/milestones_tab', learn_more_path: help_page_path('user/project/milestones/index') do
+ - if can?(current_user, :admin_milestone, @group)
+ .text-center
+ = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-confirm", data: { qa_selector: "new_group_milestone_link" }
+ - else
+ .milestones
+ %ul.content-list
+ - @milestones.each do |milestone|
+ - if milestone.project_milestone?
+ = render 'projects/milestones/milestone', milestone: milestone
+ - else
+ = render 'milestone', milestone: milestone
+ = paginate @milestones, theme: "gitlab"
- else
- .milestones
- %ul.content-list
- - @milestones.each do |milestone|
- - if milestone.project_milestone?
- = render 'projects/milestones/milestone', milestone: milestone
- - else
- = render 'milestone', milestone: milestone
- = paginate @milestones, theme: "gitlab"
+ = render 'shared/empty_states/milestones', learn_more_path: help_page_path('user/project/milestones/index') do
+ - if can?(current_user, :admin_milestone, @group)
+ .text-center
+ = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-confirm", data: { qa_selector: "new_group_milestone_link" }
diff --git a/app/views/groups/runners/_settings.html.haml b/app/views/groups/runners/_settings.html.haml
index bbcadc08a8b..087b06ee37d 100644
--- a/app/views/groups/runners/_settings.html.haml
+++ b/app/views/groups/runners/_settings.html.haml
@@ -1,4 +1,6 @@
- if Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml)
+ .gl-mb-6
+ #update-shared-runners-form{ data: group_shared_runners_settings_data(@group) }
.gl-card.gl-px-8.gl-py-6.gl-line-height-20
.gl-card-body.gl-display-flex{ :class => "gl-p-0!" }
.gl-banner-illustration
@@ -11,107 +13,107 @@
%a.btn.btn-confirm.btn-md.gl-button{ :href => group_runners_path(@group) }
%span.gl-button-text
= s_('Runners|Take me there!')
+- else
+ = render 'shared/runners/runner_description'
-= render 'shared/runners/runner_description'
-
-%hr
+ %hr
-.row
- .col-sm-6
- = render 'groups/runners/group_runners'
- .col-sm-6
- = render 'groups/runners/shared_runners'
+ .row
+ .col-sm-6
+ = render 'groups/runners/group_runners'
+ .col-sm-6
+ = render 'groups/runners/shared_runners'
-%h4.underlined-title
- = _('Available runners: %{runners}').html_safe % { runners: limited_counter_with_delimiter(@all_group_runners) }
+ %h4.underlined-title
+ = _('Available runners: %{runners}').html_safe % { runners: limited_counter_with_delimiter(@all_group_runners) }
--# haml-lint:disable NoPlainNodes
-.row
- .col-sm-9
- = form_tag group_settings_ci_cd_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
- .filtered-search-wrapper.d-flex
- .filtered-search-box
- = dropdown_tag(_('Recent searches'),
- options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
- toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
- dropdown_class: 'filtered-search-history-dropdown',
- content_class: 'filtered-search-history-dropdown-content' }) do
- .js-filtered-search-history-dropdown{ data: { full_path: group_settings_ci_cd_path } }
- .filtered-search-box-input-container.droplab-dropdown
- .scroll-container
- %ul.tokens-container.list-unstyled
- %li.input-token
- %input.form-control.filtered-search{ search_filter_input_options('runners') }
- #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
- = button_tag class: 'gl-button btn btn-link' do
- -# Encapsulate static class name `{{icon}}` inside #{} to bypass
- -# haml lint's ClassAttributeWithStaticValue
- %svg
- %use{ 'xlink:href': "#{'{{icon}}'}" }
- %span.js-filter-hint
- {{formattedKey}}
- #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
- %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
- %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
- = button_tag class: 'gl-button btn btn-link' do
- {{ title }}
- %span.btn-helptext
- {{ help }}
- #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- - Ci::Runner::AVAILABLE_STATUSES.each do |status|
- %li.filter-dropdown-item{ data: { value: status } }
+ -# haml-lint:disable NoPlainNodes
+ .row
+ .col-sm-9
+ = form_tag group_settings_ci_cd_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
+ .filtered-search-wrapper.d-flex
+ .filtered-search-box
+ = dropdown_tag(_('Recent searches'),
+ options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
+ toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
+ dropdown_class: 'filtered-search-history-dropdown',
+ content_class: 'filtered-search-history-dropdown-content' }) do
+ .js-filtered-search-history-dropdown{ data: { full_path: group_settings_ci_cd_path } }
+ .filtered-search-box-input-container.droplab-dropdown
+ .scroll-container
+ %ul.tokens-container.list-unstyled
+ %li.input-token
+ %input.form-control.filtered-search{ search_filter_input_options('runners') }
+ #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: 'gl-button btn btn-link' do
- = status.titleize
-
- #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- - Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
- - next if runner_type == 'instance_type'
- %li.filter-dropdown-item{ data: { value: runner_type } }
+ -# Encapsulate static class name `{{icon}}` inside #{} to bypass
+ -# haml lint's ClassAttributeWithStaticValue
+ %svg
+ %use{ 'xlink:href': "#{'{{icon}}'}" }
+ %span.js-filter-hint
+ {{formattedKey}}
+ #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
+ %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
= button_tag class: 'gl-button btn btn-link' do
- = runner_type.titleize
+ {{ title }}
+ %span.btn-helptext
+ {{ help }}
+ #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ - Ci::Runner::AVAILABLE_STATUSES.each do |status|
+ %li.filter-dropdown-item{ data: { value: status } }
+ = button_tag class: 'gl-button btn btn-link' do
+ = status.titleize
+
+ #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ - Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
+ - next if runner_type == 'instance_type'
+ %li.filter-dropdown-item{ data: { value: runner_type } }
+ = button_tag class: 'gl-button btn btn-link' do
+ = runner_type.titleize
- #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'none' } }
- = button_tag class: 'gl-button btn btn-link' do
- = _('No Tag')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- = button_tag class: 'gl-button btn btn-link js-data-value' do
- %span.dropdown-light-content
- {{name}}
+ #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'none' } }
+ = button_tag class: 'gl-button btn btn-link' do
+ = _('No Tag')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ = button_tag class: 'gl-button btn btn-link js-data-value' do
+ %span.dropdown-light-content
+ {{name}}
- = button_tag class: 'clear-search hidden' do
- = sprite_icon('close', size: 16, css_class: 'clear-search-icon')
- .filter-dropdown-container
- = render 'groups/runners/sort_dropdown'
+ = button_tag class: 'clear-search hidden' do
+ = sprite_icon('close', size: 16, css_class: 'clear-search-icon')
+ .filter-dropdown-container
+ = render 'groups/runners/sort_dropdown'
- .col-sm-3.text-right-lg
- = _('Runners currently online: %{active_runners_count}') % { active_runners_count: limited_counter_with_delimiter(@all_group_runners.online) }
+ .col-sm-3.text-right-lg
+ = _('Runners currently online: %{active_runners_count}') % { active_runners_count: limited_counter_with_delimiter(@all_group_runners.online) }
-- if @group_runners.any?
- .content-list{ data: { testid: 'runners-table' } }
- .table-holder
- .gl-responsive-table-row.table-row-header{ role: 'row' }
- .table-section.section-10{ role: 'rowheader' }= _('Type/State')
- .table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
- .table-section.section-10{ role: 'rowheader' }= _('Version')
- .table-section.section-10{ role: 'rowheader' }= _('IP Address')
- .table-section.section-5{ role: 'rowheader' }= _('Projects')
- .table-section.section-5{ role: 'rowheader' }= _('Jobs')
- .table-section.section-10{ role: 'rowheader' }= _('Tags')
- .table-section.section-10{ role: 'rowheader' }= _('Last contact')
- .table-section.section-10{ role: 'rowheader' }
+ - if @group_runners.any?
+ .content-list{ data: { testid: 'runners-table' } }
+ .table-holder
+ .gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-10{ role: 'rowheader' }= _('Type/State')
+ .table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
+ .table-section.section-10{ role: 'rowheader' }= _('Version')
+ .table-section.section-10{ role: 'rowheader' }= _('IP Address')
+ .table-section.section-5{ role: 'rowheader' }= _('Projects')
+ .table-section.section-5{ role: 'rowheader' }= _('Jobs')
+ .table-section.section-10{ role: 'rowheader' }= _('Tags')
+ .table-section.section-10{ role: 'rowheader' }= _('Last contact')
+ .table-section.section-10{ role: 'rowheader' }
- - @group_runners.each do |runner|
- - runner = runner.present(current_user: current_user)
- = render 'groups/runners/runner', runner: runner
- = paginate @group_runners, theme: 'gitlab', :params => { :anchor => 'runners-settings' }
-- else
- .nothing-here-block= _('No runners found')
+ - @group_runners.each do |runner|
+ - runner = runner.present(current_user: current_user)
+ = render 'groups/runners/runner', runner: runner
+ = paginate @group_runners, theme: 'gitlab', :params => { :anchor => 'runners-settings' }
+ - else
+ .nothing-here-block= _('No runners found')
diff --git a/app/views/groups/runners/_sort_dropdown.html.haml b/app/views/groups/runners/_sort_dropdown.html.haml
index e914bd00dac..b7b10cecee8 100644
--- a/app/views/groups/runners/_sort_dropdown.html.haml
+++ b/app/views/groups/runners/_sort_dropdown.html.haml
@@ -1,10 +1,3 @@
-- sorted_by = sort_options_hash[@sort] || sort_title_created_date
+- runners_sort_options = runners_sort_options_hash.map { |value, text| { value: value, text: text, href: page_filter_path(sort: value) } }
-.dropdown.inline.gl-ml-3
- %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
- = sorted_by
- = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
- %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
- %li
- = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sorted_by)
- = sortable_item(sort_title_contacted_date, page_filter_path(sort: sort_value_contacted_date), sorted_by)
+= gl_redirect_listbox_tag runners_sort_options, @sort, class: 'gl-ml-3', data: { display: 'static' }
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index 81403fd88b2..62bc574231f 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -1,12 +1,9 @@
-- return unless Feature.enabled?(:group_import_export, @group, default_enabled: true)
-
- group = local_assigns.fetch(:group)
.sub-section
%h4= s_('GroupSettings|Export group')
%p= _('Export this group with all related data.')
- .gl-alert.gl-alert-warning.gl-mb-4{ role: 'alert' }
- = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
+ = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_class: 'gl-mb-4') do
.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') }
- docs_link_end = '</a>'.html_safe
@@ -15,17 +12,18 @@
- export_information = _('After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance.') % { strong_text_start: '<strong>'.html_safe, strong_text_end: '</strong>'.html_safe}
= export_information.html_safe
= link_to _('Learn more.'), help_page_path('user/group/settings/import_export.md'), target: '_blank', rel: 'noopener noreferrer'
- .bs-callout.bs-callout-info
- %p.gl-mb-0
- %p= _('The following items will be exported:')
- %ul
- - group_export_descriptions.each do |description|
- %li= description
- %p= _('The following items will NOT be exported:')
- %ul
- %li= _('Projects')
- %li= _('Runner tokens')
- %li= _('SAML discovery tokens')
+ = render Pajamas::AlertComponent.new(dismissible: false, alert_class: 'gl-mb-5') do
+ .gl-alert-body
+ %p.gl-mb-0
+ %p= _('The following items will be exported:')
+ %ul
+ - group_export_descriptions.each do |description|
+ %li= description
+ %p= _('The following items will NOT be exported:')
+ %ul
+ %li= _('Projects')
+ %li= _('Runner tokens')
+ %li= _('SAML discovery tokens')
- if group.export_file_exists?
= link_to _('Download export'), download_export_group_path(group),
rel: 'nofollow', method: :get, class: 'btn gl-button btn-default', data: { qa_selector: 'download_export_link' }
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index dd62c9e118d..1a2f770cd59 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -34,6 +34,8 @@
= render 'groups/settings/ip_restriction_registration_features_cta', f: f
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
+ - if Feature.enabled?(:group_wiki_settings_toggle, @group, default_enabled: :yaml)
+ = render_if_exists 'groups/settings/wiki', f: f, group: @group
= render 'groups/settings/lfs', f: f
= render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/subgroup_creation_level', f: f, group: @group
diff --git a/app/views/groups/settings/_remove_button.html.haml b/app/views/groups/settings/_remove_button.html.haml
index 1d5b7160049..66d6b516a86 100644
--- a/app/views/groups/settings/_remove_button.html.haml
+++ b/app/views/groups/settings/_remove_button.html.haml
@@ -1,8 +1,7 @@
- remove_form_id = local_assigns.fetch(:remove_form_id, nil)
- if group.paid?
- .gl-alert.gl-alert-info.gl-mb-5{ data: { testid: 'group-has-linked-subscription-alert' } }
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ = render Pajamas::AlertComponent.new(dismissible: false, alert_class: 'gl-mb-5', alert_data: { testid: 'group-has-linked-subscription-alert' }) do
.gl-alert-body
= html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
index dde8213b293..f5d9d0e2587 100644
--- a/app/views/groups/settings/_transfer.html.haml
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -13,8 +13,7 @@
%li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
%li= s_("GroupSettings|If the parent group's visibility is lower than the group's current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
- if group.paid?
- .gl-alert.gl-alert-info.gl-mb-5
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ = render Pajamas::AlertComponent.new(dismissible: false, alert_class: 'gl-mb-5') do
.gl-alert-body
= html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
.js-transfer-group-form{ data: initial_data }
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
index 12c0f15aff5..86c0a8d0c52 100644
--- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -1,16 +1,16 @@
-= form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f|
+= gitlab_ui_form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f|
= form_errors(group)
%fieldset
.form-group
.card.auto-devops-card
.card-body
- .form-check
- = f.check_box :auto_devops_enabled, class: 'form-check-input', checked: group.auto_devops_enabled?
- = f.label :auto_devops_enabled, class: 'form-check-label' do
- %strong= s_('GroupSettings|Default to Auto DevOps pipeline for all projects within this group')
- = gl_badge_tag badge_for_auto_devops_scope(group), variant: :info
- .form-text.text-muted
- = s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.')
- = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
+ - learn_more_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
+ - help_text = s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.')
+ - badge = gl_badge_tag badge_for_auto_devops_scope(group), variant: :info
+ - label = s_('GroupSettings|Default to Auto DevOps pipeline for all projects within this group')
+ = f.gitlab_ui_checkbox_component :auto_devops_enabled,
+ '%{label} %{badge}'.html_safe % { label: label, badge: badge.html_safe },
+ help_text: '%{help_text} %{learn_more_link}'.html_safe % { help_text: help_text, learn_more_link: learn_more_link },
+ checkbox_options: { checked: group.auto_devops_enabled? }
= f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-5'
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 331cb31c626..f6dda9358f3 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -3,6 +3,7 @@
- expanded = expanded_by_default?
- general_expanded = @group.errors.empty? ? expanded : true
+= render_if_exists 'shared/minute_limit_banner', namespace: @group
-# Given we only have one field in this form which is also admin-only,
-# we don't want to show an empty section to non-admin users,
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index bb409190dd8..7bbc2f839f7 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -7,6 +7,8 @@
= render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i
= render_if_exists 'shared/qrtly_reconciliation_alert', group: @group
+= render_if_exists 'shared/user_over_limit_free_plan_alert', source: @group
+= render_if_exists 'shared/minute_limit_banner', namespace: @group
- if show_invite_banner?(@group)
= content_for :group_invite_members_banner do
@@ -37,16 +39,15 @@
.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
- %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs
- %li.js-subgroups_and_projects-tab
- = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do
- = _("Subgroups and projects")
- %li.js-shared-tab
- = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do
- = _("Shared projects")
- %li.js-archived-tab
- = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do
- = _("Archived projects")
+ -# `item_active` is set to `false` as the active state is set by `app/assets/javascripts/pages/groups/shared/group_details.js`
+ -# TODO: Replace this approach in https://gitlab.com/gitlab-org/gitlab/-/issues/23466
+ = gl_tabs_nav({ class: 'nav-links scrolling-tabs gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do
+ = gl_tab_link_to group_path, item_active: false, tab_class: 'js-subgroups_and_projects-tab', data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab' } do
+ = _("Subgroups and projects")
+ = gl_tab_link_to group_shared_path, item_active: false, tab_class: 'js-shared-tab', data: { target: 'div#shared', action: 'shared', toggle: 'tab' } do
+ = _("Shared projects")
+ = gl_tab_link_to group_archived_path, item_active: false, tab_class: 'js-archived-tab', data: { target: 'div#archived', action: 'archived', toggle: 'tab' } do
+ = _("Archived projects")
.nav-controls.d-block.d-md-flex
.group-search
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 8f18d68fd55..08f7cd57732 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -3,6 +3,7 @@
- extra_data = local_assigns.fetch(:extra_data, {})
- filterable = local_assigns.fetch(:filterable, true)
- paginatable = local_assigns.fetch(:paginatable, false)
+- default_namespace_path = (local_assigns[:default_namespace] || current_user.namespace).full_path
- provider_title = Gitlab::ImportSources.title(provider)
- header_title _("New project"), new_project_path
@@ -14,6 +15,7 @@
namespaces_path: import_available_namespaces_path,
repos_path: url_for([:status, :import, provider, format: :json]),
jobs_path: url_for([:realtime_changes, :import, provider, format: :json]),
+ default_target_namespace: default_namespace_path,
import_path: url_for([:import, provider, format: :json]),
filterable: filterable.to_s,
paginatable: paginatable.to_s }.merge(extra_data) }
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index ef6479f8be2..fbb27ba620a 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -10,7 +10,7 @@
= import_github_authorize_message
- if github_import_configured? && !has_ci_cd_only_params?
- = link_to status_import_github_path, class: 'gl-button btn btn-confirm' do
+ = link_to status_import_github_path(namespace_id: params[:namespace_id]), class: 'gl-button btn btn-confirm' do
= sprite_icon('github', css_class: 'gl-mr-2')
= title
@@ -23,6 +23,7 @@
= form_tag personal_access_token_import_github_path, method: :post do
.form-group
%label.label-bold= _('Personal Access Token')
+ = hidden_field_tag(:namespace_id, params[:namespace_id])
= text_field_tag :personal_access_token, '', class: 'form-control gl-form-input', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }, data: { qa_selector: 'personal_access_token_field' }
%span.form-text.text-muted
= import_github_personal_access_token_message
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index 820c2f06c8f..26b048c8195 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -7,4 +7,4 @@
- paginatable = Feature.enabled?(:remove_legacy_github_client)
-= render 'import/githubish_status', provider: 'github', paginatable: paginatable
+= render 'import/githubish_status', provider: 'github', paginatable: paginatable, default_namespace: @namespace
diff --git a/app/views/import/history/index.html.haml b/app/views/import/history/index.html.haml
new file mode 100644
index 00000000000..bca2d884848
--- /dev/null
+++ b/app/views/import/history/index.html.haml
@@ -0,0 +1,4 @@
+- add_to_breadcrumbs _('Create a new project'), new_project_path
+- page_title _('Import history')
+
+#import-history-mount-element{ data: { logo: asset_url('gitlab_logo.png') } }
diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml
index 3e8a99c541a..aa6fcc445fd 100644
--- a/app/views/import/shared/_errors.html.haml
+++ b/app/views/import/shared/_errors.html.haml
@@ -1,8 +1,7 @@
- if @errors.present?
- = render 'shared/global_alert',
- variant: :danger,
+ = render Pajamas::AlertComponent.new(variant: :danger,
dismissible: false,
- alert_class: 'gl-mb-5' do
+ alert_class: 'gl-mb-5') do
.gl-alert-body
- @errors.each do |error|
= error
diff --git a/app/views/jira_connect/users/show.html.haml b/app/views/jira_connect/users/show.html.haml
index cf88acd6976..29805a2c42d 100644
--- a/app/views/jira_connect/users/show.html.haml
+++ b/app/views/jira_connect/users/show.html.haml
@@ -1,8 +1,14 @@
-.jira-connect-users-container.gl-text-center
- - user_link = link_to(current_user.to_reference, user_path(current_user), target: '_blank', rel: 'noopener noreferrer')
- %h2= _('You are signed in to GitLab as %{user_link}').html_safe % { user_link: user_link }
+.gl-text-center.gl-mx-auto.gl-pt-6
+ %h3.gl-mb-4
+ = _('You are signed in to GitLab as:')
- %p= s_('Integrations|You can now close this window and return to the GitLab for Jira application.')
+ .gl-display-flex.gl-flex-direction-column.gl-align-items-center.gl-mb-4
+ = link_to user_path(current_user), target: '_blank', rel: 'noopener noreferrer' do
+ = user_avatar_without_link(user: current_user, size: 60, css_class: 'gl-mr-0! gl-mb-2', has_tooltip: false)
+ = link_to current_user.to_reference, user_path(current_user), target: '_blank', rel: 'noopener noreferrer'
+
+ %p.gl-mb-6
+ = s_('JiraService|You can now close this window and%{br}return to the GitLab for Jira application.').html_safe % { br: '<br>'.html_safe }
- if @jira_app_link
%p= external_link s_('Integrations|Return to GitLab for Jira'), @jira_app_link, class: 'gl-button btn btn-confirm'
diff --git a/app/views/layouts/_diffs_colors_css.haml b/app/views/layouts/_diffs_colors_css.haml
new file mode 100644
index 00000000000..d2efa392bd9
--- /dev/null
+++ b/app/views/layouts/_diffs_colors_css.haml
@@ -0,0 +1,20 @@
+- deletion_color = local_assigns.fetch(:deletion, nil)
+- addition_color = local_assigns.fetch(:addition, nil)
+
+- if deletion_color.present? || request.path == profile_preferences_path
+ = stylesheet_link_tag_defer "highlight/diff_custom_colors_deletion"
+- if deletion_color.present?
+ - deletion_color_rgb = hex_color_to_rgb_array(deletion_color).join(',')
+ :css
+ :root {
+ --diff-deletion-color: rgba(#{deletion_color_rgb},0.2);
+ }
+
+- if addition_color.present? || request.path == profile_preferences_path
+ = stylesheet_link_tag_defer "highlight/diff_custom_colors_addition"
+- if addition_color.present?
+ - addition_color_rgb = hex_color_to_rgb_array(addition_color).join(',')
+ :css
+ :root {
+ --diff-addition-color: rgba(#{addition_color_rgb},0.2);
+ }
diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml
index d2fe9a9a6ee..f7b7aac6de4 100644
--- a/app/views/layouts/_header_search.html.haml
+++ b/app/views/layouts/_header_search.html.haml
@@ -1,4 +1,4 @@
-#js-header-search.header-search{ data: { 'search-context' => header_search_context.to_json,
+#js-header-search.header-search.is-not-active.gl-relative{ data: { 'search-context' => header_search_context.to_json,
'search-path' => search_path,
'issues-path' => issues_dashboard_path,
'mr-path' => merge_requests_dashboard_path,
@@ -6,7 +6,10 @@
= form_tag search_path, method: :get do |_f|
.gl-search-box-by-type
= sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon')
- %input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'), class: 'form-control gl-form-input gl-search-box-by-type-input', autocomplete: 'off' }
+ %input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'),
+ class: 'form-control gl-form-input gl-search-box-by-type-input',
+ autocomplete: 'off',
+ data: { qa_selector: 'search_box' } }
= hidden_field_tag :group_id, header_search_context[:group][:id] if header_search_context[:group]
= hidden_field_tag :project_id, header_search_context[:project][:id] if header_search_context[:project]
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index a656b61dc8f..3c4b612f33f 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -20,6 +20,7 @@
= dispensable_render_if_exists "shared/namespace_user_cap_reached_alert"
= dispensable_render_if_exists "shared/new_user_signups_cap_reached_alert"
= yield :page_level_alert
+ = yield :user_over_limit_free_plan_alert
= yield :group_invite_members_banner
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index 8e9a5ea9406..0dad6d367c3 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -1,6 +1,6 @@
- return unless Gitlab::Tracking.enabled?
-- namespace = @group || @project&.namespace
+- namespace = @group || @project&.namespace || @namespace
= javascript_tag do
:plain
diff --git a/app/views/layouts/_startup_css.haml b/app/views/layouts/_startup_css.haml
index 67c871b95f5..64a86cf319e 100644
--- a/app/views/layouts/_startup_css.haml
+++ b/app/views/layouts/_startup_css.haml
@@ -1,6 +1,9 @@
- startup_filename_default = user_application_theme == 'gl-dark' ? 'dark' : 'general'
- startup_filename = local_assigns.fetch(:startup_filename, nil) || startup_filename_default
+- diffs_colors = user_diffs_colors
%style
= Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename
= Rails.application.assets_manifest.find_sources("startup/startup-#{startup_filename}.css").first.to_s.html_safe
+
+= render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 26e3d9b3b92..bdab5d7ea07 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,6 +1,6 @@
- page_classes = page_class << @html_class
- page_classes = page_classes.flatten.compact
-- body_classes = [user_application_theme, user_tab_width, @body_class, client_class_list]
+- body_classes = [user_application_theme, user_tab_width, @body_class, client_class_list, *custom_diff_color_classes]
!!! 5
%html{ lang: I18n.locale, class: page_classes }
diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml
index 57260ccedea..3ddd8c6780f 100644
--- a/app/views/layouts/errors.html.haml
+++ b/app/views/layouts/errors.html.haml
@@ -22,8 +22,8 @@
}
// We do not have rails_ujs here, so we're manually making a link trigger a form submit.
- document.getElementById('sign_out_link').addEventListener('click', function(e) {
+ document.querySelector('.js-sign-out-link')?.addEventListener('click', (e) => {
e.preventDefault();
- document.getElementById('sign_out_form').submit();
+ document.querySelector('.js-sign-out-form')?.submit();
});
}());
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index daa48980c5b..11dd8ba6c08 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -27,7 +27,6 @@
%li
= link_to s_("CurrentUser|Preferences"), profile_preferences_path
= render_if_exists 'layouts/header/buy_pipeline_minutes', project: @project, namespace: @group
- = render_if_exists 'layouts/header/upgrade'
- if current_user_menu?(:help)
%li.divider.d-md-none
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 512a4185bee..c15a5e54a42 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -40,7 +40,7 @@
- search_menu_item = top_nav_search_menu_item_attrs
%li.nav-item.header-search-new.d-none.d-lg-block.m-auto
- unless current_controller?(:search)
- - if Feature.enabled?(:new_header_search)
+ - if Feature.enabled?(:new_header_search, default_enabled: :yaml)
= render 'layouts/header_search'
- else
= render 'layouts/search'
diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml
index d1d23c86c81..affee15c4d0 100644
--- a/app/views/layouts/header/_registration_enabled_callout.html.haml
+++ b/app/views/layouts/header/_registration_enabled_callout.html.haml
@@ -1,11 +1,11 @@
- return unless show_registration_enabled_user_callout?
-= render 'shared/global_alert',
- title: _('Anyone can register for an account.'),
+= render Pajamas::AlertComponent.new(title: _('Anyone can register for an account.'),
variant: :warning,
alert_class: 'js-registration-enabled-callout',
- alert_data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: callouts_path },
- close_button_data: { testid: 'close-registration-enabled-callout' } do
+ alert_data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT,
+ dismiss_endpoint: callouts_path },
+ close_button_data: { testid: 'close-registration-enabled-callout' }) do
.gl-alert-body
= _('Only allow anyone to register for accounts on GitLab instances that you intend to be used by anyone. Allowing anyone to register makes GitLab instances more vulnerable.')
.gl-alert-actions
diff --git a/app/views/layouts/header/_storage_enforcement_banner.html.haml b/app/views/layouts/header/_storage_enforcement_banner.html.haml
index 851fc57e44d..92c02d6ecfd 100644
--- a/app/views/layouts/header/_storage_enforcement_banner.html.haml
+++ b/app/views/layouts/header/_storage_enforcement_banner.html.haml
@@ -3,7 +3,12 @@
- banner_info = storage_enforcement_banner_info(namespace)
- return unless banner_info.present?
-= render 'shared/global_alert', variant: :warning, alert_class: 'js-storage-enforcement-banner', alert_data: { feature_id: banner_info[:callouts_feature_name], dismiss_endpoint: banner_info[:callouts_path], group_id: namespace.id, defer_links: "true" } do
+= render Pajamas::AlertComponent.new(variant: :warning,
+ alert_class: 'js-storage-enforcement-banner',
+ alert_data: { feature_id: banner_info[:callouts_feature_name],
+ dismiss_endpoint: banner_info[:callouts_path],
+ group_id: namespace.id,
+ defer_links: "true" }) do
.gl-alert-body
= banner_info[:text]
= banner_info[:learn_more_link]
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 52eea73ecd2..94c708783e4 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -53,7 +53,7 @@
= _('Gitaly Servers')
= nav_link(controller: admin_analytics_nav_links) do
- = link_to admin_dev_ops_report_path, data: { qa_selector: 'admin_analytics_link' }, class: 'has-sub-items' do
+ = link_to admin_dev_ops_reports_path, data: { qa_selector: 'admin_analytics_link' }, class: 'has-sub-items' do
.nav-icon-container
= sprite_icon('chart')
%span.nav-item-name
@@ -61,12 +61,12 @@
%ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_analytics_submenu_content' } }
= nav_link(controller: admin_analytics_nav_links, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_dev_ops_report_path do
+ = link_to admin_dev_ops_reports_path do
%strong.fly-out-top-item-name
= _('Analytics')
%li.divider.fly-out-top-item
= nav_link(controller: :dev_ops_report) do
- = link_to admin_dev_ops_report_path, title: _('DevOps Reports') do
+ = link_to admin_dev_ops_reports_path, title: _('DevOps Reports') do
%span
= _('DevOps Reports')
= nav_link(controller: :usage_trends) do
@@ -269,11 +269,10 @@
= link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), class: 'qa-admin-settings-metrics-and-profiling-item' do
%span
= _('Metrics and profiling')
- - if Feature.enabled?(:admin_application_settings_service_usage_data_center, default_enabled: :yaml)
- = nav_link(path: ['application_settings#service_usage_data']) do
- = link_to service_usage_data_admin_application_settings_path, title: _('Service usage data') do
- %span
- = _('Service usage data')
+ = nav_link(path: ['application_settings#service_usage_data']) do
+ = link_to service_usage_data_admin_application_settings_path, title: _('Service usage data') do
+ %span
+ = _('Service usage data')
= nav_link(path: 'application_settings#network') do
= link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do
%span
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index 55984472047..2f0e62981ec 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -2,16 +2,18 @@
- diff_limit = local_assigns.fetch(:diff_limit, nil)
- target_url = local_assigns.fetch(:target_url, @target_url)
- note_style = local_assigns.fetch(:note_style, "")
+- include_stylesheet_link = local_assigns.fetch(:include_stylesheet_link, true)
-- discussion = note.discussion if note.part_of_discussion?
+- author = local_assigns.fetch(:author) { note.author }
+- discussion = local_assigns.fetch(:discussion) { note.discussion } if note.part_of_discussion?
%p{ style: "color: #777777;" }
= succeed ':' do
- = link_to note.author_name, user_url(note.author)
+ = link_to author.name, user_url(author)
- if discussion.nil?
= link_to 'commented', target_url
- else
- - if note.start_of_discussion?
+ - if discussion.first_note == note
started a new
- else
commented on a
@@ -22,14 +24,15 @@
= link_to 'discussion', target_url
- if discussion&.diff_discussion? && discussion.on_text?
- = content_for :head do
- = stylesheet_link_tag 'mailers/highlighted_diff_email'
+ - if include_stylesheet_link
+ = content_for :head do
+ = stylesheet_link_tag 'mailers/highlighted_diff_email'
- %table.code
+ %table.code.gl-mb-5
= render partial: "projects/diffs/email_line",
collection: discussion.truncated_diff_lines(diff_limit: diff_limit),
as: :line,
locals: { diff_file: discussion.diff_file }
.md{ style: note_style }
- = markdown(note.note, pipeline: :email, author: note.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
+ = markdown(note.note, pipeline: :email, author: author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index 8e2f7e6f76e..8853519fb8d 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -1,13 +1,14 @@
<% note = local_assigns.fetch(:note, @note) -%>
<% diff_limit = local_assigns.fetch(:diff_limit, nil) -%>
<% target_url = local_assigns.fetch(:target_url, @target_url) -%>
-<% discussion = note.discussion if note.part_of_discussion? -%>
+<% author = local_assigns.fetch(:author) { note.author } -%>
+<% discussion = local_assigns.fetch(:discussion) { note.discussion } if note.part_of_discussion? -%>
-<%= sanitize_name(note.author_name) -%>
+<%= sanitize_name(author.name) -%>
<% if discussion.nil? -%>
<%= 'commented' -%>:
<% else -%>
-<% if note.start_of_discussion? -%>
+<% if discussion.first_note == note -%>
<%= 'started a new discussion' -%>
<% else -%>
<%= 'commented on a discussion' -%>
diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml
index e512d7732e2..3208d061928 100644
--- a/app/views/notify/issue_due_email.html.haml
+++ b/app/views/notify/issue_due_email.html.haml
@@ -1,11 +1,11 @@
%p.details
- #{link_to @issue.author_name, user_url(@issue.author)}'s issue #{issue_reference_link(@issue)} is due soon.
+ = sprintf(s_("Notify|%{author_link}'s issue %{issue_reference_link} is due soon."), { author_link: link_to(@issue.author_name, user_url(@issue.author)), issue_reference_link: issue_reference_link(@issue) })
- if @issue.assignees.any?
%p
= assignees_label(@issue)
%p
- This issue is due on: #{@issue.due_date.to_s(:medium)}
+ = sprintf(s_('Notify|This issue is due on: %{issue_due_date}'), { issue_due_date: @issue.due_date.to_s(:medium) }).html_safe
- if @issue.description
.md
diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml
index b766cb1a523..c77a863d1a4 100644
--- a/app/views/notify/issue_moved_email.html.haml
+++ b/app/views/notify/issue_moved_email.html.haml
@@ -1,9 +1,7 @@
%p
- Issue was moved to another project.
+ = s_('Notify|Issue was moved to another project.')
- if @can_access_project
%p
- New issue:
- = link_to project_issue_url(@new_project, @new_issue) do
- = @new_issue.title
+ = sprintf(s_('Notify|New issue: %{project_issue_url}'), { project_issue_url: link_to(@new_issue.title, project_issue_url(@new_project, @new_issue)) } ).html_safe
- else
- You don't have access to the project.
+ = s_("Notify|You don't have access to the project.")
diff --git a/app/views/notify/issue_status_changed_email.html.haml b/app/views/notify/issue_status_changed_email.html.haml
index 66e73a9b03f..545f9c006af 100644
--- a/app/views/notify/issue_status_changed_email.html.haml
+++ b/app/views/notify/issue_status_changed_email.html.haml
@@ -1,2 +1,2 @@
%p
- Issue was #{@issue_status} by #{sanitize_name(@updated_by.name)}
+ = sprintf(s_('Notify|Issue was %{issue_status} by %{updated_by}'), { issue_status: @issue_status, updated_by: sanitize_name(@updated_by.name) }).html_safe
diff --git a/app/views/notify/merge_request_status_email.html.haml b/app/views/notify/merge_request_status_email.html.haml
index 49f2366c594..8e8d27b3cec 100644
--- a/app/views/notify/merge_request_status_email.html.haml
+++ b/app/views/notify/merge_request_status_email.html.haml
@@ -1,3 +1,3 @@
%p
- Merge request #{merge_request_reference_link(@merge_request)}
- was #{@mr_status} by #{sanitize_name(@updated_by.name)}
+ = sprintf(s_('Notify|Merge request %{merge_request} was %{mr_status} by %{updated_by}'),
+ { merge_request: merge_request_reference_link(@merge_request), mr_status: @mr_status, updated_by: sanitize_name(@updated_by.name) }).html_safe
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index 1a8f848218c..f3845b2b910 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -1,9 +1,9 @@
-Merge request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
+= sprintf(s_('Notify|Merge request %{merge_request} was %{mr_status}'), { merge_request: @merge_request.to_reference, mr_status: sanitize_name(@updated_by.name) })
-Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+= sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) })
= merge_path_description(@merge_request, 'to')
-Author: #{sanitize_name(@merge_request.author_name)}
+= sprintf(s_('Notify|Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) })
= assignees_label(@merge_request)
= reviewers_label(@merge_request)
diff --git a/app/views/notify/merge_request_unmergeable_email.html.haml b/app/views/notify/merge_request_unmergeable_email.html.haml
index fddf9eaf95a..6bcff28985c 100644
--- a/app/views/notify/merge_request_unmergeable_email.html.haml
+++ b/app/views/notify/merge_request_unmergeable_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge request #{merge_request_reference_link(@merge_request)} can no longer be merged due to conflict.
+ = sprintf(s_('Notify|Merge request %{merge_request} can no longer be merged due to conflict.'), { merge_request: merge_request_reference_link(@merge_request) }).html_safe
diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml
index 3db5f21e6c2..22d56e73ca8 100644
--- a/app/views/notify/merge_request_unmergeable_email.text.haml
+++ b/app/views/notify/merge_request_unmergeable_email.text.haml
@@ -1,9 +1,9 @@
-Merge request #{@merge_request.to_reference} can no longer be merged due to conflict.
+= sprintf(s_('Notify|Merge request %{merge_request} can no longer be merged due to conflict.'), { merge_request: @merge_request.to_reference })
-Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+= sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) })
= merge_path_description(@merge_request, 'to')
-Author: #{sanitize_name(@merge_request.author_name)}
+= sprintf(s_('Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) })
= assignees_label(@merge_request)
= reviewers_label(@merge_request)
diff --git a/app/views/notify/merged_merge_request_email.html.haml b/app/views/notify/merged_merge_request_email.html.haml
index f0dadd9ce91..0622e2f6ffb 100644
--- a/app/views/notify/merged_merge_request_email.html.haml
+++ b/app/views/notify/merged_merge_request_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge request #{merge_request_reference_link(@merge_request)} was merged
+ = sprintf(s_('Notify|Merge request %{merge_request} was merged'), { merge_request: merge_request_reference_link(@merge_request) }).html_safe
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index 91f920dec21..d6ec916641d 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -1,9 +1,9 @@
-Merge request #{@merge_request.to_reference} was merged
+= sprintf(s_('Notify|Merge request %{merge_request} was merged'), { merge_request: @merge_request.to_reference })
-Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+= sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) })
= merge_path_description(@merge_request, 'to')
-Author: #{sanitize_name(@merge_request.author_name)}
+= sprintf(s_('Notify|Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) })
= assignees_label(@merge_request)
= reviewers_label(@merge_request)
diff --git a/app/views/notify/new_email_address_added_email.erb b/app/views/notify/new_email_address_added_email.erb
new file mode 100644
index 00000000000..3af1953c902
--- /dev/null
+++ b/app/views/notify/new_email_address_added_email.erb
@@ -0,0 +1,5 @@
+<%= say_hi(@user) %>
+
+<%= new_email_address_added_text(@email) %>
+
+<%= remove_email_address_text %>
diff --git a/app/views/notify/new_email_address_added_email.haml b/app/views/notify/new_email_address_added_email.haml
new file mode 100644
index 00000000000..6d00aaedfd5
--- /dev/null
+++ b/app/views/notify/new_email_address_added_email.haml
@@ -0,0 +1,6 @@
+%p
+ = say_hi(@user)
+%p
+ = new_email_address_added_text(@email)
+%p
+ = remove_email_address_text(format: :html)
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index f67ac5f8fb2..1542d5bba85 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -2,18 +2,17 @@
= html_escape(_('%{user} created a merge request: %{mr_link}')) % { user: link_to(@merge_request.author_name, user_url(@merge_request.author)),
mr_link: merge_request_reference_link(@merge_request) }
-%p
- .branch
- = merge_path_description(@merge_request, 'to')
- .author
- Author: #{@merge_request.author_name}
- .assignee
- = assignees_label(@merge_request)
- .reviewer
- = reviewers_label(@merge_request)
- .approvers
- = render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter
+.branch
+ = merge_path_description(@merge_request, 'to')
+.author
+ Author: #{@merge_request.author_name}
+.assignee
+ = assignees_label(@merge_request)
+.reviewer
+ = reviewers_label(@merge_request)
+.approvers
+ = render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter
- if @merge_request.description
- .md
+ .md.gl-mt-5
= markdown(@merge_request.description, pipeline: :email, author: @merge_request.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/new_review_email.html.haml b/app/views/notify/new_review_email.html.haml
index 11da7723d8d..afc1bd68215 100644
--- a/app/views/notify/new_review_email.html.haml
+++ b/app/views/notify/new_review_email.html.haml
@@ -1,3 +1,7 @@
+- if @include_diff_discussion_stylesheet
+ = content_for :head do
+ = stylesheet_link_tag 'mailers/highlighted_diff_email'
+
%table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" }
%tbody
%tr
@@ -5,12 +9,16 @@
%table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" }
%tbody
%tr
- %td{ style: "color:#333333;border-bottom:1px solid #ededed;font-size:15px;font-weight:bold;line-height:1.4;padding: 20px 0;" }
+ %td{ style: "color:#333333;border-bottom:1px solid #ededed;font-weight:bold;line-height:1.4;padding: 20px 0;" }
- mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request))
- mr_author_link = link_to(@author_name, user_url(@author))
= _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link }
%tr
- %td{ style: "overflow:hidden;font-size:14px;line-height:1.4;display:grid;" }
+ %td{ style: "overflow:hidden;line-height:1.4;display:grid;" }
- @notes.each do |note|
+ -# Get preloaded note discussion
+ - discussion = @discussions[note.discussion_id] if note.part_of_discussion?
+ -# Preload project for discussions first note
+ - discussion.first_note.project = @project if discussion&.first_note
- target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}")
- = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed;"
+ = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author
diff --git a/app/views/notify/new_review_email.text.erb b/app/views/notify/new_review_email.text.erb
index 164735abad0..7bf878aefd0 100644
--- a/app/views/notify/new_review_email.text.erb
+++ b/app/views/notify/new_review_email.text.erb
@@ -4,8 +4,10 @@
--
<% @notes.each_with_index do |note, index| %>
+ <!-- Get preloaded note discussion-->
+ <% discussion = @discussions[note.discussion_id] if note.part_of_discussion?%>
<% target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") %>
- <%= render 'note_email', note: note, diff_limit: 3, target_url: target_url %>
+ <%= render 'note_email', note: note, diff_limit: 3, target_url: target_url, discussion: discussion, author: @author %>
<% if index != @notes.length-1 %>
--
diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml
index 3e9f9b442e0..5197a1bdd08 100644
--- a/app/views/notify/push_to_merge_request_email.html.haml
+++ b/app/views/notify/push_to_merge_request_email.html.haml
@@ -3,24 +3,25 @@
pushed new commits to merge request
= merge_request_reference_link(@merge_request)
-- if @existing_commits.any?
- - count = @existing_commits.size
+- if @total_existing_commits_count > 0
%ul
%li
- - if count == 1
+ - if @total_existing_commits_count == 1
- commit_id = @existing_commits.first[:short_id]
= link_to(commit_id, project_commit_url(@merge_request.target_project, commit_id))
- else
= link_to(project_compare_url(@merge_request.target_project, from: @existing_commits.first[:short_id], to: @existing_commits.last[:short_id])) do
#{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}
= precede '&nbsp;- ' do
- - commits_text = "#{count} commit".pluralize(count)
+ - commits_text = "#{@total_existing_commits_count} commit".pluralize(@total_existing_commits_count)
#{commits_text} from branch `#{@merge_request.target_branch}`
-- if @new_commits.any?
+- if @total_new_commits_count > 0
%ul
- @new_commits.each do |commit|
%li
= link_to(commit[:short_id], project_commit_url(@merge_request.target_project, commit[:short_id]))
= precede ' - ' do
#{commit[:title]}
+ - if @total_stripped_new_commits_count > 0
+ %li And #{@total_stripped_new_commits_count} more
diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml
index 0c16cf3315f..02f6b3914c9 100644
--- a/app/views/notify/service_desk_new_note_email.html.haml
+++ b/app/views/notify/service_desk_new_note_email.html.haml
@@ -1,5 +1,5 @@
- if Gitlab::CurrentSettings.email_author_in_body
- %div
+ .gl-mb-5
= _("%{author_link} wrote:").html_safe % { author_link: link_to(@note.author_name, user_url(@note.author)) }
.md
= markdown(@note.note, pipeline: :email, author: @note.author, issuable_reference_expansion_enabled: true)
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index fdcee3670b7..8568e61aa33 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -2,18 +2,15 @@
- @content_class = "limit-container-width" unless fluid_layout
- if current_user.ldap_user?
- = render 'shared/global_alert',
- variant: :info,
- alert_class: 'gl-my-5',
- dismissible: false do
+ = render Pajamas::AlertComponent.new(alert_class: 'gl-my-5',
+ dismissible: false) do
.gl-alert-body
= s_('Profiles|Some options are unavailable for LDAP accounts')
- if params[:two_factor_auth_enabled_successfully]
- = render 'shared/global_alert',
- variant: :success,
+ = render Pajamas::AlertComponent.new(variant: :success,
alert_class: 'gl-my-5',
- close_button_class: 'js-close-2fa-enabled-success-alert' do
+ close_button_class: 'js-close-2fa-enabled-success-alert') do
.gl-alert-body
= html_escape(_('You have set up 2FA for your account! If you lose access to your 2FA device, you can use your recovery codes to access your account. Alternatively, if you upload an SSH key, you can %{anchorOpen}use that key to generate additional recovery codes%{anchorClose}.')) % { anchorOpen: '<a href="%{href}">'.html_safe % { href: help_page_path('user/profile/account/two_factor_authentication', anchor: 'generate-new-recovery-codes-using-ssh') }, anchorClose: '</a>'.html_safe }
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index bebbe49a485..941b8545745 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -9,9 +9,9 @@
%p.form-text.text-muted= s_('Profiles|Begins with %{ssh_key_algorithms}.') % { ssh_key_algorithms: ssh_key_allowed_algorithms }
.form-row
.col.form-group
- = f.label :title, _('Title'), class: 'label-bold'
- = f.text_field :title, class: "form-control gl-form-input input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key')
- %p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publicly visible.')
+ = f.label :title, s_('Profiles|Title'), class: 'label-bold'
+ = f.text_field :title, class: "form-control gl-form-input input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|Example: MacBook key')
+ %p.form-text.text-muted= s_('Profiles|Key titles are publicly visible.')
.col.form-group
= f.label :expires_at, s_('Profiles|Expiration date'), class: 'label-bold'
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 7d4c3b6115f..5c8acc053f4 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -11,7 +11,7 @@
%h5.gl-mt-0
= _('Add an SSH key')
%p.profile-settings-content
- - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('ssh/index.md') }
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') }
= _('Add an SSH key for secure access to GitLab. %{help_link_start}Learn more.%{help_link_end}').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
= render 'form'
%hr
diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml
index f2121199412..b4db99a8bd4 100644
--- a/app/views/profiles/notifications/_email_settings.html.haml
+++ b/app/views/profiles/notifications/_email_settings.html.haml
@@ -5,6 +5,4 @@
.help-block
= local_assigns.fetch(:help_text, nil)
.form-group
- %label{ for: 'user_email_opted_in' }
- = form.check_box :email_opted_in
- %span= _('Receive product marketing emails')
+ = form.gitlab_ui_checkbox_component :email_opted_in, _('Receive product marketing emails')
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 22cb95b346a..5d74bbe9971 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -3,16 +3,11 @@
%div
- if @user.errors.any?
- .gl-alert.gl-alert-danger.gl-my-5
- .gl-alert-container
- %button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', css_class: 'gl-icon')
- = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- %ul
- - @user.errors.full_messages.each do |msg|
- %li= msg
+ = render Pajamas::AlertComponent.new(variant: :danger) do
+ .gl-alert-body
+ %ul
+ - @user.errors.full_messages.each do |msg|
+ %li= msg
= hidden_field_tag :notification_type, 'global'
.row.gl-mt-3
@@ -27,7 +22,7 @@
%h5.gl-mt-0
= _('Global notification settings')
- = form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f|
+ = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f|
= render_if_exists 'profiles/notifications/email_settings', form: f
= label_tag :global_notification_level, "Global notification level", class: "label-bold"
@@ -39,10 +34,9 @@
.clearfix
- = form_for @user, url: profile_notifications_path, method: :put do |f|
- %label{ for: 'user_notified_of_own_activity' }
- = f.check_box :notified_of_own_activity
- %span= _('Receive notifications about your own activity')
+ = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put do |f|
+ .form-group
+ = f.gitlab_ui_checkbox_component :notified_of_own_activity, _('Receive notifications about your own activity')
%hr
%h5
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 48be2001c9c..3fb48f3d3e3 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -8,7 +8,7 @@
- Gitlab::Themes.each do |theme|
= stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
-= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f|
+= gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f|
.row.gl-mt-3.js-preferences-form.js-search-settings-section
.col-lg-4.application-theme#navigation-theme
%h4.gl-mt-0
@@ -45,6 +45,19 @@
%hr
.row.js-preferences-form.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar#diffs-colors
+ %h4.gl-mt-0
+ = s_('Preferences|Diff colors')
+ %p
+ = s_('Preferences|Customize the colors of removed and added lines in diffs.')
+ .col-lg-8
+ .form-group
+ #js-profile-preferences-diffs-colors-app{ data: user_diffs_colors }
+
+ .col-sm-12
+ %hr
+
+ .row.js-preferences-form.js-search-settings-section
.col-lg-4.profile-settings-sidebar#behavior
%h4.gl-mt-0
= s_('Preferences|Behavior')
@@ -74,27 +87,19 @@
= f.select :project_view, project_view_choices, {}, class: 'select2'
.form-text.text-muted
= s_('Preferences|Choose what content you want to see on a project’s overview page.')
- .form-group.form-check
- = f.check_box :render_whitespace_in_code, class: 'form-check-input'
- = f.label :render_whitespace_in_code, class: 'form-check-label' do
- = s_('Preferences|Render whitespace characters in the Web IDE')
- .form-group.form-check
- = f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
- = f.label :show_whitespace_in_diffs, class: 'form-check-label' do
- = s_('Preferences|Show whitespace changes in diffs')
- .form-group.form-check
- = f.check_box :view_diffs_file_by_file, class: 'form-check-input'
- = f.label :view_diffs_file_by_file, class: 'form-check-label' do
- = s_("Preferences|Show one file at a time on merge request's Changes tab")
- .form-text.text-muted
- = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
- .form-group.form-check
- = f.check_box :markdown_surround_selection, class: 'form-check-input'
- = f.label :markdown_surround_selection, class: 'form-check-label' do
- = s_('Preferences|Surround text selection when typing quotes or brackets')
- .form-text.text-muted
- - supported_characters = %w(" ' ` \( [ { < * _).map {|char| "<code>#{char}</code>" }.join(', ')
- = sprintf(s_( "Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe
+ .form-group
+ = f.gitlab_ui_checkbox_component :render_whitespace_in_code, s_('Preferences|Render whitespace characters in the Web IDE')
+ .form-group
+ = f.gitlab_ui_checkbox_component :show_whitespace_in_diffs, s_('Preferences|Show whitespace changes in diffs')
+ .form-group
+ = f.gitlab_ui_checkbox_component :view_diffs_file_by_file,
+ s_("Preferences|Show one file at a time on merge request's Changes tab"),
+ help_text: s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
+ .form-group
+ - supported_characters = %w(" ' ` &#40; [ { < * _).map {|char| "<code>#{char}</code>" }.join(', ')
+ = f.gitlab_ui_checkbox_component :markdown_surround_selection,
+ s_('Preferences|Surround text selection when typing quotes or brackets'),
+ help_text: sprintf(s_( "Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe
.form-group
= f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
@@ -138,16 +143,12 @@
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'time-preferences'), target: '_blank', rel: 'noopener noreferrer'
.col-lg-8
- .form-group.form-check
- = f.check_box :time_display_relative, class: 'form-check-input'
- = f.label :time_display_relative, class: 'form-check-label' do
- = s_('Preferences|Use relative times')
- .form-text.text-muted
- = s_('Preferences|For example: 30 minutes ago.')
+ .form-group
+ = f.gitlab_ui_checkbox_component :time_display_relative,
+ s_('Preferences|Use relative times'),
+ help_text: s_('Preferences|For example: 30 minutes ago.')
- if Feature.enabled?(:user_time_settings)
- .form-group.form-check
- = f.check_box :time_format_in_24h, class: 'form-check-input'
- = f.label :time_format_in_24h, class: 'form-check-label' do
- = s_('Preferences|Display time in 24-hour format')
+ .form-group
+ = f.gitlab_ui_checkbox_component :time_format_in_24h, s_('Preferences|Display time in 24-hour format')
#js-profile-preferences-app{ data: data_attributes }
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 5f8b21b2646..3ae64643420 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -34,13 +34,15 @@
= _('To add the entry manually, provide the following details to the application on your phone.')
%p.gl-mt-0.gl-mb-0
= _('Account: %{account}') % { account: @account_string }
- %p.gl-mt-0.gl-mb-0{ data: { qa_selector: 'otp_secret_content' } }
+ %p.gl-mt-0.gl-mb-0.two-factor-secret{ data: { qa_selector: 'otp_secret_content' } }
= _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') }
%p.two-factor-new-manual-content
= _('Time based: Yes')
= form_tag profile_two_factor_auth_path, method: :post do |f|
- if @error
- = render 'shared/global_alert', title: @error[:message], variant: :danger, dismissible: false do
+ = render Pajamas::AlertComponent.new(title: @error[:message],
+ variant: :danger,
+ dismissible: false) do
.gl-alert-body
= link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml
index b713b805009..f9d8a2d2989 100644
--- a/app/views/projects/_deletion_failed.html.haml
+++ b/app/views/projects/_deletion_failed.html.haml
@@ -1,7 +1,9 @@
- project = local_assigns.fetch(:project)
- return unless project.delete_error.present?
-= render 'shared/global_alert', variant: :warning, dismissible: false, alert_class: 'project-deletion-failed-message' do
+= render Pajamas::AlertComponent.new(variant: :warning,
+ dismissible: false,
+ alert_class: 'project-deletion-failed-message') do
.gl-alert-body
This project was scheduled for deletion, but failed with the following message:
= project.delete_error
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index a7cf50623f0..74ace549bb1 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -20,6 +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 !Feature.enabled?(:new_dir_modal, default_enabled: :yaml) && can_edit_tree?
+ - if can_edit_tree?
= render 'projects/blob/new_dir'
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index a8b809d1871..6dfb338a916 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -1,10 +1,12 @@
- active_tab = local_assigns.fetch(:active_tab, 'blank')
- track_label = local_assigns.fetch(:track_label, 'import_project')
+- namespace_id = local_assigns.fetch(:destination_namespace_id, nil)
.project-import
.form-group.import-btn-container.clearfix
- %h5
+ %h5.gl-display-flex
= _("Import project from")
+ = link_to _('History'), import_history_index_path, class: 'gl-link gl-ml-auto gl-font-weight-normal'
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body', qa_selector: 'gitlab_import_button' } }
@@ -15,7 +17,7 @@
- if github_import_enabled?
%div
- = link_to new_import_github_path, class: 'gl-button btn-default btn js-import-github js-import-project-btn', data: { platform: 'github', **tracking_attrs_data(track_label, 'click_button', 'github') } do
+ = link_to new_import_github_path(namespace_id: namespace_id), class: 'gl-button btn-default btn js-import-github js-import-project-btn', data: { platform: 'github', **tracking_attrs_data(track_label, 'click_button', 'github') } do
.gl-button-icon
= sprite_icon('github')
GitHub
@@ -79,7 +81,7 @@
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
- = form_for @project, html: { class: 'new_project gl-show-field-errors js-project-import' } do |f|
+ = gitlab_ui_form_for @project, html: { class: 'new_project gl-show-field-errors js-project-import' } 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/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 88cce9e71c0..66857dadb65 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -1,9 +1,8 @@
- event = last_push_event
- if event && show_last_push_widget?(event)
- = render 'shared/global_alert',
- variant: :success,
+ = render Pajamas::AlertComponent.new(variant: :success,
alert_class: 'gl-mt-3',
- close_button_class: 'js-close-banner' do
+ close_button_class: 'js-close-banner') do
.gl-alert-body
%span= s_("LastPushEvent|You pushed to")
%strong.gl-display-inline-flex.gl-max-w-50p{ data: { toggle: 'tooltip' }, title: event.ref_name }
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 1fb045544aa..e79825bdfc4 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -9,33 +9,37 @@
= f.label :name, class: 'label-bold' do
%span= _("Project name")
= f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { qa_selector: 'project_name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
- .form-group.project-path.col-sm-6
+ .form-group.project-path.col-sm-6.gl-pr-0
= f.label :namespace_id, class: 'label-bold' do
%span= _('Project URL')
.input-group.gl-flex-nowrap
- if current_user.can_select_namespace?
- namespace_id = namespace_id_from(params)
- .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path,
- namespace_id: namespace_id,
+ .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path || @current_user_group&.full_path,
+ namespace_id: namespace_id || @current_user_group&.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.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0
#{user_url(current_user.username)}/
= f.hidden_field :namespace_id, value: current_user.namespace_id
+ .gl-align-self-center.gl-pl-5 /
.form-group.project-path.col-sm-6
= f.label :path, class: 'label-bold' do
%span= _("Project slug")
= f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_path', username: current_user.username }
+.js-group-namespace-error.form-text.gl-text-red-500.gl-display-none
+ = s_('ProjectsNew|Pick a group or namespace where you want to create this project.')
- if current_user.can_create_group?
.form-text.text-muted
- link_start_group_path = '<a href="%{path}">' % { path: new_group_path }
- project_tip = s_('ProjectsNew|Want to house several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}') % { link_start: link_start_group_path, link_end: '</a>' }
= project_tip.html_safe
-= render 'shared/global_alert', alert_class: "gl-mb-4 gl-display-none js-user-readme-repo", dismissible: false, variant: :success do
+= render Pajamas::AlertComponent.new(alert_class: "gl-mb-4 gl-display-none js-user-readme-repo",
+ dismissible: false,
+ variant: :success) do
.gl-alert-body
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') }
= html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
@@ -45,7 +49,7 @@
= s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe }
= f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { qa_selector: 'project_description', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" }
-- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
+- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? || !Gitlab.com?
.js-deployment-target-select
= f.label :visibility_level, class: 'label-bold' do
@@ -64,15 +68,14 @@
.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.variant(:candidate) do
- = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: true, with_free_badge: false
- - e.variant(:unchecked_candidate) do
- = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: false, with_free_badge: false
- - e.variant(:free_indicator) do
- = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: true, with_free_badge: true
- - e.variant(:unchecked_free_indicator) do
- = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: false, with_free_badge: true
+ .form-group
+ .form-check.gl-mb-3
+ = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', 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' }
-= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }
+= f.submit _('Create project'), class: "btn gl-button btn-confirm js-create-project-button", data: { qa_selector: 'project_create_button', 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/_new_project_initialize_with_sast.html.haml b/app/views/projects/_new_project_initialize_with_sast.html.haml
deleted file mode 100644
index ec12abbf789..00000000000
--- a/app/views/projects/_new_project_initialize_with_sast.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-- experiment_name = local_assigns.fetch(:experiment_name)
-- track_label = local_assigns.fetch(:track_label)
-
-- with_free_badge = local_assigns.fetch(:with_free_badge, false)
-- checked = local_assigns.fetch(:checked, false)
-
-.form-group
- .form-check.gl-mb-3
- = check_box_tag 'project[initialize_with_sast]', '1', checked, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: experiment_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)')
- - if with_free_badge
- = gl_badge_tag _('Free'), variant: :info, size: :sm
- .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: experiment_name }
diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml
index 9f9daa7ec6f..9e6648c71fc 100644
--- a/app/views/projects/_transfer.html.haml
+++ b/app/views/projects/_transfer.html.haml
@@ -9,6 +9,11 @@
.form-group
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transferring-an-existing-project-into-another-namespace') }
%p= _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ %p= _('When you transfer your project to a group, you can easily manage multiple projects, view usage quotas for storage, pipeline minutes, and users, and start a trial or upgrade to a paid tier.')
+ %p
+ = _("Don't have a group?")
+ = link_to _('Create one'), new_group_path, target: '_blank'
+ = _('Things to be aware of before transferring:')
%ul
%li= _("Be careful. Changing the project's namespace can have unintended side effects.")
%li= _('You can only transfer the project to namespaces you manage.')
diff --git a/app/views/projects/alert_management/details.html.haml b/app/views/projects/alert_management/details.html.haml
index b1d680e4f3d..66bbed8c5ec 100644
--- a/app/views/projects/alert_management/details.html.haml
+++ b/app/views/projects/alert_management/details.html.haml
@@ -2,4 +2,4 @@
- page_title s_('AlertManagement|Alert detail')
- add_page_specific_style 'page_bundles/alert_management_details'
-#js-alert_details{ data: alert_management_detail_data(@project, @alert_id) }
+#js-alert_details{ data: alert_management_detail_data(current_user, @project, @alert_id) }
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index ae8f89bf16a..7f72438c3f9 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -22,7 +22,7 @@
.table-responsive.file-content.blame.code{ class: user_color_scheme }
%table
- - current_line = 1
+ - current_line = @blame.first_line
- @blame.groups.each do |blame_group|
- commit_data = @blame.commit_data(blame_group[:commit])
- line_count = blame_group[:lines].count
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index 1d3bec1ad44..629fa9c0e8a 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -15,7 +15,9 @@
#{ dropzone_text.html_safe }
%br
- .dropzone-alerts.gl-alert.gl-alert-danger.gl-mb-5.data{ style: "display:none" }
+ = render Pajamas::AlertComponent.new(variant: :danger,
+ alert_class: 'dropzone-alerts gl-alert gl-alert-danger gl-mb-5 data gl-display-none',
+ dismissible: false)
= render 'shared/new_commit_form', placeholder: placeholder, ref: local_assigns[:ref]
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 773137ff3f2..aefa4a41ab5 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -4,10 +4,9 @@
- webpack_preload_asset_tag('monaco')
- if @conflict
- = render 'shared/global_alert',
- alert_class: 'gl-mb-5 gl-mt-5',
+ = render Pajamas::AlertComponent.new(alert_class: 'gl-mb-5 gl-mt-5',
variant: :danger,
- dismissible: false do
+ dismissible: false) do
- blob_url = project_blob_path(@project, @id)
- external_link_icon = content_tag 'span', { aria: { label: _('Opens new window') }} do
- sprite_icon('external-link', css_class: 'gl-icon').html_safe
diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml
deleted file mode 100644
index b20106e8c3a..00000000000
--- a/app/views/projects/blob/viewers/_balsamiq.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index f5e61c010cc..e4ec7a43d61 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -7,6 +7,7 @@
= sprite_icon('branch', size: 12, css_class: 'gl-flex-shrink-0')
= link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3 qa-branch-name' do
= branch.name
+ = clipboard_button(text: branch.name, title: _("Copy branch name"))
- if branch.name == @repository.root_ref
= gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :info, size: :sm }, { class: 'gl-ml-2' }
- elsif merged
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
index f03b5cf2eff..bd6831ff3b2 100644
--- a/app/views/projects/branches/_panel.html.haml
+++ b/app/views/projects/branches/_panel.html.haml
@@ -7,7 +7,7 @@
- return unless branches.any?
-.card.gl-mt-3
+.card
.card-header
= panel_title
%ul.content-list.all-branches.qa-all-branches
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 96acd863a4c..85a0346e691 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -27,11 +27,6 @@
= render_if_exists 'projects/commits/mirror_status'
.js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json), default_branch: @project.default_branch } }
-- if can?(current_user, :admin_project, @project)
- - project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project)
- .row-content-block
- %h5
- = s_('Branches|Protected branches can be managed in %{project_settings_link}.').html_safe % { project_settings_link: project_settings_link }
- if @gitaly_unavailable
= render 'shared/errors/gitaly_unavailable', reason: s_('Branches|Unable to load branches')
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 5cc83111b34..07bae7819a4 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -2,9 +2,7 @@
- default_ref = params[:ref] || @project.default_branch
- if @error
- = render 'shared/global_alert',
- variant: :danger,
- close_button_class: 'js-close' do
+ = render Pajamas::AlertComponent.new(variant: :danger) do
.gl-alert-body
= @error
%h3.page-title
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index a3343aa4228..23572d1d6ac 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -47,9 +47,7 @@
- if can?(current_user, :read_pipeline, @last_pipeline)
.well-segment.pipeline-info
- .status-icon-container
- = link_to project_pipeline_path(@project, @last_pipeline.id), class: "ci-status-icon-#{@last_pipeline.status}" do
- = ci_icon_for_status(@last_pipeline.status)
+ .js-commit-pipeline-status{ data: { full_path: @project.full_path, iid: @last_pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@last_pipeline) } }
#{ _('Pipeline') }
= link_to "##{@last_pipeline.id}", project_pipeline_path(@project, @last_pipeline.id)
= ci_label_for_status(@last_pipeline.status)
@@ -57,7 +55,7 @@
#{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), @last_pipeline.stages_count) }
.mr-widget-pipeline-graph
.stage-cell
- .js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe, full_path: @project.full_path, iid: @last_pipeline.iid } }
+ .js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe, full_path: @project.full_path, iid: @last_pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@last_pipeline) } }
- if @last_pipeline.duration
in
= time_interval_in_words @last_pipeline.duration
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index e22d33e3c72..c26f24dd52c 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -16,6 +16,7 @@
diffs: @diffs,
environment: @environment,
diff_page_context: "is-commit",
+ page: pagination_params[:page],
paginate_diffs: true,
paginate_diffs_per_page: Projects::CommitController::COMMIT_DIFFS_PER_PAGE
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 02b5fe00ad0..82d3bfbcfe6 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -35,9 +35,8 @@
- if hidden > 0
%li
- = render 'shared/global_alert',
- variant: :warning,
- dismissible: false do
+ = render Pajamas::AlertComponent.new(variant: :warning,
+ dismissible: false) do
.gl-alert-body
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 36641a8c508..e5be3a897a5 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -20,7 +20,7 @@
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn gl-button'
- elsif create_mr_button?(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project)
.control.d-none.d-md-block
- = link_to _("Create merge request"), create_mr_path(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project), class: 'btn gl-button btn-success'
+ = link_to _("Create merge request"), create_mr_path(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project), class: 'btn gl-button btn-confirm'
.control
= form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index 1fc067b6be1..cb2c2d488e8 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -13,6 +13,7 @@
diffs: @diffs,
environment: @environment,
diff_page_context: "is-compare",
+ page: pagination_params[:page],
paginate_diffs: true,
paginate_diffs_per_page: Projects::CompareController::COMMIT_DIFFS_PER_PAGE
- else
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
index f0214ade313..263b0025fe8 100644
--- a/app/views/projects/deploy_keys/edit.html.haml
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -3,7 +3,7 @@
%hr
%div
- = form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f|
+ = gitlab_ui_form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f|
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
= f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index bb2682bb7c0..6f4ffecd5e0 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -5,7 +5,8 @@
- load_diff_files_async = Feature.enabled?(:async_commit_diff_files, @project) && diff_page_context == "is-commit"
- paginate_diffs = local_assigns.fetch(:paginate_diffs, false) && !load_diff_files_async
- paginate_diffs_per_page = local_assigns.fetch(:paginate_diffs_per_page, nil)
-- diff_files = conditionally_paginate_diff_files(diffs, paginate: paginate_diffs, per: paginate_diffs_per_page)
+- page = local_assigns.fetch(:page, nil)
+- diff_files = conditionally_paginate_diff_files(diffs, paginate: paginate_diffs, page: page, per: paginate_diffs_per_page)
.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed
.files-changed-inner
@@ -24,10 +25,10 @@
.btn-group.gl-ml-3
= inline_diff_btn
= parallel_diff_btn
- = render 'projects/diffs/stats', diff_files: diff_files
+ = render Diffs::StatsComponent.new(diff_files: diff_files)
- if render_overflow_warning?(diffs)
- = render 'projects/diffs/warning', diff_files: diffs
+ = render Diffs::OverflowWarningComponent.new(diffs: diffs, diff_files: diff_files, project: @project, commit: @commit, merge_request: @merge_request)
.files{ data: { can_create_note: can_create_note } }
- if load_diff_files_async
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 0638481d968..64bd1bf32f0 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -15,6 +15,7 @@
- unless diff_file.submodule?
.file-actions.gl-display-none.gl-sm-display-flex
+ #js-diff-stats{ data: diff_file_stats_data(diff_file) }
- if diff_file.blob&.readable_text?
%span.has-tooltip{ title: _("Toggle comments for this file") }
= link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default btn-icon selected', disabled: @diff_notes_disabled do
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index a5d3328b439..dd5114e3cec 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -10,6 +10,8 @@
- case line.type
- when 'match'
= diff_match_line line.old_pos, line.new_pos, text: line.text
+ - when 'old-nomappinginraw', 'new-nomappinginraw', 'unchanged-nomappinginraw'
+ = diff_nomappinginraw_line line, %w[old_line diff-line-num], %w[new_line diff-line-num], %w[line_content]
- when 'old-nonewline', 'new-nonewline'
%td.old_line.diff-line-num
%td.new_line.diff-line-num
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index ebe3aad064a..03fe3e6edf5 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -11,6 +11,8 @@
- case left.type
- when 'match'
= diff_match_line left.old_pos, nil, text: left.text, view: :parallel
+ - when 'old-nomappinginraw', 'new-nomappinginraw', 'unchanged-nomappinginraw'
+ = diff_nomappinginraw_line left, %w[old_line diff-line-num], nil, %w[line_content parallel left-side]
- when 'old-nonewline', 'new-nonewline'
%td.old_line.diff-line-num
%td.line_content.match.left-side= left.text
@@ -29,6 +31,8 @@
- case right.type
- when 'match'
= diff_match_line nil, right.new_pos, text: left.text, view: :parallel
+ - when 'old-nomappinginraw', 'new-nomappinginraw', 'unchanged-nomappinginraw'
+ = diff_nomappinginraw_line right, %w[new_line diff-line-num], nil, %w[line_content parallel right-side]
- when 'old-nonewline', 'new-nonewline'
%td.new_line.diff-line-num
%td.line_content.match.right-side= right.text
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
deleted file mode 100644
index fe9658a440a..00000000000
--- a/app/views/projects/diffs/_stats.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-.js-diff-stats-dropdown{ data: { changed: diff_files.size, added: diff_files.sum(&:added_lines), deleted: diff_files.sum(&:removed_lines), files: diff_files_data(diff_files) } }
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 6e7e0244721..2cd215c5518 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -12,6 +12,8 @@
- case line.type
- when 'match'
= diff_match_line line.old_pos, line.new_pos, text: line.text
+ - when 'old-nomappinginraw', 'new-nomappinginraw', 'unchanged-nomappinginraw'
+ = diff_nomappinginraw_line line, %w[old_line diff-line-num], %w[new_line diff-line-num], %w[line_content]
- when 'old-nonewline', 'new-nonewline'
%td.old_line.diff-line-num
%td.new_line.diff-line-num
diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml
deleted file mode 100644
index 3d31773694f..00000000000
--- a/app/views/projects/diffs/_warning.html.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-= render 'shared/global_alert',
- title: _('Too many changes to show.'),
- variant: :warning,
- alert_class: 'gl-mb-5' do
- .gl-alert-body
- = html_escape(_("To preserve performance only %{strong_open}%{display_size} of %{real_size}%{strong_close} files are displayed.")) % { display_size: diff_files.size, real_size: diff_files.real_size, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- .gl-alert-actions
- - if current_controller?(:commit)
- = link_to _("Plain diff"), project_commit_path(@project, @commit, format: :diff), class: "btn gl-alert-action btn-default gl-button btn-default-secondary"
- = link_to _("Email patch"), project_commit_path(@project, @commit, format: :patch), class: "btn gl-alert-action btn-default gl-button btn-default-secondary"
- - elsif current_controller?('projects/merge_requests/diffs') && @merge_request&.persisted?
- = link_to _("Plain diff"), merge_request_path(@merge_request, format: :diff), class: "btn gl-alert-action btn-default gl-button btn-default-secondary"
- = link_to _("Email patch"), merge_request_path(@merge_request, format: :patch), class: "btn gl-alert-action btn-default gl-button btn-default-secondary"
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 1609d81c0fd..92dbde07709 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -5,6 +5,8 @@
- expanded = expanded_by_default?
- reduce_visibility_form_id = 'reduce-visibility-form'
+= render_if_exists 'shared/minute_limit_banner', namespace: @project
+
%section.settings.general-settings.no-animate.expanded#js-general-settings
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
@@ -33,7 +35,7 @@
.settings-content
= render_if_exists 'shared/promotions/promote_mr_features'
- = form_for @project, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
+ = gitlab_ui_form_for @project, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
= render 'projects/merge_request_settings', form: f
= f.submit _('Save changes'), class: "btn gl-button btn-confirm rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }
@@ -98,7 +100,7 @@
.input-group-text
#{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
= f.text_field :path, class: 'form-control h-auto', data: { qa_selector: 'project_path_field' }
- = f.submit _('Change path'), class: "gl-button btn btn-warning", data: { qa_selector: 'change_path_button' }
+ = f.submit _('Change path'), class: "gl-button btn btn-danger", data: { qa_selector: 'change_path_button' }
= render 'transfer', project: @project
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 6a54eedf6c8..b2338fa6c55 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -3,6 +3,7 @@
- escaped_default_branch_name = default_branch_name.shellescape
- @skip_current_level_breadcrumb = true
+= render_if_exists 'shared/user_over_limit_free_plan_alert', source: @project
= render partial: 'flash_messages', locals: { project: @project }
= render "home_panel"
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index 7933e0e07b3..9b64f158a1b 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -1,10 +1,9 @@
- page_title _("Fork project")
- if @forked_project && !@forked_project.saved?
- = render 'shared/global_alert',
- title: _('Fork Error!'),
+ = render Pajamas::AlertComponent.new(title: _('Fork Error!'),
variant: :danger,
alert_class: 'gl-mt-5',
- dismissible: false do
+ dismissible: false) do
.gl-alert-body
%p
= _("You tried to fork %{link_to_the_project} but it failed for the following reason:").html_safe % { link_to_the_project: link_to_project(@project) }
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index 5330c3aa6d6..905c5779c7d 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -1,5 +1,7 @@
- sort_value = @sort || sort_value_recently_created
-- sort_title = forks_sort_options_hash[sort_value]
+- excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id]
+- created_at = { value: sort_value_created_date, text: sort_title_created_date, href: page_filter_path(sort: sort_value_recently_created, without: excluded_filters) }
+- activity = { value: sort_value_latest_activity, text: sort_title_latest_activity, href: page_filter_path(sort: sort_value_latest_activity, without: excluded_filters) }
.top-area
.nav-text
@@ -14,14 +16,7 @@
.dropdown.gl-display-inline.gl-md-ml-3.issue-sort-dropdown.gl-mt-3.gl-md-mt-0
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
- %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'gl-button btn btn-default' }
- = sort_title
- = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
- %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
- %li
- - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id]
- = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_recently_created, without: excluded_filters), sort_title)
- = sortable_item(sort_title_latest_activity, page_filter_path(sort: sort_value_latest_activity, without: excluded_filters), sort_title)
+ = gl_redirect_listbox_tag [created_at, activity], @sort
= forks_sort_direction_button(sort_value)
- if current_user && can?(current_user, :fork_project, @project)
diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml
index b3f5b91596d..270cbf3facd 100644
--- a/app/views/projects/harbor/repositories/index.html.haml
+++ b/app/views/projects/harbor/repositories/index.html.haml
@@ -4,6 +4,8 @@
#js-harbor-registry-list-project{ data: { endpoint: project_harbor_registry_index_path(@project),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
- "help_page_path" => help_page_path('user/packages/container_registry/index'),
+ "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ "registry_host_url_with_port" => 'demo.harbor.com',
connection_error: (!!@connection_error).to_s,
- invalid_path_error: (!!@invalid_path_error).to_s, } }
+ invalid_path_error: (!!@invalid_path_error).to_s,
+ is_group_page: false.to_s, } }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index 4df109dbb61..74af65904cd 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -9,7 +9,7 @@
= render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-9.gl-mb-3
- = form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
+ = gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3'
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index 5ca65d55eea..7d62a851aa1 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -7,7 +7,7 @@
= render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-8.gl-mb-3
- = form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f|
+ = gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit 'Add webhook', class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index b021087c394..dc2bcfa33bb 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -12,7 +12,7 @@
:preserve
#{h(@project.import_state.last_error)}
-= form_for @project, url: project_import_path(@project), method: :post, html: { class: 'js-project-import' } do |f|
+= gitlab_ui_form_for @project, url: project_import_path(@project), method: :post, html: { class: 'js-project-import' } do |f|
= render "shared/import_form", f: f
.form-actions
diff --git a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
index 26bd65fbe26..f28b951ad62 100644
--- a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
+++ b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
@@ -2,9 +2,7 @@
- service_desk_link_url = help_page_path('user/project/service_desk')
- service_desk_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_desk_link_url }
-= render 'shared/global_alert',
- variant: :warning,
- close_button_class: 'js-close',
- alert_class: 'hide js-alert-moved-from-service-desk-warning gl-mt-5' do
+= render Pajamas::AlertComponent.new(variant: :warning,
+ alert_class: 'hide js-alert-moved-from-service-desk-warning gl-mt-5') do
.gl-alert-body.gl-mr-3
= s_('This project does not have %{service_desk_link_start}Service Desk%{service_desk_link_end} enabled, so the user who created the issue will no longer receive email notifications about new activity.').html_safe % { service_desk_link_start: service_desk_link_start, service_desk_link_end: '</a>'.html_safe }
diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml
index 34e46807fb6..11741059ee5 100644
--- a/app/views/projects/issues/_form.html.haml
+++ b/app/views/projects/issues/_form.html.haml
@@ -1,3 +1,3 @@
-= form_for [@project, @issue],
- html: { class: 'issue-form common-note-form gl-mt-3 js-quick-submit gl-show-field-errors' } do |f|
+= gitlab_ui_form_for [@project, @issue],
+ html: { class: 'issue-form common-note-form gl-mt-3 js-quick-submit gl-show-field-errors', data: issues_form_data(@project) } do |f|
= render 'shared/issuable/form', f: f, issuable: @issue
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index f6ed6c26752..801841edc26 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -13,13 +13,13 @@
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
.btn-group.unavailable
%button.gl-button.btn{ type: 'button', disabled: 'disabled' }
- .gl-spinner.align-text-bottom.gl-button-icon.hide
+ = gl_loading_icon(inline: true, css_class: 'js-create-mr-spinner gl-button-icon gl-display-none')
%span.text
Checking branch availability…
.btn-group.available.hidden
%button.gl-button.btn.js-create-merge-request.btn-confirm{ type: 'button', data: { action: data_action } }
- .gl-spinner.js-spinner.gl-mr-2.gl-display-none
+ = gl_loading_icon(css_class: 'js-create-mr-spinner js-spinner gl-mr-2 gl-display-none')
= value
%button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.create-merge-request-dropdown-toggle.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }
@@ -55,12 +55,12 @@
%label{ for: 'source-name' }
= _('Source (branch or tag)')
%input#source-name.js-ref.ref.form-control.gl-form-input{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } }
- %span.js-ref-message.form-text.text-muted
+ %span.js-ref-message.form-text
.form-group
%button.btn.gl-button.btn-confirm.js-create-target{ type: 'button', data: { action: 'create-mr' } }
= create_mr_text
- if can_create_confidential_merge_request?
- %p.text-warning.js-exposed-info-warning.hidden
+ %p.gl-text-orange-500.js-exposed-info-warning.gl-display-none
= _('This may expose confidential information as the selected fork is in another namespace that can have other members.')
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index c58c6ab8287..9a2a1e57165 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -1,9 +1,11 @@
+= render_if_exists 'shared/minute_limit_banner', namespace: @project
+
- page_title _("Jobs")
- add_page_specific_style 'page_bundles/ci_status'
- admin = local_assigns.fetch(:admin, false)
- if Feature.enabled?(:jobs_table_vue, @project, default_enabled: :yaml)
- #js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } }
+ #js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } }
- else
.top-area
- build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml
index 1efec0c017c..3d901c6f59b 100644
--- a/app/views/projects/mattermosts/_no_teams.html.haml
+++ b/app/views/projects/mattermosts/_no_teams.html.haml
@@ -1,8 +1,8 @@
- if @teams_error_message
= content_for :flash_message do
- .gl-alert.gl-alert-danger
- = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-body= @teams_error_message
+ = render Pajamas::AlertComponent.new(variant: :danger) do
+ .gl-alert-body
+ = @teams_error_message
%p
You aren’t a member of any team on the Mattermost instance at
diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml
index a68a4318538..5f1c72156eb 100644
--- a/app/views/projects/merge_requests/_form.html.haml
+++ b/app/views/projects/merge_requests/_form.html.haml
@@ -1,3 +1,3 @@
-= form_for [@project, @merge_request],
+= gitlab_ui_form_for [@project, @merge_request],
html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index d894aeaad65..49b7320d630 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -6,10 +6,9 @@
= cache(cache_key, expires_in: 1.day) do
- if @merge_request.closed_or_merged_without_fork?
- = render 'shared/global_alert',
- alert_class: 'gl-mb-5',
+ = render Pajamas::AlertComponent.new(alert_class: 'gl-mb-5',
variant: :danger,
- dismissible: false do
+ dismissible: false) do
.gl-alert-body
= _('The source project of this merge request has been removed.')
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index 253f50d5090..ef6e930bf23 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -1,6 +1,6 @@
%h3.page-title
= _('New merge request')
-= form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
+= gitlab_ui_form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter
= f.hidden_field :source_project_id
= f.hidden_field :source_branch
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index aa68fe031bb..ce5a042fbf8 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -9,9 +9,8 @@
= render "projects/merge_requests/mr_title"
= render "projects/merge_requests/mr_box"
- = render 'shared/global_alert',
- variant: :danger,
- dismissible: false do
+ = render Pajamas::AlertComponent.new(variant: :danger,
+ dismissible: false) do
.gl-alert-body
- if @merge_request.for_fork? && !@merge_request.source_project
= err_fork_project_removed
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index 059ef53c42b..154a92e6ec8 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -1,24 +1,36 @@
- page_title _('Milestones')
- add_page_specific_style 'page_bundles/milestone'
-.top-area
- = render 'shared/milestones_filter', counts: milestone_counts(@project.milestones)
+- if @milestone_states.any? { |name, count| count > 0 }
+ .top-area
+ = render 'shared/milestones_filter', counts: @milestone_states
- .nav-controls
- = render 'shared/milestones/search_form'
- = render 'shared/milestones_sort_dropdown'
- - if can?(current_user, :admin_milestone, @project)
- = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
- = _('New milestone')
+ .nav-controls
+ = render 'shared/milestones/search_form'
+ = render 'shared/milestones_sort_dropdown'
+ - if can?(current_user, :admin_milestone, @project)
+ = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
+ = _('New milestone')
-- if @milestones.blank?
- = render 'shared/empty_states/milestones'
-- else
- .milestones
- #js-delete-milestone-modal
- #promote-milestone-modal
+ - if @milestones.blank?
+ = render 'shared/empty_states/milestones_tab' do
+ - if can?(current_user, :admin_milestone, @project)
+ .text-center
+ = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
+ = _('New milestone')
+
+ - else
+ .milestones
+ #js-delete-milestone-modal
+ #promote-milestone-modal
- %ul.content-list
- = render @milestones
+ %ul.content-list
+ = render @milestones
- = paginate @milestones, theme: 'gitlab'
+ = paginate @milestones, theme: 'gitlab'
+- else
+ = render 'shared/empty_states/milestones' do
+ - if can?(current_user, :admin_milestone, @project)
+ .text-center
+ = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
+ = _('New milestone')
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 225f8c7dd66..13aa8f56d20 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -12,11 +12,9 @@
= render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project
- if can?(current_user, :read_issue, @project) && @milestone.total_issues_count == 0
- = render 'shared/global_alert',
- variant: :info,
- dismissible: false,
+ = render Pajamas::AlertComponent.new(dismissible: false,
alert_data: { testid: 'no-issues-alert' },
- alert_class: 'gl-mt-3 gl-mb-5' do
+ alert_class: 'gl-mt-3 gl-mb-5') do
.gl-alert-body
= _('Assign some issues to this milestone.')
- else
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 3af95633214..bc8400a63f9 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -14,7 +14,7 @@
.settings-content
- if mirror_settings_enabled
- = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f|
+ = gitlab_ui_form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f|
.panel.panel-default
.panel-body
%div= form_errors(@project)
@@ -37,9 +37,7 @@
.panel-footer
= f.submit _('Mirror repository'), class: 'gl-button btn btn-confirm js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
- else
- = render 'shared/global_alert',
- dismissible: false,
- variant: :info do
+ = render Pajamas::AlertComponent.new(dismissible: false) do
.gl-alert-body
= _('Mirror settings are only available to GitLab administrators.')
diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml
index 4411bc474b8..3abab0281a0 100644
--- a/app/views/projects/mirrors/_ssh_host_keys.html.haml
+++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml
@@ -4,7 +4,7 @@
.form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) }
%button.btn.gl-button.btn-inverted.btn-secondary.inline.js-detect-host-keys.gl-mr-3{ type: 'button', data: { qa_selector: 'detect_host_keys' } }
- .js-spinner.d-none.gl-spinner.mr-1
+ = gl_loading_icon(inline: true, css_class: 'js-spinner gl-display-none gl-mr-2')
= _('Detect host keys')
.fingerprint-ssh-info.js-fingerprint-ssh-info.gl-mt-3.gl-mb-3{ class: ('collapse' unless mirror.ssh_mirror_url?) }
%label.label-bold
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index e3f46d601a3..1331ed24307 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,6 +1,8 @@
- page_title _('No repository')
- @skip_current_level_breadcrumb = true
+= render_if_exists 'shared/user_over_limit_free_plan_alert', source: @project
+
%h2.gl-display-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
= sprite_icon('warning-solid', size: 24, css_class: 'gl-mr-2')
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index 15fb5755b61..0010564081e 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -2,21 +2,20 @@
- can_enforce_https_only=Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
- return unless can_edit_max_page_size || can_enforce_https_only
-= form_for @project, url: project_pages_path(@project), html: { class: 'inline', title: pages_https_only_title } do |f|
+= gitlab_ui_form_for @project, url: project_pages_path(@project), html: { class: 'inline', title: pages_https_only_title } do |f|
- if can_edit_max_page_size
= render_if_exists 'shared/pages/max_pages_size_input', form: f
- if can_enforce_https_only
.form-group
- .form-check
- = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled?
- = f.label :pages_https_only, class: pages_https_only_label_class do
- %strong
- = s_('GitLabPages|Force HTTPS (requires valid certificates)')
- - docs_link_start = "<a href='#{help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'force-https-for-gitlab-pages-websites')}' target='_blank' rel='noopener noreferrer'>".html_safe
- - link_end = '</a>'.html_safe
- %p
- = s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end }
+ = f.gitlab_ui_checkbox_component :pages_https_only,
+ s_('GitLabPages|Force HTTPS (requires valid certificates)'),
+ checkbox_options: { disabled: pages_https_only_disabled? },
+ label_options: { class: 'label-bold' }
+ - docs_link_start = "<a href='#{help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'force-https-for-gitlab-pages-websites')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ - link_end = '</a>'.html_safe
+ %p.gl-pl-6
+ = s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end }
.gl-mt-3
= f.submit s_('GitLabPages|Save changes'), class: 'btn btn-confirm gl-button'
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index 453134ce5ab..d3e2854ff19 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -1,6 +1,5 @@
- if domain_presenter.errors.any?
- .gl-alert.gl-alert-danger
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ = render Pajamas::AlertComponent.new(variant: :danger, dismissible: false) do
- domain_presenter.errors.full_messages.each do |msg|
= msg
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 0818c3d5cff..5ff0e2ccac3 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f|
+= gitlab_ui_form_for [@project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f|
= form_errors(@schedule)
.form-group.row
.col-md-9
@@ -15,7 +15,7 @@
= f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
.form-group.row
.col-md-9
- = f.label :ref, Feature.enabled?(:pipeline_schedules_with_tags, default_enabled: :yaml) ? _('Target branch or tag') : _('Target branch'), class: 'label-bold'
+ = f.label :ref, _('Target branch or tag'), class: 'label-bold'
%div{ data: { testid: 'schedule-target-ref' } }
.js-target-ref-dropdown{ data: { project_id: @project.id, default_branch: @project.default_branch } }
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
@@ -37,8 +37,7 @@
.col-md-9
= f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold'
%div
- = f.check_box :active, required: false, value: @schedule.active?
- = f.label :active, _('Active'), class: 'gl-font-weight-normal'
+ = f.gitlab_ui_checkbox_component :active, _('Active'), checkbox_options: { value: @schedule.active, required: false }
.footer-block.row-content-block
= f.submit _('Save pipeline schedule'), class: 'btn gl-button btn-confirm'
= link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index a56e8f7f5c7..10a49fbd779 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -1,3 +1,5 @@
+= render_if_exists 'shared/minute_limit_banner', namespace: @project
+
- breadcrumb_title _("Schedules")
- page_title _("Pipeline Schedules")
- add_page_specific_style 'page_bundles/pipeline_schedules'
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index f4b242ffc40..817cc6d6e6c 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,3 +1,5 @@
+= render_if_exists 'shared/minute_limit_banner', namespace: @project
+
- page_title _('Pipelines')
- add_page_specific_style 'page_bundles/pipelines'
- add_page_specific_style 'page_bundles/ci_status'
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index ba498352278..2b0a0fc1253 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -27,6 +27,8 @@
= s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe }
#js-pipeline-notification{ data: { deprecated_keywords_doc_path: help_page_path('ci/yaml/index.md', anchor: 'deprecated-keywords'), full_path: @project.full_path, pipeline_iid: @pipeline.iid } }
- = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
-
+ - if Feature.enabled?(:pipeline_tabs_vue, @project, default_enabled: :yaml)
+ #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline) }
+ - else
+ = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
.js-pipeline-details-vue{ data: { metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index f97b9a2b02f..298c2074062 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,6 +1,8 @@
- add_page_specific_style 'page_bundles/members'
- page_title _("Members")
+= render_if_exists 'shared/user_over_limit_free_plan_alert', source: @project
+
.row.gl-mt-3
.col-lg-12
- if can_invite_members_for_project?(@project)
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
index c9e964b2984..6dd3b2e8d5e 100644
--- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -20,4 +20,4 @@
- if can_admin_project
%td
- = link_to s_('ProtectedBranch|Unprotect'), [@project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, class: "btn gl-button btn-warning"
+ = link_to s_('ProtectedBranch|Unprotect'), [@project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, class: "btn gl-button btn-danger btn-sm"
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index aab5e9fca98..51f0b6319a1 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -19,7 +19,7 @@
"project_path": @project.full_path,
"gid_prefix": container_repository_gid_prefix,
"is_admin": current_user&.admin.to_s,
- "show_cleanup_policy_on_alert": show_cleanup_policy_on_alert(@project).to_s,
+ "show_cleanup_policy_link": show_cleanup_policy_link(@project).to_s,
"cleanup_policies_settings_path": project_settings_packages_and_registries_path(@project),
connection_error: (!!@connection_error).to_s,
invalid_path_error: (!!@invalid_path_error).to_s,
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 5eaf6c9d22b..18803bdd8f3 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -8,7 +8,7 @@
%span
= "##{runner.id} (#{runner.short_sha})"
- if runner.locked?
- %span.has-tooltip{ title: _('Locked to current projects') }
+ %span.has-tooltip{ title: s_('Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.') }
= sprite_icon('lock')
.gl-ml-2
.btn-group.btn-group-sm
@@ -22,10 +22,10 @@
= link_to resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Resume accepting jobs'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body' } do
= sprite_icon('play')
- if runner.belongs_to_one_project?
- = link_to _('Remove runner'), project_runner_path(@project, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger'
+ = link_to _('Remove runner'), project_runner_path(@project, runner), aria: { label: _('Remove') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'btn gl-button btn-danger'
- else
- runner_project = @project.runner_projects.find_by(runner_id: runner) # rubocop: disable CodeReuse/ActiveRecord
- = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger'
+ = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), aria: { label: _('Disable') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'btn gl-button btn-danger'
- elsif runner.project_type?
= form_for [@project, @project.runner_projects.new] do |f|
= f.hidden_field :runner_id, value: runner.id
diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml
index df14bd09a4d..4b82f74d035 100644
--- a/app/views/projects/security/configuration/show.html.haml
+++ b/app/views/projects/security/configuration/show.html.haml
@@ -3,5 +3,6 @@
- @content_class = "limit-container-width" unless fluid_layout
#js-security-configuration{ data: { **@configuration.to_html_data_attribute,
+ vulnerability_training_docs_path: vulnerability_training_docs_path,
upgrade_path: security_upgrade_path,
project_full_path: @project.full_path } }
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index 7a47b504b7c..d1d9a220068 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -5,14 +5,13 @@
-# When using integration.activate_disabled_reason[:trackers], it's potentially insecure to use the raw records
-# when passed directly to the frontend. Only use specific fields that are needed for render.
-# For example, we can get the link to each tracker with scoped_edit_integration_path(tracker, tracker.project)
- = render 'shared/global_alert',
- title: s_('ExternalIssueIntegration|Another issue tracker is already in use'),
+ = render Pajamas::AlertComponent.new(title: s_('ExternalIssueIntegration|Another issue tracker is already in use'),
variant: :warning,
- dismissible: false do
+ dismissible: false) do
.gl-alert-body
= s_('ExternalIssueIntegration|Only one issue tracker integration can be active at a time. Please disable the active tracker first and try again.')
-%h3.page-title
+%h2.gl-mb-4
= integration.title
- if integration.operating?
= sprite_icon('check', css_class: 'gl-text-green-500')
diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml
index 9b3cb8893c4..f40d8638845 100644
--- a/app/views/projects/services/prometheus/_help.html.haml
+++ b/app/views/projects/services/prometheus/_help.html.haml
@@ -1,5 +1,6 @@
- if @project
- = render 'projects/services/prometheus/configuration_banner', project: @project, integration: integration
+ = render 'shared/prometheus_configuration_banner', project: @project, integration: integration, header_tag: :h4
+ %hr
%h4.gl-mb-3
= s_('PrometheusService|Manual configuration')
diff --git a/app/views/projects/services/prometheus/_top.html.haml b/app/views/projects/services/prometheus/_top.html.haml
index f7446273a80..52b29ea2e8f 100644
--- a/app/views/projects/services/prometheus/_top.html.haml
+++ b/app/views/projects/services/prometheus/_top.html.haml
@@ -2,8 +2,7 @@
.row
.col-lg-12
- .gl-alert.gl-alert-info{ role: 'alert' }
- = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ = render Pajamas::AlertComponent.new(dismissible: false) do
.gl-alert-body
= s_('AlertSettings|You can now set up alert endpoints for manually configured Prometheus instances in the Alerts section on the Operations settings page. Alert endpoint fields on this page have been deprecated.')
.gl-alert-actions
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index 12cb1c3574a..1b0294bc967 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -18,5 +18,5 @@
%p= _("Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
= link_to _('Archive project'), archive_project_path(@project),
aria: { label: _('Archive project') },
- data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'warning' },
- method: :post, class: "gl-button btn btn-warning"
+ data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' },
+ method: :post, class: "gl-button btn btn-confirm"
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 43d34173af6..7783e83b88f 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -11,22 +11,19 @@
.row
.col-lg-12
- = form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings') do |f|
+ = gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings') do |f|
= form_errors(@project)
%fieldset.builds-feature.js-auto-devops-settings
.form-group
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
.card.auto-devops-card
.card-body
- .form-check
- = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: auto_devops_enabled, data: { qa_selector: 'enable_autodevops_checkbox' }
- = form.label :enabled, class: 'form-check-label' do
- %strong= s_('CICD|Default to Auto DevOps pipeline')
- - if auto_devops_enabled
- = gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge'}
- .form-text.text-muted
- = s_('CICD|The Auto DevOps pipeline runs if no alternative CI configuration file is found.')
- = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
+ - autodevops_help_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
+ - auto_devops_badge = auto_devops_enabled ? (gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge gl-ml-3 gl-mt-n1'}) : ''
+ = form.gitlab_ui_checkbox_component :enabled,
+ (s_('CICD|Default to Auto DevOps pipeline') + auto_devops_badge).html_safe,
+ checkbox_options: { class: 'js-toggle-extra-settings', checked: auto_devops_enabled, data: { qa_selector: 'enable_autodevops_checkbox' } },
+ help_text: (s_('CICD|The Auto DevOps pipeline runs if no alternative CI configuration file is found.') + ' ' + autodevops_help_link).html_safe
.card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' }
- if @project.all_clusters.empty?
%p.settings-message.text-center
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 66a1cbb4649..5ef56cda6d2 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -1,35 +1,28 @@
+- help_link_public_pipelines = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'change-which-users-can-view-your-pipelines'), target: '_blank', rel: 'noopener noreferrer'
+- help_link_auto_canceling = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'auto-cancel-redundant-pipelines'), target: '_blank', rel: 'noopener noreferrer'
+- help_link_skip_outdated =link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'skip-outdated-deployment-jobs'), target: '_blank', rel: 'noopener noreferrer'
+
.row.gl-mt-3
.col-lg-12
- = form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f|
+ = gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f|
= form_errors(@project)
%fieldset.builds-feature
.form-group
- .form-check
- = f.check_box :public_builds, { class: 'form-check-input' }
- = f.label :public_builds, class: 'form-check-label' do
- %strong= _("Public pipelines")
- .form-text.text-muted
- = _("Allow public access to pipelines and job details, including output logs and artifacts.")
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'change-which-users-can-view-your-pipelines'), target: '_blank', rel: 'noopener noreferrer'
+ = f.gitlab_ui_checkbox_component :public_builds,
+ _("Public pipelines"),
+ help_text: (_('Allow public access to pipelines and job details, including output logs and artifacts.') + ' ' + help_link_public_pipelines).html_safe
.form-group
- .form-check
- = f.check_box :auto_cancel_pending_pipelines, { class: 'form-check-input' }, 'enabled', 'disabled'
- = f.label :auto_cancel_pending_pipelines, class: 'form-check-label' do
- %strong= _("Auto-cancel redundant pipelines")
- .form-text.text-muted
- = _("New pipelines cause older pending or running pipelines on the same branch to be cancelled.")
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'auto-cancel-redundant-pipelines'), target: '_blank', rel: 'noopener noreferrer'
+ = f.gitlab_ui_checkbox_component :auto_cancel_pending_pipelines,
+ _("Auto-cancel redundant pipelines"),
+ checked_value: 'enabled',
+ unchecked_value: 'disabled',
+ help_text: (_('New pipelines cause older pending or running pipelines on the same branch to be cancelled.') + ' ' + help_link_auto_canceling).html_safe
.form-group
- .form-check
- = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form|
- = form.check_box :forward_deployment_enabled, { class: 'form-check-input' }
- = form.label :forward_deployment_enabled, class: 'form-check-label' do
- %strong= _("Skip outdated deployment jobs")
- .form-text.text-muted
- = _("When a deployment job is successful, skip older deployment jobs that are still pending.")
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'skip-outdated-deployment-jobs'), target: '_blank', rel: 'noopener noreferrer'
+ = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form|
+ = form.gitlab_ui_checkbox_component :forward_deployment_enabled, _("Skip outdated deployment jobs"),
+ help_text: (_('When a deployment job is successful, skip older deployment jobs that are still pending.') + ' ' + help_link_skip_outdated).html_safe
.form-group
= f.label :ci_config_path, _('CI/CD configuration file'), class: 'label-bold'
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index f342728feee..28cde994d00 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -1,3 +1,5 @@
+= render_if_exists 'shared/minute_limit_banner', namespace: @project
+
- @content_class = "limit-container-width" unless fluid_layout
- page_title _("CI/CD Settings")
- page_title _("CI/CD")
diff --git a/app/views/projects/settings/operations/_configuration_banner.html.haml b/app/views/projects/settings/operations/_configuration_banner.html.haml
deleted file mode 100644
index 9803ffc3c4e..00000000000
--- a/app/views/projects/settings/operations/_configuration_banner.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-%b
- = s_('PrometheusService|Prometheus cluster integration')
-
-- if service.manual_configuration?
- .info-well.p-2.mt-2
- = s_('PrometheusService|To use a Prometheus installed on a cluster, deactivate the manual configuration.')
-- else
- .container-fluid
- .row
- - if service.prometheus_available?
- .col-sm-2
- .svg-container
- = image_tag 'illustrations/monitoring/getting_started.svg'
- .col-sm-10
- %p.text-success.gl-mt-3
- = s_('PrometheusService|You have a cluster with the Prometheus integration enabled.')
- = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'gl-button btn btn-default'
- - else
- .col-sm-2
- = image_tag 'illustrations/monitoring/loading.svg'
- .col-sm-10
- %p.gl-mt-3
- = s_('PrometheusService|Configure GitLab to query a Prometheus installed in one of your clusters.')
- = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-confirm'
diff --git a/app/views/projects/settings/operations/_prometheus.html.haml b/app/views/projects/settings/operations/_prometheus.html.haml
index 1c7bcbbca0b..93281cc225b 100644
--- a/app/views/projects/settings/operations/_prometheus.html.haml
+++ b/app/views/projects/settings/operations/_prometheus.html.haml
@@ -9,7 +9,7 @@
= link_to _('More information'), help_page_path('user/project/integrations/prometheus'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- if @project
- = render 'projects/settings/operations/configuration_banner', project: @project, service: service
+ = render 'shared/prometheus_configuration_banner', project: @project, integration: service, header_tag: :b, info_well_classes: 'gl-p-3 gl-mt-3'
%b.gl-mb-3
= s_('PrometheusService|Manual configuration')
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 4e94c96fdde..9a31666c316 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -15,7 +15,7 @@
= s_('Deprecations|Feature deprecation and removal')
.gl-alert-body
%p
- = html_escape(s_('Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0. For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {removal_link_start: removal_epic_link_start, opstrace_link_start: opstrace_link_start, link_end: link_end }
+ = html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7. The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0. For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {removal_link_start: removal_epic_link_start, opstrace_link_start: opstrace_link_start, link_end: link_end }
= render 'projects/settings/operations/metrics_dashboard'
= render 'projects/settings/operations/tracing'
diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml
index 658b2f2e65c..378bb0f9306 100644
--- a/app/views/projects/settings/packages_and_registries/show.html.haml
+++ b/app/views/projects/settings/packages_and_registries/show.html.haml
@@ -11,5 +11,5 @@
admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s,
help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'),
- show_cleanup_policy_on_alert: show_cleanup_policy_on_alert(@project).to_s,
+ show_cleanup_policy_link: show_cleanup_policy_link(@project).to_s,
tags_regex_help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'regex-pattern-examples') } }
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index d840ea01b89..1934f293b0f 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -6,6 +6,8 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
+= render_if_exists 'shared/user_over_limit_free_plan_alert', source: @project
+= render_if_exists 'shared/minute_limit_banner', namespace: @project
= render partial: 'flash_messages', locals: { project: @project }
= render "projects/last_push"
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 4a44ad2337f..a654d0a8863 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -29,9 +29,12 @@
- else
.nothing-here-block
- = s_('TagsPage|Repository has no tags yet.')
- %br
- %small
- = s_('TagsPage|Use git tag command to add a new one:')
+ - if @search.present?
+ = s_('TagsPage|Sorry, your filter produced no results.')
+ - else
+ = s_('TagsPage|Repository has no tags yet.')
%br
- %span.monospace git tag -a v1.4 -m 'version 1.4'
+ %small
+ = s_('TagsPage|Use git tag command to add a new one:')
+ %br
+ %span.monospace git tag -a v1.4 -m 'version 1.4'
diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml
index a7a02ab917e..c9aac68b19d 100644
--- a/app/views/projects/tracings/show.html.haml
+++ b/app/views/projects/tracings/show.html.haml
@@ -14,7 +14,7 @@
= s_('Deprecations|Feature deprecation and removal')
.gl-alert-body
%p
- = html_escape(s_('Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0. For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {removal_link_start: removal_epic_link_start, opstrace_link_start: opstrace_link_start, link_end: link_end }
+ = html_escape(s_('Deprecations|The logs and tracing features were deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0. For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {removal_link_start: removal_epic_link_start, opstrace_link_start: opstrace_link_start, link_end: link_end }
- if @project.tracing_external_url.present?
%h3.page-title= _('Tracing')
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index 44dffdbf70a..6235cce5d80 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -17,7 +17,7 @@
%p.gl-text-center= html_escape(_('%{gitlab_experience_text}. We won\'t share this information with anyone.')) % { gitlab_experience_text: gitlab_experience_text }
- else
%p.gl-text-center= html_escape(_('%{gitlab_experience_text}. Don\'t worry, this information isn\'t shared outside of your self-managed GitLab instance.')) % { gitlab_experience_text: gitlab_experience_text }
- = form_for(current_user, url: users_sign_up_welcome_path, html: { class: 'card gl-w-full! gl-p-5', 'aria-live' => 'assertive' }) do |f|
+ = gitlab_ui_form_for(current_user, url: users_sign_up_welcome_path, html: { class: 'card gl-w-full! gl-p-5', 'aria-live' => 'assertive' }) do |f|
.devise-errors
= render 'devise/shared/error_messages', resource: current_user
.row
diff --git a/app/views/search/results/_blob_highlight.html.haml b/app/views/search/results/_blob_highlight.html.haml
index 729eda331b5..7ba114496af 100644
--- a/app/views/search/results/_blob_highlight.html.haml
+++ b/app/views/search/results/_blob_highlight.html.haml
@@ -6,7 +6,7 @@
.blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } }
- blob.present.highlight.lines.each_with_index do |line, index|
- i = index + offset
- .line_holder.code-search-line
+ .line_holder.code-search-line.gl-display-flex
.line-numbers
.gl-display-flex
%span.diff-line-num.gl-pl-3
@@ -22,7 +22,7 @@
%a{ href: "#{blob_link}#L#{i}", id: "blob-L#{i}", 'data-line-number' => i, class: 'gl-display-flex! gl-align-items-center gl-justify-content-end' }
= sprite_icon('link', css_class: 'gl-ml-3! gl-mr-1!')
= i
- %pre.code.highlight
+ %pre.code.highlight.flex-grow-1
%code
= line.html_safe
diff --git a/app/views/shared/_allow_request_access.html.haml b/app/views/shared/_allow_request_access.html.haml
index ca09fd39dc1..608a0ca37d9 100644
--- a/app/views/shared/_allow_request_access.html.haml
+++ b/app/views/shared/_allow_request_access.html.haml
@@ -1,6 +1,3 @@
-- label_class = local_assigns.fetch(:bold_label, false) ? 'font-weight-bold' : ''
-
= form.gitlab_ui_checkbox_component :request_access_enabled,
_('Allow users to request access (if visibility is public or internal)'),
- label_options: { class: label_class },
checkbox_options: { data: { qa_selector: 'request_access_checkbox' } }
diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
index 35a3835a522..fdd4dfba616 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -1,9 +1,7 @@
- if show_auto_devops_implicitly_enabled_banner?(project, current_user)
- = render 'shared/global_alert',
- variant: :info,
- alert_class: 'qa-auto-devops-banner auto-devops-implicitly-enabled-banner',
+ = render Pajamas::AlertComponent.new(alert_class: 'qa-auto-devops-banner auto-devops-implicitly-enabled-banner',
close_button_class: 'hide-auto-devops-implicitly-enabled-banner',
- close_button_data: { project_id: project.id } do
+ close_button_data: { project_id: project.id }) do
.gl-alert-body
= s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found.")
- unless Gitlab.config.registry.enabled
diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
index 7aaae3a88f3..ab6423e9ade 100644
--- a/app/views/shared/_broadcast_message.html.haml
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -1,12 +1,29 @@
-- is_banner = message.broadcast_type == 'banner'
+- icon_name = 'bullhorn'
+- dismissable = message.dismissable?
+- preview = local_assigns.fetch(:preview, false)
-%div{ class: "broadcast-message #{'alert-warning' if is_banner} broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'} js-broadcast-notification-#{message.id} gl-display-flex",
- style: broadcast_message_style(message), dir: 'auto' }
- .gl-flex-grow-1.gl-text-right.gl-pr-3
- = sprite_icon('bullhorn', css_class: 'vertical-align-text-top')
- %div{ class: !fluid_layout && 'container-limited' }
- = render_broadcast_message(message)
- .gl-flex-grow-1.gl-flex-basis-0.gl-text-right
- - if (message.notification? || message.dismissable?) && opts[:preview].blank?
+- unless message.notification?
+ .gl-broadcast-message.broadcast-banner-message{ role: "alert", class: "js-broadcast-notification-#{message.id} #{message.theme}" }
+ .gl-broadcast-message-content
+ .gl-broadcast-message-icon
+ = sprite_icon(icon_name)
+ .gl-broadcast-message-text.js-broadcast-message-preview
+ - if message.message.present?
+ = render_broadcast_message(message)
+ - else
+ = yield
+ - if dismissable && !preview
+ %button.btn.gl-close-btn-color-inherit.gl-broadcast-message-dismiss.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-dismiss-current-broadcast-notification{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id, expire_date: message.ends_at.iso8601 } }
+ = sprite_icon('close', size: 16, css_class: "gl-icon gl-mx-3! gl-text-white")
+- else
+ - notification_class = "js-broadcast-notification-#{message.id}"
+ - notification_class << ' preview' if preview
+ .broadcast-message.broadcast-notification-message.mt-2{ role: "alert", class: notification_class }
+ = sprite_icon(icon_name, css_class: 'vertical-align-text-top')
+ - if message.message.present?
+ = render_broadcast_message(message)
+ - else
+ = yield
+ - if !preview
%button.js-dismiss-current-broadcast-notification.btn.btn-link.gl-button{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id, expire_date: message.ends_at.iso8601 } }
- = sprite_icon('close', size: 16, css_class: "gl-icon gl-mx-3! #{is_banner ? 'gl-text-white' : 'gl-text-gray-700'}")
+ = sprite_icon('close', size: 16, css_class: "gl-icon gl-mx-3! gl-text-gray-700")
diff --git a/app/views/shared/_global_alert.html.haml b/app/views/shared/_global_alert.html.haml
deleted file mode 100644
index cb7ad32e474..00000000000
--- a/app/views/shared/_global_alert.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- icons = { info: 'information-o', warning: 'warning', success: 'check-circle', danger: 'error', tip: 'bulb' }
-
-- title = local_assigns.fetch(:title, nil)
-- variant = local_assigns.fetch(:variant, :info)
-- dismissible = local_assigns.fetch(:dismissible, true)
-- alert_class = local_assigns.fetch(:alert_class, nil)
-- alert_data = local_assigns.fetch(:alert_data, nil)
-- close_button_class = local_assigns.fetch(:close_button_class, nil)
-- close_button_data = local_assigns.fetch(:close_button_data, nil)
-- icon = icons[variant]
-
-%div{ role: 'alert', class: ['gl-alert', "gl-alert-#{variant}", alert_class], data: alert_data }
- = sprite_icon(icon, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}")
- - if dismissible
- %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ type: 'button', aria: { label: _('Dismiss') }, class: close_button_class, data: close_button_data }
- = sprite_icon('close')
- .gl-alert-content{ role: 'alert' }
- - if title
- %h4.gl-alert-title
- = title
- = yield
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 3ab2b969b75..850d58920db 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -8,11 +8,10 @@
= _('Git repository URL')
= 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: :danger,
+ = render Pajamas::AlertComponent.new(variant: :danger,
alert_class: 'gl-mt-3 js-import-url-error hide',
dismissible: false,
- close_button_class: 'js-close-2fa-enabled-success-alert' do
+ close_button_class: 'js-close-2fa-enabled-success-alert') do
.gl-alert-body
= s_('Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials.')
.row
diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml
index 29c01343358..1c6eb7aa96b 100644
--- a/app/views/shared/_milestones_sort_dropdown.html.haml
+++ b/app/views/shared/_milestones_sort_dropdown.html.haml
@@ -1,22 +1,4 @@
-.dropdown.inline.gl-ml-3
- %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
- %span.light
- - if @sort.present?
- = milestone_sort_options_hash[@sort]
- - else
- = sort_title_due_date_soon
- = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
- %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort
- %li
- = link_to page_filter_path(sort: sort_value_due_date_soon) do
- = sort_title_due_date_soon
- = link_to page_filter_path(sort: sort_value_due_date_later) do
- = sort_title_due_date_later
- = link_to page_filter_path(sort: sort_value_start_date_soon) do
- = sort_title_start_date_soon
- = link_to page_filter_path(sort: sort_value_start_date_later) do
- = sort_title_start_date_later
- = link_to page_filter_path(sort: sort_value_name) do
- = sort_title_name_asc
- = link_to page_filter_path(sort: sort_value_name_desc) do
- = sort_title_name_desc
+- milestones_sort_options = milestones_sort_options_hash.map { |value, text| { value: value, text: text, href: page_filter_path(sort: value) } }
+
+%div{ data: {testid: 'milestone_sort_by_dropdown'} }
+ = gl_redirect_listbox_tag milestones_sort_options, @sort, class: 'gl-ml-3'
diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml
index d1e1a8a819d..195bd15f840 100644
--- a/app/views/shared/_no_password.html.haml
+++ b/app/views/shared/_no_password.html.haml
@@ -1,8 +1,7 @@
- if show_no_password_message?
- = render 'shared/global_alert',
- variant: :warning,
+ = render Pajamas::AlertComponent.new(variant: :warning,
alert_class: 'js-no-password-message',
- close_button_class: 'js-hide-no-password-message' do
+ close_button_class: 'js-hide-no-password-message') do
.gl-alert-body
= no_password_message
.gl-alert-actions
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index 20dc1b41970..d30679b4305 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -1,8 +1,7 @@
- if show_no_ssh_key_message?
- = render 'shared/global_alert',
- variant: :warning,
+ = render Pajamas::AlertComponent.new(variant: :warning,
alert_class: 'js-no-ssh-message',
- close_button_class: 'js-hide-no-ssh-message' do
+ close_button_class: 'js-hide-no-ssh-message') do
.gl-alert-body
= s_("MissingSSHKeyWarningLink|You can't push or pull repositories using SSH until you add an SSH key to your profile.")
.gl-alert-actions
diff --git a/app/views/shared/_outdated_browser.html.haml b/app/views/shared/_outdated_browser.html.haml
index f5a32050a79..76fb34985c0 100644
--- a/app/views/shared/_outdated_browser.html.haml
+++ b/app/views/shared/_outdated_browser.html.haml
@@ -1,6 +1,5 @@
- if outdated_browser?
- .gl-alert.gl-alert-danger.outdated-browser{ :role => "alert" }
- = sprite_icon('error', css_class: "gl-alert-icon gl-alert-icon-no-title gl-icon")
+ = render Pajamas::AlertComponent.new(variant: :danger, dismissible: false) do
.gl-alert-body
= s_('OutdatedBrowser|GitLab may not work properly, because you are using an outdated web browser.')
%br
diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml
index 90612ba623f..7e1874f3416 100644
--- a/app/views/shared/_project_limit.html.haml
+++ b/app/views/shared/_project_limit.html.haml
@@ -1,8 +1,7 @@
- if cookies[:hide_project_limit_message].blank? && !current_user.hide_project_limit && !current_user.can_create_project? && current_user.projects_limit > 0
- = render 'shared/global_alert',
- variant: :warning,
+ = render Pajamas::AlertComponent.new(variant: :warning,
dismissible: false,
- alert_class: 'project-limit-message' do
+ alert_class: 'project-limit-message') do
.gl-alert-body
= _("You won't be able to create new projects because you have reached your project limit.")
.gl-alert-actions
diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/shared/_prometheus_configuration_banner.html.haml
index a34aa22acbb..2d948cf28a6 100644
--- a/app/views/projects/services/prometheus/_configuration_banner.html.haml
+++ b/app/views/shared/_prometheus_configuration_banner.html.haml
@@ -1,8 +1,11 @@
-%h4
- = s_('PrometheusService|Prometheus cluster integration')
+- header_tag = local_assigns.fetch(:header_tag)
+- info_well_classes = local_assigns.fetch(:info_well_classes, '')
+- integration = local_assigns.fetch(:integration)
+
+= content_tag(header_tag, s_('PrometheusService|Prometheus cluster integration'))
- if integration.manual_configuration?
- .info-well
+ .info-well{ class: info_well_classes }
= s_('PrometheusService|To use a Prometheus installed on a cluster, deactivate the manual configuration.')
- else
.container-fluid
@@ -14,7 +17,7 @@
.col-sm-10
%p.text-success.gl-mt-3
= s_('PrometheusService|You have a cluster with the Prometheus integration enabled.')
- = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button'
+ = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-default'
- else
.col-sm-2
= image_tag 'illustrations/monitoring/loading.svg'
@@ -22,5 +25,3 @@
%p.gl-mt-3
= s_('PrometheusService|Configure GitLab to query a Prometheus installed in one of your clusters.')
= link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-confirm'
-
-%hr
diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml
index 9cdff35ead2..96f015c7a4b 100644
--- a/app/views/shared/_service_ping_consent.html.haml
+++ b/app/views/shared/_service_ping_consent.html.haml
@@ -1,7 +1,5 @@
- if session[:ask_for_usage_stats_consent]
- = render 'shared/global_alert',
- variant: :info,
- alert_class: 'service-ping-consent-message' do
+ = render Pajamas::AlertComponent.new(alert_class: 'service-ping-consent-message') do
.gl-alert-body
- docs_link = link_to _('collect usage information'), help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link'
- settings_link = link_to _('your settings'), metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link'
diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
index f21acd26ada..2294c44d49f 100644
--- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
+++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
@@ -1,8 +1,9 @@
-= render 'shared/global_alert',
- variant: :warning,
+= render Pajamas::AlertComponent.new(variant: :warning,
alert_class: 'js-recovery-settings-callout gl-mt-5',
- alert_data: { feature_id: Users::CalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: callouts_path, defer_links: 'true' },
- close_button_data: { testid: 'close-account-recovery-regular-check-callout' } do
+ alert_data: { feature_id: Users::CalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK,
+ dismiss_endpoint: callouts_path,
+ defer_links: 'true' },
+ close_button_data: { testid: 'close-account-recovery-regular-check-callout' }) do
.gl-alert-body
= s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.')
= link_to _('Learn more.'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'recovery-codes'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index 0b68cfe65e5..d4106ba4e5d 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -10,7 +10,7 @@
%p.profile-settings-content
= _("Enter the name of your application, and we'll return a unique %{type}.") % { type: type }
-= form_for token, as: prefix, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
+= gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
= form_errors(token)
@@ -42,7 +42,7 @@
%p.text-secondary#select_scope_help_text
= s_('Tokens|Scopes set the permission levels granted to the token.')
= link_to _("Learn more."), help_path, target: '_blank', rel: 'noopener noreferrer'
- = render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes
+ = render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes, f: f
- if prefix == :personal_access_token && Feature.enabled?(:personal_access_tokens_scoped_to_projects, current_user)
.js-access-tokens-projects
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index 98752345074..c070baf02b1 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -16,6 +16,4 @@
- page_title("#{board.name}", _("Boards"))
- add_page_specific_style 'page_bundles/boards'
-= render 'shared/issuable/search_bar', type: :boards, board: board
-
#js-issuable-board-app{ data: board_data }
diff --git a/app/views/shared/boards/_switcher.html.haml b/app/views/shared/boards/_switcher.html.haml
deleted file mode 100644
index c667b3a4626..00000000000
--- a/app/views/shared/boards/_switcher.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-- parent = board.resource_parent
-- milestone_filter_opts = { format: :json }
-- milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board?
-- weights = Gitlab.ee? ? ([Issue::WEIGHT_ANY] + Issue.weight_options) : []
-
-#js-multiple-boards-switcher.inline.boards-switcher{ data: { milestone_path: milestones_filter_path(milestone_filter_opts),
- board_base_url: board_base_url,
- has_missing_boards: (!multiple_boards_available? && current_board_parent.boards.size > 1).to_s,
- can_admin_board: can?(current_user, :admin_issue_board, parent).to_s,
- multiple_issue_boards_available: parent.multiple_issue_boards_available?.to_s,
- scoped_issue_board_feature_enabled: Gitlab.ee? && parent.feature_available?(:scoped_issue_board) ? 'true' : 'false',
- weights: weights.to_json } }
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index bf2514f8b0d..b60d433bafa 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -27,8 +27,5 @@
.form-group
.col-form-label.col-sm-2
.col-sm-10
- = deploy_keys_project_form.label :can_push do
- = deploy_keys_project_form.check_box :can_push
- %strong= _('Grant write permissions to this key')
- %p.light.gl-mb-0
- = _('Allow this key to push to this repository')
+ = deploy_keys_project_form.gitlab_ui_checkbox_component :can_push, _('Grant write permissions to this key'),
+ help_text: _('Allow this key to push to this repository')
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index 8da48a7936a..c9edf09b350 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f|
+= gitlab_ui_form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f|
= form_errors(@deploy_keys.new_key)
.form-group.row
= f.label :title, class: "label-bold"
@@ -13,12 +13,8 @@
= f.fields_for :deploy_keys_projects do |deploy_keys_project_form|
.form-group.row
- = deploy_keys_project_form.label :can_push do
- = deploy_keys_project_form.check_box :can_push
- %strong= _('Grant write permissions to this key')
- .form-group.row
- %p.light.gl-mb-0
- = _('Allow this key to push to this repository')
+ = deploy_keys_project_form.gitlab_ui_checkbox_component :can_push, _('Grant write permissions to this key'),
+ help_text: _('Allow this key to push to this repository')
.form-group.row
= f.submit _("Add key"), class: "btn gl-button btn-confirm", data: { qa_selector: "add_deploy_key_button"}
diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml
index adfd7ea98b7..c1650405776 100644
--- a/app/views/shared/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
+= gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
= form_errors(@application)
.form-group
@@ -12,22 +12,19 @@
%span.form-text.text-muted
= _('Use one line per URI')
- .form-group.form-check
- = f.check_box :confidential, class: 'form-check-input'
- = f.label :confidential, class: 'label-bold form-check-label'
- %span.form-text.text-muted
- = _('Enable only for confidential applications exclusively used by a trusted backend server that can securely store the client secret. Do not enable for native-mobile, single-page, or other JavaScript applications because they cannot keep the client secret confidential.')
+ .form-group
+ = f.gitlab_ui_checkbox_component :confidential, _('Confidential'),
+ help_text: _('Enable only for confidential applications exclusively used by a trusted backend server that can securely store the client secret. Do not enable for native-mobile, single-page, or other JavaScript applications because they cannot keep the client secret confidential.')
- .form-group.form-check
- = f.check_box :expire_access_tokens, class: 'form-check-input'
- = f.label :expire_access_tokens, class: 'label-bold form-check-label'
- %span.form-text.text-muted
- = _('Enable access tokens to expire after 2 hours. If disabled, tokens do not expire.')
- = link_to _('Learn more.'), help_page_path('integration/oauth_provider.md', anchor: 'expiring-access-tokens'), target: '_blank', rel: 'noopener noreferrer'
+ .form-group
+ - help_text = _('Enable access tokens to expire after 2 hours. If disabled, tokens do not expire.')
+ - help_link = link_to _('Learn more.'), help_page_path('integration/oauth_provider.md', anchor: 'expiring-access-tokens'), target: '_blank', rel: 'noopener noreferrer'
+ = f.gitlab_ui_checkbox_component :expire_access_tokens, _('Expire access tokens'),
+ help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
.form-group
= f.label :scopes, class: 'label-bold'
- = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: @application, scopes: @scopes
+ = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: @application, scopes: @scopes, f: f
.gl-mt-3
= f.submit _('Save application'), class: "gl-button btn btn-confirm"
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 6b571794625..fb410274859 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -47,7 +47,7 @@
= link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link'
- if show_import_button
- .js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: issuable_type, import_csv_issues_path: import_csv_namespace_project_issues_path, 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), container_class: 'gl-display-inline-flex gl-vertical-align-middle', show_label: 'true' } }
+ .js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: issuable_type, import_csv_issues_path: import_csv_namespace_project_issues_path, 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), container_class: 'gl-w-full gl-sm-w-auto gl-sm-mr-3 gl-display-inline-flex gl-vertical-align-middle', show_label: 'true' } }
%hr
%p.gl-text-center.gl-mb-0
%strong
diff --git a/app/views/shared/empty_states/_milestones.html.haml b/app/views/shared/empty_states/_milestones.html.haml
index c22869fb7e6..fb69e75370e 100644
--- a/app/views/shared/empty_states/_milestones.html.haml
+++ b/app/views/shared/empty_states/_milestones.html.haml
@@ -1,7 +1,13 @@
+- learn_more_path = local_assigns.fetch(:learn_more_path, help_page_path('user/project/milestones/index'))
+- learn_more_link = link_to _('Learn more.'), learn_more_path
+
.row.empty-state
.col-12
.svg-content
= image_tag 'illustrations/milestone_burndown_chart.svg'
.col-12
.text-content
- %h4.text-center= _('No milestones to show')
+ %h4= s_('Milestones|Use milestones to track issues and merge requests over a fixed period of time')
+ %p.state-description
+ = s_('Milestones|Organize issues and merge requests into a cohesive group, and set optional start and due dates. %{learn_more_link}').html_safe % { learn_more_link: learn_more_link }
+ = yield
diff --git a/app/views/shared/empty_states/_milestones_tab.html.haml b/app/views/shared/empty_states/_milestones_tab.html.haml
new file mode 100644
index 00000000000..f6760b0a3f4
--- /dev/null
+++ b/app/views/shared/empty_states/_milestones_tab.html.haml
@@ -0,0 +1,17 @@
+- learn_more_path = local_assigns.fetch(:learn_more_path, help_page_path('user/project/milestones/index'))
+- learn_more_link = link_to _('Learn more.'), learn_more_path
+- closed_tab_selected = params[:state] == 'closed'
+
+.row.empty-state
+ .col-12
+ .svg-content
+ = image_tag 'illustrations/milestone_burndown_chart.svg'
+ .col-12
+ .text-content
+ - if closed_tab_selected
+ %h4.text-center= s_('Milestones|There are no closed milestones')
+ - else
+ %h4.text-center= s_('Milestones|There are no open milestones')
+ %p.state-description
+ = s_('Milestones|Create a milestone to better track your issues and merge requests. %{learn_more_link}').html_safe % { learn_more_link: learn_more_link }
+ = yield
diff --git a/app/views/shared/errors/_gitaly_unavailable.html.haml b/app/views/shared/errors/_gitaly_unavailable.html.haml
index 366d4585435..e99c41f2496 100644
--- a/app/views/shared/errors/_gitaly_unavailable.html.haml
+++ b/app/views/shared/errors/_gitaly_unavailable.html.haml
@@ -1,7 +1,6 @@
-= render 'shared/global_alert',
- alert_class: 'gl-my-5',
+= render Pajamas::AlertComponent.new(alert_class: 'gl-my-5',
variant: :danger,
dismissible: false,
- title: reason do
+ title: reason) do
.gl-alert-body
= s_('The git server, Gitaly, is not available at this time. Please contact your administrator.')
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 75c34102935..80edce8e7c4 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -1,26 +1,5 @@
- options_hash = local_assigns.fetch(:options_hash, groups_sort_options_hash)
-- show_archive_options = local_assigns.fetch(:show_archive_options, false)
+- groups_sort_options = options_hash.map { |value, title| { value: value, text: title, href: filter_groups_path(sort: value) } }
-.dropdown.inline.js-group-filter-dropdown-wrap.gl-mr-3
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.dropdown-label
- = options_hash[project_list_sort_by]
- = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
- %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
- %li.dropdown-header
- = _("Sort by")
- - options_hash.each do |value, title|
- %li.js-filter-sort-order
- = link_to filter_groups_path(sort: value), class: ("is-active" if project_list_sort_by == value) do
- = title
- - if show_archive_options
- %li.divider
- %li.js-filter-archived-projects
- = link_to filter_groups_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
- = _("Hide archived projects")
- %li.js-filter-archived-projects
- = link_to filter_groups_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
- = _("Show archived projects")
- %li.js-filter-archived-projects
- = link_to filter_groups_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
- = _("Show archived projects only")
+%div{ data: { testid: 'group_sort_by_dropdown' } }
+ = gl_redirect_listbox_tag groups_sort_options, project_list_sort_by, data: { right: true }
diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml
index 95590d6e515..ce04e24b09f 100644
--- a/app/views/shared/hook_logs/_content.html.haml
+++ b/app/views/shared/hook_logs/_content.html.haml
@@ -10,14 +10,11 @@
%hr
- if hook_log.internal_error_message.present?
- .gl-alert-container
- .gl-alert.gl-alert-danger
- .gl-alert-container
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- %h4.gl-alert-title= _('Internal error occurred while delivering this webhook.')
- .gl-alert-body
- = _('Error: %{error}') % { error: hook_log.internal_error_message }
+ = render Pajamas::AlertComponent.new(title: _('Internal error occurred while delivering this webhook.'),
+ variant: :danger,
+ dismissible: false) do
+ .gl-alert-body
+ = _('Error: %{error}') % { error: hook_log.internal_error_message }
%h4= _('Response')
= render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml
index f2a31400698..0ae0eea59d8 100644
--- a/app/views/shared/integrations/edit.html.haml
+++ b/app/views/shared/integrations/edit.html.haml
@@ -3,7 +3,7 @@
- page_title @integration.title, _('Integrations')
- @content_class = 'limit-container-width' unless fluid_layout
-%h3.page-title
+%h2.gl-mb-4
= @integration.title
= render 'shared/integrations/tabs', integration: @integration, active_tab: 'edit' do
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
index e6d722cb08d..73f1e35f03f 100644
--- a/app/views/shared/issuable/_assignees.html.haml
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -7,7 +7,7 @@
= render 'shared/issuable/merge_request_assignees', issuable: issuable, count: render_count
- else
- issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
- = link_to_member(@project, assignee, name: false, title: s_("MrList|Assigned to %{name}, go to their profile.") % { name: assignee.name})
+ = link_to_member(@project, assignee, name: false, title: s_("MrList|Assigned to %{name}") % { name: assignee.name})
- if more_assignees_count > 0
%span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', qa_selector: 'avatar_counter_content' }, title: _("+%{more_assignees_count} more assignees") % { more_assignees_count: more_assignees_count} }
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 3f6e7a6fb32..e0d5f738273 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -6,10 +6,9 @@
= form_errors(issuable)
- if @conflict
- = render 'shared/global_alert',
- variant: :danger,
+ = render Pajamas::AlertComponent.new(variant: :danger,
dismissible: false,
- alert_class: 'gl-mb-5' do
+ alert_class: 'gl-mb-5') do
.gl-alert-body
Someone edited the #{issuable.class.model_name.human.downcase} the same time you did.
Please check out
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index 6a58acf8c05..7ab82362e85 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -6,7 +6,7 @@
.dropdown-page-two.dropdown-new-label
= dropdown_title(create_label_title(subject), options: { back: true, close: show_close })
= dropdown_content do
- = render 'shared/global_alert', variant: :danger, alert_class: 'js-label-error gl-mb-3', dismissible: false
+ = render Pajamas::AlertComponent.new(variant: :danger, alert_class: 'js-label-error gl-mb-3', dismissible: false)
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
.suggest-colors.suggest-colors-dropdown
= render_suggested_colors
diff --git a/app/views/shared/issuable/_merge_request_assignees.html.haml b/app/views/shared/issuable/_merge_request_assignees.html.haml
index 13dc6ae4abb..6c7a2496ec6 100644
--- a/app/views/shared/issuable/_merge_request_assignees.html.haml
+++ b/app/views/shared/issuable/_merge_request_assignees.html.haml
@@ -1,6 +1,6 @@
- issuable.merge_request_assignees.take(count).each do |merge_request_assignee| # rubocop: disable CodeReuse/ActiveRecord
- assignee = merge_request_assignee.assignee
- - assignee_tooltip = ( merge_request_assignee.attention_requested? ? s_("MrList|Attention requested from assignee %{name}, go to their profile.") : s_("MrList|Assigned to %{name}, go to their profile.") ) % { name: assignee.name}
+ - assignee_tooltip = ( merge_request_assignee.attention_requested? ? s_("MrList|Attention requested from assignee %{name}") : s_("MrList|Assigned to %{name}") ) % { name: assignee.name}
= link_to_member(@project, assignee, name: false, title: assignee_tooltip, extra_class: "gl-flex-direction-row-reverse") do
- if merge_request_assignee.attention_requested?
diff --git a/app/views/shared/issuable/_merge_request_reviewers.html.haml b/app/views/shared/issuable/_merge_request_reviewers.html.haml
index df5c69e309f..8dd74e12aff 100644
--- a/app/views/shared/issuable/_merge_request_reviewers.html.haml
+++ b/app/views/shared/issuable/_merge_request_reviewers.html.haml
@@ -1,6 +1,6 @@
- issuable.merge_request_reviewers.take(count).each do |merge_request_reviewer| # rubocop: disable CodeReuse/ActiveRecord
- reviewer = merge_request_reviewer.reviewer
- - reviewer_tooltip = ( merge_request_reviewer.attention_requested? ? s_("MrList|Attention requested from reviewer %{name}, go to their profile.") : s_("MrList|Review requested from %{name}, go to their profile.") ) % { name: reviewer.name}
+ - reviewer_tooltip = ( merge_request_reviewer.attention_requested? ? s_("MrList|Attention requested from reviewer %{name}") : s_("MrList|Review requested from %{name}") ) % { name: reviewer.name}
= link_to_member(@project, reviewer, name: false, title: reviewer_tooltip, extra_class: "gl-flex-direction-row-reverse") do
- if merge_request_reviewer.attention_requested?
diff --git a/app/views/shared/issuable/_reviewers.html.haml b/app/views/shared/issuable/_reviewers.html.haml
index 0bb0faa0bb8..4af2cb00859 100644
--- a/app/views/shared/issuable/_reviewers.html.haml
+++ b/app/views/shared/issuable/_reviewers.html.haml
@@ -7,7 +7,7 @@
= render 'shared/issuable/merge_request_reviewers', issuable: issuable, count: render_count
- else
- issuable.reviewers.take(render_count).each do |reviewer| # rubocop: disable CodeReuse/ActiveRecord
- = link_to_member(@project, reviewer, name: false, title: s_("MrList|Review requested from %{name}, go to their profile.") % { name: reviewer.name})
+ = link_to_member(@project, reviewer, name: false, title: s_("MrList|Review requested from %{name}") % { name: reviewer.name})
- if more_reviewers_count > 0
%span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old' }, title: _("+%{more_reviewers_count} more reviewers") % { more_reviewers_count: more_reviewers_count} }
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 37a79a50fb1..7fdf8ea7796 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -1,22 +1,12 @@
- type = local_assigns.fetch(:type)
-- board = local_assigns.fetch(:board, nil)
- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true)
- disable_target_branch = local_assigns.fetch(:disable_target_branch, false)
- placeholder = local_assigns[:placeholder] || _('Search or filter results...')
- block_css_class = type != :productivity_analytics ? 'row-content-block second-block' : ''
-- is_epic_board = board&.to_type == "EpicBoard"
-
-- if is_epic_board
- - user_can_admin_list = can?(current_user, :admin_epic_board_list, board.resource_parent)
-- elsif board
- - user_can_admin_list = can?(current_user, :admin_issue_board_list, board.resource_parent)
.issues-filters
.issues-details-filters.filtered-search-block.d-flex.flex-column.flex-lg-row{ class: block_css_class }
.d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0.w-100
- - if type == :boards
- = render "shared/boards/switcher", board: board
- .js-new-board{ data: { multiple_issue_boards_available: parent.multiple_issue_boards_available?.to_s, can_admin_board: can?(current_user, :admin_issue_board, parent).to_s, } }
= form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
@@ -25,201 +15,188 @@
- 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"
- - if is_epic_board
- #js-board-filtered-search{ data: { full_path: @group&.full_path } }
- - elsif board
- #js-issue-board-filtered-search
- - else
- .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
- .filtered-search-box
- - if type != :boards
- - text = tag.span(sprite_icon('history'), class: "d-md-none") + tag.span(_('Recent searches'), class: "d-none d-md-inline")
- = dropdown_tag(text,
- options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
- toggle_class: "gl-button btn btn-default filtered-search-history-dropdown-toggle-button",
- dropdown_class: "filtered-search-history-dropdown",
- content_class: "filtered-search-history-dropdown-content" }) do
- .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } }
- .filtered-search-box-input-container.droplab-dropdown
- .scroll-container
- %ul.tokens-container.list-unstyled
- %li.input-token
- %input.form-control.filtered-search{ search_filter_input_options(type, placeholder) }
- #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- -# Encapsulate static class name `{{icon}}` inside #{} to bypass
- -# haml lint's ClassAttributeWithStaticValue
- %svg
- %use{ 'xlink:href': "#{'{{icon}}'}" }
- %span.js-filter-hint
- {{formattedKey}}
- #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
- %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
- %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- {{ title }}
- %span.btn-helptext
- {{ help }}
- #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
+ .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
+ .filtered-search-box
+ - if type != :boards
+ - text = tag.span(sprite_icon('history'), class: "d-md-none") + tag.span(_('Recent searches'), class: "d-none d-md-inline")
+ = dropdown_tag(text,
+ options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
+ toggle_class: "gl-button btn btn-default filtered-search-history-dropdown-toggle-button",
+ dropdown_class: "filtered-search-history-dropdown",
+ content_class: "filtered-search-history-dropdown-content" }) do
+ .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } }
+ .filtered-search-box-input-container.droplab-dropdown
+ .scroll-container
+ %ul.tokens-container.list-unstyled
+ %li.input-token
+ %input.form-control.filtered-search{ search_filter_input_options(type, placeholder) }
+ #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ -# Encapsulate static class name `{{icon}}` inside #{} to bypass
+ -# haml lint's ClassAttributeWithStaticValue
+ %svg
+ %use{ 'xlink:href': "#{'{{icon}}'}" }
+ %span.js-filter-hint
+ {{formattedKey}}
+ #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
+ %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ {{ title }}
+ %span.btn-helptext
+ {{ help }}
+ #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
+ - if current_user
+ %ul{ data: { dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
- if current_user
- %ul{ data: { dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: current_user
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
= render 'shared/issuable/user_dropdown_item',
- user: User.new(username: '{{username}}', name: '{{name}}'),
- avatar: { lazy: true, url: '{{avatar_url}}' }
- #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- - if current_user
- = render 'shared/issuable/user_dropdown_item',
- user: current_user
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ #js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ - if current_user
= render 'shared/issuable/user_dropdown_item',
- user: User.new(username: '{{username}}', name: '{{name}}'),
- avatar: { lazy: true, url: '{{avatar_url}}' }
- #js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- - if current_user
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ - if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml)
+ #js-dropdown-attention-requested.filtered-search-input-dropdown-menu.dropdown-menu
+ - if current_user
+ %ul{ data: { dropdown: true } }
= render 'shared/issuable/user_dropdown_item',
user: current_user
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
- - if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml)
- #js-dropdown-attention-requested.filtered-search-input-dropdown-menu.dropdown-menu
- - if current_user
- %ul{ data: { dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: current_user
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: User.new(username: '{{username}}', name: '{{name}}'),
- avatar: { lazy: true, url: '{{avatar_url}}' }
- = render_if_exists 'shared/issuable/approver_dropdown'
- = render_if_exists 'shared/issuable/approved_by_dropdown'
- #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.filter-dropdown-item{ data: { value: 'Upcoming' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Upcoming')
- %li.filter-dropdown-item{ data: { value: 'Started' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Started')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
+ = render_if_exists 'shared/issuable/approver_dropdown'
+ = render_if_exists 'shared/issuable/approved_by_dropdown'
+ #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.filter-dropdown-item{ data: { value: 'Upcoming' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Upcoming')
+ %li.filter-dropdown-item{ data: { value: 'Started' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Started')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
+ {{title}}
+ = render_if_exists 'shared/issuable/filter_iteration', type: type
+ #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
+ {{title}}
+ #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ %span.dropdown-label-box{ style: 'background: {{color}}' }
+ %span.label-title.js-data-value
{{title}}
- = render_if_exists 'shared/issuable/filter_iteration', type: type
- #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
- {{title}}
- #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.gl-button.btn.btn-link{ type: 'button' }
- %span.dropdown-label-box{ style: 'background: {{color}}' }
- %span.label-title.js-data-value
- {{title}}
- #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.gl-button.btn.btn-link{ type: 'button' }
- %gl-emoji
- %span.js-data-value.gl-ml-3
- {{name}}
- #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
- %ul.filter-dropdown{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Yes')
- %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('No')
- #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu
- %ul.filter-dropdown{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('Yes')
- %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.gl-button.btn.btn-link{ type: 'button' }
- = _('No')
- - unless disable_target_branch
- #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.gl-button.btn.btn-link.js-data-value.monospace
- {{title}}
- #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu
+ #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ %gl-emoji
+ %span.js-data-value.gl-ml-3
+ {{name}}
+ #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Yes')
+ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('No')
+ #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Yes')
+ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('No')
+ - unless disable_target_branch
+ #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
+ %button.gl-button.btn.btn-link.js-data-value.monospace
{{title}}
+ #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
+ {{title}}
- = render_if_exists 'shared/issuable/filter_weight', type: type
+ = render_if_exists 'shared/issuable/filter_weight', type: type
- = render_if_exists 'shared/issuable/filter_epic', type: type
+ = render_if_exists 'shared/issuable/filter_epic', type: type
- %button.clear-search.hidden{ type: 'button' }
- = sprite_icon('close', size: 16, css_class: 'clear-search-icon')
+ %button.clear-search.hidden{ type: 'button' }
+ = sprite_icon('close', size: 16, css_class: 'clear-search-icon')
.filter-dropdown-container.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-align-items-flex-start
- - if type == :boards
- #js-board-labels-toggle
- - if current_user
- #js-board-epics-swimlanes-toggle
- .js-board-config{ data: { can_admin_list: user_can_admin_list.to_s, has_scope: board.scoped?.to_s } }
- - if user_can_admin_list
- .js-create-column-trigger{ data: board_list_data }
- #js-toggle-focus-btn
- - elsif type != :productivity_analytics && show_sorting_dropdown
+ - if type != :productivity_analytics && show_sorting_dropdown
= render 'shared/issuable/sort_dropdown'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 37d31515307..b99294f504c 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -97,7 +97,7 @@
- if issuable_sidebar.dig(:current_user, :can_move)
.block.js-sidebar-move-issue-block
.sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') }
- = custom_icon('icon_arrow_right')
+ = sprite_icon('long-arrow')
.dropdown.sidebar-move-issue-dropdown.hide-collapsed
%button.gl-button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_action: "click_button", track_value: "" } }
diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml
index dc6abfd2c9e..c9dda22de46 100644
--- a/app/views/shared/issuable/form/_contribution.html.haml
+++ b/app/views/shared/issuable/form/_contribution.html.haml
@@ -1,5 +1,7 @@
- issuable = local_assigns.fetch(:issuable)
- form = local_assigns.fetch(:form)
+- contribution_help_link = help_page_path('user/project/merge_requests/allow_collaboration')
+- contribution_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contribution_help_link }
- return unless issuable.is_a?(MergeRequest)
- return unless issuable.for_fork?
@@ -8,13 +10,10 @@
%hr
.form-group.row
- %label.col-form-label.col-sm-2
+ %label.col-form-label.col-sm-2.pt-sm-0
= _('Contribution')
.col-sm-10
- .form-check.gl-mt-2
- = form.check_box :allow_collaboration, disabled: !issuable.can_allow_collaboration?(current_user), class: 'form-check-input'
- = form.label :allow_collaboration, class: 'form-check-label' do
- = _('Allow commits from members who can merge to the target branch.')
- = link_to 'About this feature', help_page_path('user/project/merge_requests/allow_collaboration'), target: '_blank', rel: 'noopener noreferrer nofollow'
- .form-text.text-muted
- = allow_collaboration_unavailable_reason(issuable)
+ = form.gitlab_ui_checkbox_component :allow_collaboration,
+ _('Allow commits from members who can merge to the target branch. %{link_start}About this feature.%{link_end}').html_safe % { link_start: contribution_help_link_start, link_end: '</a>'.html_safe },
+ checkbox_options: { disabled: !issuable.can_allow_collaboration?(current_user) },
+ help_text: allow_collaboration_unavailable_reason(issuable)
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 34720576526..e941eaadbc9 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -17,10 +17,8 @@
- if issuable.respond_to?(:confidential) && can?(current_user, :set_confidentiality, issuable)
.form-group.row
.offset-sm-2.col-sm-10
- .form-check
- = form.check_box :confidential, class: 'form-check-input'
- = form.label :confidential, class: 'form-check-label' do
- #{_('This issue is confidential and should only be visible to team members with at least Reporter access.')}
+ = form.gitlab_ui_checkbox_component :confidential,
+ _('This issue is confidential and should only be visible to team members with at least Reporter access.')
- if can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable)
%hr
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index 1babc6885c2..7276175db59 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -1,8 +1,8 @@
- related_branches_path = related_branches_project_issue_path(@project, issuable)
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
-.issue-details.issuable-details
- .detail-page-description.content-block
+.issue-details.issuable-details.js-issue-details
+ .detail-page-description.content-block.js-detail-page-description
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, full_path: @project.full_path } }
.title-container
%h1.title= markdown_field(issuable, :title)
@@ -12,6 +12,7 @@
= edited_time_ago_with_tooltip(issuable, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
+ .js-issue-widgets
= render 'shared/issue_type/sentry_stack_trace', issuable: issuable
= render 'projects/issues/design_management'
@@ -28,8 +29,9 @@
#related-branches{ data: { url: related_branches_path } }
-# This element is filled in using JavaScript.
- = render 'shared/issue_type/emoji_block', issuable: issuable, api_awards_path: api_awards_path
+ .js-issue-widgets
+ = render 'shared/issue_type/emoji_block', issuable: issuable, api_awards_path: api_awards_path
- = render 'projects/issues/discussion'
+ = render 'projects/issues/discussion'
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees
diff --git a/app/views/shared/milestones/_milestone_complete_alert.html.haml b/app/views/shared/milestones/_milestone_complete_alert.html.haml
index 5b05fdb6019..4685a93a343 100644
--- a/app/views/shared/milestones/_milestone_complete_alert.html.haml
+++ b/app/views/shared/milestones/_milestone_complete_alert.html.haml
@@ -1,9 +1,8 @@
- milestone = local_assigns[:milestone]
- if milestone.complete? && milestone.active?
- = render 'shared/global_alert',
- variant: :success,
+ = render Pajamas::AlertComponent.new(variant: :success,
alert_data: { testid: 'all-issues-closed-alert' },
- dismissible: false do
+ dismissible: false) do
.gl-alert-body
= yield
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 6fae6a15567..c39dc561801 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -37,8 +37,8 @@
- css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
= render "shared/projects/project", project: project, skip_namespace: skip_namespace,
avatar: avatar, stars: stars, css_class: css_class, use_creator_avatar: use_creator_avatar,
- forks: project.forking_enabled?, show_last_commit_as_description: show_last_commit_as_description, user: user,
- merge_requests: project.merge_requests_enabled?, issues: project.issues_enabled?,
+ forks: project.forking_enabled?, show_last_commit_as_description: show_last_commit_as_description,
+ user: user, merge_requests: able_to_see_merge_requests?(project, user), issues: able_to_see_issues?(project, user),
pipeline_status: pipeline_status, compact_mode: compact_mode
= paginate_collection(projects, remote: remote) unless skip_pagination
- else
diff --git a/app/views/shared/runners/_runner_type_alert.html.haml b/app/views/shared/runners/_runner_type_alert.html.haml
index e0cc1e924d8..365cee5fadc 100644
--- a/app/views/shared/runners/_runner_type_alert.html.haml
+++ b/app/views/shared/runners/_runner_type_alert.html.haml
@@ -1,20 +1,16 @@
-.gl-alert.gl-alert-info.gl-my-5
- = sprite_icon('information-o', css_class: 'gl-alert-icon')
- - if runner.instance_type?
- %h4.gl-alert-title
- = s_('Runners|This runner is available to all groups and projects in your GitLab instance.')
- .gl-alert-body
- = s_('Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner.')
- = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'shared-runners'), target: '_blank', rel: 'noopener noreferrer'
- - elsif runner.group_type?
- %h4.gl-alert-title
- = s_('Runners|This runner is available to all projects and subgroups in a group.')
+- alert_class = 'gl-mb-5'
+
+- if runner.group_type?
+ = render Pajamas::AlertComponent.new(alert_class: alert_class,
+ title: s_('Runners|This runner is available to all projects and subgroups in a group.'),
+ dismissible: false) do
.gl-alert-body
= s_('Runners|Use Group runners when you want all projects in a group to have access to a set of runners.')
= link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'group-runners'), target: '_blank', rel: 'noopener noreferrer'
- - else
- %h4.gl-alert-title
- = s_('Runners|This runner is associated with specific projects.')
+- else
+ = render Pajamas::AlertComponent.new(alert_class: alert_class,
+ title: s_('Runners|This runner is associated with specific projects.'),
+ dismissible: false) do
.gl-alert-body
= s_('Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner.')
= link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'specific-runners'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml
index 33e95446bd7..010376464f1 100644
--- a/app/views/shared/tokens/_scopes_form.html.haml
+++ b/app/views/shared/tokens/_scopes_form.html.haml
@@ -1,9 +1,14 @@
- scopes = local_assigns.fetch(:scopes)
- prefix = local_assigns.fetch(:prefix)
- token = local_assigns.fetch(:token)
+- f = local_assigns.fetch(:f)
-- scopes.each do |scope|
- %fieldset.form-group.form-check
- = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: "form-check-input", data: { qa_selector: "#{scope}_checkbox" }
- = label_tag "#{prefix}_scopes_#{scope}", scope, class: 'label-bold form-check-label'
- .text-secondary= t scope, scope: scope_description(prefix)
+%fieldset
+ - scopes.each do |scope|
+ - help_text = t scope, scope: scope_description(prefix)
+ = f.gitlab_ui_checkbox_component :scopes, scope,
+ help_text: help_text,
+ checkbox_options: { checked: token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", multiple: true, data: { qa_selector: "#{scope}_checkbox" } },
+ checked_value: scope,
+ unchecked_value: nil,
+ label_options: { data: { qa_selector: "#{scope}_label" } }
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 5650f08b2a9..afe72767b9a 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -14,92 +14,67 @@
= s_('Webhooks|Used to validate received payloads. Sent with the request in the %{code_start}X-Gitlab-Token HTTP%{code_end} header.').html_safe % { code_start: code_start, code_end: code_end }
.form-group
= form.label :url, s_('Webhooks|Trigger'), class: 'label-bold'
- %ul.list-unstyled.gl-ml-6
- %li
- = form.check_box :push_events, class: 'form-check-input'
- = form.label :push_events, class: 'list-label form-check-label gl-ml-1 gl-mb-3' do
- %strong= s_('Webhooks|Push events')
- = form.text_field :push_events_branch_filter, class: 'form-control gl-form-input', placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)'
- %p.text-muted.gl-ml-1
+ %ul.list-unstyled
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :push_events, s_('Webhooks|Push events')
+ .gl-pl-6
+ = form.text_field :push_events_branch_filter, class: 'form-control gl-form-input',
+ placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)'
+ %p.form-text.text-muted.custom-control
= s_('Webhooks|Push to the repository.')
- %li
- = form.check_box :tag_push_events, class: 'form-check-input'
- = form.label :tag_push_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Tag push events')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A new tag is pushed to the repository.')
- %li
- = form.check_box :note_events, class: 'form-check-input'
- = form.label :note_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Comments')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A comment is added to an issue.')
- %li
- = form.check_box :confidential_note_events, class: 'form-check-input'
- = form.label :confidential_note_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Confidential comments')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A comment is added to a confidential issue.')
- %li
- = form.check_box :issues_events, class: 'form-check-input'
- = form.label :issues_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Issues events')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|An issue is created, updated, closed, or reopened.')
- %li
- = form.check_box :confidential_issues_events, class: 'form-check-input'
- = form.label :confidential_issues_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Confidential issues events')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A confidential issue is created, updated, closed, or reopened.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :tag_push_events,
+ s_('Webhooks|Tag push events'),
+ help_text: s_('Webhooks|A new tag is pushed to the repository.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :note_events,
+ s_('Webhooks|Comments'),
+ help_text: s_('Webhooks|A comment is added to an issue or merge request.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :confidential_note_events,
+ s_('Webhooks|Confidential comments'),
+ help_text: s_('Webhooks|A comment is added to a confidential issue.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :issues_events,
+ s_('Webhooks|Issues events'),
+ help_text: s_('Webhooks|An issue is created, updated, closed, or reopened.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :confidential_issues_events,
+ s_('Webhooks|Confidential issues events'),
+ help_text: s_('Webhooks|A confidential issue is created, updated, closed, or reopened.')
- if @group
= render_if_exists 'groups/hooks/member_events', form: form
= render_if_exists 'groups/hooks/subgroup_events', form: form
- %li
- = form.check_box :merge_requests_events, class: 'form-check-input'
- = form.label :merge_requests_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Merge request events')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A merge request is created, updated, or merged.')
- %li
- = form.check_box :job_events, class: 'form-check-input'
- = form.label :job_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Job events')
- %p.text-muted.gl-ml-1
- = s_("Webhooks|A job's status changes.")
- %li
- = form.check_box :pipeline_events, class: 'form-check-input'
- = form.label :pipeline_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Pipeline events')
- %p.text-muted.gl-ml-1
- = s_("Webhooks|A pipeline's status changes.")
- %li
- = form.check_box :wiki_page_events, class: 'form-check-input'
- = form.label :wiki_page_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Wiki page events')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A wiki page is created or updated.')
- %li
- = form.check_box :deployment_events, class: 'form-check-input'
- = form.label :deployment_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Deployment events')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A deployment starts, finishes, fails, or is canceled.')
- %li
- = form.check_box :feature_flag_events, class: 'form-check-input'
- = form.label :feature_flag_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Feature flag events')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A feature flag is turned on or off.')
- %li
- = form.check_box :releases_events, class: 'form-check-input'
- = form.label :releases_events, class: 'list-label form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Releases events')
- %p.text-muted.gl-ml-1
- = s_('Webhooks|A release is created or updated.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :merge_requests_events,
+ s_('Webhooks|Merge request events'),
+ help_text: s_('Webhooks|A merge request is created, updated, or merged.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :job_events,
+ s_('Webhooks|Job events'),
+ help_text: s_("Webhooks|A job's status changes.")
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :pipeline_events,
+ s_('Webhooks|Pipeline events'),
+ help_text: s_("Webhooks|A pipeline's status changes.")
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :wiki_page_events,
+ s_('Webhooks|Wiki page events'),
+ help_text: s_('Webhooks|A wiki page is created or updated.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :deployment_events,
+ s_('Webhooks|Deployment events'),
+ help_text: s_('Webhooks|A deployment starts, finishes, fails, or is canceled.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :feature_flag_events,
+ s_('Webhooks|Feature flag events'),
+ help_text: s_('Webhooks|A feature flag is turned on or off.')
+ %li.gl-pb-5
+ = form.gitlab_ui_checkbox_component :releases_events,
+ s_('Webhooks|Releases events'),
+ help_text: s_('Webhooks|A release is created or updated.')
.form-group
= form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox'
- .form-check
- = form.check_box :enable_ssl_verification, class: 'form-check-input'
- = form.label :enable_ssl_verification, class: 'form-check-label gl-ml-1' do
- %strong= s_('Webhooks|Enable SSL verification')
+ %ul.list-unstyled
+ %li
+ = form.gitlab_ui_checkbox_component :enable_ssl_verification, s_('Webhooks|Enable SSL verification')
diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml
index 03f373783f8..a100a620cea 100644
--- a/app/views/shared/web_hooks/_hook_errors.html.haml
+++ b/app/views/shared/web_hooks/_hook_errors.html.haml
@@ -10,17 +10,13 @@
limit: hook.rate_limit,
support_link_start: link_start % { url: support_path },
support_link_end: link_end }
- = render 'shared/global_alert',
- title: s_('Webhooks|Webhook was automatically disabled'),
- variant: :danger,
- close_button_class: 'js-close' do
+ = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook was automatically disabled'),
+ variant: :danger) do
.gl-alert-body
= s_('Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook.').html_safe % placeholders
- elsif hook.permanently_disabled?
- = render 'shared/global_alert',
- title: s_('Webhooks|Webhook failed to connect'),
- variant: :danger,
- close_button_class: 'js-close' do
+ = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook failed to connect'),
+ variant: :danger) do
.gl-alert-body
= s_('Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % { strong_start: strong_start, strong_end: strong_end }
- elsif hook.temporarily_disabled?
@@ -30,9 +26,7 @@
retry_time: time_interval_in_words(hook.disabled_until - Time.now),
help_link_start: link_start % { url: help_path },
help_link_end: link_end }
- = render 'shared/global_alert',
- title: s_('Webhooks|Webhook fails to connect'),
- variant: :warning,
- close_button_class: 'js-close' do
+ = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook fails to connect'),
+ variant: :warning) do
.gl-alert-body
= s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end}, and will retry in %{retry_time}. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % placeholders
diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index e121725b9af..34bedbd928a 100644
--- a/app/views/shared/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -3,4 +3,4 @@
.gl-mt-3
= form_errors(@page, truncate: :title)
-#js-wiki-form{ data: { page_info: page_info.to_json, format_options: Wiki::MARKUPS.to_json } }
+#js-wiki-form{ data: { page_info: page_info.to_json, format_options: wiki_markup_hash_by_name_id.to_json } }
diff --git a/app/views/shared/wikis/_main_links.html.haml b/app/views/shared/wikis/_main_links.html.haml
index 02794950895..c1fd8c48c60 100644
--- a/app/views/shared/wikis/_main_links.html.haml
+++ b/app/views/shared/wikis/_main_links.html.haml
@@ -1,5 +1,5 @@
- if @page&.persisted?
- = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn gl-button", role: "button", data: { qa_selector: 'page_history_button' } do
+ = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn gl-button btn-default", role: "button", data: { qa_selector: 'page_history_button' } do
= s_("Wiki|Page history")
- if can?(current_user, :create_wiki, @wiki.container)
= link_to wiki_path(@wiki, action: :new), class: "btn gl-button btn-confirm-secondary", role: "button", data: { qa_selector: 'new_page_button' } do
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index e6980aae3e1..6591e8fae7b 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -26,6 +26,7 @@
%div
- if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
= link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }
- = render 'shared/wikis/wiki_content'
+
+ .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } }
= render 'shared/wikis/sidebar'
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 88eacaefcb0..361beda4d02 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -188,7 +188,7 @@
= s_('UserProfile|Most Recent Activity')
.content_list{ data: { href: user_activity_path } }
.loading
- .gl-spinner.gl-spinner-md
+ = gl_loading_icon(size: 'md')
- unless @user.bot?
- if profile_tab?(:groups)
#groups.tab-pane
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 48bdee4062b..bfb70e0d496 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -192,6 +192,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:bulk_imports_stuck_import
+ :worker_name: BulkImports::StuckImportWorker
+ :feature_category: :importers
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:ci_archive_traces_cron
:worker_name: Ci::ArchiveTracesCronWorker
:feature_category: :continuous_integration
@@ -255,6 +264,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:ci_update_locked_unknown_artifacts
+ :worker_name: Ci::UpdateLockedUnknownArtifactsWorker
+ :feature_category: :build_artifacts
+ :has_external_dependencies:
+ :urgency: :throttled
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: cronjob:clusters_integrations_check_prometheus_health
:worker_name: Clusters::Integrations::CheckPrometheusHealthWorker
:feature_category: :incident_management
@@ -318,6 +336,24 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:database_ci_namespace_mirrors_consistency_check
+ :worker_name: Database::CiNamespaceMirrorsConsistencyCheckWorker
+ :feature_category: :sharding
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: cronjob:database_ci_project_mirrors_consistency_check
+ :worker_name: Database::CiProjectMirrorsConsistencyCheckWorker
+ :feature_category: :sharding
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:database_drop_detached_partitions
:worker_name: Database::DropDetachedPartitionsWorker
:feature_category: :database
@@ -579,15 +615,6 @@
:weight: 1
:idempotent:
:tags: []
-- :name: cronjob:quality_test_data_cleanup
- :worker_name: Quality::TestDataCleanupWorker
- :feature_category: :quality_management
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: true
- :tags: []
- :name: cronjob:releases_manage_evidence
:worker_name: Releases::ManageEvidenceWorker
:feature_category: :release_evidence
@@ -2578,15 +2605,6 @@
:weight: 1
:idempotent:
:tags: []
-- :name: namespaces_invite_team_email
- :worker_name: Namespaces::InviteTeamEmailWorker
- :feature_category: :experimentation_activation
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent:
- :tags: []
- :name: namespaces_onboarding_issue_created
:worker_name: Namespaces::OnboardingIssueCreatedWorker
:feature_category: :onboarding
@@ -2771,7 +2789,7 @@
:worker_name: ProjectExportWorker
:feature_category: :importers
:has_external_dependencies:
- :urgency: :throttled
+ :urgency: :low
:resource_boundary: :memory
:weight: 1
:idempotent:
@@ -2812,6 +2830,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: projects_record_target_platforms
+ :worker_name: Projects::RecordTargetPlatformsWorker
+ :feature_category: :experimentation_activation
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_refresh_build_artifacts_size_statistics
:worker_name: Projects::RefreshBuildArtifactsSizeStatisticsWorker
:feature_category: :build_artifacts
diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb
index d560ebcc6e6..157586ca397 100644
--- a/app/workers/bulk_import_worker.rb
+++ b/app/workers/bulk_import_worker.rb
@@ -3,15 +3,12 @@
class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- data_consistency :always
+ PERFORM_DELAY = 5.seconds
+ data_consistency :always
feature_category :importers
-
sidekiq_options retry: false, dead: false
- PERFORM_DELAY = 5.seconds
- DEFAULT_BATCH_SIZE = 5
-
def perform(bulk_import_id)
@bulk_import = BulkImport.find_by_id(bulk_import_id)
@@ -19,11 +16,10 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
return if @bulk_import.finished? || @bulk_import.failed?
return @bulk_import.fail_op! if all_entities_failed?
return @bulk_import.finish! if all_entities_processed? && @bulk_import.started?
- return re_enqueue if max_batch_size_exceeded? # Do not start more jobs if max allowed are already running
@bulk_import.start! if @bulk_import.created?
- created_entities.first(next_batch_size).each do |entity|
+ created_entities.find_each do |entity|
entity.create_pipeline_trackers!
BulkImports::ExportRequestWorker.perform_async(entity.id)
@@ -45,10 +41,6 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
@entities ||= @bulk_import.entities
end
- def started_entities
- entities.with_status(:started)
- end
-
def created_entities
entities.with_status(:created)
end
@@ -61,14 +53,6 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
entities.all? { |entity| entity.failed? }
end
- def max_batch_size_exceeded?
- started_entities.count >= DEFAULT_BATCH_SIZE
- end
-
- def next_batch_size
- [DEFAULT_BATCH_SIZE - started_entities.count, 0].max
- end
-
# A new BulkImportWorker job is enqueued to either
# - Process the new BulkImports::Entity created during import (e.g. for the subgroups)
# - Or to mark the `bulk_import` as finished
diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb
index 70d6626df91..f6b1c693fe4 100644
--- a/app/workers/bulk_imports/entity_worker.rb
+++ b/app/workers/bulk_imports/entity_worker.rb
@@ -4,24 +4,32 @@ module BulkImports
class EntityWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
+ idempotent!
+ deduplicate :until_executing
data_consistency :always
-
feature_category :importers
-
sidekiq_options retry: false, dead: false
-
worker_has_external_dependencies!
- idempotent!
- deduplicate :until_executed, including_scheduled: true
-
def perform(entity_id, current_stage = nil)
- return if stage_running?(entity_id, current_stage)
+ if stage_running?(entity_id, current_stage)
+ logger.info(
+ structured_payload(
+ entity_id: entity_id,
+ current_stage: current_stage,
+ message: 'Stage running'
+ )
+ )
+
+ return
+ end
logger.info(
- worker: self.class.name,
- entity_id: entity_id,
- current_stage: current_stage
+ structured_payload(
+ entity_id: entity_id,
+ current_stage: current_stage,
+ message: 'Stage starting'
+ )
)
next_pipeline_trackers_for(entity_id).each do |pipeline_tracker|
@@ -33,10 +41,11 @@ module BulkImports
end
rescue StandardError => e
logger.error(
- worker: self.class.name,
- entity_id: entity_id,
- current_stage: current_stage,
- error_message: e.message
+ structured_payload(
+ entity_id: entity_id,
+ current_stage: current_stage,
+ message: e.message
+ )
)
Gitlab::ErrorTracking.track_exception(e, entity_id: entity_id)
diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb
index 21040178cee..0d3e4f013dd 100644
--- a/app/workers/bulk_imports/export_request_worker.rb
+++ b/app/workers/bulk_imports/export_request_worker.rb
@@ -42,10 +42,12 @@ module BulkImports
correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id
}
- Gitlab::Import::Logger.warn(
- attributes.merge(
- bulk_import_id: entity.bulk_import.id,
- bulk_import_entity_type: entity.source_type
+ Gitlab::Import::Logger.error(
+ structured_payload(
+ attributes.merge(
+ bulk_import_id: entity.bulk_import.id,
+ bulk_import_entity_type: entity.source_type
+ )
)
)
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index 03ec2f058ca..1a98705c151 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -4,14 +4,11 @@ module BulkImports
class PipelineWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- data_consistency :always
-
- NDJSON_PIPELINE_PERFORM_DELAY = 1.minute
+ NDJSON_PIPELINE_PERFORM_DELAY = 10.seconds
+ data_consistency :always
feature_category :importers
-
sidekiq_options retry: false, dead: false
-
worker_has_external_dependencies!
def perform(pipeline_tracker_id, stage, entity_id)
@@ -21,18 +18,20 @@ module BulkImports
if pipeline_tracker.present?
logger.info(
- worker: self.class.name,
- entity_id: pipeline_tracker.entity.id,
- pipeline_name: pipeline_tracker.pipeline_name
+ structured_payload(
+ entity_id: pipeline_tracker.entity.id,
+ pipeline_name: pipeline_tracker.pipeline_name
+ )
)
run(pipeline_tracker)
else
logger.error(
- worker: self.class.name,
- entity_id: entity_id,
- pipeline_tracker_id: pipeline_tracker_id,
- message: 'Unstarted pipeline not found'
+ structured_payload(
+ entity_id: entity_id,
+ pipeline_tracker_id: pipeline_tracker_id,
+ message: 'Unstarted pipeline not found'
+ )
)
end
@@ -66,10 +65,11 @@ module BulkImports
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}"
+ structured_payload(
+ entity_id: pipeline_tracker.entity.id,
+ pipeline_name: pipeline_tracker.pipeline_name,
+ message: "Retrying error: #{e.message}"
+ )
)
pipeline_tracker.update!(status_event: 'retry', jid: jid)
@@ -86,10 +86,11 @@ module BulkImports
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: exception.message
+ structured_payload(
+ entity_id: pipeline_tracker.entity.id,
+ pipeline_name: pipeline_tracker.pipeline_name,
+ message: exception.message
+ )
)
Gitlab::ErrorTracking.track_exception(
diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb
index 9324b79cc75..dcac841b3b2 100644
--- a/app/workers/bulk_imports/relation_export_worker.rb
+++ b/app/workers/bulk_imports/relation_export_worker.rb
@@ -3,12 +3,12 @@
module BulkImports
class RelationExportWorker
include ApplicationWorker
-
- data_consistency :always
include ExceptionBacktrace
idempotent!
+ deduplicate :until_executed
loggable_arguments 2, 3
+ data_consistency :always
feature_category :importers
sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
diff --git a/app/workers/bulk_imports/stuck_import_worker.rb b/app/workers/bulk_imports/stuck_import_worker.rb
new file mode 100644
index 00000000000..3fa4221728b
--- /dev/null
+++ b/app/workers/bulk_imports/stuck_import_worker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class StuckImportWorker
+ include ApplicationWorker
+
+ # This worker does not schedule other workers that require context.
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ idempotent!
+ data_consistency :always
+
+ feature_category :importers
+
+ def perform
+ BulkImport.stale.find_each do |import|
+ import.cleanup_stale
+ end
+
+ BulkImports::Entity.includes(:trackers).stale.find_each do |import| # rubocop: disable CodeReuse/ActiveRecord
+ ApplicationRecord.transaction do
+ import.cleanup_stale
+
+ import.trackers.find_each do |tracker|
+ tracker.cleanup_stale
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/update_locked_unknown_artifacts_worker.rb b/app/workers/ci/update_locked_unknown_artifacts_worker.rb
new file mode 100644
index 00000000000..2d37ebb3c93
--- /dev/null
+++ b/app/workers/ci/update_locked_unknown_artifacts_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Ci
+ class UpdateLockedUnknownArtifactsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ data_consistency :sticky
+ urgency :throttled
+
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ feature_category :build_artifacts
+
+ def perform
+ return unless ::Feature.enabled?(:ci_job_artifacts_backlog_work)
+
+ artifact_counts = Ci::JobArtifacts::UpdateUnknownLockedStatusService.new.execute
+
+ log_extra_metadata_on_done(:removed_count, artifact_counts[:removed])
+ log_extra_metadata_on_done(:locked_count, artifact_counts[:locked])
+ end
+ end
+end
diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb
index a9c557f0175..23e58b5182b 100644
--- a/app/workers/concerns/chaos_queue.rb
+++ b/app/workers/concerns/chaos_queue.rb
@@ -5,6 +5,6 @@ module ChaosQueue
included do
queue_namespace :chaos
- feature_category_not_owned!
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
end
end
diff --git a/app/workers/concerns/git_garbage_collect_methods.rb b/app/workers/concerns/git_garbage_collect_methods.rb
index 13b7e7b5b1f..308ffacfc6b 100644
--- a/app/workers/concerns/git_garbage_collect_methods.rb
+++ b/app/workers/concerns/git_garbage_collect_methods.rb
@@ -121,8 +121,12 @@ module GitGarbageCollectMethods
end.new(repository)
end
+ # The option to enable/disable bitmaps has been removed in https://gitlab.com/gitlab-org/gitlab/-/issues/353777
+ # Now the options is always enabled
+ # This method and all the deprecated RPCs are going to be removed in
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/353779
def bitmaps_enabled?
- Gitlab::CurrentSettings.housekeeping_bitmaps_enabled
+ true
end
def flush_ref_caches(resource)
diff --git a/app/workers/concerns/packages/cleanup_artifact_worker.rb b/app/workers/concerns/packages/cleanup_artifact_worker.rb
index d4ad023b4a8..a01d7e8abba 100644
--- a/app/workers/concerns/packages/cleanup_artifact_worker.rb
+++ b/app/workers/concerns/packages/cleanup_artifact_worker.rb
@@ -14,7 +14,9 @@ module Packages
artifact.destroy!
rescue StandardError
- artifact&.error!
+ unless artifact&.destroyed?
+ artifact&.update_column(:status, :error)
+ end
end
after_destroy
@@ -48,7 +50,7 @@ module Packages
to_delete = next_item
if to_delete
- to_delete.processing!
+ to_delete.update_column(:status, :processing)
log_cleanup_item(to_delete)
end
diff --git a/app/workers/concerns/reactive_cacheable_worker.rb b/app/workers/concerns/reactive_cacheable_worker.rb
index 78fcf8087c2..a598b8a9d7d 100644
--- a/app/workers/concerns/reactive_cacheable_worker.rb
+++ b/app/workers/concerns/reactive_cacheable_worker.rb
@@ -8,7 +8,10 @@ module ReactiveCacheableWorker
sidekiq_options retry: 3
- feature_category_not_owned!
+ # Feature category is different depending on the model that is using the
+ # reactive cache. Identified by the `related_class` attribute.
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
+
loggable_arguments 0
def self.context_for_arguments(arguments)
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 6f91418e38c..8f7a3da5429 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -35,17 +35,9 @@ module WorkerAttributes
class_methods do
def feature_category(value, *extras)
- raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned
-
set_class_attribute(:feature_category, value)
end
- # Special case: mark this work as not associated with a feature category
- # this should be used for cross-cutting concerns, such as mailer workers.
- def feature_category_not_owned!
- 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
diff --git a/app/workers/container_registry/migration/enqueuer_worker.rb b/app/workers/container_registry/migration/enqueuer_worker.rb
index 5feaba870e6..8705deb0cb2 100644
--- a/app/workers/container_registry/migration/enqueuer_worker.rb
+++ b/app/workers/container_registry/migration/enqueuer_worker.rb
@@ -6,6 +6,9 @@ module ContainerRegistry
include ApplicationWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
include Gitlab::Utils::StrongMemoize
+ include ExclusiveLeaseGuard
+
+ DEFAULT_LEASE_TIMEOUT = 30.minutes.to_i.freeze
data_consistency :always
feature_category :container_registry
@@ -14,70 +17,103 @@ module ContainerRegistry
idempotent!
def perform
- return unless migration.enabled?
- return unless below_capacity?
- return unless waiting_time_passed?
+ re_enqueue = false
+ try_obtain_lease do
+ break unless runnable?
- re_enqueue_if_capacity if handle_aborted_migration || handle_next_migration
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(
- e,
- next_repository_id: next_repository&.id,
- next_aborted_repository_id: next_aborted_repository&.id
- )
-
- next_repository&.abort_import
+ re_enqueue = handle_aborted_migration || handle_next_migration
+ end
+ re_enqueue_if_capacity if re_enqueue
end
private
def handle_aborted_migration
- return unless next_aborted_repository&.retry_aborted_migration
+ return unless next_aborted_repository
- log_extra_metadata_on_done(:container_repository_id, next_aborted_repository.id)
log_extra_metadata_on_done(:import_type, 'retry')
+ log_repository(next_aborted_repository)
+
+ next_aborted_repository.retry_aborted_migration
+
+ true
+ rescue StandardError => e
+ Gitlab::ErrorTracking.log_exception(e, next_aborted_repository_id: next_aborted_repository&.id)
true
+ ensure
+ log_repository_migration_state(next_aborted_repository)
end
def handle_next_migration
return unless next_repository
+
+ log_extra_metadata_on_done(:import_type, 'next')
+ log_repository(next_repository)
+
# We return true because the repository was successfully processed (migration_state is changed)
return true if tag_count_too_high?
return unless next_repository.start_pre_import
- log_extra_metadata_on_done(:container_repository_id, next_repository.id)
- log_extra_metadata_on_done(:import_type, 'next')
-
true
+ rescue StandardError => e
+ Gitlab::ErrorTracking.log_exception(e, next_repository_id: next_repository&.id)
+ next_repository&.abort_import
+
+ false
+ ensure
+ log_repository_migration_state(next_repository)
end
def tag_count_too_high?
return false unless next_repository.tags_count > migration.max_tags_count
next_repository.skip_import(reason: :too_many_tags)
+ log_extra_metadata_on_done(:tags_count_too_high, true)
+ log_extra_metadata_on_done(:max_tags_count_setting, migration.max_tags_count)
true
end
def below_capacity?
- current_capacity <= maximum_capacity
+ current_capacity < maximum_capacity
end
def waiting_time_passed?
delay = migration.enqueue_waiting_time
return true if delay == 0
- return true unless last_step_completed_repository
+ return true unless last_step_completed_repository&.last_import_step_done_at
last_step_completed_repository.last_import_step_done_at < Time.zone.now - delay
end
- def current_capacity
- strong_memoize(:current_capacity) do
- ContainerRepository.with_migration_states(
- %w[pre_importing pre_import_done importing]
- ).count
+ def runnable?
+ unless migration.enabled?
+ log_extra_metadata_on_done(:migration_enabled, false)
+ return false
+ end
+
+ unless below_capacity?
+ log_extra_metadata_on_done(:max_capacity_setting, maximum_capacity)
+ log_extra_metadata_on_done(:below_capacity, false)
+
+ return false
+ end
+
+ unless waiting_time_passed?
+ log_extra_metadata_on_done(:waiting_time_passed, false)
+ log_extra_metadata_on_done(:current_waiting_time_setting, migration.enqueue_waiting_time)
+
+ return false
end
+
+ true
+ end
+
+ def current_capacity
+ ContainerRepository.with_migration_states(
+ %w[pre_importing pre_import_done importing]
+ ).count
end
def maximum_capacity
@@ -107,10 +143,31 @@ module ContainerRegistry
end
def re_enqueue_if_capacity
- return unless current_capacity < maximum_capacity
+ return unless below_capacity?
self.class.perform_async
end
+
+ def log_repository(repository)
+ log_extra_metadata_on_done(:container_repository_id, repository&.id)
+ log_extra_metadata_on_done(:container_repository_path, repository&.path)
+ end
+
+ def log_repository_migration_state(repository)
+ return unless repository
+
+ log_extra_metadata_on_done(:container_repository_migration_state, repository.migration_state)
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_key
+ 'container_registry:migration:enqueuer_worker'
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_timeout
+ DEFAULT_LEASE_TIMEOUT
+ end
end
end
end
diff --git a/app/workers/container_registry/migration/guard_worker.rb b/app/workers/container_registry/migration/guard_worker.rb
index 77ae111c1cb..bab6b8c2a72 100644
--- a/app/workers/container_registry/migration/guard_worker.rb
+++ b/app/workers/container_registry/migration/guard_worker.rb
@@ -29,46 +29,45 @@ module ContainerRegistry
log_extra_metadata_on_done(:stale_migrations_count, repositories.to_a.size)
repositories.each do |repository|
- if abortable?(repository)
+ if actively_importing?(repository)
+ # if a repository is actively importing but not yet long_running, do nothing
+ if long_running_migration?(repository)
+ long_running_migration_ids << repository.id
+ cancel_long_running_migration(repository)
+ aborts_count += 1
+ end
+ else
repository.abort_import
aborts_count += 1
- else
- long_running_migration_ids << repository.id if long_running_migration?(repository)
end
end
log_extra_metadata_on_done(:aborted_stale_migrations_count, aborts_count)
if long_running_migration_ids.any?
- log_extra_metadata_on_done(:long_running_stale_migration_container_repository_ids, long_running_migration_ids)
+ log_extra_metadata_on_done(:aborted_long_running_migration_ids, long_running_migration_ids)
end
end
private
- # This can ping the Container Registry API.
- # We loop on a set of repositories to calls this function (see #perform)
- # In the worst case scenario, we have a n+1 API calls situation here.
- #
- # This is reasonable because the maximum amount of repositories looped
- # on is `25`. See ::ContainerRegistry::Migration.capacity.
- #
- # TODO We can remove this n+1 situation by having a Container Registry API
- # endpoint that accepts multiple repository paths at once. This is issue
+ # A repository is actively_importing if it has an importing migration state
+ # and that state matches the state in the registry
+ # TODO We can have an API call n+1 situation here. It can be solved when the
+ # endpoint accepts multiple repository paths at once. This is issue
# https://gitlab.com/gitlab-org/container-registry/-/issues/582
- def abortable?(repository)
- # early return to save one Container Registry API request
- return true unless repository.importing? || repository.pre_importing?
- return true unless external_migration_in_progress?(repository)
+ def actively_importing?(repository)
+ return false unless repository.importing? || repository.pre_importing?
+ return false unless external_state_matches_migration_state?(repository)
- false
+ true
end
def long_running_migration?(repository)
migration_start_timestamp(repository).before?(long_running_migration_threshold)
end
- def external_migration_in_progress?(repository)
+ def external_state_matches_migration_state?(repository)
status = repository.external_import_status
(status == 'pre_import_in_progress' && repository.pre_importing?) ||
@@ -96,6 +95,21 @@ module ContainerRegistry
def long_running_migration_threshold
@threshold ||= 30.minutes.ago
end
+
+ def cancel_long_running_migration(repository)
+ result = repository.migration_cancel
+
+ case result[:status]
+ when :ok
+ repository.skip_import(reason: :migration_canceled)
+ when :bad_request
+ repository.reconcile_import_status(result[:state]) do
+ repository.abort_import
+ end
+ else
+ repository.abort_import
+ end
+ end
end
end
end
diff --git a/app/workers/database/batched_background_migration/ci_database_worker.rb b/app/workers/database/batched_background_migration/ci_database_worker.rb
index 98ec6f98123..13314cf95e2 100644
--- a/app/workers/database/batched_background_migration/ci_database_worker.rb
+++ b/app/workers/database/batched_background_migration/ci_database_worker.rb
@@ -4,6 +4,10 @@ module Database
class CiDatabaseWorker # rubocop:disable Scalability/IdempotentWorker
include SingleDatabaseWorker
+ def self.enabled?
+ Feature.enabled?(:execute_batched_migrations_on_schedule_ci_database, type: :ops, default_enabled: :yaml)
+ end
+
def self.tracking_database
@tracking_database ||= Gitlab::Database::CI_DATABASE_NAME
end
diff --git a/app/workers/database/batched_background_migration/single_database_worker.rb b/app/workers/database/batched_background_migration/single_database_worker.rb
index 78c82a6549f..aeadda4b8e1 100644
--- a/app/workers/database/batched_background_migration/single_database_worker.rb
+++ b/app/workers/database/batched_background_migration/single_database_worker.rb
@@ -23,6 +23,10 @@ module Database
def tracking_database
raise NotImplementedError, "#{self.name} does not implement #{__method__}"
end
+
+ def enabled?
+ raise NotImplementedError, "#{self.name} does not implement #{__method__}"
+ end
# :nocov:
def lease_key
@@ -41,7 +45,7 @@ module Database
end
Gitlab::Database::SharedModel.using_connection(base_model.connection) do
- break unless Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops, default_enabled: :yaml) && active_migration
+ break unless self.class.enabled? && active_migration
with_exclusive_lease(active_migration.interval) do
# Now that we have the exclusive lease, reload migration in case another process has changed it.
diff --git a/app/workers/database/batched_background_migration_worker.rb b/app/workers/database/batched_background_migration_worker.rb
index 29804be832d..6a41fe70915 100644
--- a/app/workers/database/batched_background_migration_worker.rb
+++ b/app/workers/database/batched_background_migration_worker.rb
@@ -4,6 +4,10 @@ module Database
class BatchedBackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
include BatchedBackgroundMigration::SingleDatabaseWorker
+ def self.enabled?
+ Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops, default_enabled: :yaml)
+ end
+
def self.tracking_database
@tracking_database ||= Gitlab::Database::MAIN_DATABASE_NAME.to_sym
end
diff --git a/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb b/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb
new file mode 100644
index 00000000000..2b4253947ac
--- /dev/null
+++ b/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Database
+ class CiNamespaceMirrorsConsistencyCheckWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop: disable Scalability/CronWorkerContext
+
+ sidekiq_options retry: false
+ feature_category :sharding
+ data_consistency :sticky
+ idempotent!
+
+ version 1
+
+ def perform
+ return if Feature.disabled?(:ci_namespace_mirrors_consistency_check, default_enabled: :yaml)
+
+ results = ConsistencyCheckService.new(
+ source_model: Namespace,
+ target_model: Ci::NamespaceMirror,
+ source_columns: %w[id traversal_ids],
+ target_columns: %w[namespace_id traversal_ids]
+ ).execute
+
+ log_extra_metadata_on_done(:results, results)
+ end
+ end
+end
diff --git a/app/workers/database/ci_project_mirrors_consistency_check_worker.rb b/app/workers/database/ci_project_mirrors_consistency_check_worker.rb
new file mode 100644
index 00000000000..e9413256617
--- /dev/null
+++ b/app/workers/database/ci_project_mirrors_consistency_check_worker.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Database
+ class CiProjectMirrorsConsistencyCheckWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop: disable Scalability/CronWorkerContext
+
+ sidekiq_options retry: false
+ feature_category :sharding
+ data_consistency :sticky
+ idempotent!
+
+ version 1
+
+ def perform
+ return if Feature.disabled?(:ci_project_mirrors_consistency_check, default_enabled: :yaml)
+
+ results = ConsistencyCheckService.new(
+ source_model: Project,
+ target_model: Ci::ProjectMirror,
+ source_columns: %w[id namespace_id],
+ target_columns: %w[project_id namespace_id]
+ ).execute
+
+ log_extra_metadata_on_done(:results, results)
+ end
+ end
+end
diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb
index d1080c8df64..86167a7fafe 100644
--- a/app/workers/delete_stored_files_worker.rb
+++ b/app/workers/delete_stored_files_worker.rb
@@ -7,7 +7,7 @@ class DeleteStoredFilesWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
- feature_category_not_owned!
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
loggable_arguments 0
def perform(class_name, keys)
diff --git a/app/workers/environments/auto_stop_worker.rb b/app/workers/environments/auto_stop_worker.rb
index 672a4f4121e..aee6e977550 100644
--- a/app/workers/environments/auto_stop_worker.rb
+++ b/app/workers/environments/auto_stop_worker.rb
@@ -10,8 +10,10 @@ module Environments
def perform(environment_id, params = {})
Environment.find_by_id(environment_id).try do |environment|
- user = environment.stop_action&.user
- environment.stop_with_action!(user)
+ stop_actions = environment.stop_actions
+
+ user = stop_actions.last&.user
+ environment.stop_with_actions!(user)
end
end
end
diff --git a/app/workers/flush_counter_increments_worker.rb b/app/workers/flush_counter_increments_worker.rb
index c4a3a5283cc..e21a7ee35e7 100644
--- a/app/workers/flush_counter_increments_worker.rb
+++ b/app/workers/flush_counter_increments_worker.rb
@@ -12,7 +12,10 @@ class FlushCounterIncrementsWorker
sidekiq_options retry: 3
- feature_category_not_owned!
+ # The increments in `ProjectStatistics` are owned by several teams depending
+ # on the counter
+ feature_category :not_owned # rubocop:disable Gitlab/AvoidFeatureCategoryNotOwned
+
urgency :low
deduplicate :until_executing, including_scheduled: true
diff --git a/app/workers/namespaces/invite_team_email_worker.rb b/app/workers/namespaces/invite_team_email_worker.rb
deleted file mode 100644
index eabf33a7fba..00000000000
--- a/app/workers/namespaces/invite_team_email_worker.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-module Namespaces
- class InviteTeamEmailWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- feature_category :experimentation_activation
- urgency :low
-
- def perform(group_id, user_id)
- # rubocop: disable CodeReuse/ActiveRecord
- user = User.find_by(id: user_id)
- group = Group.find_by(id: group_id)
- # rubocop: enable CodeReuse/ActiveRecord
- return unless user && group
-
- Namespaces::InviteTeamEmailService.send_email(user, group)
- end
- end
-end
diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb
index b97dbca2c1c..e1271dae335 100644
--- a/app/workers/namespaces/root_statistics_worker.rb
+++ b/app/workers/namespaces/root_statistics_worker.rb
@@ -20,8 +20,17 @@ module Namespaces
Namespaces::StatisticsRefresherService.new.execute(namespace)
namespace.aggregation_schedule.destroy
+
+ notify_storage_usage(namespace)
rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex
Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id, namespace: namespace&.full_path)
end
+
+ private
+
+ def notify_storage_usage(namespace)
+ end
end
end
+
+Namespaces::RootStatisticsWorker.prepend_mod_with('Namespaces::RootStatisticsWorker')
diff --git a/app/workers/object_storage/background_move_worker.rb b/app/workers/object_storage/background_move_worker.rb
index 2204e504702..bb51f0d7e1f 100644
--- a/app/workers/object_storage/background_move_worker.rb
+++ b/app/workers/object_storage/background_move_worker.rb
@@ -8,7 +8,7 @@ module ObjectStorage
include ObjectStorageQueue
sidekiq_options retry: 5
- feature_category_not_owned!
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
loggable_arguments 0, 1, 2, 3
def perform(uploader_class_name, subject_class_name, file_field, subject_id)
diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb
index ea4a90cf9d2..b7d938e6b68 100644
--- a/app/workers/object_storage/migrate_uploads_worker.rb
+++ b/app/workers/object_storage/migrate_uploads_worker.rb
@@ -10,7 +10,7 @@ module ObjectStorage
sidekiq_options retry: 3
include ObjectStorageQueue
- feature_category_not_owned!
+ feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
loggable_arguments 0, 1, 2, 3
SanityCheckError = Class.new(StandardError)
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index e3f8c4bcd9d..ee892d43313 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -8,7 +8,7 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :importers
worker_resource_boundary :memory
- urgency :throttled
+ urgency :low
loggable_arguments 2, 3
sidekiq_options retry: false, dead: false
sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
@@ -21,7 +21,10 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
export_job&.start
- ::Projects::ImportExport::ExportService.new(project, current_user, params).execute(after_export)
+ export_service = ::Projects::ImportExport::ExportService.new(project, current_user, params)
+ export_service.execute(after_export)
+
+ log_exporters_duration(export_service)
export_job&.finish
rescue ActiveRecord::RecordNotFound => e
@@ -46,4 +49,13 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
def log_failure(project_id, ex)
logger.error("Failed to export project #{project_id}: #{ex.message}")
end
+
+ def log_exporters_duration(export_service)
+ export_service.exporters.each do |exporter|
+ exporter_key = "#{exporter.class.name.demodulize.underscore}_duration_s".to_sym # e.g. uploads_saver_duration_s
+ exporter_duration = exporter.duration_s&.round(6)
+
+ log_extra_metadata_on_done(exporter_key, exporter_duration)
+ end
+ end
end
diff --git a/app/workers/projects/record_target_platforms_worker.rb b/app/workers/projects/record_target_platforms_worker.rb
new file mode 100644
index 00000000000..5b1f85ecca0
--- /dev/null
+++ b/app/workers/projects/record_target_platforms_worker.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Projects
+ class RecordTargetPlatformsWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+
+ LEASE_TIMEOUT = 1.hour.to_i
+ APPLE_PLATFORM_LANGUAGES = %w(swift objective-c).freeze
+
+ feature_category :experimentation_activation
+ data_consistency :always
+ deduplicate :until_executed
+ urgency :low
+ idempotent!
+
+ def perform(project_id)
+ @project = Project.find_by_id(project_id)
+
+ return unless project
+ return unless uses_apple_platform_languages?
+
+ try_obtain_lease do
+ @target_platforms = Projects::RecordTargetPlatformsService.new(project).execute
+ log_target_platforms_metadata
+ end
+ end
+
+ private
+
+ attr_reader :target_platforms, :project
+
+ def uses_apple_platform_languages?
+ project.repository_languages.with_programming_language(*APPLE_PLATFORM_LANGUAGES).present?
+ end
+
+ def log_target_platforms_metadata
+ return unless target_platforms.present?
+
+ log_extra_metadata_on_done(:target_platforms, target_platforms)
+ end
+
+ def lease_key
+ @lease_key ||= "#{self.class.name.underscore}:#{project.id}"
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+
+ def lease_release?
+ false
+ end
+ end
+end
diff --git a/app/workers/quality/test_data_cleanup_worker.rb b/app/workers/quality/test_data_cleanup_worker.rb
deleted file mode 100644
index 68b36cacbbf..00000000000
--- a/app/workers/quality/test_data_cleanup_worker.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-# frozen_string_literal: true
-
-module Quality
- class TestDataCleanupWorker
- include ApplicationWorker
-
- data_consistency :always
- feature_category :quality_management
- urgency :low
-
- include CronjobQueue
- idempotent!
-
- KEEP_RECENT_DATA_DAY = 3
- GROUP_PATH_PATTERN = 'test-group-fulfillment'
- GROUP_OWNER_EMAIL_PATTERN = %w(test-user- gitlab-qa-user qa-user-).freeze
-
- # Remove test groups generated in E2E tests on gstg
- # rubocop: disable CodeReuse/ActiveRecord
- def perform
- return unless Gitlab.staging?
-
- Group.where('path like ?', "#{GROUP_PATH_PATTERN}%").where('created_at < ?', KEEP_RECENT_DATA_DAY.days.ago).each do |group|
- next unless GROUP_OWNER_EMAIL_PATTERN.any? { |pattern| group.owners.first.email.include?(pattern) }
-
- with_context(namespace: group, user: group.owners.first) do
- Groups::DestroyService.new(group, group.owners.first).execute
- end
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
- end
-end