summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-11-18 13:16:36 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-11-18 13:16:36 +0000
commit311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch)
tree07e7870bca8aed6d61fdcc810731c50d2c40af47 /app/assets/javascripts
parent27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff)
downloadgitlab-ce-056d9df33865790d1b3c10f77912e00f61193000.tar.gz
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql2
-rw-r--r--app/assets/javascripts/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue54
-rw-r--r--app/assets/javascripts/admin/deploy_keys/index.js23
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue2
-rw-r--r--app/assets/javascripts/admin/users/index.js2
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue6
-rw-r--r--app/assets/javascripts/alert_management/list.js3
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql.js1
-rw-r--r--app/assets/javascripts/analytics/devops_reports/components/devops_score.vue (renamed from app/assets/javascripts/analytics/devops_report/components/devops_score.vue)0
-rw-r--r--app/assets/javascripts/analytics/devops_reports/components/devops_score_callout.vue (renamed from app/assets/javascripts/analytics/devops_report/components/devops_score_callout.vue)0
-rw-r--r--app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue (renamed from app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue)0
-rw-r--r--app/assets/javascripts/analytics/devops_reports/constants.js (renamed from app/assets/javascripts/analytics/devops_report/constants.js)4
-rw-r--r--app/assets/javascripts/analytics/devops_reports/devops_score.js (renamed from app/assets/javascripts/analytics/devops_report/devops_score.js)0
-rw-r--r--app/assets/javascripts/analytics/devops_reports/devops_score_disabled_service_ping.js (renamed from app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js)0
-rw-r--r--app/assets/javascripts/analytics/shared/graphql/projects.query.graphql2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/index.js2
-rw-r--r--app/assets/javascripts/api/namespaces_api.js (renamed from app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js)5
-rw-r--r--app/assets/javascripts/artifacts_settings/index.js2
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue133
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue6
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue10
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue4
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue2
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue15
-rw-r--r--app/assets/javascripts/blob/components/table_contents.vue5
-rw-r--r--app/assets/javascripts/blob/pipeline_tour_success_modal.vue4
-rw-r--r--app/assets/javascripts/boards/boards_util.js4
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue36
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue160
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue82
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue33
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue88
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue53
-rw-r--r--app/assets/javascripts/boards/components/new_board_button.vue47
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue6
-rw-r--r--app/assets/javascripts/boards/graphql.js1
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/board_lists.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/board_scope.fragment.graphql6
-rw-r--r--app/assets/javascripts/boards/graphql/group_board.query.graphql9
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql10
-rw-r--r--app/assets/javascripts/boards/graphql/group_projects.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/lists_issues.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/project_board.query.graphql9
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql10
-rw-r--r--app/assets/javascripts/boards/graphql/project_milestones.query.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js8
-rw-r--r--app/assets/javascripts/boards/mount_filtered_search_issue_boards.js5
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js19
-rw-r--r--app/assets/javascripts/boards/new_board.js29
-rw-r--r--app/assets/javascripts/boards/stores/actions.js95
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js5
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js18
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/breadcrumb.js18
-rw-r--r--app/assets/javascripts/chronic_duration.js417
-rw-r--r--app/assets/javascripts/ci_lint/index.js4
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue1
-rw-r--r--app/assets/javascripts/clusters/agents/index.js2
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue32
-rw-r--r--app/assets/javascripts/clusters_list/clusters_util.js4
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_empty_state.vue81
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue162
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue36
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue54
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue44
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_empty_state.vue76
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_main_view.vue73
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_view_all.vue218
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue52
-rw-r--r--app/assets/javascripts/clusters_list/constants.js94
-rw-r--r--app/assets/javascripts/clusters_list/graphql/cache_update.js29
-rw-r--r--app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql10
-rw-r--r--app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql4
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql12
-rw-r--r--app/assets/javascripts/clusters_list/index.js4
-rw-r--r--app/assets/javascripts/clusters_list/load_agents.js44
-rw-r--r--app/assets/javascripts/clusters_list/load_clusters.js7
-rw-r--r--app/assets/javascripts/clusters_list/load_main_view.js50
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js16
-rw-r--r--app/assets/javascripts/clusters_list/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/clusters_list/store/mutations.js5
-rw-r--r--app/assets/javascripts/clusters_list/store/state.js7
-rw-r--r--app/assets/javascripts/confirm_danger_modal.js10
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue6
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_alert.vue33
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_error.vue31
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue22
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue4
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue4
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_list.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/details.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/emoji.js36
-rw-r--r--app/assets/javascripts/content_editor/extensions/frontmatter.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/horizontal_rule.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/html_marks.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/inline_diff.js18
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js18
-rw-r--r--app/assets/javascripts/content_editor/extensions/math_inline.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/subscript.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/superscript.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js43
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_cell.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_header.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_of_contents.js21
-rw-r--r--app/assets/javascripts/content_editor/extensions/word_break.js6
-rw-r--r--app/assets/javascripts/content_editor/services/feature_flags.js3
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js16
-rw-r--r--app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js23
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js10
-rw-r--r--app/assets/javascripts/contextual_sidebar.js5
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue2
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js40
-rw-r--r--app/assets/javascripts/crm/components/contacts_root.vue80
-rw-r--r--app/assets/javascripts/crm/components/organizations_root.vue71
-rw-r--r--app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql22
-rw-r--r--app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql15
-rw-r--r--app/assets/javascripts/crm/contacts_bundle.js27
-rw-r--r--app/assets/javascripts/crm/organizations_bundle.js27
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue17
-rw-r--r--app/assets/javascripts/cycle_analytics/components/metric_popover.vue61
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue49
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js7
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js49
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js69
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js60
-rw-r--r--app/assets/javascripts/deploy_tokens/components/revoke_button.vue2
-rw-r--r--app/assets/javascripts/design_management/graphql.js1
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue18
-rw-r--r--app/assets/javascripts/diffs/components/app.vue15
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue12
-rw-r--r--app/assets/javascripts/diffs/components/diff_comment_cell.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue11
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue31
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue15
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue2
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js34
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js6
-rw-r--r--app/assets/javascripts/diffs/utils/diff_line.js10
-rw-r--r--app/assets/javascripts/diffs/utils/discussions.js76
-rw-r--r--app/assets/javascripts/diffs/utils/file_reviews.js2
-rw-r--r--app/assets/javascripts/dropzone_input.js41
-rw-r--r--app/assets/javascripts/editor/constants.js36
-rw-r--r--app/assets/javascripts/editor/extensions/example_source_editor_extension.js116
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_extension_base.js39
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js293
-rw-r--r--app/assets/javascripts/editor/schema/ci.json49
-rw-r--r--app/assets/javascripts/editor/source_editor_extension.js17
-rw-r--r--app/assets/javascripts/editor/source_editor_instance.js271
-rw-r--r--app/assets/javascripts/emoji/index.js1
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue4
-rw-r--r--app/assets/javascripts/environments/components/new_environment_folder.vue69
-rw-r--r--app/assets/javascripts/environments/components/new_environments_app.vue47
-rw-r--r--app/assets/javascripts/environments/components/rollback_modal_manager.vue5
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js2
-rw-r--r--app/assets/javascripts/environments/graphql/client.js25
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql5
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/delete_environment.mutation.graphql5
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/rollback_environment.mutation.graphql5
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql5
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql2
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql8
-rw-r--r--app/assets/javascripts/environments/graphql/queries/folder.query.graphql7
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js50
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql35
-rw-r--r--app/assets/javascripts/environments/index.js69
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js6
-rw-r--r--app/assets/javascripts/environments/new_index.js38
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue5
-rw-r--r--app/assets/javascripts/experimentation/utils.js23
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue1
-rw-r--r--app/assets/javascripts/flash.js4
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue3
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js24
-rw-r--r--app/assets/javascripts/google_cloud/components/app.vue50
-rw-r--r--app/assets/javascripts/google_cloud/components/incubation_banner.vue44
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts.vue65
-rw-r--r--app/assets/javascripts/google_cloud/index.js11
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql4
-rw-r--r--app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql5
-rw-r--r--app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql2
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js8
-rw-r--r--app/assets/javascripts/group.js4
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue41
-rw-r--r--app/assets/javascripts/group_settings/constants.js5
-rw-r--r--app/assets/javascripts/group_settings/mount_shared_runners.js21
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue5
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue4
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/shared/commit_message_field.vue137
-rw-r--r--app/assets/javascripts/ide/constants.js2
-rw-r--r--app/assets/javascripts/import_entities/components/group_dropdown.vue2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue33
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue14
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue310
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue84
-rw-r--r--app/assets/javascripts/import_entities/import_groups/constants.js15
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js406
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql23
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql23
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql13
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql7
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js74
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js87
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql52
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/services/status_poller.js (renamed from app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js)4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/utils.js23
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue6
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue10
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue11
-rw-r--r--app/assets/javascripts/incidents/constants.js5
-rw-r--r--app/assets/javascripts/incidents/list.js4
-rw-r--r--app/assets/javascripts/init_confirm_danger.js38
-rw-r--r--app/assets/javascripts/integrations/constants.js8
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue53
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js16
-rw-r--r--app/assets/javascripts/invite_members/components/confetti.vue33
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue232
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue27
-rw-r--r--app/assets/javascripts/invite_members/constants.js127
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js19
-rw-r--r--app/assets/javascripts/issuable/components/issuable_by_email.vue2
-rw-r--r--app/assets/javascripts/issuable_suggestions/index.js7
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue29
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue14
-rw-r--r--app/assets/javascripts/issue_show/components/fields/type.vue17
-rw-r--r--app/assets/javascripts/issue_show/components/header_actions.vue7
-rw-r--r--app/assets/javascripts/issue_show/constants.js7
-rw-r--r--app/assets/javascripts/issue_show/incident.js56
-rw-r--r--app/assets/javascripts/issue_show/issue.js7
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue26
-rw-r--r--app/assets/javascripts/issues_list/components/new_issue_dropdown.vue7
-rw-r--r--app/assets/javascripts/issues_list/constants.js28
-rw-r--r--app/assets/javascripts/issues_list/index.js6
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues.query.graphql10
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql22
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql2
-rw-r--r--app/assets/javascripts/issues_list/queries/iteration.fragment.graphql6
-rw-r--r--app/assets/javascripts/issues_list/queries/search_projects.query.graphql1
-rw-r--r--app/assets/javascripts/issues_list/service_desk_helper.js8
-rw-r--r--app/assets/javascripts/issues_list/utils.js34
-rw-r--r--app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql2
-rw-r--r--app/assets/javascripts/jira_connect/branches/index.js7
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue24
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue26
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue (renamed from app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue)0
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue (renamed from app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue)2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue112
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue36
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue54
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js16
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/utils.js15
-rw-r--r--app/assets/javascripts/jira_import/index.js2
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql2
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql2
-rw-r--r--app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql2
-rw-r--r--app/assets/javascripts/jira_import/queries/search_project_members.query.graphql2
-rw-r--r--app/assets/javascripts/jobs/components/manual_variables_form.vue229
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue8
-rw-r--r--app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js4
-rw-r--r--app/assets/javascripts/lib/graphql.js5
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js27
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue47
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js55
-rw-r--r--app/assets/javascripts/lib/utils/constants.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js48
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js20
-rw-r--r--app/assets/javascripts/lib/utils/rails_ujs.js38
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js2
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js6
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue2
-rw-r--r--app/assets/javascripts/members/components/app.vue5
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget.vue285
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue324
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue7
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue106
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue2
-rw-r--r--app/assets/javascripts/monitoring/monitoring_app.js6
-rw-r--r--app/assets/javascripts/monitoring/utils.js1
-rw-r--r--app/assets/javascripts/mr_notes/index.js4
-rw-r--r--app/assets/javascripts/mr_popover/index.js7
-rw-r--r--app/assets/javascripts/nav/components/responsive_home.vue1
-rw-r--r--app/assets/javascripts/nav/components/top_nav_new_dropdown.vue1
-rw-r--r--app/assets/javascripts/network/branch_graph.js2
-rw-r--r--app/assets/javascripts/notebook/cells/output/latex.vue11
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue90
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue13
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue32
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue18
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js56
-rw-r--r--app/assets/javascripts/notes/stores/actions.js46
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js41
-rw-r--r--app/assets/javascripts/packages/list/components/package_search.vue57
-rw-r--r--app/assets/javascripts/packages/list/components/package_title.vue47
-rw-r--r--app/assets/javascripts/packages/list/components/packages_list_app.vue28
-rw-r--r--app/assets/javascripts/packages/list/components/tokens/package_type_token.vue26
-rw-r--r--app/assets/javascripts/packages/list/constants.js6
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue (renamed from app/assets/javascripts/registry/explorer/components/delete_button.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue (renamed from app/assets/javascripts/registry/explorer/components/delete_image.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/details_header.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/empty_state.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/status_alert.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue (renamed from app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue (renamed from app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue (renamed from app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue (renamed from app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue (renamed from app/assets/javascripts/registry/explorer/components/list_page/image_list.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue (renamed from app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue (renamed from app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue (renamed from app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue (renamed from app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js (renamed from app/assets/javascripts/registry/explorer/constants/common.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js (renamed from app/assets/javascripts/registry/explorer/constants/details.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js (renamed from app/assets/javascripts/registry/explorer/constants/expiration_policies.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js (renamed from app/assets/javascripts/registry/explorer/constants/index.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js (renamed from app/assets/javascripts/registry/explorer/constants/list.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js (renamed from app/assets/javascripts/registry/explorer/constants/quick_start.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js (renamed from app/assets/javascripts/registry/explorer/graphql/index.js)1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql (renamed from app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql (renamed from app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql (renamed from app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql (renamed from app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql (renamed from app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql (renamed from app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js (renamed from app/assets/javascripts/registry/explorer/index.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue (renamed from app/assets/javascripts/registry/explorer/pages/details.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue (renamed from app/assets/javascripts/registry/explorer/pages/index.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue (renamed from app/assets/javascripts/registry/explorer/pages/list.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js (renamed from app/assets/javascripts/registry/explorer/router.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue87
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue49
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue52
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql19
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue86
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue62
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue116
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue100
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js63
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql31
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/utils.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js7
-rw-r--r--app/assets/javascripts/pages/admin/deploy_keys/index/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/dev_ops_report/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue4
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js8
-rw-r--r--app/assets/javascripts/pages/groups/crm/contacts/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/crm/organizations/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue2
-rw-r--r--app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue2
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js4
-rw-r--r--app/assets/javascripts/pages/groups/packages/index/index.js11
-rw-r--r--app/assets/javascripts/pages/groups/registry/repositories/index.js2
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue4
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/environments/index/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue6
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js6
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue14
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue1
-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.js1
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/index/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue6
-rw-r--r--app/assets/javascripts/pages/projects/project.js8
-rw-r--r--app/assets/javascripts/pages/projects/registry/repositories/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue17
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue7
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/work_items/index.js (renamed from app/assets/javascripts/pages/projects/work_items/index/index.js)0
-rw-r--r--app/assets/javascripts/pages/shared/mount_runner_instructions.js7
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue18
-rw-r--r--app/assets/javascripts/pages/shared/wikis/constants.js5
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js11
-rw-r--r--app/assets/javascripts/pages/users/terms/index/index.js4
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue4
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue16
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue23
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue14
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue49
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue18
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue52
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue56
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue53
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue7
-rw-r--r--app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue83
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js17
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql5
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js23
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js11
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue51
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue70
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue58
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue8
-rw-r--r--app/assets/javascripts/pipelines/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql3
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_shared_client.js1
-rw-r--r--app/assets/javascripts/profile/account/components/delete_account_modal.vue4
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue6
-rw-r--r--app/assets/javascripts/project_visibility.js62
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue10
-rw-r--r--app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js3
-rw-r--r--app/assets/javascripts/projects/commit/init_revert_commit_modal.js3
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js7
-rw-r--r--app/assets/javascripts/projects/components/project_delete_button.vue58
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/projects/details/upload_button.vue7
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue2
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue27
-rw-r--r--app/assets/javascripts/projects/new/index.js2
-rw-r--r--app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql3
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue12
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js4
-rw-r--r--app/assets/javascripts/projects/project_delete_button.js16
-rw-r--r--app/assets/javascripts/projects/project_new.js7
-rw-r--r--app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue92
-rw-r--r--app/assets/javascripts/projects/settings/topics/index.js51
-rw-r--r--app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql9
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue7
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue60
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue115
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js2
-rw-r--r--app/assets/javascripts/projects/storage_counter/components/storage_table.vue62
-rw-r--r--app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue35
-rw-r--r--app/assets/javascripts/projects/storage_counter/constants.js12
-rw-r--r--app/assets/javascripts/projects/storage_counter/index.js2
-rw-r--r--app/assets/javascripts/projects/storage_counter/utils.js4
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue2
-rw-r--r--app/assets/javascripts/projects/upload_file.js (renamed from app/assets/javascripts/projects/upload_file_experiment.js)2
-rw-r--r--app/assets/javascripts/projects/upload_file_experiment_tracking.js9
-rw-r--r--app/assets/javascripts/ref/constants.js4
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue87
-rw-r--r--app/assets/javascripts/related_merge_requests/store/actions.js4
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue7
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql2
-rw-r--r--app/assets/javascripts/releases/mount_index.js1
-rw-r--r--app/assets/javascripts/releases/mount_show.js7
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue19
-rw-r--r--app/assets/javascripts/repository/components/delete_blob_modal.vue13
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue19
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue4
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue27
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue4
-rw-r--r--app/assets/javascripts/repository/graphql.js1
-rw-r--r--app/assets/javascripts/repository/mixins/preload.js3
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql1
-rw-r--r--app/assets/javascripts/rest_api.js1
-rw-r--r--app/assets/javascripts/right_sidebar.js7
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue87
-rw-r--r--app/assets/javascripts/runner/admin_runners/index.js29
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue4
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_status_cell.vue40
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue27
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_type_cell.vue47
-rw-r--r--app/assets/javascripts/runner/components/helpers/masked_value.vue60
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_dropdown.vue112
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token.vue83
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue (renamed from app/assets/javascripts/runner/components/runner_registration_token_reset.vue)19
-rw-r--r--app/assets/javascripts/runner/components/runner_contacted_state_badge.vue69
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue27
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue39
-rw-r--r--app/assets/javascripts/runner/components/runner_manual_setup_help.vue108
-rw-r--r--app/assets/javascripts/runner/components/runner_paused_badge.vue (renamed from app/assets/javascripts/runner/components/runner_state_paused_badge.vue)0
-rw-r--r--app/assets/javascripts/runner/components/runner_state_locked_badge.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_tag.vue35
-rw-r--r--app/assets/javascripts/runner/components/runner_tags.vue10
-rw-r--r--app/assets/javascripts/runner/components/runner_type_alert.vue5
-rw-r--r--app/assets/javascripts/runner/components/runner_type_badge.vue5
-rw-r--r--app/assets/javascripts/runner/components/runner_type_tabs.vue66
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/status_token_config.js28
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/type_token_config.js20
-rw-r--r--app/assets/javascripts/runner/constants.js16
-rw-r--r--app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql14
-rw-r--r--app/assets/javascripts/runner/graphql/runner_node.fragment.graphql3
-rw-r--r--app/assets/javascripts/runner/graphql/runner_update.mutation.graphql3
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue24
-rw-r--r--app/assets/javascripts/runner/group_runners/index.js9
-rw-r--r--app/assets/javascripts/runner/runner_details/index.js7
-rw-r--r--app/assets/javascripts/runner/runner_search_utils.js88
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue6
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue4
-rw-r--r--app/assets/javascripts/search/sidebar/constants/state_filter_data.js2
-rw-r--r--app/assets/javascripts/search/store/actions.js10
-rw-r--r--app/assets/javascripts/search/store/constants.js5
-rw-r--r--app/assets/javascripts/search/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/search/store/mutations.js3
-rw-r--r--app/assets/javascripts/search/store/state.js3
-rw-r--r--app/assets/javascripts/search/store/utils.js12
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue22
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js51
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue6
-rw-r--r--app/assets/javascripts/security_configuration/graphql/configure_iac.mutation.graphql6
-rw-r--r--app/assets/javascripts/security_configuration/index.js2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue50
-rw-r--r--app/assets/javascripts/sidebar/components/attention_requested_toggle.vue74
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue8
-rw-r--r--app/assets/javascripts/sidebar/constants.js28
-rw-r--r--app/assets/javascripts/sidebar/graphql.js1
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js4
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql7
-rw-r--r--app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js12
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js43
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js12
-rw-r--r--app/assets/javascripts/snippets/index.js1
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js1
-rw-r--r--app/assets/javascripts/static_site_editor/pages/success.vue2
-rw-r--r--app/assets/javascripts/task_list.js2
-rw-r--r--app/assets/javascripts/terms/components/app.vue117
-rw-r--r--app/assets/javascripts/terms/index.js23
-rw-r--r--app/assets/javascripts/token_access/index.js2
-rw-r--r--app/assets/javascripts/user_lists/components/user_lists_table.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue77
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue74
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue28
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue28
-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/commit_edit.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue7
-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.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue254
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql (renamed from app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql)2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js6
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue7
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js28
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue104
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/constants.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue85
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue60
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/settings/settings_block.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue64
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue82
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue184
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue4
-rw-r--r--app/assets/javascripts/vue_shared/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js6
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/provider.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue10
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js1
-rw-r--r--app/assets/javascripts/work_items/components/app.vue10
-rw-r--r--app/assets/javascripts/work_items/constants.js3
-rw-r--r--app/assets/javascripts/work_items/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js55
-rw-r--r--app/assets/javascripts/work_items/graphql/resolvers.js0
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql38
-rw-r--r--app/assets/javascripts/work_items/graphql/widget.fragment.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql16
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue48
-rw-r--r--app/assets/javascripts/work_items/router/index.js14
-rw-r--r--app/assets/javascripts/work_items/router/routes.js8
686 files changed, 11347 insertions, 4893 deletions
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 60110437ecd..09278e1776a 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,6 +1,6 @@
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-query getProjects(
+query accessTokensGetProjects(
$search: String = ""
$after: String = ""
$first: Int = null
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 2cd3a8f12ee..7f5f0403de6 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -49,7 +49,7 @@ export const initProjectsField = () => {
{ default: createDefaultClient },
]) => {
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
new file mode 100644
index 00000000000..97a5a2f2f32
--- /dev/null
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlTable, GlButton } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+
+export default {
+ name: 'DeployKeysTable',
+ i18n: {
+ pageTitle: __('Public deploy keys'),
+ newDeployKeyButtonText: __('New deploy key'),
+ },
+ fields: [
+ {
+ key: 'title',
+ label: __('Title'),
+ },
+ {
+ key: 'fingerprint',
+ label: __('Fingerprint'),
+ },
+ {
+ key: 'projects',
+ label: __('Projects with write access'),
+ },
+ {
+ key: 'created',
+ label: __('Created'),
+ },
+ {
+ key: 'actions',
+ label: __('Actions'),
+ },
+ ],
+ components: {
+ GlTable,
+ GlButton,
+ },
+ inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'],
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-5">
+ <h4 class="gl-m-0">
+ {{ $options.i18n.pageTitle }}
+ </h4>
+ <gl-button variant="confirm" :href="createPath">{{
+ $options.i18n.newDeployKeyButtonText
+ }}</gl-button>
+ </div>
+ <gl-table :fields="$options.fields" data-testid="deploy-keys-list" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/deploy_keys/index.js b/app/assets/javascripts/admin/deploy_keys/index.js
new file mode 100644
index 00000000000..d86de4229de
--- /dev/null
+++ b/app/assets/javascripts/admin/deploy_keys/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import DeployKeysTable from './components/table.vue';
+
+export const initAdminDeployKeysTable = () => {
+ const el = document.getElementById('js-admin-deploy-keys-table');
+
+ if (!el) return false;
+
+ const { editPath, deletePath, createPath, emptyStateSvgPath } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ editPath,
+ deletePath,
+ createPath,
+ emptyStateSvgPath,
+ },
+ render(createElement) {
+ return createElement(DeployKeysTable);
+ },
+ });
+};
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 ed90343777d..e949498c55b 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
@@ -138,7 +138,7 @@ export default {
/>
</form>
<template #modal-footer>
- <gl-button @click="onCancel">{{ s__('Cancel') }}</gl-button>
+ <gl-button @click="onCancel">{{ __('Cancel') }}</gl-button>
<gl-button
:disabled="!canSubmit"
category="secondary"
diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js
index 852b253d25a..0c485d2a239 100644
--- a/app/assets/javascripts/admin/users/index.js
+++ b/app/assets/javascripts/admin/users/index.js
@@ -15,7 +15,7 @@ import {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
const initApp = (el, component, userPropKey, props = {}) => {
diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index e59d7fc058a..79a6bac3ba7 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -17,7 +17,6 @@ import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue';
-import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue';
import {
tdClass,
thClass,
@@ -26,7 +25,6 @@ import {
} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ALERTS_STATUS_TABS, SEVERITY_LEVELS, trackAlertListViewsOptions } from '../constants';
import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
@@ -98,7 +96,6 @@ export default {
severityLabels: SEVERITY_LEVELS,
statusTabs: ALERTS_STATUS_TABS,
components: {
- AlertsDeprecationWarning,
GlAlert,
GlLoadingIcon,
GlTable,
@@ -115,7 +112,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
inject: ['projectPath', 'textQuery', 'assigneeUsernameQuery', 'populatingAlertsHelpUrl'],
apollo: {
alerts: {
@@ -277,8 +273,6 @@ export default {
</gl-sprintf>
</gl-alert>
- <alerts-deprecation-warning v-if="!glFeatures.managedAlertsDeprecation" />
-
<paginated-table-with-search-and-tabs
:show-error-msg="showErrorMsg"
:i18n="$options.i18n"
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index 57d1f135606..b23f8a8eba4 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -23,7 +23,6 @@ export default () => {
assigneeUsernameQuery,
alertManagementEnabled,
userCanEnableAlertManagement,
- hasManagedPrometheus,
} = domEl.dataset;
const apolloProvider = new VueApollo({
@@ -39,7 +38,6 @@ export default () => {
return defaultDataIdFromObject(object);
},
},
- assumeImmutableResults: true,
},
),
});
@@ -66,7 +64,6 @@ export default () => {
alertManagementEnabled: parseBoolean(alertManagementEnabled),
trackAlertStatusUpdateOptions: PAGE_CONFIG.OPERATIONS.TRACK_ALERT_STATUS_UPDATE_OPTIONS,
userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement),
- hasManagedPrometheus: parseBoolean(hasManagedPrometheus),
},
apolloProvider,
render(createElement) {
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index a5f7b84446f..6b5aac57f1c 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -16,6 +16,7 @@ import {
import * as Sentry from '@sentry/browser';
import { isEqual, isEmpty, omit } from 'lodash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
import {
integrationTypes,
integrationSteps,
@@ -129,6 +130,7 @@ export default {
name: true,
apiUrl: true,
},
+ pricingLink: `${PROMO_URL}/pricing`,
};
},
computed: {
@@ -436,7 +438,7 @@ export default {
disabled="true"
class="gl-display-inline-block gl-my-4"
:message="$options.i18n.integrationFormSteps.selectType.enterprise"
- link="https://about.gitlab.com/pricing"
+ :link="pricingLink"
data-testid="multi-integrations-not-supported"
/>
</gl-form-group>
diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js
index 15862f4034a..b64e2e3eefa 100644
--- a/app/assets/javascripts/alerts_settings/graphql.js
+++ b/app/assets/javascripts/alerts_settings/graphql.js
@@ -59,6 +59,5 @@ export default new VueApollo({
cacheConfig: {
fragmentMatcher,
},
- assumeImmutableResults: true,
}),
});
diff --git a/app/assets/javascripts/analytics/devops_report/components/devops_score.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
index 238081cc3c0..238081cc3c0 100644
--- a/app/assets/javascripts/analytics/devops_report/components/devops_score.vue
+++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
diff --git a/app/assets/javascripts/analytics/devops_report/components/devops_score_callout.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score_callout.vue
index e594b4e360a..e594b4e360a 100644
--- a/app/assets/javascripts/analytics/devops_report/components/devops_score_callout.vue
+++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score_callout.vue
diff --git a/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue
index 400326e41e1..400326e41e1 100644
--- a/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue
+++ b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue
diff --git a/app/assets/javascripts/analytics/devops_report/constants.js b/app/assets/javascripts/analytics/devops_reports/constants.js
index b395d7eb464..6091fcb5724 100644
--- a/app/assets/javascripts/analytics/devops_report/constants.js
+++ b/app/assets/javascripts/analytics/devops_reports/constants.js
@@ -2,10 +2,10 @@ import { __ } from '~/locale';
export const INTRO_COOKIE_KEY = 'dev_ops_report_intro_callout_dismissed';
-export const INTRO_BANNER_TITLE = __('Introducing Your DevOps Report');
+export const INTRO_BANNER_TITLE = __('Introducing Your DevOps Reports');
export const INTRO_BANNER_BODY = __(
- 'Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. Use it to view how you compare with other organizations.',
+ 'Your DevOps Reports give an overview of how you are using GitLab from a feature perspective. Use them to view how you compare with other organizations, and how your teams compare against each other.',
);
export const INTRO_BANNER_ACTION_TEXT = __('Read more');
diff --git a/app/assets/javascripts/analytics/devops_report/devops_score.js b/app/assets/javascripts/analytics/devops_reports/devops_score.js
index 0bf98b65ed5..0bf98b65ed5 100644
--- a/app/assets/javascripts/analytics/devops_report/devops_score.js
+++ b/app/assets/javascripts/analytics/devops_reports/devops_score.js
diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js b/app/assets/javascripts/analytics/devops_reports/devops_score_disabled_service_ping.js
index eb2992422a4..eb2992422a4 100644
--- a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js
+++ b/app/assets/javascripts/analytics/devops_reports/devops_score_disabled_service_ping.js
diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
index 63e95d6804c..b870ed4dcbf 100644
--- a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
+++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
@@ -1,4 +1,4 @@
-query getGroupProjects(
+query analyticsGetGroupProjects(
$groupFullPath: ID!
$search: String!
$first: Int!
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 1eb4832a2a3..63ec40d4ec6 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -3,7 +3,7 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import createFlash from '~/flash';
import { number } from '~/lib/utils/unit_format';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql';
const defaultPrecision = 0;
@@ -52,7 +52,7 @@ export default {
mergeRequests: s__('UsageTrends|Merge requests'),
pipelines: s__('UsageTrends|Pipelines'),
},
- loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'),
+ loadCountsError: __('Could not load usage counts. Please refresh the page to try again.'),
},
};
</script>
diff --git a/app/assets/javascripts/analytics/usage_trends/index.js b/app/assets/javascripts/analytics/usage_trends/index.js
index 3e85832edcf..d1880b09f15 100644
--- a/app/assets/javascripts/analytics/usage_trends/index.js
+++ b/app/assets/javascripts/analytics/usage_trends/index.js
@@ -6,7 +6,7 @@ import UsageTrendsApp from './components/app.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
export default () => {
diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/api/namespaces_api.js
index 301e0b4f7a2..166a95b749a 100644
--- a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
+++ b/app/assets/javascripts/api/namespaces_api.js
@@ -3,10 +3,11 @@ import axios from '~/lib/utils/axios_utils';
const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists';
-export default function fetchGroupPathAvailability(groupPath, parentId) {
+export function getGroupPathAvailability(groupPath, parentId, axiosOptions = {}) {
const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath));
return axios.get(url, {
- params: { parent_id: parentId },
+ params: { parent_id: parentId, ...axiosOptions.params },
+ ...axiosOptions,
});
}
diff --git a/app/assets/javascripts/artifacts_settings/index.js b/app/assets/javascripts/artifacts_settings/index.js
index 5c9f1c3129c..531b42bc185 100644
--- a/app/assets/javascripts/artifacts_settings/index.js
+++ b/app/assets/javascripts/artifacts_settings/index.js
@@ -6,7 +6,7 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
export default (containerId = 'js-artifacts-settings-app') => {
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
index 0b748f18cb2..484c6524d0e 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
@@ -1,26 +1,46 @@
<script>
-import { GlFormInput, GlFormGroup, GlButton, GlForm } from '@gitlab/ui';
+import { GlFormInput, GlFormGroup, GlButton, GlForm, GlModal } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale';
export const i18n = {
currentPassword: __('Current password'),
+ confirmTitle: __('Are you sure?'),
confirmWebAuthn: __(
- 'Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.',
+ 'This will invalidate your registered applications and U2F / WebAuthn devices.',
),
- confirm: __('Are you sure? This will invalidate your registered applications and U2F devices.'),
+ confirm: __('This will invalidate your registered applications and U2F devices.'),
disableTwoFactor: __('Disable two-factor authentication'),
+ disable: __('Disable'),
+ cancel: __('Cancel'),
regenerateRecoveryCodes: __('Regenerate recovery codes'),
+ currentPasswordInvalidFeedback: __('Please enter your current password.'),
};
export default {
name: 'ManageTwoFactorForm',
i18n,
+ modalId: 'manage-two-factor-auth-confirm-modal',
+ modalActions: {
+ primary: {
+ text: i18n.disable,
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ secondary: {
+ text: i18n.cancel,
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
components: {
GlForm,
GlFormInput,
GlFormGroup,
GlButton,
+ GlModal,
},
inject: [
'webauthnEnabled',
@@ -32,8 +52,11 @@ export default {
],
data() {
return {
- method: '',
- action: '#',
+ method: null,
+ action: null,
+ currentPassword: '',
+ currentPasswordState: null,
+ showConfirmModal: false,
};
},
computed: {
@@ -46,9 +69,34 @@ export default {
},
},
methods: {
- handleFormSubmit(event) {
- this.method = event.submitter.dataset.formMethod;
- this.action = event.submitter.dataset.formAction;
+ submitForm() {
+ this.$refs.form.$el.submit();
+ },
+ async handleSubmitButtonClick({ method, action, confirm = false }) {
+ this.method = method;
+ this.action = action;
+
+ if (this.isCurrentPasswordRequired && this.currentPassword === '') {
+ this.currentPasswordState = false;
+
+ return;
+ }
+
+ this.currentPasswordState = null;
+
+ if (confirm) {
+ this.showConfirmModal = true;
+
+ return;
+ }
+
+ // Wait for form action and method to be updated
+ await this.$nextTick();
+
+ this.submitForm();
+ },
+ handleModalPrimary() {
+ this.submitForm();
},
},
csrf,
@@ -57,10 +105,11 @@ export default {
<template>
<gl-form
- class="gl-display-inline-block"
+ ref="form"
+ class="gl-sm-display-inline-block"
method="post"
:action="action"
- @submit="handleFormSubmit($event)"
+ @submit.prevent
>
<input type="hidden" name="_method" data-testid="test-2fa-method-field" :value="method" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
@@ -69,35 +118,59 @@ export default {
v-if="isCurrentPasswordRequired"
:label="$options.i18n.currentPassword"
label-for="current-password"
+ :state="currentPasswordState"
+ :invalid-feedback="$options.i18n.currentPasswordInvalidFeedback"
>
<gl-form-input
id="current-password"
+ v-model="currentPassword"
type="password"
name="current_password"
- required
+ :state="currentPasswordState"
data-qa-selector="current_password_field"
/>
</gl-form-group>
- <gl-button
- type="submit"
- class="btn-danger gl-mr-3 gl-display-inline-block"
- data-testid="test-2fa-disable-button"
- variant="danger"
- :data-confirm="confirmText"
- :data-form-action="profileTwoFactorAuthPath"
- :data-form-method="profileTwoFactorAuthMethod"
- >
- {{ $options.i18n.disableTwoFactor }}
- </gl-button>
- <gl-button
- type="submit"
- class="gl-display-inline-block"
- data-testid="test-2fa-regenerate-codes-button"
- :data-form-action="codesProfileTwoFactorAuthPath"
- :data-form-method="codesProfileTwoFactorAuthMethod"
+ <div class="gl-display-flex gl-flex-wrap">
+ <gl-button
+ type="submit"
+ class="gl-sm-mr-3 gl-w-full gl-sm-w-auto"
+ data-testid="test-2fa-disable-button"
+ variant="danger"
+ @click.prevent="
+ handleSubmitButtonClick({
+ method: profileTwoFactorAuthMethod,
+ action: profileTwoFactorAuthPath,
+ confirm: true,
+ })
+ "
+ >
+ {{ $options.i18n.disableTwoFactor }}
+ </gl-button>
+ <gl-button
+ type="submit"
+ class="gl-mt-3 gl-sm-mt-0 gl-w-full gl-sm-w-auto"
+ data-testid="test-2fa-regenerate-codes-button"
+ @click.prevent="
+ handleSubmitButtonClick({
+ method: codesProfileTwoFactorAuthMethod,
+ action: codesProfileTwoFactorAuthPath,
+ })
+ "
+ >
+ {{ $options.i18n.regenerateRecoveryCodes }}
+ </gl-button>
+ </div>
+ <gl-modal
+ v-model="showConfirmModal"
+ :modal-id="$options.modalId"
+ size="sm"
+ :title="$options.i18n.confirmTitle"
+ :action-primary="$options.modalActions.primary"
+ :action-secondary="$options.modalActions.secondary"
+ @primary="handleModalPrimary"
>
- {{ $options.i18n.regenerateRecoveryCodes }}
- </gl-button>
+ {{ confirmText }}
+ </gl-modal>
</gl-form>
</template>
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
index 825807e833e..0303930de5d 100644
--- a/app/assets/javascripts/badges/components/badge_settings.vue
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -2,7 +2,7 @@
import { GlSprintf, GlModal } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import createFlash from '~/flash';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import Badge from './badge.vue';
import BadgeForm from './badge_form.vue';
import BadgeList from './badge_list.vue';
@@ -25,13 +25,13 @@ export default {
...mapState(['badgeInModal', 'isEditing']),
primaryProps() {
return {
- text: s__('Delete badge'),
+ text: __('Delete badge'),
attributes: [{ category: 'primary' }, { variant: 'danger' }],
};
},
cancelProps() {
return {
- text: s__('Cancel'),
+ text: __('Cancel'),
};
},
},
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index f5e3bab6ff0..918519f386b 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import NoteableNote from '~/notes/components/noteable_note.vue';
import PublishButton from './publish_button.vue';
@@ -10,6 +10,9 @@ export default {
PublishButton,
GlButton,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
draft: {
type: Object,
@@ -72,6 +75,9 @@ export default {
}
},
},
+ safeHtmlConfig: {
+ ADD_TAGS: ['use', 'gl-emoji'],
+ },
};
</script>
<template>
@@ -104,8 +110,8 @@ export default {
<template v-if="!isEditingDraft">
<div
v-if="draftCommands"
+ v-safe-html:[$options.safeHtmlConfig]="draftCommands"
class="referenced-commands draft-note-commands"
- v-html="draftCommands /* eslint-disable-line vue/no-v-html */"
></div>
<p class="draft-note-actions d-flex">
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index 91b3b6a685c..e90c29e939f 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -15,14 +15,14 @@ export default {
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
},
methods: {
- ...mapActions('diffs', ['toggleActiveFileByHash']),
+ ...mapActions('diffs', ['setCurrentFileHash']),
...mapActions('batchComments', ['scrollToDraft']),
isLast(index) {
return index === this.sortedDrafts.length - 1;
},
async onClickDraft(draft) {
if (this.viewDiffsFileByFile && draft.file_hash) {
- await this.toggleActiveFileByHash(draft.file_hash);
+ await this.setCurrentFileHash(draft.file_hash);
}
await this.scrollToDraft(draft);
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index 213e026c41f..e3e43ea3a0e 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -65,7 +65,7 @@ export default {
};
</script>
<template>
- <div class="blob-viewer" :data-type="activeViewer.type">
+ <div class="blob-viewer" :data-type="activeViewer.type" :data-loaded="!loading">
<gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" />
<template v-else>
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index 4742b4ae4b4..933ad448c77 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -3,12 +3,14 @@ import DefaultActions from './blob_header_default_actions.vue';
import BlobFilepath from './blob_header_filepath.vue';
import ViewerSwitcher from './blob_header_viewer_switcher.vue';
import { SIMPLE_BLOB_VIEWER } from './constants';
+import TableOfContents from './table_contents.vue';
export default {
components: {
ViewerSwitcher,
DefaultActions,
BlobFilepath,
+ TableOfContents,
},
props: {
blob: {
@@ -70,11 +72,14 @@ export default {
</script>
<template>
<div class="js-file-title file-title-flex-parent">
- <blob-filepath :blob="blob">
- <template #filepath-prepend>
- <slot name="prepend"></slot>
- </template>
- </blob-filepath>
+ <div class="gl-display-flex">
+ <table-of-contents class="gl-pr-2" />
+ <blob-filepath :blob="blob">
+ <template #filepath-prepend>
+ <slot name="prepend"></slot>
+ </template>
+ </blob-filepath>
+ </div>
<div class="gl-display-none gl-sm-display-flex">
<viewer-switcher v-if="showViewerSwitcher" v-model="viewer" />
diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue
index 78ecb82f2cd..07da262ec9a 100644
--- a/app/assets/javascripts/blob/components/table_contents.vue
+++ b/app/assets/javascripts/blob/components/table_contents.vue
@@ -18,11 +18,12 @@ export default {
},
mounted() {
this.blobViewer = document.querySelector('.blob-viewer[data-type="rich"]');
+ const blobViewerAttr = (attr) => this.blobViewer.getAttribute(attr);
this.observer = new MutationObserver(() => {
- if (this.blobViewer.classList.contains('hidden')) {
+ if (this.blobViewer.classList.contains('hidden') || blobViewerAttr('data-type') !== 'rich') {
this.isHidden = true;
- } else if (this.blobViewer.getAttribute('data-loaded') === 'true') {
+ } else if (blobViewerAttr('data-loaded') === 'true') {
this.isHidden = false;
this.generateHeaders();
}
diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
index a3278f8bde2..e75aa523ed0 100644
--- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
+++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
@@ -1,7 +1,7 @@
<script>
import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
import Cookies from 'js-cookie';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
const trackingMixin = Tracking.mixin();
@@ -69,7 +69,7 @@ export default {
},
},
i18n: {
- modalTitle: s__("That's it, well done!"),
+ modalTitle: __("That's it, well done!"),
pipelinesButton: s__('MR widget|See your pipeline in action'),
mergeRequestButton: s__('MR widget|Back to the Merge request'),
bodyMessage: s__(
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index c10241d00d7..e6c91c7ac1f 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,4 +1,5 @@
import { sortBy, cloneDeep } from 'lodash';
+import { isGid } from '~/graphql_shared/utils';
import { ListType, MilestoneIDs } from './constants';
export function getMilestone() {
@@ -95,6 +96,9 @@ export function fullMilestoneId(id) {
}
export function fullLabelId(label) {
+ if (isGid(label.id)) {
+ return label.id;
+ }
if (label.project_id && label.project_id !== null) {
return `gid://gitlab/ProjectLabel/${label.id}`;
}
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 1e780f9ef84..563bed6a6b8 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -83,7 +83,7 @@ export default {
:data-item-path="item.referencePath"
data-testid="board_card"
class="board-card gl-p-5 gl-rounded-base"
- @mouseup="toggleIssue($event)"
+ @click="toggleIssue($event)"
>
<board-card-inner :list="list" :item="item" :update-filters="true" />
</li>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 9bbb8a1a1b2..54668c9e88e 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -15,6 +15,7 @@ import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
+import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@@ -53,6 +54,9 @@ export default {
allowLabelEdit: {
default: false,
},
+ labelsFilterBasePath: {
+ default: '',
+ },
},
inheritAttrs: false,
computed: {
@@ -63,7 +67,7 @@ export default {
'groupPathForActiveIssue',
'projectPathForActiveIssue',
]),
- ...mapState(['sidebarType', 'issuableType', 'isSettingLabels']),
+ ...mapState(['sidebarType', 'issuableType']),
isIssuableSidebar() {
return this.sidebarType === ISSUABLE;
},
@@ -84,7 +88,15 @@ export default {
});
},
attrWorkspacePath() {
- return this.isGroupBoard ? this.groupPathForActiveIssue : undefined;
+ return this.isGroupBoard ? this.groupPathForActiveIssue : this.projectPathForActiveIssue;
+ },
+ labelType() {
+ return this.isGroupBoard ? LabelType.group : LabelType.project;
+ },
+ labelsFilterPath() {
+ return this.isGroupBoard
+ ? this.labelsFilterBasePath.replace(':project_path', this.projectPathForActiveIssue)
+ : this.labelsFilterBasePath;
},
},
methods: {
@@ -98,21 +110,19 @@ export default {
handleClose() {
this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
},
- handleUpdateSelectedLabels(input) {
+ handleUpdateSelectedLabels({ labels, id }) {
this.setActiveBoardItemLabels({
- iid: this.activeBoardItem.iid,
+ id,
projectPath: this.projectPathForActiveIssue,
- addLabelIds: input.map((label) => getIdFromGraphQLId(label.id)),
- removeLabelIds: this.activeBoardItem.labels
- .filter((label) => !input.find((selected) => selected.id === label.id))
- .map((label) => label.id),
+ labelIds: labels.map((label) => getIdFromGraphQLId(label.id)),
+ labels,
});
},
- handleLabelRemove(input) {
+ handleLabelRemove(removeLabelId) {
this.setActiveBoardItemLabels({
iid: this.activeBoardItem.iid,
projectPath: this.projectPathForActiveIssue,
- removeLabelIds: [input],
+ removeLabelIds: [removeLabelId],
});
},
},
@@ -207,14 +217,14 @@ export default {
:full-path="projectPathForActiveIssue"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
- :selected-labels="activeBoardItem.labels"
- :labels-select-in-progress="isSettingLabels"
:footer-create-label-title="createLabelTitle"
:footer-manage-label-title="manageLabelTitle"
:labels-create-title="createLabelTitle"
- :labels-filter-base-path="projectPathForActiveIssue"
+ :labels-filter-base-path="labelsFilterPath"
:attr-workspace-path="attrWorkspacePath"
+ workspace-type="project"
:issuable-type="issuableType"
+ :label-create-type="labelType"
@onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 7f242dea644..6e6ada2d109 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -1,6 +1,7 @@
<script>
-import { pickBy } from 'lodash';
+import { pickBy, isEmpty } from 'lodash';
import { mapActions } from 'vuex';
+import { getIdFromGraphQLId } 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';
@@ -19,6 +20,11 @@ export default {
type: Array,
required: true,
},
+ eeFilters: {
+ required: false,
+ type: Object,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -26,57 +32,6 @@ export default {
};
},
computed: {
- urlParams() {
- const {
- authorUsername,
- labelName,
- assigneeUsername,
- search,
- milestoneTitle,
- types,
- weight,
- } = this.filterParams;
- let notParams = {};
-
- if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
- notParams = pickBy(
- {
- 'not[label_name][]': this.filterParams.not.labelName,
- 'not[author_username]': this.filterParams.not.authorUsername,
- 'not[assignee_username]': this.filterParams.not.assigneeUsername,
- 'not[types]': this.filterParams.not.types,
- 'not[milestone_title]': this.filterParams.not.milestoneTitle,
- 'not[weight]': this.filterParams.not.weight,
- },
- undefined,
- );
- }
-
- return {
- ...notParams,
- author_username: authorUsername,
- 'label_name[]': labelName,
- assignee_username: assigneeUsername,
- milestone_title: milestoneTitle,
- search,
- types,
- weight,
- };
- },
- },
- methods: {
- ...mapActions(['performSearch']),
- handleFilter(filters) {
- this.filterParams = this.getFilterParams(filters);
-
- updateHistory({
- url: setUrlParams(this.urlParams, window.location.href, true, false, true),
- title: document.title,
- replace: true,
- });
-
- this.performSearch();
- },
getFilteredSearchValue() {
const {
authorUsername,
@@ -86,6 +41,8 @@ export default {
milestoneTitle,
types,
weight,
+ epicId,
+ myReactionEmoji,
} = this.filterParams;
const filteredSearchValue = [];
@@ -133,6 +90,20 @@ export default {
});
}
+ if (myReactionEmoji) {
+ filteredSearchValue.push({
+ type: 'my_reaction_emoji',
+ value: { data: myReactionEmoji, operator: '=' },
+ });
+ }
+
+ if (epicId) {
+ filteredSearchValue.push({
+ type: 'epic_id',
+ value: { data: epicId, operator: '=' },
+ });
+ }
+
if (this.filterParams['not[authorUsername]']) {
filteredSearchValue.push({
type: 'author_username',
@@ -177,12 +148,89 @@ export default {
});
}
+ if (this.filterParams['not[epicId]']) {
+ filteredSearchValue.push({
+ type: 'epic_id',
+ value: { data: this.filterParams['not[epicId]'], operator: '!=' },
+ });
+ }
+
+ if (this.filterParams['not[myReactionEmoji]']) {
+ filteredSearchValue.push({
+ type: 'my_reaction_emoji',
+ value: { data: this.filterParams['not[myReactionEmoji]'], operator: '!=' },
+ });
+ }
+
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
+ urlParams() {
+ const {
+ authorUsername,
+ labelName,
+ assigneeUsername,
+ search,
+ milestoneTitle,
+ types,
+ weight,
+ epicId,
+ myReactionEmoji,
+ } = this.filterParams;
+
+ let notParams = {};
+
+ if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
+ notParams = pickBy(
+ {
+ 'not[label_name][]': this.filterParams.not.labelName,
+ 'not[author_username]': this.filterParams.not.authorUsername,
+ 'not[assignee_username]': this.filterParams.not.assigneeUsername,
+ 'not[types]': this.filterParams.not.types,
+ 'not[milestone_title]': this.filterParams.not.milestoneTitle,
+ 'not[weight]': this.filterParams.not.weight,
+ 'not[epic_id]': this.filterParams.not.epicId,
+ 'not[my_reaction_emoji]': this.filterParams.not.myReactionEmoji,
+ },
+ undefined,
+ );
+ }
+
+ return {
+ ...notParams,
+ author_username: authorUsername,
+ 'label_name[]': labelName,
+ assignee_username: assigneeUsername,
+ milestone_title: milestoneTitle,
+ search,
+ types,
+ weight,
+ epic_id: getIdFromGraphQLId(epicId),
+ my_reaction_emoji: myReactionEmoji,
+ };
+ },
+ },
+ created() {
+ if (!isEmpty(this.eeFilters)) {
+ this.filterParams = this.eeFilters;
+ }
+ },
+ methods: {
+ ...mapActions(['performSearch']),
+ handleFilter(filters) {
+ this.filterParams = this.getFilterParams(filters);
+
+ updateHistory({
+ url: setUrlParams(this.urlParams, window.location.href, true, false, true),
+ title: document.title,
+ replace: true,
+ });
+
+ this.performSearch();
+ },
getFilterParams(filters = []) {
const notFilters = filters.filter((item) => item.value.operator === '!=');
const equalsFilters = filters.filter(
@@ -216,6 +264,12 @@ export default {
case 'weight':
filterParams.weight = filter.value.data;
break;
+ case 'epic_id':
+ filterParams.epicId = filter.value.data;
+ break;
+ case 'my_reaction_emoji':
+ filterParams.myReactionEmoji = filter.value.data;
+ break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
@@ -243,7 +297,7 @@ export default {
namespace=""
:tokens="tokens"
:search-input-placeholder="$options.i18n.search"
- :initial-filter-value="getFilteredSearchValue()"
+ :initial-filter-value="getFilteredSearchValue"
@onFilter="handleFilter"
/>
</template>
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index e939f0c0ebe..6ad57fd8985 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -2,10 +2,10 @@
import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
import { TYPE_USER, TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
-import { fullLabelId, fullBoardId } from '../boards_util';
+import { fullLabelId } from '../boards_util';
import { formType } from '../constants';
import createBoardMutation from '../graphql/board_create.mutation.graphql';
@@ -18,11 +18,11 @@ const boardDefaults = {
name: '',
labels: [],
milestone: {},
- iteration_id: undefined,
+ iteration: {},
assignee: {},
weight: null,
- hide_backlog_list: false,
- hide_closed_list: false,
+ hideBacklogList: false,
+ hideClosedList: false,
};
export default {
@@ -57,39 +57,16 @@ export default {
type: Boolean,
required: true,
},
- labelsPath: {
- type: String,
- required: true,
- },
- labelsWebUrl: {
- type: String,
- required: true,
- },
scopedIssueBoardFeatureEnabled: {
type: Boolean,
required: false,
default: false,
},
- projectId: {
- type: Number,
- required: false,
- default: 0,
- },
- groupId: {
- type: Number,
- required: false,
- default: 0,
- },
weights: {
type: Array,
required: false,
default: () => [],
},
- enableScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
currentBoard: {
type: Object,
required: true,
@@ -167,17 +144,16 @@ export default {
return destroyBoardMutation;
},
baseMutationVariables() {
- const { board } = this;
- const variables = {
- name: board.name,
- hideBacklogList: board.hide_backlog_list,
- hideClosedList: board.hide_closed_list,
- };
+ const {
+ board: { name, hideBacklogList, hideClosedList, id },
+ } = this;
- return board.id
+ const variables = { name, hideBacklogList, hideClosedList };
+
+ return id
? {
...variables,
- id: fullBoardId(board.id),
+ id,
}
: {
...variables,
@@ -191,11 +167,13 @@ export default {
assigneeId: this.board.assignee?.id
? convertToGraphQLId(TYPE_USER, this.board.assignee.id)
: null,
+ // Temporarily converting to milestone ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779
milestoneId: this.board.milestone?.id
- ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id)
+ ? convertToGraphQLId(TYPE_MILESTONE, getIdFromGraphQLId(this.board.milestone.id))
: null,
- iterationId: this.board.iteration_id
- ? convertToGraphQLId(TYPE_ITERATION, this.board.iteration_id)
+ // Temporarily converting to iteration ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779
+ iterationId: this.board.iteration?.id
+ ? convertToGraphQLId(TYPE_ITERATION, getIdFromGraphQLId(this.board.iteration.id))
: null,
};
},
@@ -249,7 +227,7 @@ export default {
await this.$apollo.mutate({
mutation: this.deleteMutation,
variables: {
- id: fullBoardId(this.board.id),
+ id: this.board.id,
},
});
},
@@ -285,19 +263,12 @@ export default {
}
},
setIteration(iterationId) {
- this.board.iteration_id = iterationId;
+ this.$set(this.board, 'iteration', {
+ id: iterationId,
+ });
},
setBoardLabels(labels) {
- labels.forEach((label) => {
- if (label.set && !this.board.labels.find((l) => l.id === label.id)) {
- this.board.labels.push({
- ...label,
- textColor: label.text_color,
- });
- } else if (!label.set) {
- this.board.labels = this.board.labels.filter((selected) => selected.id !== label.id);
- }
- });
+ this.board.labels = labels;
},
setAssignee(assigneeId) {
this.$set(this.board, 'assignee', {
@@ -361,8 +332,8 @@ export default {
</div>
<board-configuration-options
- :hide-backlog-list.sync="board.hide_backlog_list"
- :hide-closed-list.sync="board.hide_closed_list"
+ :hide-backlog-list.sync="board.hideBacklogList"
+ :hide-closed-list.sync="board.hideClosedList"
:readonly="readonly"
/>
@@ -371,11 +342,6 @@ export default {
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
- :labels-path="labelsPath"
- :labels-web-url="labelsWebUrl"
- :enable-scoped-labels="enableScopedLabels"
- :project-id="projectId"
- :group-id="groupId"
:weights="weights"
@set-iteration="setIteration"
@set-board-labels="setBoardLabels"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index a8d71ab7a35..e985a368e64 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -15,6 +15,8 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
@@ -40,7 +42,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [Tracking.mixin()],
+ mixins: [Tracking.mixin(), glFeatureFlagMixin()],
inject: {
boardId: {
default: '',
@@ -86,6 +88,13 @@ export default {
listTitle() {
return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
},
+ listIterationPeriod() {
+ const iteration = this.list?.iteration;
+ return iteration ? this.getIterationPeriod(iteration) : '';
+ },
+ isIterationList() {
+ return this.listType === ListType.iteration;
+ },
showListHeaderButton() {
return !this.disabled && this.listType !== ListType.closed;
},
@@ -96,7 +105,10 @@ export default {
return this.listType === ListType.assignee && this.showListDetails;
},
showIterationListDetails() {
- return this.listType === ListType.iteration && this.showListDetails;
+ return this.isIterationList && this.showListDetails;
+ },
+ iterationCadencesAvailable() {
+ return this.isIterationList && this.glFeatures.iterationCadences;
},
showListDetails() {
return !this.list.collapsed || !this.isSwimlanesHeader;
@@ -208,6 +220,16 @@ export default {
updateListFunction() {
this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
},
+ /**
+ * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619
+ * This method also exists as a utility function in ee/../iterations/utils.js
+ * Remove the duplication when the EE code is separated from this compoment.
+ */
+ getIterationPeriod({ startDate, dueDate }) {
+ const start = formatDate(startDate, 'mmm d, yyyy', true);
+ const due = formatDate(dueDate, 'mmm d, yyyy', true);
+ return `${start} - ${due}`;
+ },
},
};
</script>
@@ -307,6 +329,13 @@ export default {
class="board-title-main-text gl-text-truncate"
>
{{ listTitle }}
+ <span
+ v-if="iterationCadencesAvailable"
+ class="gl-display-inline-block gl-text-gray-400"
+ data-testid="board-list-iteration-period"
+ >
+ {{ listIterationPeriod }}</span
+ >
</span>
<span
v-if="listType === 'assignee'"
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 98027917221..71facba1378 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -9,17 +9,20 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { throttle } from 'lodash';
-import { mapGetters, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
+import { s__ } from '~/locale';
import eventHub from '../eventhub';
-import groupQuery from '../graphql/group_boards.query.graphql';
-import projectQuery from '../graphql/project_boards.query.graphql';
+import groupBoardsQuery from '../graphql/group_boards.query.graphql';
+import projectBoardsQuery from '../graphql/project_boards.query.graphql';
+import groupBoardQuery from '../graphql/group_board.query.graphql';
+import projectBoardQuery from '../graphql/project_board.query.graphql';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
@@ -39,10 +42,6 @@ export default {
},
inject: ['fullPath', 'recentBoardsEndpoint'],
props: {
- currentBoard: {
- type: Object,
- required: true,
- },
throttleDuration: {
type: Number,
default: 200,
@@ -64,22 +63,6 @@ export default {
type: Boolean,
required: true,
},
- labelsPath: {
- type: String,
- required: true,
- },
- labelsWebUrl: {
- type: String,
- required: true,
- },
- projectId: {
- type: Number,
- required: true,
- },
- groupId: {
- type: Number,
- required: true,
- },
scopedIssueBoardFeatureEnabled: {
type: Boolean,
required: true,
@@ -88,11 +71,6 @@ export default {
type: Array,
required: true,
},
- enabledScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
@@ -107,14 +85,47 @@ export default {
maxPosition: 0,
filterTerm: '',
currentPage: '',
+ board: {},
};
},
+ apollo: {
+ board: {
+ query() {
+ return this.currentBoardQuery;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ boardId: this.fullBoardId,
+ };
+ },
+ update(data) {
+ const board = data.workspace?.board;
+ return {
+ ...board,
+ labels: board?.labels?.nodes,
+ };
+ },
+ error() {
+ this.setError({ message: this.$options.i18n.errorFetchingBoard });
+ },
+ },
+ },
computed: {
- ...mapState(['boardType']),
- ...mapGetters(['isGroupBoard']),
+ ...mapState(['boardType', 'fullBoardId']),
+ ...mapGetters(['isGroupBoard', 'isProjectBoard']),
parentType() {
return this.boardType;
},
+ currentBoardQueryCE() {
+ return this.isGroupBoard ? groupBoardQuery : projectBoardQuery;
+ },
+ currentBoardQuery() {
+ return this.currentBoardQueryCE;
+ },
+ isBoardLoading() {
+ return this.$apollo.queries.board.loading;
+ },
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
},
@@ -123,9 +134,6 @@ export default {
board.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
},
- board() {
- return this.currentBoard;
- },
showCreate() {
return this.multipleIssueBoardsAvailable;
},
@@ -158,6 +166,7 @@ export default {
eventHub.$off('showBoardModal', this.showPage);
},
methods: {
+ ...mapActions(['setError']),
showPage(page) {
this.currentPage = page;
},
@@ -174,7 +183,7 @@ export default {
}));
},
boardQuery() {
- return this.isGroupBoard ? groupQuery : projectQuery;
+ return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
},
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
@@ -250,6 +259,9 @@ export default {
this.hasScrollFade = this.isScrolledUp();
},
},
+ i18n: {
+ errorFetchingBoard: s__('Board|An error occurred while fetching the board, please try again.'),
+ },
};
</script>
@@ -260,6 +272,7 @@ export default {
data-qa-selector="boards_dropdown"
toggle-class="dropdown-menu-toggle js-dropdown-toggle"
menu-class="flex-column dropdown-extended-height"
+ :loading="isBoardLoading"
:text="board.name"
@show="loadBoards"
>
@@ -354,15 +367,10 @@ export default {
<board-form
v-if="currentPage"
- :labels-path="labelsPath"
- :labels-web-url="labelsWebUrl"
- :project-id="projectId"
- :group-id="groupId"
:can-admin-board="canAdminBoard"
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights"
- :enable-scoped-labels="enabledScopedLabels"
- :current-board="currentBoard"
+ :current-board="board"
:current-page="currentPage"
@cancel="cancel"
/>
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 b6c5ef955c6..bdb9c2be836 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -1,13 +1,20 @@
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { mapActions } from 'vuex';
-import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
+import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue';
+import { BoardType } from '~/boards/constants';
+import axios from '~/lib/utils/axios_utils';
import issueBoardFilters from '~/boards/issue_board_filters';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
-import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ DEFAULT_MILESTONES_GRAPHQL,
+ TOKEN_TITLE_MY_REACTION,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
@@ -19,6 +26,7 @@ export default {
},
i18n: {
search: __('Search'),
+ epic: __('Epic'),
label: __('Label'),
author: __('Author'),
assignee: __('Assignee'),
@@ -31,6 +39,7 @@ export default {
isNot: __('is not'),
},
components: { BoardFilteredSearch },
+ inject: ['isSignedIn'],
props: {
fullPath: {
type: String,
@@ -42,7 +51,15 @@ export default {
},
},
computed: {
- tokens() {
+ isGroupBoard() {
+ return this.boardType === BoardType.group;
+ },
+ epicsGroupPath() {
+ return this.isGroupBoard
+ ? this.fullPath
+ : this.fullPath.slice(0, this.fullPath.lastIndexOf('/'));
+ },
+ tokensCE() {
const {
label,
is,
@@ -103,6 +120,32 @@ export default {
symbol: '~',
fetchLabels,
},
+ ...(this.isSignedIn
+ ? [
+ {
+ type: 'my_reaction_emoji',
+ title: TOKEN_TITLE_MY_REACTION,
+ icon: 'thumb-up',
+ token: EmojiToken,
+ unique: true,
+ fetchEmojis: (search = '') => {
+ // TODO: Switch to GraphQL query when backend is ready: https://gitlab.com/gitlab-org/gitlab/-/issues/339694
+ return axios
+ .get(`${gon.relative_url_root || ''}/-/autocomplete/award_emojis`)
+ .then(({ data }) => {
+ if (search) {
+ return {
+ data: fuzzaldrinPlus.filter(data, search, {
+ key: ['name'],
+ }),
+ };
+ }
+ return { data };
+ });
+ },
+ },
+ ]
+ : []),
{
type: 'milestone_title',
title: milestone,
@@ -117,7 +160,6 @@ export default {
icon: 'issues',
title: type,
type: 'types',
- operators: [{ value: '=', description: is }],
token: GlFilteredSearchToken,
unique: true,
options: [
@@ -134,6 +176,9 @@ export default {
},
];
},
+ tokens() {
+ return this.tokensCE;
+ },
},
methods: {
...mapActions(['fetchMilestones']),
diff --git a/app/assets/javascripts/boards/components/new_board_button.vue b/app/assets/javascripts/boards/components/new_board_button.vue
new file mode 100644
index 00000000000..f7914c636cc
--- /dev/null
+++ b/app/assets/javascripts/boards/components/new_board_button.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { formType } from '~/boards/constants';
+import eventHub from '~/boards/eventhub';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
+
+export default {
+ components: {
+ GlButton,
+ GitlabExperiment,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['multipleIssueBoardsAvailable', 'canAdminBoard'],
+ computed: {
+ canShowCreateButton() {
+ return this.canAdminBoard && this.multipleIssueBoardsAvailable;
+ },
+ createButtonText() {
+ return s__('Boards|New board');
+ },
+ },
+ methods: {
+ showDialog() {
+ this.track('click_button', { label: 'create_board' });
+ eventHub.$emit('showBoardModal', formType.new);
+ },
+ },
+};
+</script>
+
+<template>
+ <gitlab-experiment name="prominent_create_board_btn">
+ <template #control> </template>
+ <template #candidate>
+ <div v-if="canShowCreateButton" class="gl-ml-1 gl-mr-3 gl-display-flex gl-align-items-center">
+ <gl-button data-qa-selector="new_board_button" @click.prevent="showDialog">
+ {{ createButtonText }}
+ </gl-button>
+ </div>
+ </template>
+ </gitlab-experiment>
+</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
index e74463825c5..ec53947fd5f 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
@@ -91,9 +91,7 @@ export default {
try {
const addLabelIds = payload.filter((label) => label.set).map((label) => label.id);
- const removeLabelIds = this.selectedLabels
- .filter((label) => !payload.find((selected) => selected.id === label.id))
- .map((label) => label.id);
+ const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id);
const input = {
addLabelIds,
@@ -164,7 +162,7 @@ export default {
:labels-list-title="__('Select label')"
:dropdown-button-text="__('Choose labels')"
:is-editing="edit"
- variant="embedded"
+ variant="sidebar"
class="gl-display-block labels gl-w-full"
@updateSelectedLabels="setLabels"
>
diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js
index d8d16184936..64938cb42ed 100644
--- a/app/assets/javascripts/boards/graphql.js
+++ b/app/assets/javascripts/boards/graphql.js
@@ -17,6 +17,5 @@ export const gqlClient = createDefaultClient(
fragmentMatcher,
},
- assumeImmutableResults: true,
},
);
diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
index 3eb23f62940..0e1d11727cf 100644
--- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
@@ -1,6 +1,6 @@
#import "./board_list.fragment.graphql"
-mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) {
+mutation createBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) {
boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) {
list {
...BoardListFragment
diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
index 734867c77e9..47e87907d76 100644
--- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
@@ -1,6 +1,6 @@
#import "ee_else_ce/boards/graphql/board_list.fragment.graphql"
-query ListIssues(
+query BoardLists(
$fullPath: ID!
$boardId: ID!
$filters: BoardIssueInput
diff --git a/app/assets/javascripts/boards/graphql/board_scope.fragment.graphql b/app/assets/javascripts/boards/graphql/board_scope.fragment.graphql
new file mode 100644
index 00000000000..57f51822d91
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_scope.fragment.graphql
@@ -0,0 +1,6 @@
+fragment BoardScopeFragment on Board {
+ id
+ name
+ hideBacklogList
+ hideClosedList
+}
diff --git a/app/assets/javascripts/boards/graphql/group_board.query.graphql b/app/assets/javascripts/boards/graphql/group_board.query.graphql
new file mode 100644
index 00000000000..77c8e0378f0
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/group_board.query.graphql
@@ -0,0 +1,9 @@
+#import "ee_else_ce/boards/graphql/board_scope.fragment.graphql"
+
+query GroupBoard($fullPath: ID!, $boardId: ID!) {
+ workspace: group(fullPath: $fullPath) {
+ board(id: $boardId) {
+ ...BoardScopeFragment
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql b/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql
deleted file mode 100644
index 1c382c4747b..00000000000
--- a/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-query GroupBoardIterations($fullPath: ID!, $title: String) {
- group(fullPath: $fullPath) {
- iterations(includeAncestors: true, title: $title) {
- nodes {
- id
- title
- }
- }
- }
-}
diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
index 3218c06357c..c5732bbaff3 100644
--- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
@@ -1,6 +1,6 @@
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-query getGroupProjects($fullPath: ID!, $search: String, $after: String) {
+query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) {
group(fullPath: $fullPath) {
projects(search: $search, after: $after, first: 100, includeSubgroups: true) {
nodes {
diff --git a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
index 3c574fd8c87..570731ecac6 100644
--- a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
@@ -1,6 +1,6 @@
#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
-mutation IssueMoveList(
+mutation issueMoveList(
$projectPath: ID!
$iid: String!
$boardId: ID!
diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
index 787dd77b901..9f93bc6d5bf 100644
--- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
@@ -1,6 +1,6 @@
#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
-query ListIssues(
+query BoardListEE(
$fullPath: ID!
$boardId: ID!
$id: ID
diff --git a/app/assets/javascripts/boards/graphql/project_board.query.graphql b/app/assets/javascripts/boards/graphql/project_board.query.graphql
new file mode 100644
index 00000000000..6e4cd6bed57
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/project_board.query.graphql
@@ -0,0 +1,9 @@
+#import "ee_else_ce/boards/graphql/board_scope.fragment.graphql"
+
+query ProjectBoard($fullPath: ID!, $boardId: ID!) {
+ workspace: project(fullPath: $fullPath) {
+ board(id: $boardId) {
+ ...BoardScopeFragment
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql b/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql
deleted file mode 100644
index 078151a275a..00000000000
--- a/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-query ProjectBoardIterations($fullPath: ID!, $title: String) {
- project(fullPath: $fullPath) {
- iterations(includeAncestors: true, title: $title) {
- nodes {
- id
- title
- }
- }
- }
-}
diff --git a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
index 724b7f5a34c..61c9ddded9b 100644
--- a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
@@ -1,4 +1,4 @@
-query projectMilestones(
+query boardProjectMilestones(
$fullPath: ID!
$state: MilestoneStateEnum
$includeAncestors: Boolean
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index b6b1094fb3a..6fa8dd63245 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -13,9 +13,10 @@ import FilteredSearchBoards from '~/boards/filtered_search_boards';
import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores';
import toggleFocusMode from '~/boards/toggle_focus';
-import { NavigationType, parseBoolean } from '~/lib/utils/common_utils';
+import { NavigationType, isLoggedIn, parseBoolean } from '~/lib/utils/common_utils';
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';
@@ -109,7 +110,7 @@ export default () => {
});
if (gon?.features?.issueBoardsFilteredSearch) {
- initBoardsFilteredSearch(apolloProvider);
+ initBoardsFilteredSearch(apolloProvider, isLoggedIn());
}
mountBoardApp($boardApp);
@@ -130,6 +131,7 @@ export default () => {
}
boardConfigToggle();
+ initNewBoard();
toggleFocusMode();
toggleLabels();
@@ -142,5 +144,7 @@ export default () => {
fullPath: $boardApp.dataset.fullPath,
rootPath: $boardApp.dataset.boardsEndpoint,
recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
+ 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
index 7732091ef34..1ea74d5685c 100644
--- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
+++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
@@ -1,10 +1,10 @@
import Vue from 'vue';
-import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.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) => {
+export default (apolloProvider, isSignedIn) => {
const el = document.getElementById('js-issue-board-filtered-search');
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
@@ -20,6 +20,7 @@ export default (apolloProvider) => {
el,
provide: {
initialFilterParams,
+ isSignedIn,
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider,
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index a3a8ad06c43..ed32579a9c3 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -1,23 +1,32 @@
+import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
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';
+import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
Vue.use(VueApollo);
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+ introspectionQueryResultData,
+});
+
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
- assumeImmutableResults: true,
+ cacheConfig: {
+ fragmentMatcher,
+ },
},
),
});
export default (params = {}) => {
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
+ const { dataset } = boardsSwitcherElement;
return new Vue({
el: boardsSwitcherElement,
components: {
@@ -29,18 +38,16 @@ export default (params = {}) => {
fullPath: params.fullPath,
rootPath: params.rootPath,
recentBoardsEndpoint: params.recentBoardsEndpoint,
+ allowScopedLabels: params.allowScopedLabels,
+ labelsManagePath: params.labelsManagePath,
+ allowLabelCreate: parseBoolean(dataset.canAdminBoard),
},
data() {
- const { dataset } = boardsSwitcherElement;
-
const boardsSelectorProps = {
...dataset,
- currentBoard: JSON.parse(dataset.currentBoard),
hasMissingBoards: parseBoolean(dataset.hasMissingBoards),
canAdminBoard: parseBoolean(dataset.canAdminBoard),
multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable),
- projectId: dataset.projectId ? Number(dataset.projectId) : 0,
- groupId: Number(dataset.groupId),
scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled),
weights: JSON.parse(dataset.weights),
};
diff --git a/app/assets/javascripts/boards/new_board.js b/app/assets/javascripts/boards/new_board.js
new file mode 100644
index 00000000000..34f2fea79a9
--- /dev/null
+++ b/app/assets/javascripts/boards/new_board.js
@@ -0,0 +1,29 @@
+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/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index ca993e75cf9..3a96e535cf7 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -36,13 +36,11 @@ import {
} from '../boards_util';
import { gqlClient } from '../graphql';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
-import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
import issueCreateMutation from '../graphql/issue_create.mutation.graphql';
import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql';
import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
-import projectBoardIterationsQuery from '../graphql/project_board_iterations.query.graphql';
import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
import * as types from './mutation_types';
@@ -203,52 +201,6 @@ export default {
});
},
- fetchIterations({ state, commit }, title) {
- commit(types.RECEIVE_ITERATIONS_REQUEST);
-
- const { fullPath, boardType } = state;
-
- const variables = {
- fullPath,
- title,
- };
-
- let query;
- if (boardType === BoardType.project) {
- query = projectBoardIterationsQuery;
- }
- if (boardType === BoardType.group) {
- query = groupBoardIterationsQuery;
- }
-
- if (!query) {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- throw new Error('Unknown board type');
- }
-
- return gqlClient
- .query({
- query,
- variables,
- })
- .then(({ data }) => {
- const errors = data[boardType]?.errors;
- const iterations = data[boardType]?.iterations.nodes;
-
- if (errors?.[0]) {
- throw new Error(errors[0]);
- }
-
- commit(types.RECEIVE_ITERATIONS_SUCCESS, iterations);
-
- return iterations;
- })
- .catch((e) => {
- commit(types.RECEIVE_ITERATIONS_FAILURE);
- throw e;
- });
- },
-
fetchMilestones({ state, commit }, searchTerm) {
commit(types.RECEIVE_MILESTONES_REQUEST);
@@ -656,30 +608,45 @@ export default {
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
- commit(types.SET_LABELS_LOADING, true);
const { activeBoardItem } = getters;
- const { data } = await gqlClient.mutate({
- mutation: issueSetLabelsMutation,
- variables: {
- input: {
- iid: input.iid || String(activeBoardItem.iid),
- addLabelIds: input.addLabelIds ?? [],
- removeLabelIds: input.removeLabelIds ?? [],
- projectPath: input.projectPath,
+
+ if (!gon.features?.labelsWidget) {
+ const { data } = await gqlClient.mutate({
+ mutation: issueSetLabelsMutation,
+ variables: {
+ input: {
+ iid: input.iid || String(activeBoardItem.iid),
+ labelIds: input.labelsId ?? undefined,
+ addLabelIds: input.addLabelIds ?? [],
+ removeLabelIds: input.removeLabelIds ?? [],
+ projectPath: input.projectPath,
+ },
},
- },
- });
+ });
- commit(types.SET_LABELS_LOADING, false);
+ if (data.updateIssue?.errors?.length > 0) {
+ throw new Error(data.updateIssue.errors);
+ }
+
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: data.updateIssue?.issue?.id || activeBoardItem.id,
+ prop: 'labels',
+ value: data.updateIssue?.issue?.labels.nodes,
+ });
- if (data.updateIssue?.errors?.length > 0) {
- throw new Error(data.updateIssue.errors);
+ return;
}
+ let labels = input?.labels || [];
+ if (input.removeLabelIds) {
+ labels = activeBoardItem.labels.filter(
+ (label) => input.removeLabelIds[0] !== getIdFromGraphQLId(label.id),
+ );
+ }
commit(types.UPDATE_BOARD_ITEM_BY_ID, {
- itemId: data.updateIssue?.issue?.id || activeBoardItem.id,
+ itemId: input.id || activeBoardItem.id,
prop: 'labels',
- value: data.updateIssue.issue.labels.nodes,
+ value: labels,
});
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 26b785932bb..31b78014525 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -28,7 +28,6 @@ export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST';
export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID';
-export const SET_LABELS_LOADING = 'SET_LABELS_LOADING';
export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
export const RESET_ISSUES = 'RESET_ISSUES';
export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';
@@ -42,7 +41,3 @@ export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
export const SET_ERROR = 'SET_ERROR';
-
-export const RECEIVE_ITERATIONS_REQUEST = 'RECEIVE_ITERATIONS_REQUEST';
-export const RECEIVE_ITERATIONS_SUCCESS = 'RECEIVE_ITERATIONS_SUCCESS';
-export const RECEIVE_ITERATIONS_FAILURE = 'RECEIVE_ITERATIONS_FAILURE';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index d381c076c19..2a2ce7652e6 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -64,20 +64,6 @@ export default {
);
},
- [mutationTypes.RECEIVE_ITERATIONS_REQUEST](state) {
- state.iterationsLoading = true;
- },
-
- [mutationTypes.RECEIVE_ITERATIONS_SUCCESS](state, iterations) {
- state.iterations = iterations;
- state.iterationsLoading = false;
- },
-
- [mutationTypes.RECEIVE_ITERATIONS_FAILURE](state) {
- state.iterationsLoading = false;
- state.error = __('Failed to load iterations.');
- },
-
[mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) {
state.activeId = id;
state.sidebarType = sidebarType;
@@ -195,10 +181,6 @@ export default {
Vue.set(state.boardItems[itemId], prop, value);
},
- [mutationTypes.SET_LABELS_LOADING](state, isLoading) {
- state.isSettingLabels = isLoading;
- },
-
[mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
state.isSettingAssignees = isLoading;
},
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 2a6605e687b..80c51c966d2 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -12,7 +12,6 @@ export default () => ({
listsFlags: {},
boardItemsByListId: {},
backupItemsList: [],
- isSettingLabels: false,
isSettingAssignees: false,
pageInfoByListId: {},
boardItems: {},
diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js
index b8b8a0b2867..b9d3742974c 100644
--- a/app/assets/javascripts/breadcrumb.js
+++ b/app/assets/javascripts/breadcrumb.js
@@ -1,5 +1,4 @@
import $ from 'jquery';
-import { hide } from '~/tooltips';
export const addTooltipToEl = (el) => {
const textEl = el.querySelector('.js-breadcrumb-item-text');
@@ -19,16 +18,23 @@ export default () => {
.filter((el) => !el.classList.contains('dropdown'))
.map((el) => el.querySelector('a'))
.filter((el) => el);
- const $expander = $('.js-breadcrumbs-collapsed-expander');
+ const $expanderBtn = $('.js-breadcrumbs-collapsed-expander');
topLevelLinks.forEach((el) => addTooltipToEl(el));
- $expander.closest('.dropdown').on('show.bs.dropdown hide.bs.dropdown', (e) => {
- const $el = $('.js-breadcrumbs-collapsed-expander', e.currentTarget);
+ $expanderBtn.on('click', () => {
+ const detailItems = $('.breadcrumbs-detail-item');
+ const hiddenClass = 'gl-display-none!';
- $el.toggleClass('open');
+ $.each(detailItems, (_key, item) => {
+ $(item).toggleClass(hiddenClass);
+ });
- hide($el);
+ // remove the ellipsis
+ $('li.expander').remove();
+
+ // set focus on first breadcrumb item
+ $('.breadcrumb-item-text').first().focus();
});
}
};
diff --git a/app/assets/javascripts/chronic_duration.js b/app/assets/javascripts/chronic_duration.js
new file mode 100644
index 00000000000..1073d736b06
--- /dev/null
+++ b/app/assets/javascripts/chronic_duration.js
@@ -0,0 +1,417 @@
+/*
+ * NOTE:
+ * Changes to this file should be kept in sync with
+ * https://gitlab.com/gitlab-org/gitlab-chronic-duration/-/blob/master/lib/gitlab_chronic_duration.rb.
+ */
+
+/*
+ * This code is based on code from
+ * https://gitlab.com/gitlab-org/gitlab-chronic-duration and is
+ * distributed under the following license:
+ *
+ * MIT License
+ *
+ * Copyright (c) Henry Poydar
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+export class DurationParseError extends Error {}
+
+// On average, there's a little over 4 weeks in month.
+const FULL_WEEKS_PER_MONTH = 4;
+
+const HOURS_PER_DAY = 24;
+const DAYS_PER_MONTH = 30;
+
+const FLOAT_MATCHER = /[0-9]*\.?[0-9]+/g;
+const DURATION_UNITS_LIST = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years'];
+
+const MAPPINGS = {
+ seconds: 'seconds',
+ second: 'seconds',
+ secs: 'seconds',
+ sec: 'seconds',
+ s: 'seconds',
+ minutes: 'minutes',
+ minute: 'minutes',
+ mins: 'minutes',
+ min: 'minutes',
+ m: 'minutes',
+ hours: 'hours',
+ hour: 'hours',
+ hrs: 'hours',
+ hr: 'hours',
+ h: 'hours',
+ days: 'days',
+ day: 'days',
+ dy: 'days',
+ d: 'days',
+ weeks: 'weeks',
+ week: 'weeks',
+ wks: 'weeks',
+ wk: 'weeks',
+ w: 'weeks',
+ months: 'months',
+ mo: 'months',
+ mos: 'months',
+ month: 'months',
+ years: 'years',
+ year: 'years',
+ yrs: 'years',
+ yr: 'years',
+ y: 'years',
+};
+
+const JOIN_WORDS = ['and', 'with', 'plus'];
+
+function convertToNumber(string) {
+ const f = parseFloat(string);
+ return f % 1 > 0 ? f : parseInt(string, 10);
+}
+
+function durationUnitsSecondsMultiplier(unit, opts) {
+ if (!DURATION_UNITS_LIST.includes(unit)) {
+ return 0;
+ }
+
+ const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY;
+ const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH;
+ const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH);
+
+ switch (unit) {
+ case 'years':
+ return 31557600;
+ case 'months':
+ return 3600 * hoursPerDay * daysPerMonth;
+ case 'weeks':
+ return 3600 * hoursPerDay * daysPerWeek;
+ case 'days':
+ return 3600 * hoursPerDay;
+ case 'hours':
+ return 3600;
+ case 'minutes':
+ return 60;
+ case 'seconds':
+ return 1;
+ default:
+ return 0;
+ }
+}
+
+function calculateFromWords(string, opts) {
+ let val = 0;
+ const words = string.split(' ');
+ words.forEach((v, k) => {
+ if (v === '') {
+ return;
+ }
+ if (v.search(FLOAT_MATCHER) >= 0) {
+ val +=
+ convertToNumber(v) *
+ durationUnitsSecondsMultiplier(
+ words[parseInt(k, 10) + 1] || opts.defaultUnit || 'seconds',
+ opts,
+ );
+ }
+ });
+ return val;
+}
+
+// Parse 3:41:59 and return 3 hours 41 minutes 59 seconds
+function filterByType(string) {
+ const chronoUnitsList = DURATION_UNITS_LIST.filter((v) => v !== 'weeks');
+ if (
+ string
+ .replace(/ +/g, '')
+ .search(RegExp(`${FLOAT_MATCHER.source}(:${FLOAT_MATCHER.source})+`, 'g')) >= 0
+ ) {
+ const res = [];
+ string
+ .replace(/ +/g, '')
+ .split(':')
+ .reverse()
+ .forEach((v, k) => {
+ if (!chronoUnitsList[k]) {
+ return;
+ }
+ res.push(`${v} ${chronoUnitsList[k]}`);
+ });
+ return res.reverse().join(' ');
+ }
+ return string;
+}
+
+// Get rid of unknown words and map found
+// words to defined time units
+function filterThroughWhiteList(string, opts) {
+ const res = [];
+ string.split(' ').forEach((word) => {
+ if (word === '') {
+ return;
+ }
+ if (word.search(FLOAT_MATCHER) >= 0) {
+ res.push(word.trim());
+ return;
+ }
+ const strippedWord = word.trim().replace(/^,/g, '').replace(/,$/g, '');
+ if (MAPPINGS[strippedWord] !== undefined) {
+ res.push(MAPPINGS[strippedWord]);
+ } else if (!JOIN_WORDS.includes(strippedWord) && opts.raiseExceptions) {
+ throw new DurationParseError(
+ `An invalid word ${JSON.stringify(word)} was used in the string to be parsed.`,
+ );
+ }
+ });
+ // add '1' at front if string starts with something recognizable but not with a number, like 'day' or 'minute 30sec'
+ if (res.length > 0 && MAPPINGS[res[0]]) {
+ res.splice(0, 0, 1);
+ }
+ return res.join(' ');
+}
+
+function cleanup(string, opts) {
+ let res = string.toLowerCase();
+ /*
+ * TODO The Ruby implementation of this algorithm uses the Numerizer module,
+ * which converts strings like "forty two" to "42", but there is no
+ * JavaScript equivalent of Numerizer. Skip it for now until Numerizer is
+ * ported to JavaScript.
+ */
+ res = filterByType(res);
+ res = res
+ .replace(FLOAT_MATCHER, (n) => ` ${n} `)
+ .replace(/ +/g, ' ')
+ .trim();
+ return filterThroughWhiteList(res, opts);
+}
+
+function humanizeTimeUnit(number, unit, pluralize, keepZero) {
+ if (number === '0' && !keepZero) {
+ return null;
+ }
+ let res = number + unit;
+ // A poor man's pluralizer
+ if (number !== '1' && pluralize) {
+ res += 's';
+ }
+ return res;
+}
+
+// Given a string representation of elapsed time,
+// return an integer (or float, if fractions of a
+// second are input)
+export function parseChronicDuration(string, opts = {}) {
+ const result = calculateFromWords(cleanup(string, opts), opts);
+ return !opts.keepZero && result === 0 ? null : result;
+}
+
+// Given an integer and an optional format,
+// returns a formatted string representing elapsed time
+export function outputChronicDuration(seconds, opts = {}) {
+ const units = {
+ years: 0,
+ months: 0,
+ weeks: 0,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds,
+ };
+
+ const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY;
+ const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH;
+ const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH);
+
+ const decimalPlaces =
+ seconds % 1 !== 0 ? seconds.toString().split('.').reverse()[0].length : null;
+
+ const minute = 60;
+ const hour = 60 * minute;
+ const day = hoursPerDay * hour;
+ const month = daysPerMonth * day;
+ const year = 31557600;
+
+ if (units.seconds >= 31557600 && units.seconds % year < units.seconds % month) {
+ units.years = Math.trunc(units.seconds / year);
+ units.months = Math.trunc((units.seconds % year) / month);
+ units.days = Math.trunc(((units.seconds % year) % month) / day);
+ units.hours = Math.trunc((((units.seconds % year) % month) % day) / hour);
+ units.minutes = Math.trunc(((((units.seconds % year) % month) % day) % hour) / minute);
+ units.seconds = Math.trunc(((((units.seconds % year) % month) % day) % hour) % minute);
+ } else if (seconds >= 60) {
+ units.minutes = Math.trunc(seconds / 60);
+ units.seconds %= 60;
+ if (units.minutes >= 60) {
+ units.hours = Math.trunc(units.minutes / 60);
+ units.minutes = Math.trunc(units.minutes % 60);
+ if (!opts.limitToHours) {
+ if (units.hours >= hoursPerDay) {
+ units.days = Math.trunc(units.hours / hoursPerDay);
+ units.hours = Math.trunc(units.hours % hoursPerDay);
+ if (opts.weeks) {
+ if (units.days >= daysPerWeek) {
+ units.weeks = Math.trunc(units.days / daysPerWeek);
+ units.days = Math.trunc(units.days % daysPerWeek);
+ if (units.weeks >= FULL_WEEKS_PER_MONTH) {
+ units.months = Math.trunc(units.weeks / FULL_WEEKS_PER_MONTH);
+ units.weeks = Math.trunc(units.weeks % FULL_WEEKS_PER_MONTH);
+ }
+ }
+ } else if (units.days >= daysPerMonth) {
+ units.months = Math.trunc(units.days / daysPerMonth);
+ units.days = Math.trunc(units.days % daysPerMonth);
+ }
+ }
+ }
+ }
+ }
+
+ let joiner = opts.joiner || ' ';
+ let process = null;
+
+ let dividers;
+ switch (opts.format) {
+ case 'micro':
+ dividers = {
+ years: 'y',
+ months: 'mo',
+ weeks: 'w',
+ days: 'd',
+ hours: 'h',
+ minutes: 'm',
+ seconds: 's',
+ };
+ joiner = '';
+ break;
+ case 'short':
+ dividers = {
+ years: 'y',
+ months: 'mo',
+ weeks: 'w',
+ days: 'd',
+ hours: 'h',
+ minutes: 'm',
+ seconds: 's',
+ };
+ break;
+ case 'long':
+ dividers = {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ years: ' year',
+ months: ' month',
+ weeks: ' week',
+ days: ' day',
+ hours: ' hour',
+ minutes: ' minute',
+ seconds: ' second',
+ /* eslint-enable @gitlab/require-i18n-strings */
+ pluralize: true,
+ };
+ break;
+ case 'chrono':
+ dividers = {
+ years: ':',
+ months: ':',
+ weeks: ':',
+ days: ':',
+ hours: ':',
+ minutes: ':',
+ seconds: ':',
+ keepZero: true,
+ };
+ process = (str) => {
+ // Pad zeros
+ // Get rid of lead off times if they are zero
+ // Get rid of lead off zero
+ // Get rid of trailing:
+ const divider = ':';
+ const processed = [];
+ str.split(divider).forEach((n) => {
+ if (n === '') {
+ return;
+ }
+ // add zeros only if n is an integer
+ if (n.search('\\.') >= 0) {
+ processed.push(
+ parseFloat(n)
+ .toFixed(decimalPlaces)
+ .padStart(3 + decimalPlaces, '0'),
+ );
+ } else {
+ processed.push(n.padStart(2, '0'));
+ }
+ });
+ return processed
+ .join(divider)
+ .replace(/^(00:)+/g, '')
+ .replace(/^0/g, '')
+ .replace(/:$/g, '');
+ };
+ joiner = '';
+ break;
+ default:
+ dividers = {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ years: ' yr',
+ months: ' mo',
+ weeks: ' wk',
+ days: ' day',
+ hours: ' hr',
+ minutes: ' min',
+ seconds: ' sec',
+ /* eslint-enable @gitlab/require-i18n-strings */
+ pluralize: true,
+ };
+ break;
+ }
+
+ let result = [];
+ ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'].forEach((t) => {
+ if (t === 'weeks' && !opts.weeks) {
+ return;
+ }
+ let num = units[t];
+ if (t === 'seconds' && num % 0 !== 0) {
+ num = num.toFixed(decimalPlaces);
+ } else {
+ num = num.toString();
+ }
+ const keepZero = !dividers.keepZero && t === 'seconds' ? opts.keepZero : dividers.keepZero;
+ const humanized = humanizeTimeUnit(num, dividers[t], dividers.pluralize, keepZero);
+ if (humanized !== null) {
+ result.push(humanized);
+ }
+ });
+
+ if (opts.units) {
+ result = result.slice(0, opts.units);
+ }
+
+ result = result.join(joiner);
+
+ if (process) {
+ result = process(result);
+ }
+
+ return result.length === 0 ? null : result;
+}
diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js
index f97590ec5db..274aab45deb 100644
--- a/app/assets/javascripts/ci_lint/index.js
+++ b/app/assets/javascripts/ci_lint/index.js
@@ -8,9 +8,7 @@ import CiLint from './components/ci_lint.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(resolvers, {
- assumeImmutableResults: true,
- }),
+ defaultClient: createDefaultClient(resolvers),
});
export default (containerId = '#js-ci-lint') => {
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index 5c672d288c5..afbba9d1f7c 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -128,6 +128,7 @@ export default {
</p>
<gl-tabs>
+ <slot name="ee-security-tab"></slot>
<gl-tab>
<template #title>
<span data-testid="cluster-agent-token-count">
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
index bcb5b271203..426d8d83847 100644
--- a/app/assets/javascripts/clusters/agents/index.js
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import AgentShowPage from './components/show.vue';
+import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue';
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
index 0da7be4040f..98db620e3ab 100644
--- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -1,8 +1,7 @@
<script>
-import { GlModal, GlButton, GlFormInput } from '@gitlab/ui';
-import { escape } from 'lodash';
+import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
-import { s__, sprintf } from '~/locale';
+import { s__ } from '~/locale';
import SplitButton from '~/vue_shared/components/split_button.vue';
const splitButtonActionItems = [
@@ -29,6 +28,7 @@ export default {
GlModal,
GlButton,
GlFormInput,
+ GlSprintf,
},
props: {
clusterPath: {
@@ -67,17 +67,11 @@ export default {
: s__('ClusterIntegration|You are about to remove your cluster integration.');
},
confirmationTextLabel() {
- return sprintf(
- this.confirmCleanup
- ? s__(
- 'ClusterIntegration|To remove your integration and resources, type %{clusterName} to confirm:',
- )
- : s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:'),
- {
- clusterName: `<code>${escape(this.clusterName)}</code>`,
- },
- false,
- );
+ return this.confirmCleanup
+ ? s__(
+ 'ClusterIntegration|To remove your integration and resources, type %{clusterName} to confirm:',
+ )
+ : s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:');
},
canSubmit() {
return this.enteredClusterName === this.clusterName;
@@ -140,7 +134,13 @@ export default {
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
</ul>
</div>
- <strong v-html="confirmationTextLabel /* eslint-disable-line vue/no-v-html */"></strong>
+ <strong>
+ <gl-sprintf :message="confirmationTextLabel">
+ <template #clusterName>
+ <code>{{ clusterName }}</code>
+ </template>
+ </gl-sprintf>
+ </strong>
<form ref="form" :action="clusterPath" method="post" class="gl-mb-5">
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
@@ -159,7 +159,7 @@ export default {
)
}}</span>
<template #modal-footer>
- <gl-button variant="secondary" @click="handleCancel">{{ s__('Cancel') }}</gl-button>
+ <gl-button variant="secondary" @click="handleCancel">{{ __('Cancel') }}</gl-button>
<template v-if="confirmCleanup">
<gl-button
:disabled="!canSubmit"
diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js
index 9b870134512..c78c93fe1ba 100644
--- a/app/assets/javascripts/clusters_list/clusters_util.js
+++ b/app/assets/javascripts/clusters_list/clusters_util.js
@@ -6,3 +6,7 @@ export function generateAgentRegistrationCommand(agentToken, kasAddress) {
--agent-version stable \\
--namespace gitlab-kubernetes-agent | kubectl apply -f -`;
}
+
+export function getAgentConfigPath(clusterAgentName) {
+ return `.gitlab/agents/${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 405339b3d36..af44a23b4b3 100644
--- a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
@@ -1,9 +1,16 @@
<script>
import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui';
-import { INSTALL_AGENT_MODAL_ID } from '../constants';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { INSTALL_AGENT_MODAL_ID, I18N_AGENTS_EMPTY_STATE } from '../constants';
export default {
+ i18n: I18N_AGENTS_EMPTY_STATE,
modalId: INSTALL_AGENT_MODAL_ID,
+ multipleClustersDocsUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'),
+ installDocsUrl: helpPagePath('administration/clusters/kas'),
+ getStartedDocsUrl: helpPagePath('user/clusters/agent/index', {
+ anchor: 'define-a-configuration-repository',
+ }),
components: {
GlButton,
GlEmptyState,
@@ -14,19 +21,17 @@ export default {
directives: {
GlModalDirective,
},
- inject: [
- 'emptyStateImage',
- 'projectPath',
- 'agentDocsUrl',
- 'installDocsUrl',
- 'getStartedDocsUrl',
- 'integrationDocsUrl',
- ],
+ inject: ['emptyStateImage', 'projectPath'],
props: {
hasConfigurations: {
type: Boolean,
required: true,
},
+ isChildComponent: {
+ default: false,
+ required: false,
+ type: Boolean,
+ },
},
computed: {
repositoryPath() {
@@ -37,22 +42,19 @@ export default {
</script>
<template>
- <gl-empty-state
- :svg-path="emptyStateImage"
- :title="s__('ClusterAgents|Integrate Kubernetes with a GitLab Agent')"
- class="empty-state--agent"
- >
+ <gl-empty-state :svg-path="emptyStateImage" title="" class="agents-empty-state">
<template #description>
- <p class="mw-460 gl-mx-auto">
- <gl-sprintf
- :message="
- s__(
- 'ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}',
- )
- "
- >
+ <p class="mw-460 gl-mx-auto gl-text-left">
+ {{ $options.i18n.introText }}
+ </p>
+ <p class="mw-460 gl-mx-auto gl-text-left">
+ <gl-sprintf :message="$options.i18n.multipleClustersText">
<template #link="{ content }">
- <gl-link :href="agentDocsUrl" target="_blank" data-testid="agent-docs-link">
+ <gl-link
+ :href="$options.multipleClustersDocsUrl"
+ target="_blank"
+ data-testid="multiple-clusters-docs-link"
+ >
{{ content }}
</gl-link>
</template>
@@ -60,19 +62,9 @@ export default {
</p>
<p class="mw-460 gl-mx-auto">
- <gl-sprintf
- :message="
- s__(
- 'ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link :href="installDocsUrl" target="_blank" data-testid="install-docs-link">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
+ <gl-link :href="$options.installDocsUrl" target="_blank" data-testid="install-docs-link">
+ {{ $options.i18n.learnMoreText }}
+ </gl-link>
</p>
<gl-alert
@@ -81,24 +73,20 @@ export default {
class="gl-mb-5 text-left"
:dismissible="false"
>
- {{
- s__(
- 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.',
- )
- }}
+ {{ $options.i18n.warningText }}
<template #actions>
<gl-button
category="primary"
variant="info"
- :href="getStartedDocsUrl"
+ :href="$options.getStartedDocsUrl"
target="_blank"
class="gl-ml-0!"
>
- {{ s__('ClusterAgents|Read more about getting started') }}
+ {{ $options.i18n.readMoreText }}
</gl-button>
<gl-button category="secondary" variant="info" :href="repositoryPath">
- {{ s__('ClusterAgents|Go to the repository') }}
+ {{ $options.i18n.repositoryButtonText }}
</gl-button>
</template>
</gl-alert>
@@ -106,13 +94,14 @@ export default {
<template #actions>
<gl-button
+ v-if="!isChildComponent"
v-gl-modal-directive="$options.modalId"
:disabled="!hasConfigurations"
data-testid="integration-primary-button"
category="primary"
- variant="success"
+ variant="confirm"
>
- {{ s__('ClusterAgents|Integrate with the GitLab Agent') }}
+ {{ $options.i18n.primaryButtonText }}
</gl-button>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index 487e512c06d..000730ac1ba 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -1,6 +1,5 @@
<script>
import {
- GlButton,
GlLink,
GlModalDirective,
GlTable,
@@ -12,11 +11,12 @@ import {
import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, TROUBLESHOOTING_LINK } from '../constants';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES } from '../constants';
+import { getAgentConfigPath } from '../clusters_util';
export default {
components: {
- GlButton,
GlLink,
GlTable,
GlIcon,
@@ -29,10 +29,12 @@ export default {
GlModalDirective,
},
mixins: [timeagoMixin],
- inject: ['integrationDocsUrl'],
INSTALL_AGENT_MODAL_ID,
AGENT_STATUSES,
- TROUBLESHOOTING_LINK,
+
+ troubleshooting_link: helpPagePath('user/clusters/agent/index', {
+ anchor: 'troubleshooting',
+ }),
props: {
agents: {
required: true,
@@ -41,112 +43,102 @@ export default {
},
computed: {
fields() {
+ const tdClass = 'gl-py-5!';
return [
{
key: 'name',
label: s__('ClusterAgents|Name'),
+ tdClass,
},
{
key: 'status',
label: s__('ClusterAgents|Connection status'),
+ tdClass,
},
{
key: 'lastContact',
label: s__('ClusterAgents|Last contact'),
+ tdClass,
},
{
key: 'configuration',
label: s__('ClusterAgents|Configuration'),
+ tdClass,
},
];
},
},
+ methods: {
+ getCellId(item) {
+ return `connection-status-${item.name}`;
+ },
+ getAgentConfigPath,
+ },
};
</script>
<template>
- <div>
- <div class="gl-display-block gl-text-right gl-my-3">
- <gl-button
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
- variant="confirm"
- category="primary"
- >{{ s__('ClusterAgents|Install a new GitLab Agent') }}
- </gl-button>
- </div>
+ <gl-table
+ :items="agents"
+ :fields="fields"
+ stacked="md"
+ head-variant="white"
+ thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100"
+ class="gl-mb-4!"
+ data-testid="cluster-agent-list-table"
+ >
+ <template #cell(name)="{ item }">
+ <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">
+ {{ item.name }}
+ </gl-link>
+ </template>
- <gl-table
- :items="agents"
- :fields="fields"
- stacked="md"
- head-variant="white"
- thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
- data-testid="cluster-agent-list-table"
- >
- <template #cell(name)="{ item }">
- <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">
- {{ item.name }}
- </gl-link>
- </template>
-
- <template #cell(status)="{ item }">
- <span
- :id="`connection-status-${item.name}`"
- class="gl-pr-5"
- data-testid="cluster-agent-connection-status"
- >
- <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
- <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span
- >{{ $options.AGENT_STATUSES[item.status].name }}
- </span>
- <gl-tooltip
- v-if="item.status === 'active'"
- :target="`connection-status-${item.name}`"
- placement="right"
- >
- <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
- ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
- </gl-sprintf>
- </gl-tooltip>
- <gl-popover
- v-else
- :target="`connection-status-${item.name}`"
- :title="$options.AGENT_STATUSES[item.status].tooltip.title"
- placement="right"
- container="viewport"
- >
- <p>
- <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body"
- ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf
- >
- </p>
- <p class="gl-mb-0">
- {{ s__('ClusterAgents|For more troubleshooting information go to') }}
- <gl-link :href="$options.TROUBLESHOOTING_LINK" target="_blank" class="gl-font-sm">
- {{ $options.TROUBLESHOOTING_LINK }}</gl-link
- >
- </p>
- </gl-popover>
- </template>
+ <template #cell(status)="{ item }">
+ <span :id="getCellId(item)" class="gl-md-pr-5" data-testid="cluster-agent-connection-status">
+ <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
+ <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span
+ >{{ $options.AGENT_STATUSES[item.status].name }}
+ </span>
+ <gl-tooltip v-if="item.status === 'active'" :target="getCellId(item)" placement="right">
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
+ </gl-sprintf>
+ </gl-tooltip>
+ <gl-popover
+ v-else
+ :target="getCellId(item)"
+ :title="$options.AGENT_STATUSES[item.status].tooltip.title"
+ placement="right"
+ container="viewport"
+ >
+ <p>
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf
+ >
+ </p>
+ <p class="gl-mb-0">
+ <gl-link :href="$options.troubleshooting_link" target="_blank" class="gl-font-sm">
+ {{ s__('ClusterAgents|Learn how to troubleshoot') }}</gl-link
+ >
+ </p>
+ </gl-popover>
+ </template>
- <template #cell(lastContact)="{ item }">
- <span data-testid="cluster-agent-last-contact">
- <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
- <span v-else>{{ s__('ClusterAgents|Never') }}</span>
- </span>
- </template>
+ <template #cell(lastContact)="{ item }">
+ <span data-testid="cluster-agent-last-contact">
+ <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
+ <span v-else>{{ s__('ClusterAgents|Never') }}</span>
+ </span>
+ </template>
- <template #cell(configuration)="{ item }">
- <span data-testid="cluster-agent-configuration-link">
- <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
- .gitlab/agents/{{ item.name }}
- </gl-link>
+ <template #cell(configuration)="{ item }">
+ <span data-testid="cluster-agent-configuration-link">
+ <gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
+ {{ getAgentConfigPath(item.name) }}
+ </gl-link>
- <span v-else>.gitlab/agents/{{ item.name }}</span>
- <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
- </span>
- </template>
- </gl-table>
- </div>
+ <span v-else>{{ getAgentConfigPath(item.name) }}</span>
+ </span>
+ </template>
+ </gl-table>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index ed44c1f5fa7..fb5cf7d1206 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -4,7 +4,6 @@ import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import AgentEmptyState from './agent_empty_state.vue';
import AgentTable from './agent_table.vue';
-import InstallAgentModal from './install_agent_modal.vue';
export default {
apollo: {
@@ -21,12 +20,14 @@ export default {
this.updateTreeList(data);
return data;
},
+ result() {
+ this.emitAgentsLoaded();
+ },
},
},
components: {
AgentEmptyState,
AgentTable,
- InstallAgentModal,
GlAlert,
GlKeysetPagination,
GlLoadingIcon,
@@ -38,11 +39,21 @@ export default {
required: false,
type: String,
},
+ isChildComponent: {
+ default: false,
+ required: false,
+ type: Boolean,
+ },
+ limit: {
+ default: null,
+ required: false,
+ type: Number,
+ },
},
data() {
return {
cursor: {
- first: MAX_LIST_COUNT,
+ first: this.limit ? this.limit : MAX_LIST_COUNT,
last: null,
},
folderList: {},
@@ -70,7 +81,7 @@ export default {
return this.$apollo.queries.agents.loading;
},
showPagination() {
- return this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage;
+ return !this.limit && (this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage);
},
treePageInfo() {
return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
@@ -130,24 +141,31 @@ export default {
}
return 'unused';
},
+ emitAgentsLoaded() {
+ const count = this.agents?.project?.clusterAgents?.count;
+ this.$emit('onAgentsLoad', count);
+ },
},
};
</script>
<template>
- <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" />
+ <gl-loading-icon v-if="isLoading" size="md" />
- <section v-else-if="agentList" class="gl-mt-3">
+ <section v-else-if="agentList">
<div v-if="agentList.length">
- <AgentTable :agents="agentList" />
+ <agent-table :agents="agentList" />
<div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" />
</div>
</div>
- <AgentEmptyState v-else :has-configurations="hasConfigurations" />
- <InstallAgentModal @agentRegistered="reloadAgents" />
+ <agent-empty-state
+ v-else
+ :has-configurations="hasConfigurations"
+ :is-child-component="isChildComponent"
+ />
</section>
<gl-alert v-else variant="danger" :dismissible="false">
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 0d1534d20e0..9c330045596 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -14,6 +14,7 @@ import { __, sprintf } from '~/locale';
import { CLUSTER_TYPES, STATUSES } from '../constants';
import AncestorNotice from './ancestor_notice.vue';
import NodeErrorHelpText from './node_error_help_text.vue';
+import ClustersEmptyState from './clusters_empty_state.vue';
export default {
nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'),
@@ -28,10 +29,23 @@ export default {
GlSprintf,
GlTable,
NodeErrorHelpText,
+ ClustersEmptyState,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ props: {
+ isChildComponent: {
+ default: false,
+ required: false,
+ type: Boolean,
+ },
+ limit: {
+ default: null,
+ required: false,
+ type: Number,
+ },
+ },
computed: {
...mapState([
'clusters',
@@ -40,7 +54,7 @@ export default {
'loadingNodes',
'page',
'providers',
- 'totalCulsters',
+ 'totalClusters',
]),
contentAlignClasses() {
return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start';
@@ -55,43 +69,57 @@ export default {
},
},
fields() {
+ const tdClass = 'gl-py-5!';
return [
{
key: 'name',
label: __('Kubernetes cluster'),
+ tdClass,
},
{
key: 'environment_scope',
label: __('Environment scope'),
+ tdClass,
},
{
key: 'node_size',
label: __('Nodes'),
+ tdClass,
},
{
key: 'total_cpu',
label: __('Total cores (CPUs)'),
+ tdClass,
},
{
key: 'total_memory',
label: __('Total memory (GB)'),
+ tdClass,
},
{
key: 'cluster_type',
label: __('Cluster level'),
+ tdClass,
formatter: (value) => CLUSTER_TYPES[value],
},
];
},
- hasClusters() {
+ hasClustersPerPage() {
return this.clustersPerPage > 0;
},
+ hasClusters() {
+ return this.totalClusters > 0;
+ },
},
mounted() {
+ if (this.limit) {
+ this.setClustersPerPage(this.limit);
+ }
+
this.fetchClusters();
},
methods: {
- ...mapActions(['fetchClusters', 'reportSentryError', 'setPage']),
+ ...mapActions(['fetchClusters', 'reportSentryError', 'setPage', 'setClustersPerPage']),
k8sQuantityToGb(quantity) {
if (!quantity) {
return 0;
@@ -196,18 +224,20 @@ export default {
</script>
<template>
- <gl-loading-icon v-if="loadingClusters" size="md" class="gl-mt-3" />
+ <gl-loading-icon v-if="loadingClusters" size="md" />
<section v-else>
<ancestor-notice />
<gl-table
+ v-if="hasClusters"
:items="clusters"
:fields="fields"
+ fixed
stacked="md"
head-variant="white"
- thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
- class="qa-clusters-table"
+ thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100"
+ class="qa-clusters-table gl-mb-4!"
data-testid="cluster_list_table"
>
<template #cell(name)="{ item }">
@@ -241,7 +271,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
- <NodeErrorHelpText
+ <node-error-help-text
v-else-if="item.kubernetes_errors"
:class="contentAlignClasses"
:error-type="item.kubernetes_errors.connection_error"
@@ -262,7 +292,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
- <NodeErrorHelpText
+ <node-error-help-text
v-else-if="item.kubernetes_errors"
:class="contentAlignClasses"
:error-type="item.kubernetes_errors.node_connection_error"
@@ -283,7 +313,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
- <NodeErrorHelpText
+ <node-error-help-text
v-else-if="item.kubernetes_errors"
:class="contentAlignClasses"
:error-type="item.kubernetes_errors.metrics_connection_error"
@@ -298,11 +328,13 @@ export default {
</template>
</gl-table>
+ <clusters-empty-state v-else :is-child-component="isChildComponent" />
+
<gl-pagination
- v-if="hasClusters"
+ v-if="hasClustersPerPage && !limit"
v-model="currentPage"
:per-page="clustersPerPage"
- :total-items="totalCulsters"
+ :total-items="totalClusters"
:prev-text="__('Prev')"
:next-text="__('Next')"
align="center"
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
new file mode 100644
index 00000000000..25f67462223
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants';
+
+export default {
+ i18n: CLUSTERS_ACTIONS,
+ INSTALL_AGENT_MODAL_ID,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ inject: ['newClusterPath', 'addClusterPath'],
+};
+</script>
+
+<template>
+ <div class="nav-controls gl-ml-auto">
+ <gl-dropdown
+ ref="dropdown"
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ category="primary"
+ variant="confirm"
+ :text="$options.i18n.actionsButton"
+ split
+ right
+ >
+ <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
+ {{ $options.i18n.createNewCluster }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ data-testid="connect-new-agent-link"
+ >
+ {{ $options.i18n.connectWithAgent }}
+ </gl-dropdown-item>
+ <gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop>
+ {{ $options.i18n.connectExistingCluster }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </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
new file mode 100644
index 00000000000..3879af6e9cb
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlEmptyState, GlButton, GlLink, GlSprintf } from '@gitlab/ui';
+import { mapState } from 'vuex';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { I18N_CLUSTERS_EMPTY_STATE } from '../constants';
+
+export default {
+ i18n: I18N_CLUSTERS_EMPTY_STATE,
+ components: {
+ GlEmptyState,
+ GlButton,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'],
+ props: {
+ isChildComponent: {
+ default: false,
+ required: false,
+ type: Boolean,
+ },
+ },
+ learnMoreHelpUrl: helpPagePath('user/project/clusters/index'),
+ multipleClustersHelpUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'),
+ computed: {
+ ...mapState(['canAddCluster']),
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state :svg-path="clustersEmptyStateImage" title="">
+ <template #description>
+ <p class="gl-text-left">
+ {{ $options.i18n.description }}
+ </p>
+ <p class="gl-text-left">
+ <gl-sprintf :message="$options.i18n.multipleClustersText">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.multipleClustersHelpUrl"
+ target="_blank"
+ data-testid="multiple-clusters-docs-link"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text">
+ {{ emptyStateHelpText }}
+ </p>
+
+ <p>
+ <gl-link :href="$options.learnMoreHelpUrl" target="_blank" data-testid="clusters-docs-link">
+ {{ $options.i18n.learnMoreLinkText }}
+ </gl-link>
+ </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="newClusterPath"
+ >
+ {{ $options.i18n.buttonText }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
new file mode 100644
index 00000000000..9e03093aa67
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlTabs, GlTab } from '@gitlab/ui';
+import { CLUSTERS_TABS, MAX_CLUSTERS_LIST, MAX_LIST_COUNT, AGENT } from '../constants';
+import Agents from './agents.vue';
+import InstallAgentModal from './install_agent_modal.vue';
+import ClustersActions from './clusters_actions.vue';
+import Clusters from './clusters.vue';
+import ClustersViewAll from './clusters_view_all.vue';
+
+export default {
+ components: {
+ GlTabs,
+ GlTab,
+ ClustersActions,
+ ClustersViewAll,
+ Clusters,
+ Agents,
+ InstallAgentModal,
+ },
+ CLUSTERS_TABS,
+ props: {
+ defaultBranchName: {
+ default: '.noBranch',
+ required: false,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ selectedTabIndex: 0,
+ maxAgents: MAX_CLUSTERS_LIST,
+ };
+ },
+ methods: {
+ onTabChange(tabName) {
+ this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName);
+
+ this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-tabs
+ v-model="selectedTabIndex"
+ sync-active-tab-with-query-params
+ nav-class="gl-flex-grow-1 gl-align-items-center"
+ lazy
+ >
+ <gl-tab
+ v-for="(tab, idx) in $options.CLUSTERS_TABS"
+ :key="idx"
+ :title="tab.title"
+ :query-param-value="tab.queryParamValue"
+ class="gl-line-height-20 gl-mt-5"
+ >
+ <component
+ :is="tab.component"
+ :default-branch-name="defaultBranchName"
+ data-testid="clusters-tab-component"
+ @changeTab="onTabChange"
+ />
+ </gl-tab>
+
+ <template #tabs-end>
+ <clusters-actions />
+ </template>
+ </gl-tabs>
+
+ <install-agent-modal :default-branch-name="defaultBranchName" :max-agents="maxAgents" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
new file mode 100644
index 00000000000..285876e57d8
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
@@ -0,0 +1,218 @@
+<script>
+import {
+ GlCard,
+ GlSprintf,
+ GlPopover,
+ GlLink,
+ GlButton,
+ GlBadge,
+ GlLoadingIcon,
+ GlModalDirective,
+} 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 Clusters from './clusters.vue';
+import Agents from './agents.vue';
+
+export default {
+ components: {
+ GlCard,
+ GlSprintf,
+ GlPopover,
+ GlLink,
+ GlButton,
+ GlBadge,
+ GlLoadingIcon,
+ Clusters,
+ Agents,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ AGENT_CARD_INFO,
+ CERTIFICATE_BASED_CARD_INFO,
+ MAX_CLUSTERS_LIST,
+ INSTALL_AGENT_MODAL_ID,
+ inject: ['addClusterPath'],
+ props: {
+ defaultBranchName: {
+ default: '.noBranch',
+ required: false,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ loadingAgents: true,
+ totalAgents: null,
+ };
+ },
+ computed: {
+ ...mapState(['loadingClusters', 'totalClusters']),
+ isLoading() {
+ return this.loadingAgents || this.loadingClusters;
+ },
+ agentsCardTitle() {
+ let cardTitle;
+ if (this.totalAgents > 0) {
+ cardTitle = {
+ message: AGENT_CARD_INFO.title,
+ number: this.totalAgents < MAX_CLUSTERS_LIST ? this.totalAgents : MAX_CLUSTERS_LIST,
+ total: this.totalAgents,
+ };
+ } else {
+ cardTitle = {
+ message: AGENT_CARD_INFO.emptyTitle,
+ };
+ }
+
+ return cardTitle;
+ },
+ clustersCardTitle() {
+ let cardTitle;
+ if (this.totalClusters > 0) {
+ cardTitle = {
+ message: CERTIFICATE_BASED_CARD_INFO.title,
+ number: this.totalClusters < MAX_CLUSTERS_LIST ? this.totalClusters : MAX_CLUSTERS_LIST,
+ total: this.totalClusters,
+ };
+ } else {
+ cardTitle = {
+ message: CERTIFICATE_BASED_CARD_INFO.emptyTitle,
+ };
+ }
+
+ return cardTitle;
+ },
+ },
+ methods: {
+ cardFooterNumber(number) {
+ return number > MAX_CLUSTERS_LIST ? number : '';
+ },
+ onAgentsLoad(number) {
+ this.totalAgents = number;
+ this.loadingAgents = false;
+ },
+ changeTab($event, tab) {
+ $event.preventDefault();
+ this.$emit('changeTab', tab);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-loading-icon v-if="isLoading" size="md" />
+ <div v-show="!isLoading" data-testid="clusters-cards-container">
+ <gl-card
+ header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between gl-py-4"
+ body-class="gl-pb-0"
+ footer-class="gl-text-right"
+ >
+ <template #header>
+ <h3 data-testid="agent-card-title" class="gl-my-0 gl-font-weight-normal gl-font-size-h2">
+ <gl-sprintf :message="agentsCardTitle.message"
+ ><template #number>{{ agentsCardTitle.number }}</template>
+ <template #total>{{ agentsCardTitle.total }}</template>
+ </gl-sprintf>
+ </h3>
+
+ <gl-badge id="clusters-recommended-badge" size="md" variant="info">{{
+ $options.AGENT_CARD_INFO.tooltip.label
+ }}</gl-badge>
+
+ <gl-popover
+ target="clusters-recommended-badge"
+ container="viewport"
+ placement="bottom"
+ :title="$options.AGENT_CARD_INFO.tooltip.title"
+ >
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.AGENT_CARD_INFO.tooltip.text">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.AGENT_CARD_INFO.tooltip.link"
+ target="_blank"
+ class="gl-font-sm"
+ >
+ {{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-popover>
+ </template>
+
+ <agents
+ :limit="$options.MAX_CLUSTERS_LIST"
+ :default-branch-name="defaultBranchName"
+ :is-child-component="true"
+ @onAgentsLoad="onAgentsLoad"
+ />
+
+ <template #footer>
+ <gl-link
+ v-if="totalAgents"
+ data-testid="agents-tab-footer-link"
+ :href="`?tab=${$options.AGENT_CARD_INFO.tabName}`"
+ @click="changeTab($event, $options.AGENT_CARD_INFO.tabName)"
+ ><gl-sprintf :message="$options.AGENT_CARD_INFO.footerText"
+ ><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf
+ ></gl-link
+ ><gl-button
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ class="gl-ml-4"
+ category="secondary"
+ variant="confirm"
+ >{{ $options.AGENT_CARD_INFO.actionText }}</gl-button
+ >
+ </template>
+ </gl-card>
+
+ <gl-card
+ class="gl-mt-6"
+ header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ body-class="gl-pb-0"
+ footer-class="gl-text-right"
+ >
+ <template #header>
+ <h3
+ class="gl-my-1 gl-font-weight-normal gl-font-size-h2"
+ data-testid="clusters-card-title"
+ >
+ <gl-sprintf :message="clustersCardTitle.message"
+ ><template #number>{{ clustersCardTitle.number }}</template>
+ <template #total>{{ clustersCardTitle.total }}</template>
+ </gl-sprintf>
+ </h3>
+ </template>
+
+ <clusters :limit="$options.MAX_CLUSTERS_LIST" :is-child-component="true" />
+
+ <template #footer>
+ <gl-link
+ v-if="totalClusters"
+ data-testid="clusters-tab-footer-link"
+ :href="`?tab=${$options.CERTIFICATE_BASED_CARD_INFO.tabName}`"
+ @click="changeTab($event, $options.CERTIFICATE_BASED_CARD_INFO.tabName)"
+ ><gl-sprintf :message="$options.CERTIFICATE_BASED_CARD_INFO.footerText"
+ ><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf
+ ></gl-link
+ ><gl-button
+ category="secondary"
+ data-qa-selector="connect_existing_cluster_button"
+ variant="confirm"
+ class="gl-ml-4"
+ :href="addClusterPath"
+ >{{ $options.CERTIFICATE_BASED_CARD_INFO.actionText }}</gl-button
+ >
+ </template>
+ </gl-card>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
index 5f192fe4d5a..6eb2e85ecea 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -13,8 +13,10 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import { generateAgentRegistrationCommand } from '../clusters_util';
import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants';
+import { addAgentToStore } from '../graphql/cache_update';
import createAgent from '../graphql/mutations/create_agent.mutation.graphql';
import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql';
+import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import AvailableAgentsDropdown from './available_agents_dropdown.vue';
export default {
@@ -33,12 +35,24 @@ export default {
GlSprintf,
},
inject: ['projectPath', 'kasAddress'],
+ props: {
+ defaultBranchName: {
+ default: '.noBranch',
+ required: false,
+ type: String,
+ },
+ maxAgents: {
+ required: true,
+ type: Number,
+ },
+ },
data() {
return {
registering: false,
agentName: null,
agentToken: null,
error: null,
+ clusterAgent: null,
};
},
computed: {
@@ -55,27 +69,31 @@ export default {
return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
},
basicInstallPath() {
- return helpPagePath('user/clusters/agent/index', {
+ return helpPagePath('user/clusters/agent/install/index', {
anchor: 'install-the-agent-into-the-cluster',
});
},
advancedInstallPath() {
- return helpPagePath('user/clusters/agent/index', { anchor: 'advanced-installation' });
+ return helpPagePath('user/clusters/agent/install/index', { anchor: 'advanced-installation' });
+ },
+ getAgentsQueryVariables() {
+ return {
+ defaultBranchName: this.defaultBranchName,
+ first: this.maxAgents,
+ last: null,
+ projectPath: this.projectPath,
+ };
},
},
methods: {
setAgentName(name) {
this.agentName = name;
},
- cancelClicked() {
- this.$refs.modal.hide();
- },
- doneClicked() {
- this.$emit('agentRegistered');
+ closeModal() {
this.$refs.modal.hide();
},
resetModal() {
- this.registering = null;
+ this.registering = false;
this.agentName = null;
this.agentToken = null;
this.error = null;
@@ -90,6 +108,14 @@ export default {
projectPath: this.projectPath,
},
},
+ update: (store, { data: { createClusterAgent } }) => {
+ addAgentToStore(
+ store,
+ createClusterAgent,
+ getAgentsQuery,
+ this.getAgentsQueryVariables,
+ );
+ },
})
.then(({ data: { createClusterAgent } }) => createClusterAgent);
},
@@ -117,6 +143,8 @@ export default {
throw new Error(agentErrors[0]);
}
+ this.clusterAgent = clusterAgent;
+
const { errors: tokenErrors, secret } = await this.createAgentTokenMutation(
clusterAgent.id,
);
@@ -240,10 +268,10 @@ export default {
</template>
<template #modal-footer>
- <gl-button v-if="canCancel" @click="cancelClicked">{{ $options.i18n.cancel }} </gl-button>
+ <gl-button v-if="canCancel" @click="closeModal">{{ $options.i18n.cancel }} </gl-button>
- <gl-button v-if="registered" variant="confirm" category="primary" @click="doneClicked"
- >{{ $options.i18n.done }}
+ <gl-button v-if="registered" variant="confirm" category="primary" @click="closeModal"
+ >{{ $options.i18n.close }}
</gl-button>
<gl-button
@@ -252,7 +280,7 @@ export default {
variant="confirm"
category="primary"
@click="registerAgent"
- >{{ $options.i18n.next }}
+ >{{ $options.i18n.registerAgentButton }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 0bade1fc281..9fefdf450c4 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -1,10 +1,9 @@
import { __, s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const MAX_LIST_COUNT = 25;
export const INSTALL_AGENT_MODAL_ID = 'install-agent';
export const ACTIVE_CONNECTION_TIME = 480000;
-export const TROUBLESHOOTING_LINK =
- 'https://docs.gitlab.com/ee/user/clusters/agent/#troubleshooting';
export const CLUSTER_ERRORS = {
default: {
@@ -66,8 +65,8 @@ export const STATUSES = {
};
export const I18N_INSTALL_AGENT_MODAL = {
- next: __('Next'),
- done: __('Done'),
+ registerAgentButton: s__('ClusterAgents|Register Agent'),
+ close: __('Close'),
cancel: __('Cancel'),
modalTitle: s__('ClusterAgents|Install new Agent'),
@@ -91,7 +90,7 @@ export const I18N_INSTALL_AGENT_MODAL = {
),
basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
- basicInstallBody: s__(
+ 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.`,
),
@@ -100,7 +99,7 @@ export const I18N_INSTALL_AGENT_MODAL = {
'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.',
),
- registrationErrorTitle: s__('Failed to register Agent'),
+ registrationErrorTitle: __('Failed to register Agent'),
unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
};
@@ -141,3 +140,86 @@ export const AGENT_STATUSES = {
},
},
};
+
+export const I18N_AGENTS_EMPTY_STATE = {
+ introText: s__(
+ 'ClusterAgents|Use GitLab Agents to more securely integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more.',
+ ),
+ multipleClustersText: s__(
+ 'ClusterAgents|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}',
+ ),
+ learnMoreText: s__('ClusterAgents|Learn more about the GitLab Kubernetes Agent.'),
+ warningText: s__(
+ 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.',
+ ),
+ readMoreText: s__('ClusterAgents|Read more about getting started'),
+ repositoryButtonText: s__('ClusterAgents|Go to the repository'),
+ primaryButtonText: s__('ClusterAgents|Connect with a GitLab Agent'),
+};
+
+export const I18N_CLUSTERS_EMPTY_STATE = {
+ description: s__(
+ 'ClusterIntegration|Use certificates to integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more in an easy way.',
+ ),
+ multipleClustersText: s__(
+ 'ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}',
+ ),
+ learnMoreLinkText: s__('ClusterIntegration|Learn more about the GitLab managed clusters'),
+ buttonText: s__('ClusterIntegration|Connect with a certificate'),
+};
+
+export const AGENT_CARD_INFO = {
+ tabName: 'agent',
+ title: sprintf(s__('ClusterAgents|%{number} of %{total} Agent based integrations')),
+ emptyTitle: s__('ClusterAgents|No Agent based integrations'),
+ tooltip: {
+ label: s__('ClusterAgents|Recommended'),
+ title: s__('ClusterAgents|GitLab Agents'),
+ text: sprintf(
+ s__(
+ 'ClusterAgents|GitLab Agents provide an increased level of security when integrating with clusters. %{linkStart}Learn more about the GitLab Kubernetes Agent.%{linkEnd}',
+ ),
+ ),
+ link: helpPagePath('user/clusters/agent/index'),
+ },
+ actionText: s__('ClusterAgents|Install new Agent'),
+ footerText: sprintf(s__('ClusterAgents|View all %{number} Agent based integrations')),
+};
+
+export const CERTIFICATE_BASED_CARD_INFO = {
+ tabName: 'certificate_based',
+ title: sprintf(s__('ClusterAgents|%{number} of %{total} Certificate based integrations')),
+ emptyTitle: s__('ClusterAgents|No Certificate based integrations'),
+ actionText: s__('ClusterAgents|Connect existing cluster'),
+ footerText: sprintf(s__('ClusterAgents|View all %{number} Certificate based integrations')),
+};
+
+export const MAX_CLUSTERS_LIST = 6;
+
+export const CLUSTERS_TABS = [
+ {
+ title: s__('ClusterAgents|All'),
+ component: 'ClustersViewAll',
+ queryParamValue: 'all',
+ },
+ {
+ title: s__('ClusterAgents|Agent'),
+ component: 'agents',
+ queryParamValue: 'agent',
+ },
+ {
+ title: s__('ClusterAgents|Certificate based'),
+ component: 'clusters',
+ queryParamValue: 'certificate_based',
+ },
+];
+
+export const CLUSTERS_ACTIONS = {
+ actionsButton: s__('ClusterAgents|Actions'),
+ createNewCluster: s__('ClusterAgents|Create new cluster'),
+ connectWithAgent: s__('ClusterAgents|Connect with Agent'),
+ connectExistingCluster: s__('ClusterAgents|Connect with certificate'),
+};
+
+export const AGENT = 'agent';
+export const CERTIFICATE_BASED = 'certificate_based';
diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js
new file mode 100644
index 00000000000..dd633820952
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js
@@ -0,0 +1,29 @@
+import produce from 'immer';
+import { getAgentConfigPath } from '../clusters_util';
+
+export function addAgentToStore(store, createClusterAgent, query, variables) {
+ const { clusterAgent } = createClusterAgent;
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ const configuration = {
+ name: clusterAgent.name,
+ path: getAgentConfigPath(clusterAgent.name),
+ webPath: clusterAgent.webPath,
+ __typename: 'TreeEntry',
+ };
+
+ draftData.project.clusterAgents.nodes.push(clusterAgent);
+ draftData.project.clusterAgents.count += 1;
+ draftData.project.repository.tree.trees.nodes.push(configuration);
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
new file mode 100644
index 00000000000..9b40260471c
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
@@ -0,0 +1,10 @@
+fragment ClusterAgentFragment on ClusterAgent {
+ id
+ name
+ webPath
+ tokens {
+ nodes {
+ lastUsedAt
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
index c29756159f5..996b388089b 100644
--- a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
@@ -1,7 +1,9 @@
+#import "../fragments/cluster_agent.fragment.graphql"
+
mutation createClusterAgent($input: CreateClusterAgentInput!) {
createClusterAgent(input: $input) {
clusterAgent {
- id
+ ...ClusterAgentFragment
}
errors
}
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 61989e00d9e..47b25988877 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,5 @@
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/cluster_agent.fragment.graphql"
query getAgents(
$defaultBranchName: String!
@@ -13,19 +14,14 @@ query getAgents(
project(fullPath: $projectPath) {
clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) {
nodes {
- id
- name
- webPath
- tokens {
- nodes {
- lastUsedAt
- }
- }
+ ...ClusterAgentFragment
}
pageInfo {
...PageInfo
}
+
+ count
}
repository {
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index de18965abbd..7f1ef37814b 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import loadClusters from './load_clusters';
-import loadAgents from './load_agents';
+import loadMainView from './load_main_view';
Vue.use(VueApollo);
export default () => {
loadClusters(Vue);
- loadAgents(Vue, VueApollo);
+ loadMainView(Vue, VueApollo);
};
diff --git a/app/assets/javascripts/clusters_list/load_agents.js b/app/assets/javascripts/clusters_list/load_agents.js
deleted file mode 100644
index b77d386df20..00000000000
--- a/app/assets/javascripts/clusters_list/load_agents.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import createDefaultClient from '~/lib/graphql';
-import Agents from './components/agents.vue';
-
-export default (Vue, VueApollo) => {
- const el = document.querySelector('#js-cluster-agents-list');
-
- if (!el) {
- return null;
- }
-
- const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
-
- const {
- emptyStateImage,
- defaultBranchName,
- projectPath,
- agentDocsUrl,
- installDocsUrl,
- getStartedDocsUrl,
- integrationDocsUrl,
- kasAddress,
- } = el.dataset;
-
- return new Vue({
- el,
- apolloProvider: new VueApollo({ defaultClient }),
- provide: {
- emptyStateImage,
- projectPath,
- agentDocsUrl,
- installDocsUrl,
- getStartedDocsUrl,
- integrationDocsUrl,
- kasAddress,
- },
- render(createElement) {
- return createElement(Agents, {
- props: {
- defaultBranchName,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js
index 01430230879..1bb3ea546b2 100644
--- a/app/assets/javascripts/clusters_list/load_clusters.js
+++ b/app/assets/javascripts/clusters_list/load_clusters.js
@@ -8,8 +8,15 @@ export default (Vue) => {
return null;
}
+ const { emptyStateHelpText, newClusterPath, clustersEmptyStateImage } = el.dataset;
+
return new Vue({
el,
+ provide: {
+ emptyStateHelpText,
+ newClusterPath,
+ clustersEmptyStateImage,
+ },
store: createStore(el.dataset),
render(createElement) {
return createElement(Clusters);
diff --git a/app/assets/javascripts/clusters_list/load_main_view.js b/app/assets/javascripts/clusters_list/load_main_view.js
new file mode 100644
index 00000000000..08c99b46e16
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/load_main_view.js
@@ -0,0 +1,50 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import ClustersMainView from './components/clusters_main_view.vue';
+import { createStore } from './store';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.querySelector('.js-clusters-main-view');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient();
+
+ const {
+ emptyStateImage,
+ defaultBranchName,
+ projectPath,
+ kasAddress,
+ newClusterPath,
+ addClusterPath,
+ emptyStateHelpText,
+ clustersEmptyStateImage,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ provide: {
+ emptyStateImage,
+ projectPath,
+ kasAddress,
+ newClusterPath,
+ addClusterPath,
+ emptyStateHelpText,
+ clustersEmptyStateImage,
+ },
+ store: createStore(el.dataset),
+ render(createElement) {
+ return createElement(ClustersMainView, {
+ props: {
+ defaultBranchName,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 5f35a0b26f3..d70b36e63bc 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -3,7 +3,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import { MAX_REQUESTS } from '../constants';
import * as types from './mutation_types';
@@ -30,7 +30,13 @@ export const fetchClusters = ({ state, commit, dispatch }) => {
const poll = new Poll({
resource: {
- fetchClusters: (paginatedEndPoint) => axios.get(paginatedEndPoint),
+ fetchClusters: (paginatedEndPoint) =>
+ axios.get(paginatedEndPoint, {
+ params: {
+ page: state.page,
+ per_page: state.clustersPerPage,
+ },
+ }),
},
data: `${state.endpoint}?page=${state.page}`,
method: 'fetchClusters',
@@ -65,7 +71,7 @@ export const fetchClusters = ({ state, commit, dispatch }) => {
commit(types.SET_LOADING_CLUSTERS, false);
commit(types.SET_LOADING_NODES, false);
createFlash({
- message: __('Clusters|An error occurred while loading clusters'),
+ message: s__('Clusters|An error occurred while loading clusters'),
});
dispatch('reportSentryError', { error: response, tag: 'fetchClustersErrorCallback' });
@@ -78,3 +84,7 @@ export const fetchClusters = ({ state, commit, dispatch }) => {
export const setPage = ({ commit }, page) => {
commit(types.SET_PAGE, page);
};
+
+export const setClustersPerPage = ({ commit }, limit) => {
+ commit(types.SET_CLUSTERS_PER_PAGE, limit);
+};
diff --git a/app/assets/javascripts/clusters_list/store/mutation_types.js b/app/assets/javascripts/clusters_list/store/mutation_types.js
index beb4388c93e..e88d4c74761 100644
--- a/app/assets/javascripts/clusters_list/store/mutation_types.js
+++ b/app/assets/javascripts/clusters_list/store/mutation_types.js
@@ -2,3 +2,4 @@ export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA';
export const SET_LOADING_CLUSTERS = 'SET_LOADING_CLUSTERS';
export const SET_LOADING_NODES = 'SET_LOADING_NODES';
export const SET_PAGE = 'SET_PAGE';
+export const SET_CLUSTERS_PER_PAGE = 'SET_CLUSTERS_PER_PAGE';
diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js
index 5b462928518..93156c9200f 100644
--- a/app/assets/javascripts/clusters_list/store/mutations.js
+++ b/app/assets/javascripts/clusters_list/store/mutations.js
@@ -12,10 +12,13 @@ export default {
clusters: data.clusters,
clustersPerPage: paginationInformation.perPage,
hasAncestorClusters: data.has_ancestor_clusters,
- totalCulsters: paginationInformation.total,
+ totalClusters: paginationInformation.total,
});
},
[types.SET_PAGE](state, value) {
state.page = Number(value) || 1;
},
+ [types.SET_CLUSTERS_PER_PAGE](state, value) {
+ state.clustersPerPage = Number(value) || 1;
+ },
};
diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js
index 51fafd49479..763d7389d0f 100644
--- a/app/assets/javascripts/clusters_list/store/state.js
+++ b/app/assets/javascripts/clusters_list/store/state.js
@@ -1,9 +1,11 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
export default (initialState = {}) => ({
ancestorHelperPath: initialState.ancestorHelpPath,
endpoint: initialState.endpoint,
hasAncestorClusters: false,
clusters: [],
- clustersPerPage: 0,
+ clustersPerPage: 20,
loadingClusters: true,
loadingNodes: true,
page: 1,
@@ -12,5 +14,6 @@ export default (initialState = {}) => ({
default: { path: initialState.imgTagsDefaultPath, text: initialState.imgTagsDefaultText },
gcp: { path: initialState.imgTagsGcpPath, text: initialState.imgTagsGcpText },
},
- totalCulsters: 0,
+ totalClusters: 0,
+ canAddCluster: parseBoolean(initialState.canAddCluster),
});
diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js
index 5b9e70e3c09..ad70d9be16f 100644
--- a/app/assets/javascripts/confirm_danger_modal.js
+++ b/app/assets/javascripts/confirm_danger_modal.js
@@ -3,14 +3,14 @@ import { Rails } from '~/lib/utils/rails_ujs';
import { rstrip } from './lib/utils/common_utils';
function openConfirmDangerModal($form, $modal, text) {
- const $input = $('.js-confirm-danger-input', $modal);
+ const $input = $('.js-legacy-confirm-danger-input', $modal);
$input.val('');
$('.js-confirm-text', $modal).text(text || '');
$modal.modal('show');
- const confirmTextMatch = $('.js-confirm-danger-match', $modal).text();
- const $submit = $('.js-confirm-danger-submit', $modal);
+ const confirmTextMatch = $('.js-legacy-confirm-danger-match', $modal).text();
+ const $submit = $('.js-legacy-confirm-danger-submit', $modal);
$submit.disable();
$input.focus();
@@ -25,7 +25,7 @@ function openConfirmDangerModal($form, $modal, text) {
});
// eslint-disable-next-line @gitlab/no-global-event-off
- $('.js-confirm-danger-submit', $modal)
+ $('.js-legacy-confirm-danger-submit', $modal)
.off('click')
.on('click', () => {
if ($form.data('remote')) {
@@ -47,7 +47,7 @@ function getModal($btn) {
}
export default function initConfirmDangerModal() {
- $(document).on('click', '.js-confirm-danger', (e) => {
+ $(document).on('click', '.js-legacy-confirm-danger', (e) => {
const $btn = $(e.target);
const checkFieldName = $btn.data('checkFieldName');
const checkFieldCompareValue = $btn.data('checkCompareValue');
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 02ab34447ca..a8405fe37c7 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -3,7 +3,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
import { createContentEditor } from '../services/create_content_editor';
-import ContentEditorError from './content_editor_error.vue';
+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';
@@ -12,7 +12,7 @@ import TopToolbar from './top_toolbar.vue';
export default {
components: {
GlLoadingIcon,
- ContentEditorError,
+ ContentEditorAlert,
ContentEditorProvider,
TiptapEditorContent,
TopToolbar,
@@ -92,7 +92,7 @@ export default {
<content-editor-provider :content-editor="contentEditor">
<div>
<editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
- <content-editor-error />
+ <content-editor-alert />
<div
data-testid="content-editor"
data-qa-selector="content_editor_container"
diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
new file mode 100644
index 00000000000..c6737da1d77
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
@@ -0,0 +1,33 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import EditorStateObserver from './editor_state_observer.vue';
+
+export default {
+ components: {
+ GlAlert,
+ EditorStateObserver,
+ },
+ data() {
+ return {
+ message: null,
+ variant: 'danger',
+ };
+ },
+ methods: {
+ displayAlert({ message, variant }) {
+ this.message = message;
+ this.variant = variant;
+ },
+ dismissAlert() {
+ this.message = null;
+ },
+ },
+};
+</script>
+<template>
+ <editor-state-observer @alert="displayAlert">
+ <gl-alert v-if="message" class="gl-mb-6" :variant="variant" @dismiss="dismissAlert">
+ {{ message }}
+ </gl-alert>
+ </editor-state-observer>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_error.vue b/app/assets/javascripts/content_editor/components/content_editor_error.vue
deleted file mode 100644
index 031ea92a7e9..00000000000
--- a/app/assets/javascripts/content_editor/components/content_editor_error.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<script>
-import { GlAlert } from '@gitlab/ui';
-import EditorStateObserver from './editor_state_observer.vue';
-
-export default {
- components: {
- GlAlert,
- EditorStateObserver,
- },
- data() {
- return {
- error: null,
- };
- },
- methods: {
- displayError({ error }) {
- this.error = error;
- },
- dismissError() {
- this.error = null;
- },
- },
-};
-</script>
-<template>
- <editor-state-observer @error="displayError">
- <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="dismissError">
- {{ error }}
- </gl-alert>
- </editor-state-observer>
-</template>
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 2eeb0719096..0604047a953 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -7,7 +7,7 @@ export const tiptapToComponentMap = {
transaction: 'transaction',
focus: 'focus',
blur: 'blur',
- error: 'error',
+ alert: 'alert',
};
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
index c44e8145982..41c083111c5 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -26,8 +26,8 @@ export default {
type: Object,
required: true,
},
- getPos: {
- type: Function,
+ node: {
+ type: Object,
required: true,
},
},
@@ -61,7 +61,17 @@ export default {
const { state } = this.editor;
const { $cursor } = state.selection;
- this.displayActionsDropdown = $cursor?.pos - $cursor?.parentOffset - 1 === this.getPos();
+ if (!$cursor) return;
+
+ this.displayActionsDropdown = false;
+
+ for (let level = 0; level < $cursor.depth; level += 1) {
+ if ($cursor.node(level) === this.node) {
+ this.displayActionsDropdown = true;
+ break;
+ }
+ }
+
if (this.displayActionsDropdown) {
this.selectedRect = getSelectedRect(state);
}
@@ -99,7 +109,11 @@ export default {
:as="cellType"
@click="hideDropdown"
>
- <span v-if="displayActionsDropdown" class="gl-absolute gl-right-0 gl-top-0">
+ <span
+ v-if="displayActionsDropdown"
+ contenteditable="false"
+ class="gl-absolute gl-right-0 gl-top-0"
+ >
<gl-dropdown
ref="dropdown"
dropup
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue
index 6b4343dd5b8..47cd837d060 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue
@@ -11,8 +11,8 @@ export default {
type: Object,
required: true,
},
- getPos: {
- type: Function,
+ node: {
+ type: Object,
required: true,
},
},
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue
index 5f9889374f6..150f78bc84f 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue
@@ -11,8 +11,8 @@ export default {
type: Object,
required: true,
},
- getPos: {
- type: Function,
+ node: {
+ type: Object,
required: true,
},
},
diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js
index 4512ead44bc..5632bc28592 100644
--- a/app/assets/javascripts/content_editor/extensions/blockquote.js
+++ b/app/assets/javascripts/content_editor/extensions/blockquote.js
@@ -1,10 +1,8 @@
import { Blockquote } from '@tiptap/extension-blockquote';
-import { wrappingInputRule } from 'prosemirror-inputrules';
+import { wrappingInputRule } from '@tiptap/core';
import { getParents } from '~/lib/utils/dom_utils';
import { getMarkdownSource } from '../services/markdown_sourcemap';
-export const multilineInputRegex = /^\s*>>>\s$/gm;
-
export default Blockquote.extend({
addAttributes() {
return {
@@ -25,9 +23,15 @@ export default Blockquote.extend({
},
addInputRules() {
+ const multilineInputRegex = /^\s*>>>\s$/gm;
+
return [
...this.parent?.(),
- wrappingInputRule(multilineInputRegex, this.type, () => ({ multiline: true })),
+ wrappingInputRule({
+ find: multilineInputRegex,
+ type: this.type,
+ getAttributes: () => ({ multiline: true }),
+ }),
];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/description_list.js b/app/assets/javascripts/content_editor/extensions/description_list.js
index a516dfad2b8..8f5b145cfa3 100644
--- a/app/assets/javascripts/content_editor/extensions/description_list.js
+++ b/app/assets/javascripts/content_editor/extensions/description_list.js
@@ -1,7 +1,4 @@
-import { Node, mergeAttributes } from '@tiptap/core';
-import { wrappingInputRule } from 'prosemirror-inputrules';
-
-export const inputRegex = /^\s*(<dl>)$/;
+import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core';
export default Node.create({
name: 'descriptionList',
@@ -18,6 +15,8 @@ export default Node.create({
},
addInputRules() {
- return [wrappingInputRule(inputRegex, this.type)];
+ const inputRegex = /^\s*(<dl>)$/;
+
+ return [wrappingInputRule({ find: inputRegex, type: this.type })];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/details.js b/app/assets/javascripts/content_editor/extensions/details.js
index e3d54ed01fd..46c906d45b1 100644
--- a/app/assets/javascripts/content_editor/extensions/details.js
+++ b/app/assets/javascripts/content_editor/extensions/details.js
@@ -1,10 +1,7 @@
-import { Node } from '@tiptap/core';
+import { Node, wrappingInputRule } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
-import { wrappingInputRule } from 'prosemirror-inputrules';
import DetailsWrapper from '../components/wrappers/details.vue';
-export const inputRegex = /^\s*(<details>)$/;
-
export default Node.create({
name: 'details',
content: 'detailsContent+',
@@ -24,7 +21,9 @@ export default Node.create({
},
addInputRules() {
- return [wrappingInputRule(inputRegex, this.type)];
+ const inputRegex = /^\s*(<details>)$/;
+
+ return [wrappingInputRule({ find: inputRegex, type: this.type })];
},
addCommands() {
diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js
index de608c3aaa2..7f8b5da5f46 100644
--- a/app/assets/javascripts/content_editor/extensions/emoji.js
+++ b/app/assets/javascripts/content_editor/extensions/emoji.js
@@ -1,9 +1,6 @@
-import { Node } from '@tiptap/core';
-import { InputRule } from 'prosemirror-inputrules';
+import { Node, InputRule } from '@tiptap/core';
import { initEmojiMap, getAllEmoji } from '~/emoji';
-export const emojiInputRegex = /(?:^|\s)((?::)((?:\w+))(?::))$/;
-
export default Node.create({
name: 'emoji',
@@ -54,23 +51,28 @@ export default Node.create({
},
addInputRules() {
+ const emojiInputRegex = /(?:^|\s)(:(\w+):)$/;
+
return [
- new InputRule(emojiInputRegex, (state, match, start, end) => {
- const [, , name] = match;
- const emojis = getAllEmoji();
- const emoji = emojis[name];
- const { tr } = state;
+ new InputRule({
+ find: emojiInputRegex,
+ handler: ({ state, range: { from, to }, match }) => {
+ const [, , name] = match;
+ const emojis = getAllEmoji();
+ const emoji = emojis[name];
+ const { tr } = state;
- if (emoji) {
- tr.replaceWith(start, end, [
- state.schema.text(' '),
- this.type.create({ name, moji: emoji.e, unicodeVersion: emoji.u, title: emoji.d }),
- ]);
+ if (emoji) {
+ tr.replaceWith(from, to, [
+ state.schema.text(' '),
+ this.type.create({ name, moji: emoji.e, unicodeVersion: emoji.u, title: emoji.d }),
+ ]);
- return tr;
- }
+ return tr;
+ }
- return null;
+ return null;
+ },
}),
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js
index 64c84fe046b..c09c10bc524 100644
--- a/app/assets/javascripts/content_editor/extensions/frontmatter.js
+++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js
@@ -17,4 +17,7 @@ export default CodeBlockHighlight.extend({
addNodeView() {
return new VueNodeViewRenderer(FrontmatterWrapper);
},
+ addInputRules() {
+ return [];
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
index c8ec45d835c..c4f31e5f981 100644
--- a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
+++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
@@ -1,10 +1,10 @@
import { nodeInputRule } from '@tiptap/core';
import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
-export const hrInputRuleRegExp = /^---$/;
-
export default HorizontalRule.extend({
addInputRules() {
- return [nodeInputRule(hrInputRuleRegExp, this.type)];
+ const hrInputRuleRegExp = /^---$/;
+
+ return [nodeInputRule({ find: hrInputRuleRegExp, type: this.type })];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js
index 54adb9efa0c..3abf0e3eee2 100644
--- a/app/assets/javascripts/content_editor/extensions/html_marks.js
+++ b/app/assets/javascripts/content_editor/extensions/html_marks.js
@@ -60,7 +60,13 @@ export default marks.map((name) =>
},
addInputRules() {
- return [markInputRule(markInputRegex(name), this.type, extractMarkAttributesFromMatch)];
+ return [
+ markInputRule({
+ find: markInputRegex(name),
+ type: this.type,
+ getAttributes: extractMarkAttributesFromMatch,
+ }),
+ ];
},
}),
);
diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js
index 3bd328958df..22bb1ac072e 100644
--- a/app/assets/javascripts/content_editor/extensions/inline_diff.js
+++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js
@@ -1,8 +1,5 @@
import { Mark, markInputRule, mergeAttributes } from '@tiptap/core';
-export const inputRegexAddition = /(\{\+(.+?)\+\})$/gm;
-export const inputRegexDeletion = /(\{-(.+?)-\})$/gm;
-
export default Mark.create({
name: 'inlineDiff',
@@ -38,9 +35,20 @@ export default Mark.create({
},
addInputRules() {
+ const inputRegexAddition = /(\{\+(.+?)\+\})$/gm;
+ const inputRegexDeletion = /(\{-(.+?)-\})$/gm;
+
return [
- markInputRule(inputRegexAddition, this.type, () => ({ type: 'addition' })),
- markInputRule(inputRegexDeletion, this.type, () => ({ type: 'deletion' })),
+ markInputRule({
+ find: inputRegexAddition,
+ type: this.type,
+ getAttributes: () => ({ type: 'addition' }),
+ }),
+ markInputRule({
+ find: inputRegexDeletion,
+ type: this.type,
+ getAttributes: () => ({ type: 'deletion' }),
+ }),
];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index fc0f38e6935..27bc05dce6f 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -1,9 +1,6 @@
import { markInputRule } from '@tiptap/core';
import { Link } from '@tiptap/extension-link';
-export const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
-export const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
-
const extractHrefFromMatch = (match) => {
return { href: match.groups.href };
};
@@ -26,9 +23,20 @@ export default Link.extend({
openOnClick: false,
},
addInputRules() {
+ const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
+ const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
+
return [
- markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink),
- markInputRule(urlSyntaxRegExp, this.type, extractHrefFromMatch),
+ markInputRule({
+ find: markdownLinkSyntaxInputRuleRegExp,
+ type: this.type,
+ getAttributes: extractHrefFromMarkdownLink,
+ }),
+ markInputRule({
+ find: urlSyntaxRegExp,
+ type: this.type,
+ getAttributes: extractHrefFromMatch,
+ }),
];
},
addAttributes() {
diff --git a/app/assets/javascripts/content_editor/extensions/math_inline.js b/app/assets/javascripts/content_editor/extensions/math_inline.js
index 60f5288dcf6..4844f6feb29 100644
--- a/app/assets/javascripts/content_editor/extensions/math_inline.js
+++ b/app/assets/javascripts/content_editor/extensions/math_inline.js
@@ -2,8 +2,6 @@ import { Mark, markInputRule } from '@tiptap/core';
import { __ } from '~/locale';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
-export const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm;
-
export default Mark.create({
name: 'mathInline',
@@ -30,6 +28,8 @@ export default Mark.create({
},
addInputRules() {
- return [markInputRule(inputRegex, this.type)];
+ const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm;
+
+ return [markInputRule({ find: inputRegex, type: this.type })];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/subscript.js b/app/assets/javascripts/content_editor/extensions/subscript.js
index d0766f42308..a8c087e8bf0 100644
--- a/app/assets/javascripts/content_editor/extensions/subscript.js
+++ b/app/assets/javascripts/content_editor/extensions/subscript.js
@@ -4,6 +4,12 @@ import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark
export default Subscript.extend({
addInputRules() {
- return [markInputRule(markInputRegex('sub'), this.type, extractMarkAttributesFromMatch)];
+ return [
+ markInputRule({
+ find: markInputRegex('sub'),
+ type: this.type,
+ getAttributes: extractMarkAttributesFromMatch,
+ }),
+ ];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/superscript.js b/app/assets/javascripts/content_editor/extensions/superscript.js
index 6cd814977ea..b86906f01f2 100644
--- a/app/assets/javascripts/content_editor/extensions/superscript.js
+++ b/app/assets/javascripts/content_editor/extensions/superscript.js
@@ -4,6 +4,12 @@ import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark
export default Superscript.extend({
addInputRules() {
- return [markInputRule(markInputRegex('sup'), this.type, extractMarkAttributesFromMatch)];
+ return [
+ markInputRule({
+ find: markInputRegex('sup'),
+ type: this.type,
+ getAttributes: extractMarkAttributesFromMatch,
+ }),
+ ];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js
index 0f0477cba2e..004bb8b815c 100644
--- a/app/assets/javascripts/content_editor/extensions/table.js
+++ b/app/assets/javascripts/content_editor/extensions/table.js
@@ -1 +1,42 @@
-export { Table as default } from '@tiptap/extension-table';
+import { Table } from '@tiptap/extension-table';
+import { debounce } from 'lodash';
+import { __ } from '~/locale';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
+import { shouldRenderHTMLTable } from '../services/serialization_helpers';
+
+let alertShown = false;
+const onUpdate = debounce((editor) => {
+ if (alertShown) return;
+
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === 'table' && node.attrs.isMarkdown && shouldRenderHTMLTable(node)) {
+ editor.emit('alert', {
+ message: __(
+ 'The content editor may change the markdown formatting style of the document, which may not match your original markdown style.',
+ ),
+ variant: 'warning',
+ });
+
+ alertShown = true;
+
+ return false;
+ }
+
+ return true;
+ });
+}, 1000);
+
+export default Table.extend({
+ addAttributes() {
+ return {
+ isMarkdown: {
+ default: null,
+ parseHTML: (element) => Boolean(getMarkdownSource(element)),
+ },
+ };
+ },
+
+ onUpdate({ editor }) {
+ onUpdate(editor);
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js
index befc33e669f..9f437ce066c 100644
--- a/app/assets/javascripts/content_editor/extensions/table_cell.js
+++ b/app/assets/javascripts/content_editor/extensions/table_cell.js
@@ -1,10 +1,9 @@
import { TableCell } from '@tiptap/extension-table-cell';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue';
-import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableCell.extend({
- content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
+ content: 'block+',
addNodeView() {
return VueNodeViewRenderer(TableCellBodyWrapper);
diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js
index 829b06fc14b..045fd03199b 100644
--- a/app/assets/javascripts/content_editor/extensions/table_header.js
+++ b/app/assets/javascripts/content_editor/extensions/table_header.js
@@ -1,10 +1,9 @@
import { TableHeader } from '@tiptap/extension-table-header';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue';
-import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableHeader.extend({
- content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
+ content: 'block+',
addNodeView() {
return VueNodeViewRenderer(TableCellHeaderWrapper);
},
diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
index 9e31158837e..a8882f9ede4 100644
--- a/app/assets/javascripts/content_editor/extensions/table_of_contents.js
+++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
@@ -1,10 +1,7 @@
-import { Node } from '@tiptap/core';
-import { InputRule } from 'prosemirror-inputrules';
+import { Node, InputRule } from '@tiptap/core';
import { s__ } from '~/locale';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
-export const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/];
-
export default Node.create({
name: 'tableOfContents',
@@ -34,17 +31,21 @@ export default Node.create({
addInputRules() {
const { type } = this;
+ const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/];
return inputRuleRegExps.map(
(regex) =>
- new InputRule(regex, (state, match, start, end) => {
- const { tr } = state;
+ new InputRule({
+ find: regex,
+ handler: ({ state, range: { from, to }, match }) => {
+ const { tr } = state;
- if (match) {
- tr.replaceWith(start - 1, end, type.create());
- }
+ if (match) {
+ tr.replaceWith(from - 1, to, type.create());
+ }
- return tr;
+ return tr;
+ },
}),
);
},
diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js
index 93b42466850..fa7e02f8cc8 100644
--- a/app/assets/javascripts/content_editor/extensions/word_break.js
+++ b/app/assets/javascripts/content_editor/extensions/word_break.js
@@ -1,7 +1,5 @@
import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
-export const inputRegex = /^<wbr>$/;
-
export default Node.create({
name: 'wordBreak',
inline: true,
@@ -24,6 +22,8 @@ export default Node.create({
},
addInputRules() {
- return [nodeInputRule(inputRegex, this.type)];
+ const inputRegex = /^<wbr>$/;
+
+ return [nodeInputRule({ find: inputRegex, type: this.type })];
},
});
diff --git a/app/assets/javascripts/content_editor/services/feature_flags.js b/app/assets/javascripts/content_editor/services/feature_flags.js
deleted file mode 100644
index 5f7a4595938..00000000000
--- a/app/assets/javascripts/content_editor/services/feature_flags.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export function isBlockTablesFeatureEnabled() {
- return gon.features?.contentEditorBlockTables;
-}
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index b2327555b45..ed5910fca18 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -1,5 +1,4 @@
import { uniq } from 'lodash';
-import { isBlockTablesFeatureEnabled } from './feature_flags';
const defaultAttrs = {
td: { colspan: 1, rowspan: 1, colwidth: null },
@@ -75,7 +74,7 @@ function getChildren(node) {
return children;
}
-function shouldRenderHTMLTable(table) {
+export function shouldRenderHTMLTable(table) {
const { rows, cells } = getRowsAndCells(table);
const cellChildCount = Math.max(...cells.map((cell) => cell.childCount));
@@ -282,11 +281,6 @@ export function renderOrderedList(state, node) {
}
export function renderTableCell(state, node) {
- if (!isBlockTablesFeatureEnabled()) {
- state.renderInline(node);
- return;
- }
-
if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) {
state.renderInline(node.child(0));
} else {
@@ -303,9 +297,7 @@ export function renderTableRow(state, node) {
}
export function renderTable(state, node) {
- if (isBlockTablesFeatureEnabled()) {
- setIsInBlockTable(node, shouldRenderHTMLTable(node));
- }
+ setIsInBlockTable(node, shouldRenderHTMLTable(node));
if (isInBlockTable(node)) renderTagOpen(state, 'table');
@@ -317,9 +309,7 @@ export function renderTable(state, node) {
state.closeBlock(node);
state.flushClose();
- if (isBlockTablesFeatureEnabled()) {
- unsetIsInBlockTable(node);
- }
+ unsetIsInBlockTable(node);
}
export function renderHardBreak(state, node, parent, index) {
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 d26f32a7e7a..9b1cb76f845 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
@@ -1,5 +1,5 @@
import { mapValues } from 'lodash';
-import { InputRule } from 'prosemirror-inputrules';
+import { InputRule } from '@tiptap/core';
import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys';
import Tracking from '~/tracking';
import {
@@ -17,17 +17,20 @@ const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => {
};
const trackInputRule = (contentType, inputRule) => {
- return new InputRule(inputRule.match, (...args) => {
- const result = inputRule.handler(...args);
+ return new InputRule({
+ find: inputRule.find,
+ handler: (...args) => {
+ const result = inputRule.handler(...args);
- if (result) {
- Tracking.event(undefined, INPUT_RULE_TRACKING_ACTION, {
- label: CONTENT_EDITOR_TRACKING_LABEL,
- property: contentType,
- });
- }
+ if (result !== null) {
+ Tracking.event(undefined, INPUT_RULE_TRACKING_ACTION, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property: contentType,
+ });
+ }
- return result;
+ return result;
+ },
});
};
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index 8ac3f719309..f5bf2742748 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -72,8 +72,9 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
- editor.emit('error', {
- error: __('An error occurred while uploading the image. Please try again.'),
+ editor.emit('alert', {
+ message: __('An error occurred while uploading the image. Please try again.'),
+ variant: 'danger',
});
}
};
@@ -102,8 +103,9 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
);
} catch (e) {
editor.commands.deleteRange({ from, to: from + 1 });
- editor.emit('error', {
- error: __('An error occurred while uploading the file. Please try again.'),
+ editor.emit('alert', {
+ message: __('An error occurred while uploading the file. Please try again.'),
+ variant: 'danger',
});
}
};
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 08cf0197993..08942374120 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -2,6 +2,8 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import Cookies from 'js-cookie';
import { debounce } from 'lodash';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
+import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { parseBoolean } from '~/lib/utils/common_utils';
export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
@@ -112,5 +114,8 @@ export default class ContextualSidebar {
const collapse = parseBoolean(Cookies.get('sidebar_collapsed'));
this.toggleCollapsedSidebar(collapse, true);
}
+
+ initInviteMembersModal();
+ initInviteMembersTrigger();
}
}
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index 9d4eddc510a..73458a463f2 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -84,7 +84,7 @@ export default {
),
subnetDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#subnets',
securityGroupDropdownHelpText: s__(
- 'ClusterIntegration|Choose the %{linkStart}security group %{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
+ 'ClusterIntegration|Choose the %{linkStart}security group%{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
),
securityGroupDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#securityGroups',
instanceTypesDropdownHelpText: s__(
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index f4a27dc7d1f..ae6e6bf02e4 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -54,6 +54,7 @@ export default class CreateMergeRequestDropdown {
this.isCreatingBranch = false;
this.isCreatingMergeRequest = false;
this.isGettingRef = false;
+ this.refCancelToken = null;
this.mergeRequestCreated = false;
this.refDebounce = debounce((value, target) => this.getRef(value, target), 500);
this.refIsValid = true;
@@ -101,9 +102,18 @@ export default class CreateMergeRequestDropdown {
'click',
this.onClickCreateMergeRequestButton.bind(this),
);
+ this.branchInput.addEventListener('input', this.onChangeInput.bind(this));
this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
+ // Detect for example when user pastes ref using the mouse
+ this.refInput.addEventListener('input', this.onChangeInput.bind(this));
+ // Detect for example when user presses right arrow to apply the suggested ref
this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
+ // Detect when user clicks inside the input to apply the suggested ref
+ this.refInput.addEventListener('click', this.onChangeInput.bind(this));
+ // Detect when user clicks outside the input to apply the suggested ref
+ this.refInput.addEventListener('blur', this.onChangeInput.bind(this));
+ // Detect when user presses tab to apply the suggested ref
this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
}
@@ -247,8 +257,12 @@ export default class CreateMergeRequestDropdown {
getRef(ref, target = 'all') {
if (!ref) return false;
+ this.refCancelToken = axios.CancelToken.source();
+
return axios
- .get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`)
+ .get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`, {
+ cancelToken: this.refCancelToken.token,
+ })
.then(({ data }) => {
const branches = data[Object.keys(data)[0]];
const tags = data[Object.keys(data)[1]];
@@ -267,7 +281,10 @@ export default class CreateMergeRequestDropdown {
return this.updateInputState(target, ref, result);
})
- .catch(() => {
+ .catch((thrown) => {
+ if (axios.isCancel(thrown)) {
+ return false;
+ }
this.unavailable();
this.disable();
createFlash({
@@ -325,14 +342,23 @@ export default class CreateMergeRequestDropdown {
let target;
let value;
+ // User changed input, cancel to prevent previous request from interfering
+ if (this.refCancelToken !== null) {
+ this.refCancelToken.cancel();
+ }
+
if (event.target === this.branchInput) {
target = 'branch';
({ value } = this.branchInput);
} else if (event.target === this.refInput) {
target = 'ref';
- value =
- event.target.value.slice(0, event.target.selectionStart) +
- event.target.value.slice(event.target.selectionEnd);
+ if (event.target === document.activeElement) {
+ value =
+ event.target.value.slice(0, event.target.selectionStart) +
+ event.target.value.slice(event.target.selectionEnd);
+ } else {
+ value = event.target.value;
+ }
} else {
return false;
}
@@ -358,6 +384,7 @@ export default class CreateMergeRequestDropdown {
this.enable();
this.showAvailableMessage(target);
+ this.refDebounce(value, target);
return true;
}
@@ -414,7 +441,8 @@ export default class CreateMergeRequestDropdown {
if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return;
event.preventDefault();
- window.getSelection().removeAllRanges();
+ const caretPositionEnd = this.refInput.value.length;
+ this.refInput.setSelectionRange(caretPositionEnd, caretPositionEnd);
}
removeMessage(target) {
diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/components/contacts_root.vue
new file mode 100644
index 00000000000..83c02f7d5fe
--- /dev/null
+++ b/app/assets/javascripts/crm/components/contacts_root.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__, __ } from '~/locale';
+import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlTable,
+ },
+ inject: ['groupFullPath'],
+ data() {
+ return { contacts: [] };
+ },
+ apollo: {
+ contacts: {
+ query() {
+ return getGroupContactsQuery;
+ },
+ variables() {
+ return {
+ groupFullPath: this.groupFullPath,
+ };
+ },
+ update(data) {
+ return this.extractContacts(data);
+ },
+ error(error) {
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ error,
+ captureError: true,
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.contacts.loading;
+ },
+ },
+ methods: {
+ extractContacts(data) {
+ const contacts = data?.group?.contacts?.nodes || [];
+ return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName));
+ },
+ },
+ fields: [
+ { key: 'firstName', sortable: true },
+ { key: 'lastName', sortable: true },
+ { key: 'email', sortable: true },
+ { key: 'phone', sortable: true },
+ { key: 'description', sortable: true },
+ {
+ key: 'organization',
+ formatter: (organization) => {
+ return organization?.name;
+ },
+ sortable: true,
+ },
+ ],
+ i18n: {
+ emptyText: s__('Crm|No contacts found'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
+ <gl-table
+ v-else
+ :items="contacts"
+ :fields="$options.fields"
+ :empty-text="$options.i18n.emptyText"
+ show-empty
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/crm/components/organizations_root.vue b/app/assets/javascripts/crm/components/organizations_root.vue
new file mode 100644
index 00000000000..98b45d0a042
--- /dev/null
+++ b/app/assets/javascripts/crm/components/organizations_root.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__, __ } from '~/locale';
+import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlTable,
+ },
+ inject: ['groupFullPath'],
+ data() {
+ return { organizations: [] };
+ },
+ apollo: {
+ organizations: {
+ query() {
+ return getGroupOrganizationsQuery;
+ },
+ variables() {
+ return {
+ groupFullPath: this.groupFullPath,
+ };
+ },
+ update(data) {
+ return this.extractOrganizations(data);
+ },
+ error(error) {
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ error,
+ captureError: true,
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.organizations.loading;
+ },
+ },
+ methods: {
+ extractOrganizations(data) {
+ const organizations = data?.group?.organizations?.nodes || [];
+ return organizations.slice().sort((a, b) => a.name.localeCompare(b.name));
+ },
+ },
+ fields: [
+ { key: 'name', sortable: true },
+ { key: 'defaultRate', sortable: true },
+ { key: 'description', sortable: true },
+ ],
+ i18n: {
+ emptyText: s__('Crm|No organizations found'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
+ <gl-table
+ v-else
+ :items="organizations"
+ :fields="$options.fields"
+ :empty-text="$options.i18n.emptyText"
+ show-empty
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql
new file mode 100644
index 00000000000..f6acd258585
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql
@@ -0,0 +1,22 @@
+query contacts($groupFullPath: ID!) {
+ group(fullPath: $groupFullPath) {
+ __typename
+ id
+ contacts {
+ nodes {
+ __typename
+ id
+ firstName
+ lastName
+ email
+ phone
+ description
+ organization {
+ __typename
+ id
+ name
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
new file mode 100644
index 00000000000..7c4ec6ec585
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
@@ -0,0 +1,15 @@
+query organizations($groupFullPath: ID!) {
+ group(fullPath: $groupFullPath) {
+ __typename
+ id
+ organizations {
+ nodes {
+ __typename
+ id
+ name
+ defaultRate
+ description
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts_bundle.js
new file mode 100644
index 00000000000..6438953596e
--- /dev/null
+++ b/app/assets/javascripts/crm/contacts_bundle.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import CrmContactsRoot from './components/contacts_root.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.getElementById('js-crm-contacts-app');
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: { groupFullPath: el.dataset.groupFullPath },
+ render(createElement) {
+ return createElement(CrmContactsRoot);
+ },
+ });
+};
diff --git a/app/assets/javascripts/crm/organizations_bundle.js b/app/assets/javascripts/crm/organizations_bundle.js
new file mode 100644
index 00000000000..ac9990b9fb4
--- /dev/null
+++ b/app/assets/javascripts/crm/organizations_bundle.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import CrmOrganizationsRoot from './components/organizations_root.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.getElementById('js-crm-organizations-app');
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: { groupFullPath: el.dataset.groupFullPath },
+ render(createElement) {
+ return createElement(CrmOrganizationsRoot);
+ },
+ });
+};
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index 1d98a42ce58..36430e51dd2 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -2,10 +2,12 @@
import { GlLoadingIcon } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { toYmd } from '~/analytics/shared/utils';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
import { __ } from '~/locale';
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
@@ -19,6 +21,7 @@ export default {
StageTable,
ValueStreamFilters,
ValueStreamMetrics,
+ UrlSync,
},
props: {
noDataSvgPath: {
@@ -54,6 +57,9 @@ export default {
'pagination',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
+ isLoaded() {
+ return !this.isLoading && !this.isLoadingStage;
+ },
displayStageEvents() {
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
@@ -98,6 +104,16 @@ export default {
metricsRequests() {
return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
},
+ query() {
+ return {
+ created_after: toYmd(this.createdAfter),
+ created_before: toYmd(this.createdBefore),
+ stage_id: this.selectedStage?.id || null,
+ sort: this.pagination?.sort || null,
+ direction: this.pagination?.direction || null,
+ page: this.pagination?.page || null,
+ };
+ },
},
methods: {
...mapActions([
@@ -176,5 +192,6 @@ export default {
:pagination="pagination"
@handleUpdatePagination="onHandleUpdatePagination"
/>
+ <url-sync v-if="isLoaded" :query="query" />
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/metric_popover.vue b/app/assets/javascripts/cycle_analytics/components/metric_popover.vue
new file mode 100644
index 00000000000..8d90e7b2392
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/metric_popover.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlPopover, GlLink, GlIcon } from '@gitlab/ui';
+
+export default {
+ name: 'MetricPopover',
+ components: {
+ GlPopover,
+ GlLink,
+ GlIcon,
+ },
+ props: {
+ metric: {
+ type: Object,
+ required: true,
+ },
+ target: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ metricLinks() {
+ return this.metric.links?.filter((link) => !link.docs_link) || [];
+ },
+ docsLink() {
+ return this.metric.links?.find((link) => link.docs_link);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover :target="target" placement="bottom">
+ <template #title>
+ <span class="gl-display-block gl-text-left" data-testid="metric-label">{{
+ metric.label
+ }}</span>
+ </template>
+ <div
+ v-for="(link, idx) in metricLinks"
+ :key="`link-${idx}`"
+ class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1"
+ data-testid="metric-link"
+ >
+ <span>{{ link.label }}</span>
+ <gl-link :href="link.url" class="gl-font-sm">
+ {{ link.name }}
+ </gl-link>
+ </div>
+ <span v-if="metric.description" data-testid="metric-description">{{ metric.description }}</span>
+ <gl-link
+ v-if="docsLink"
+ :href="docsLink.url"
+ class="gl-font-sm"
+ target="_blank"
+ data-testid="metric-docs-link"
+ >{{ docsLink.label }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
index 7371ffd2c7c..9671742e564 100644
--- a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
@@ -1,11 +1,13 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { flatten } from 'lodash';
import createFlash from '~/flash';
import { sprintf, s__ } from '~/locale';
+import { redirectTo } from '~/lib/utils/url_utility';
import { METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils';
+import MetricPopover from './metric_popover.vue';
const requestData = ({ request, endpoint, path, params, name }) => {
return request({ endpoint, params, requestPath: path })
@@ -31,9 +33,9 @@ const fetchMetricsData = (reqs = [], path, params) => {
export default {
name: 'ValueStreamMetrics',
components: {
- GlPopover,
GlSingleStat,
GlSkeletonLoading,
+ MetricPopover,
},
props: {
requestPath: {
@@ -76,32 +78,33 @@ export default {
this.isLoading = false;
});
},
+ hasLinks(links) {
+ return links?.length && links[0].url;
+ },
+ clickHandler({ links }) {
+ if (this.hasLinks(links)) {
+ redirectTo(links[0].url);
+ }
+ },
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics">
- <div v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6">
- <gl-skeleton-loading />
+ <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6" />
+ <div v-for="metric in metrics" v-show="!isLoading" :key="metric.key" class="gl-my-6 gl-pr-9">
+ <gl-single-stat
+ :id="metric.key"
+ :value="`${metric.value}`"
+ :title="metric.label"
+ :unit="metric.unit || ''"
+ :should-animate="true"
+ :animation-decimal-places="1"
+ :class="{ 'gl-hover-cursor-pointer': hasLinks(metric.links) }"
+ tabindex="0"
+ @click="clickHandler(metric)"
+ />
+ <metric-popover :metric="metric" :target="metric.key" />
</div>
- <template v-else>
- <div v-for="metric in metrics" :key="metric.key" class="gl-my-6 gl-pr-9">
- <gl-single-stat
- :id="metric.key"
- :value="`${metric.value}`"
- :title="metric.label"
- :unit="metric.unit || ''"
- :should-animate="true"
- :animation-decimal-places="1"
- tabindex="0"
- />
- <gl-popover :target="metric.key" placement="bottom">
- <template #title>
- <span class="gl-display-block gl-text-left">{{ metric.label }}</span>
- </template>
- <span v-if="metric.description">{{ metric.description }}</span>
- </gl-popover>
- </div>
- </template>
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index c205aa1e831..7d5822b0824 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -5,8 +5,6 @@ import {
} from '~/api/analytics_api';
import { __, s__ } from '~/locale';
-export const DEFAULT_DAYS_IN_PAST = 30;
-export const DEFAULT_DAYS_TO_DISPLAY = 30;
export const OVERVIEW_STAGE_ID = 'overview';
export const DEFAULT_VALUE_STREAM = {
@@ -47,6 +45,11 @@ export const METRICS_POPOVER_CONTENT = {
"ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
),
},
+ 'lead-time-for-changes': {
+ description: s__(
+ 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.',
+ ),
+ },
'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
index 34ef03409b8..3da8696edeb 100644
--- a/app/assets/javascripts/cycle_analytics/index.js
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -1,44 +1,36 @@
import Vue from 'vue';
+import {
+ extractFilterQueryParameters,
+ extractPaginationQueryParameters,
+} from '~/analytics/shared/utils';
import Translate from '../vue_shared/translate';
import CycleAnalytics from './components/base.vue';
-import { DEFAULT_DAYS_TO_DISPLAY } from './constants';
import createStore from './store';
-import { calculateFormattedDayInPast } from './utils';
+import { buildCycleAnalyticsInitialData } from './utils';
Vue.use(Translate);
export default () => {
const store = createStore();
const el = document.querySelector('#js-cycle-analytics');
- const {
- noAccessSvgPath,
- noDataSvgPath,
- requestPath,
- fullPath,
- projectId,
- groupId,
- groupPath,
- labelsPath,
- milestonesPath,
- } = el.dataset;
+ const { noAccessSvgPath, noDataSvgPath } = el.dataset;
+ const initialData = buildCycleAnalyticsInitialData({ ...el.dataset, gon });
- const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
+ const pagination = extractPaginationQueryParameters(window.location.search);
+ const {
+ selectedAuthor,
+ selectedMilestone,
+ selectedAssigneeList,
+ selectedLabelList,
+ } = extractFilterQueryParameters(window.location.search);
store.dispatch('initializeVsa', {
- projectId: parseInt(projectId, 10),
- endpoints: {
- requestPath,
- fullPath,
- labelsPath,
- milestonesPath,
- groupId: parseInt(groupId, 10),
- groupPath,
- },
- features: {
- cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
- },
- createdBefore: new Date(now),
- createdAfter: new Date(past),
+ ...initialData,
+ selectedAuthor,
+ selectedMilestone,
+ selectedAssigneeList,
+ selectedLabelList,
+ pagination,
});
// eslint-disable-next-line no-new
@@ -52,7 +44,6 @@ export default () => {
props: {
noDataSvgPath,
noAccessSvgPath,
- fullPath,
},
}),
});
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index 24b62849db7..e0156b24f9d 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -14,7 +14,7 @@ import * as types from './mutation_types';
export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => {
commit(types.SET_SELECTED_VALUE_STREAM, valueStream);
- return Promise.all([dispatch('fetchValueStreamStages'), dispatch('fetchCycleAnalyticsData')]);
+ return dispatch('fetchValueStreamStages');
};
export const fetchValueStreamStages = ({ commit, state }) => {
@@ -46,10 +46,8 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => {
} = state;
commit(types.REQUEST_VALUE_STREAMS);
- const stageRequests = ['setSelectedStage', 'fetchStageMedians', 'fetchStageCountValues'];
return getProjectValueStreams(fullPath)
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
- .then(() => Promise.all(stageRequests.map((r) => dispatch(r))))
.catch(({ response: { status } }) => {
commit(types.RECEIVE_VALUE_STREAMS_ERROR, status);
});
@@ -153,33 +151,36 @@ export const fetchStageCountValues = ({
});
};
-export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => {
- const stage = selectedStage || stages[0];
- commit(types.SET_SELECTED_STAGE, stage);
- return dispatch('fetchStageData');
+export const fetchValueStreamStageData = ({ dispatch }) =>
+ Promise.all([
+ dispatch('fetchCycleAnalyticsData'),
+ dispatch('fetchStageData'),
+ dispatch('fetchStageMedians'),
+ dispatch('fetchStageCountValues'),
+ ]);
+
+export const refetchStageData = async ({ dispatch, commit }) => {
+ commit(types.SET_LOADING, true);
+ await dispatch('fetchValueStreamStageData');
+ commit(types.SET_LOADING, false);
};
-export const setLoading = ({ commit }, value) => commit(types.SET_LOADING, value);
-
-const refetchStageData = (dispatch) => {
- return Promise.resolve()
- .then(() => dispatch('setLoading', true))
- .then(() =>
- Promise.all([
- dispatch('fetchCycleAnalyticsData'),
- dispatch('fetchStageData'),
- dispatch('fetchStageMedians'),
- dispatch('fetchStageCountValues'),
- ]),
- )
- .finally(() => dispatch('setLoading', false));
+export const setSelectedStage = ({ dispatch, commit }, selectedStage = null) => {
+ commit(types.SET_SELECTED_STAGE, selectedStage);
+ return dispatch('refetchStageData');
};
-export const setFilters = ({ dispatch }) => refetchStageData(dispatch);
+export const setFilters = ({ dispatch }) => dispatch('refetchStageData');
export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore }) => {
commit(types.SET_DATE_RANGE, { createdAfter, createdBefore });
- return refetchStageData(dispatch);
+ return dispatch('refetchStageData');
+};
+
+export const setInitialStage = ({ dispatch, commit, state: { stages } }, stage) => {
+ const selectedStage = stage || stages[0];
+ commit(types.SET_SELECTED_STAGE, selectedStage);
+ return dispatch('fetchValueStreamStageData');
};
export const updateStageTablePagination = (
@@ -190,12 +191,18 @@ export const updateStageTablePagination = (
return dispatch('fetchStageData', selectedStage.id);
};
-export const initializeVsa = ({ commit, dispatch }, initialData = {}) => {
+export const initializeVsa = async ({ commit, dispatch }, initialData = {}) => {
commit(types.INITIALIZE_VSA, initialData);
const {
endpoints: { fullPath, groupPath, milestonesPath = '', labelsPath = '' },
+ selectedAuthor,
+ selectedMilestone,
+ selectedAssigneeList,
+ selectedLabelList,
+ selectedStage = null,
} = initialData;
+
dispatch('filters/setEndpoints', {
labelsEndpoint: labelsPath,
milestonesEndpoint: milestonesPath,
@@ -203,7 +210,15 @@ export const initializeVsa = ({ commit, dispatch }, initialData = {}) => {
projectEndpoint: fullPath,
});
- return dispatch('setLoading', true)
- .then(() => dispatch('fetchValueStreams'))
- .finally(() => dispatch('setLoading', false));
+ dispatch('filters/initialize', {
+ selectedAuthor,
+ selectedMilestone,
+ selectedAssigneeList,
+ selectedLabelList,
+ });
+
+ commit(types.SET_LOADING, true);
+ await dispatch('fetchValueStreams');
+ await dispatch('setInitialStage', selectedStage);
+ commit(types.SET_LOADING, false);
};
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
index 3c6267bac06..9af63f5f9cc 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -1,7 +1,4 @@
-import dateFormat from 'dateformat';
-import { dateFormats } from '~/analytics/shared/constants';
import { hideFlash } from '~/flash';
-import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
import { slugify } from '~/lib/utils/text_utility';
@@ -74,23 +71,6 @@ export const formatMedianValues = (medians = []) =>
export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
-const toIsoFormat = (d) => dateFormat(d, dateFormats.isoDate);
-
-/**
- * Takes an integer specifying the number of days to subtract
- * from the date specified will return the 2 dates, formatted as ISO dates
- *
- * @param {Number} daysInPast - Number of days in the past to subtract
- * @param {Date} [today=new Date] - Date to subtract days from, defaults to today
- * @returns {Object} Returns 'now' and the 'past' date formatted as ISO dates
- */
-export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => {
- return {
- now: toIsoFormat(today),
- past: toIsoFormat(getDateInPast(today, daysInPast)),
- };
-};
-
/**
* @typedef {Object} MetricData
* @property {String} title - Title of the metric measured
@@ -123,3 +103,43 @@ export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
description: popoverContent[key]?.description || '',
};
});
+
+const extractFeatures = (gon) => ({
+ cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
+});
+
+/**
+ * Builds the initial data object for Value Stream Analytics with data loaded from the backend
+ *
+ * @param {Object} dataset - dataset object paseed to the frontend via data-* properties
+ * @returns {Object} - The initial data to load the app with
+ */
+export const buildCycleAnalyticsInitialData = ({
+ fullPath,
+ requestPath,
+ projectId,
+ groupId,
+ groupPath,
+ labelsPath,
+ milestonesPath,
+ stage,
+ createdAfter,
+ createdBefore,
+ gon,
+} = {}) => {
+ return {
+ projectId: parseInt(projectId, 10),
+ endpoints: {
+ requestPath,
+ fullPath,
+ labelsPath,
+ milestonesPath,
+ groupId: parseInt(groupId, 10),
+ groupPath,
+ },
+ createdAfter: new Date(createdAfter),
+ createdBefore: new Date(createdBefore),
+ selectedStage: stage ? JSON.parse(stage) : null,
+ features: extractFeatures(gon),
+ };
+};
diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
index e026391ae22..fdf8b7796bf 100644
--- a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
+++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
@@ -62,7 +62,7 @@ export default {
</gl-sprintf>
{{ s__('DeployTokens|This action cannot be undone.') }}
<template #modal-footer>
- <gl-button category="secondary" @click="cancelHandler">{{ s__('Cancel') }}</gl-button>
+ <gl-button category="secondary" @click="cancelHandler">{{ __('Cancel') }}</gl-button>
<gl-button
category="primary"
variant="danger"
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
index fa57537f74e..5cf32cb7fe3 100644
--- a/app/assets/javascripts/design_management/graphql.js
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -88,7 +88,6 @@ const defaultClient = createDefaultClient(
fragmentMatcher,
},
typeDefs,
- assumeImmutableResults: true,
},
);
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 5092c30aa60..42d5d8fb359 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -4,7 +4,7 @@ import VueDraggable from 'vuedraggable';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
-import { getFilename } from '~/lib/utils/file_upload';
+import { getFilename, validateImageName } from '~/lib/utils/file_upload';
import { __, s__, sprintf } from '~/locale';
import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import DeleteButton from '../components/delete_button.vue';
@@ -284,12 +284,16 @@ export default {
return;
}
event.preventDefault();
- let filename = getFilename(event);
- if (!filename || filename === 'image.png') {
- filename = `design_${Date.now()}.png`;
- }
- const newFile = new File([files[0]], filename);
- this.onUploadDesign([newFile]);
+ const fileList = [...files];
+ fileList.forEach((file) => {
+ let filename = getFilename(file);
+ filename = validateImageName(file);
+ if (!filename || filename === 'image.png') {
+ filename = `design_${Date.now()}.png`;
+ }
+ const newFile = new File([file], filename);
+ this.onUploadDesign([newFile]);
+ });
}
},
toggleOnPasteListener() {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 465f9836140..f405b82b05b 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -44,6 +44,7 @@ import {
TRACKING_MULTIPLE_FILES_MODE,
} from '../constants';
+import { discussionIntersectionObserverHandlerFactory } from '../utils/discussions';
import diffsEventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
@@ -86,6 +87,9 @@ export default {
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
},
+ provide: {
+ discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
+ },
props: {
endpoint: {
type: String,
@@ -392,8 +396,6 @@ export default {
diffsApp.instrument();
},
created() {
- this.mergeRequestContainers = document.querySelectorAll('.merge-request-container');
-
this.adjustView();
this.subscribeToEvents();
@@ -521,13 +523,6 @@ export default {
} else {
this.removeEventListeners();
}
-
- if (!this.isFluidLayout && this.glFeatures.mrChangesFluidLayout) {
- this.mergeRequestContainers.forEach((el) => {
- el.classList.toggle('limit-container-width', !this.shouldShow);
- el.classList.toggle('container-limited', !this.shouldShow);
- });
- }
},
setEventListeners() {
Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1));
@@ -579,7 +574,7 @@ export default {
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
if (targetIndex >= 0 && targetIndex < this.diffFiles.length) {
- this.scrollToFile(this.diffFiles[targetIndex].file_path);
+ this.scrollToFile({ path: this.diffFiles[targetIndex].file_path });
}
},
setTreeDisplay() {
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 4435a533591..e54fde72847 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -34,6 +34,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -88,6 +89,9 @@ export default {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
};
</script>
@@ -101,7 +105,7 @@ export default {
>
<div
v-if="commit.signature_html"
- v-html="commit.signature_html /* eslint-disable-line vue/no-v-html */"
+ v-safe-html:[$options.safeHtmlConfig]="commit.signature_html"
></div>
<commit-pipeline-status
v-if="commit.pipeline_status_path"
@@ -142,9 +146,9 @@ export default {
<div class="commit-detail flex-list">
<div class="commit-content" data-qa-selector="commit_content">
<a
+ v-safe-html:[$options.safeHtmlConfig]="commit.title_html"
:href="commit.commit_url"
class="commit-row-message item-title"
- v-html="commit.title_html /* eslint-disable-line vue/no-v-html */"
></a>
<span class="commit-row-message d-block d-sm-none">&middot; {{ commit.short_id }}</span>
@@ -174,9 +178,9 @@ export default {
<div>
<pre
v-if="commit.description_html"
+ v-safe-html:[$options.safeHtmlConfig]="commitDescription"
:class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }"
class="commit-row-description gl-mb-3 gl-text-body"
- v-html="commitDescription /* eslint-disable-line vue/no-v-html */"
></pre>
</div>
</li>
diff --git a/app/assets/javascripts/diffs/components/diff_comment_cell.vue b/app/assets/javascripts/diffs/components/diff_comment_cell.vue
index 4af4b46f94c..a4fae652d02 100644
--- a/app/assets/javascripts/diffs/components/diff_comment_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_comment_cell.vue
@@ -29,6 +29,11 @@ export default {
required: false,
default: false,
},
+ lineRange: {
+ type: Object,
+ required: false,
+ default: null,
+ },
linePosition: {
type: String,
required: false,
@@ -59,6 +64,7 @@ export default {
<diff-line-note-form
:diff-file-hash="diffFileHash"
:line="line"
+ :range="lineRange"
:note-target-line="line"
:help-page-path="helpPagePath"
:line-position="linePosition"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 4bcb99424db..238f07ac22c 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -14,7 +14,6 @@ import {
import { escape } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
-import { diffViewerModes } from '~/ide/constants';
import { scrollToElement } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
@@ -50,7 +49,7 @@ export default {
mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.diffFile.file_hash })],
i18n: {
...DIFF_FILE_HEADER,
- compareButtonLabel: s__('Compare submodule commit revisions'),
+ compareButtonLabel: __('Compare submodule commit revisions'),
},
props: {
discussionPath: {
@@ -130,7 +129,7 @@ export default {
const truncatedOldSha = escape(truncateSha(this.diffFile.submodule_compare.old_sha));
const truncatedNewSha = escape(truncateSha(this.diffFile.submodule_compare.new_sha));
return sprintf(
- s__('Compare %{oldCommitId}...%{newCommitId}'),
+ __('Compare %{oldCommitId}...%{newCommitId}'),
{
oldCommitId: `<span class="commit-sha">${truncatedOldSha}</span>`,
newCommitId: `<span class="commit-sha">${truncatedNewSha}</span>`,
@@ -181,7 +180,7 @@ export default {
return this.diffFile.renamed_file;
},
isModeChanged() {
- return this.diffFile.viewer.name === diffViewerModes.mode_changed;
+ return this.diffFile.mode_changed;
},
expandDiffToFullFileTitle() {
if (this.diffFile.isShowingFullFile) {
@@ -221,7 +220,7 @@ export default {
'toggleFileDiscussions',
'toggleFileDiscussionWrappers',
'toggleFullDiff',
- 'toggleActiveFileByHash',
+ 'setCurrentFileHash',
'reviewFile',
'setFileCollapsedByUser',
]),
@@ -244,7 +243,7 @@ export default {
scrollToElement(document.querySelector(selector));
window.location.hash = selector;
if (!this.viewDiffsFileByFile) {
- this.toggleActiveFileByHash(this.diffFile.file_hash);
+ this.setCurrentFileHash(this.diffFile.file_hash);
}
}
},
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 c445989f143..9d355c96af1 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -2,6 +2,7 @@
import { mapState, mapGetters, mapActions } from 'vuex';
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 {
@@ -32,6 +33,11 @@ export default {
type: Object,
required: true,
},
+ range: {
+ type: Object,
+ required: false,
+ default: null,
+ },
linePosition: {
type: String,
required: false,
@@ -49,6 +55,7 @@ export default {
},
data() {
return {
+ lines: null,
commentLineStart: {
line_code: this.line.line_code,
type: this.line.type,
@@ -116,10 +123,8 @@ export default {
return commentLineOptions(lines, this.line, this.line.line_code, side);
},
commentLines() {
- if (!this.selectedCommentPosition) return [];
-
const lines = [];
- const { start, end } = this.selectedCommentPosition;
+ const { start, end } = this.lines;
const diffLines = this.diffFile[INLINE_DIFF_LINES_KEY];
let isAdding = false;
@@ -144,6 +149,13 @@ export default {
return lines;
},
},
+ created() {
+ if (this.range) {
+ this.lines = { ...this.range };
+ } else if (this.line) {
+ this.lines = { start: this.line, end: this.line };
+ }
+ },
mounted() {
if (this.isLoggedIn) {
const keys = [
@@ -166,16 +178,16 @@ export default {
'saveDiffDiscussion',
'setSuggestPopoverDismissed',
]),
- handleCancelCommentForm(shouldConfirm, isDirty) {
+ async handleCancelCommentForm(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
- // eslint-disable-next-line no-alert
- if (!window.confirm(msg)) {
+ const confirmed = await confirmAction(msg);
+
+ if (!confirmed) {
return;
}
}
-
this.cancelCommentForm({
lineCode: this.line.line_code,
fileHash: this.diffFileHash,
@@ -189,6 +201,9 @@ export default {
this.handleCancelCommentForm(),
);
},
+ updateStartLine(line) {
+ this.lines.start = line;
+ },
},
};
</script>
@@ -199,7 +214,9 @@ export default {
<multiline-comment-form
v-model="commentLineStart"
:line="line"
+ :line-range="lines"
:comment-line-options="commentLineOptions"
+ @input="updateStartLine"
/>
</div>
<note-form
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 64ded1ca8ca..55c796182ee 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -6,6 +6,7 @@ import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
import { hide } from '~/tooltips';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { pickDirection } from '../utils/diff_line';
import DiffCommentCell from './diff_comment_cell.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
import DiffRow from './diff_row.vue';
@@ -106,6 +107,16 @@ export default {
});
this.idState.dragStart = null;
},
+ singleLineComment(code, line) {
+ const lineDir = pickDirection({ line, code });
+
+ this.idState.updatedLineRange = {
+ start: lineDir,
+ end: lineDir,
+ };
+
+ this.showCommentForm({ lineCode: lineDir.line_code, fileHash: this.diffFile.file_hash });
+ },
isHighlighted(line) {
return isHighlighted(
this.highlightedRow,
@@ -169,7 +180,7 @@ export default {
:index="index"
:is-highlighted="isHighlighted(line)"
:file-line-coverage="fileLineCoverage"
- @showCommentForm="(lineCode) => showCommentForm({ lineCode, fileHash: diffFile.file_hash })"
+ @showCommentForm="(code) => singleLineComment(code, line)"
@setHighlightedRow="setHighlightedRow"
@toggleLineDiscussions="
({ lineCode, expanded }) =>
@@ -193,6 +204,7 @@ export default {
<diff-comment-cell
v-if="line.left && (line.left.renderDiscussion || line.left.hasCommentForm)"
:line="line.left"
+ :line-range="idState.updatedLineRange"
:diff-file-hash="diffFile.file_hash"
:help-page-path="helpPagePath"
line-position="left"
@@ -206,6 +218,7 @@ export default {
<diff-comment-cell
v-if="line.right && (line.right.renderDiscussion || line.right.hasCommentForm)"
:line="line.right"
+ :line-range="idState.updatedLineRange"
:diff-file-hash="diffFile.file_hash"
:line-index="index"
:help-page-path="helpPagePath"
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 41d885d3dc1..85e4199d1c1 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -98,7 +98,7 @@ export default {
:file-row-component="$options.DiffFileRow"
:current-diff-file-id="currentDiffFileId"
@toggleTreeOpen="toggleTreeOpen"
- @clickFile="scrollToFile"
+ @clickFile="(path) => scrollToFile({ path })"
/>
</template>
<p v-else class="prepend-top-20 append-bottom-20 text-center">
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 1b1ab59b2b4..260ebdf2141 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -138,7 +138,7 @@ export default function initDiffsApp(store) {
...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']),
openFile(file) {
window.mrTabs.tabShown('diffs');
- this.scrollToFile(file.path);
+ this.scrollToFile({ path: file.path });
},
},
render(createElement) {
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 5c94c6b803b..692cb913a57 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -85,6 +85,12 @@ export const setBaseConfig = ({ commit }, options) => {
viewDiffsFileByFile,
mrReviews,
});
+
+ Array.from(new Set(Object.values(mrReviews).flat())).forEach((id) => {
+ const viewedId = id.replace(/^hash:/, '');
+
+ commit(types.SET_DIFF_FILE_VIEWED, { id: viewedId, seen: true });
+ });
};
export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
@@ -127,7 +133,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
}
if (!isNoteLink && !state.currentDiffFileId) {
- commit(types.VIEW_DIFF_FILE, diff_files[0]?.file_hash);
+ commit(types.SET_CURRENT_DIFF_FILE, diff_files[0]?.file_hash);
}
if (isNoteLink) {
@@ -143,7 +149,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
!state.diffFiles.some((f) => f.file_hash === state.currentDiffFileId) &&
!isNoteLink
) {
- commit(types.VIEW_DIFF_FILE, state.diffFiles[0].file_hash);
+ commit(types.SET_CURRENT_DIFF_FILE, state.diffFiles[0].file_hash);
}
if (state.diffFiles?.length) {
@@ -248,7 +254,7 @@ export const fetchCoverageFiles = ({ commit, state }) => {
export const setHighlightedRow = ({ commit }, lineCode) => {
const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode);
- commit(types.VIEW_DIFF_FILE, fileHash);
+ commit(types.SET_CURRENT_DIFF_FILE, fileHash);
handleLocationHash();
};
@@ -514,23 +520,25 @@ export const toggleTreeOpen = ({ commit }, path) => {
commit(types.TOGGLE_FOLDER_OPEN, path);
};
-export const toggleActiveFileByHash = ({ commit }, hash) => {
- commit(types.VIEW_DIFF_FILE, hash);
+export const setCurrentFileHash = ({ commit }, hash) => {
+ commit(types.SET_CURRENT_DIFF_FILE, hash);
};
-export const scrollToFile = ({ state, commit, getters }, path) => {
+export const scrollToFile = ({ state, commit, getters }, { path, setHash = true }) => {
if (!state.treeEntries[path]) return;
const { fileHash } = state.treeEntries[path];
- commit(types.VIEW_DIFF_FILE, fileHash);
+ commit(types.SET_CURRENT_DIFF_FILE, fileHash);
if (getters.isVirtualScrollingEnabled) {
eventHub.$emit('scrollToFileHash', fileHash);
- setTimeout(() => {
- window.history.replaceState(null, null, `#${fileHash}`);
- });
+ if (setHash) {
+ setTimeout(() => {
+ window.history.replaceState(null, null, `#${fileHash}`);
+ });
+ }
} else {
document.location.hash = fileHash;
@@ -804,7 +812,7 @@ export const setCurrentDiffFileIdFromNote = ({ commit, state, rootGetters }, not
const fileHash = rootGetters.getDiscussion(note.discussion_id).diff_file?.file_hash;
if (fileHash && state.diffFiles.some((f) => f.file_hash === fileHash)) {
- commit(types.VIEW_DIFF_FILE, fileHash);
+ commit(types.SET_CURRENT_DIFF_FILE, fileHash);
}
};
@@ -812,7 +820,7 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
const fileHash = state.diffFiles[index].file_hash;
document.location.hash = fileHash;
- commit(types.VIEW_DIFF_FILE, fileHash);
+ commit(types.SET_CURRENT_DIFF_FILE, fileHash);
};
export const setFileByFile = ({ state, commit }, { fileByFile }) => {
@@ -848,6 +856,8 @@ export function reviewFile({ commit, state }, { file, reviewed = true }) {
const reviews = markFileReview(state.mrReviews, file, reviewed);
setReviewsForMergeRequest(mrPath, reviews);
+
+ commit(types.SET_DIFF_FILE_VIEWED, { id: file.file_hash, seen: reviewed });
commit(types.SET_MR_FILE_REVIEWS, reviews);
}
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 60836f747f5..51c21c1bfc4 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -20,7 +20,8 @@ export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE';
export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE';
export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN';
export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST';
-export const VIEW_DIFF_FILE = 'VIEW_DIFF_FILE';
+export const SET_CURRENT_DIFF_FILE = 'SET_CURRENT_DIFF_FILE';
+export const SET_DIFF_FILE_VIEWED = 'SET_DIFF_FILE_VIEWED';
export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM';
export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 6bc927b9d1f..4a9df0eafcc 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -254,9 +254,11 @@ export default {
[types.SET_SHOW_TREE_LIST](state, showTreeList) {
state.showTreeList = showTreeList;
},
- [types.VIEW_DIFF_FILE](state, fileId) {
+ [types.SET_CURRENT_DIFF_FILE](state, fileId) {
state.currentDiffFileId = fileId;
- Vue.set(state.viewedDiffFileIds, fileId, true);
+ },
+ [types.SET_DIFF_FILE_VIEWED](state, { id, seen }) {
+ Vue.set(state.viewedDiffFileIds, id, seen);
},
[types.OPEN_DIFF_FILE_COMMENT_FORM](state, formData) {
state.commentForms.push({
diff --git a/app/assets/javascripts/diffs/utils/diff_line.js b/app/assets/javascripts/diffs/utils/diff_line.js
new file mode 100644
index 00000000000..a248cc6318b
--- /dev/null
+++ b/app/assets/javascripts/diffs/utils/diff_line.js
@@ -0,0 +1,10 @@
+export function pickDirection({ line, code } = {}) {
+ const { left, right } = line;
+ let direction = left || right;
+
+ if (right?.line_code === code) {
+ direction = right;
+ }
+
+ return direction;
+}
diff --git a/app/assets/javascripts/diffs/utils/discussions.js b/app/assets/javascripts/diffs/utils/discussions.js
new file mode 100644
index 00000000000..c404705d209
--- /dev/null
+++ b/app/assets/javascripts/diffs/utils/discussions.js
@@ -0,0 +1,76 @@
+function normalize(processable) {
+ const { entry } = processable;
+ const offset = entry.rootBounds.bottom - entry.boundingClientRect.top;
+ const direction =
+ offset < 0 ? 'Up' : 'Down'; /* eslint-disable-line @gitlab/require-i18n-strings */
+
+ return {
+ ...processable,
+ entry: {
+ time: entry.time,
+ type: entry.isIntersecting ? 'intersection' : `scroll${direction}`,
+ },
+ };
+}
+
+function sort({ entry: alpha }, { entry: beta }) {
+ const diff = alpha.time - beta.time;
+ let order = 0;
+
+ if (diff < 0) {
+ order = -1;
+ } else if (diff > 0) {
+ order = 1;
+ } else if (alpha.type === 'intersection' && beta.type === 'scrollUp') {
+ order = 2;
+ } else if (alpha.type === 'scrollUp' && beta.type === 'intersection') {
+ order = -2;
+ }
+
+ return order;
+}
+
+function filter(entry) {
+ return entry.type !== 'scrollDown';
+}
+
+export function discussionIntersectionObserverHandlerFactory() {
+ let unprocessed = [];
+ let timer = null;
+
+ return (processable) => {
+ unprocessed.push(processable);
+
+ if (timer) {
+ clearTimeout(timer);
+ }
+
+ timer = setTimeout(() => {
+ unprocessed
+ .map(normalize)
+ .filter(filter)
+ .sort(sort)
+ .forEach((discussionObservationContainer) => {
+ const {
+ entry: { type },
+ currentDiscussion,
+ isFirstUnresolved,
+ isDiffsPage,
+ functions: { setCurrentDiscussionId, getPreviousUnresolvedDiscussionId },
+ } = discussionObservationContainer;
+
+ if (type === 'intersection') {
+ setCurrentDiscussionId(currentDiscussion.id);
+ } else if (type === 'scrollUp') {
+ setCurrentDiscussionId(
+ isFirstUnresolved
+ ? null
+ : getPreviousUnresolvedDiscussionId(currentDiscussion.id, isDiffsPage),
+ );
+ }
+ });
+
+ unprocessed = [];
+ }, 0);
+ };
+}
diff --git a/app/assets/javascripts/diffs/utils/file_reviews.js b/app/assets/javascripts/diffs/utils/file_reviews.js
index 7a4b1aa6b17..227be4e4a6c 100644
--- a/app/assets/javascripts/diffs/utils/file_reviews.js
+++ b/app/assets/javascripts/diffs/utils/file_reviews.js
@@ -52,8 +52,10 @@ export function markFileReview(reviews, file, reviewed = true) {
if (reviewed) {
fileReviews.add(file.id);
+ fileReviews.add(`hash:${file.file_hash}`);
} else {
fileReviews.delete(file.id);
+ fileReviews.delete(`hash:${file.file_hash}`);
}
updatedReviews[file.file_identifier_hash] = Array.from(fileReviews);
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index f98f63529fc..f404fa4e0e8 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -43,7 +43,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
let pasteText;
let addFileToForm;
let updateAttachingMessage;
- let isImage;
let uploadFile;
formTextarea.wrap('<div class="div-dropzone"></div>');
@@ -173,7 +172,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
return dropzoneInstance.addFile(file);
});
});
- // eslint-disable-next-line consistent-return
+
handlePaste = (event) => {
const pasteEvent = event.originalEvent;
const { clipboardData } = pasteEvent;
@@ -186,32 +185,22 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
const text = converter.convertToTableMarkdown();
pasteText(text);
} else {
- const image = isImage(pasteEvent);
-
- if (image) {
- event.preventDefault();
- const MAX_FILE_NAME_LENGTH = 246;
- const filename = getFilename(pasteEvent) || 'image.png';
- const truncateFilename = truncate(filename, MAX_FILE_NAME_LENGTH);
- const text = `{{${truncateFilename}}}`;
- pasteText(text);
-
- return uploadFile(image.getAsFile(), truncateFilename);
- }
- }
- }
- };
-
- isImage = (data) => {
- let i = 0;
- while (i < data.clipboardData.items.length) {
- const item = data.clipboardData.items[i];
- if (item.type.indexOf('image') !== -1) {
- return item;
+ const fileList = [...clipboardData.files];
+ fileList.forEach((file) => {
+ if (file.type.indexOf('image') !== -1) {
+ event.preventDefault();
+ const MAX_FILE_NAME_LENGTH = 246;
+
+ const filename = getFilename(file) || 'image.png';
+ const truncateFilename = truncate(filename, MAX_FILE_NAME_LENGTH);
+ const text = `{{${truncateFilename}}}`;
+ pasteText(text);
+
+ uploadFile(file, truncateFilename);
+ }
+ });
}
- i += 1;
}
- return false;
};
pasteText = (text, shouldPad) => {
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index d40d19000fb..e855e304d27 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -1,17 +1,9 @@
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { __ } from '~/locale';
-
-export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = __(
- '"el" parameter is required for createInstance()',
-);
+import { s__ } from '~/locale';
export const URI_PREFIX = 'gitlab';
export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
-export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __(
- 'Source Editor instance is required to set up an extension.',
-);
-
export const EDITOR_READY_EVENT = 'editor-ready';
export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor';
@@ -20,6 +12,32 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
+export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__(
+ 'SourceEditor|"el" parameter is required for createInstance()',
+);
+export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__(
+ 'SourceEditor|Source Editor instance is required to set up an extension.',
+);
+export const EDITOR_EXTENSION_DEFINITION_ERROR = s__(
+ 'SourceEditor|Extension definition should be either a class or a function',
+);
+export const EDITOR_EXTENSION_NO_DEFINITION_ERROR = s__(
+ 'SourceEditor|`definition` property is expected on the extension.',
+);
+export const EDITOR_EXTENSION_DEFINITION_TYPE_ERROR = s__(
+ 'SourceEditor|Extension definition should be either class, function, or an Array of definitions.',
+);
+export const EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR = s__(
+ 'SourceEditor|No extension for unuse has been specified.',
+);
+export const EDITOR_EXTENSION_NOT_REGISTERED_ERROR = s__('SourceEditor|%{name} is not registered.');
+export const EDITOR_EXTENSION_NAMING_CONFLICT_ERROR = s__(
+ 'SourceEditor|Name conflict for "%{prop}()" method.',
+);
+export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__(
+ 'SourceEditor|Extensions Store is required to check for an extension.',
+);
+
//
// EXTENSIONS' CONSTANTS
//
diff --git a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js
new file mode 100644
index 00000000000..119a2aea9eb
--- /dev/null
+++ b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js
@@ -0,0 +1,116 @@
+// THIS IS AN EXAMPLE
+//
+// This file contains a basic documented example of the Source Editor extensions'
+// API for your convenience. You can copy/paste it into your own file
+// and adjust as you see fit
+//
+
+export class MyFancyExtension {
+ /**
+ * THE LIFE-CYCLE CALLBACKS
+ */
+
+ /**
+ * Is called before the extension gets used by an instance,
+ * Use `onSetup` to setup Monaco directly:
+ * actions, keystrokes, update options, etc.
+ * Is called only once before the extension gets registered
+ *
+ * @param { Object } [setupOptions] The setupOptions object
+ * @param { Object } [instance] The Source Editor instance
+ */
+ // eslint-disable-next-line class-methods-use-this,no-unused-vars
+ onSetup(setupOptions, instance) {}
+
+ /**
+ * The first thing called after the extension is
+ * registered and used by an instance.
+ * Is called every time the extension is applied
+ *
+ * @param { Object } [instance] The Source Editor instance
+ */
+ // eslint-disable-next-line class-methods-use-this,no-unused-vars
+ onUse(instance) {}
+
+ /**
+ * Is called before un-using an extension. Can be used for time-critical
+ * actions like cleanup, reverting visual changes, and other user-facing
+ * updates.
+ *
+ * @param { Object } [instance] The Source Editor instance
+ */
+ // eslint-disable-next-line class-methods-use-this,no-unused-vars
+ onBeforeUnuse(instance) {}
+
+ /**
+ * Is called right after an extension is removed from an instance (un-used)
+ * Can be used for non time-critical tasks like cleanup on the Monaco level
+ * (removing actions, keystrokes, etc.).
+ * onUnuse() will be executed during the browser's idle period
+ * (https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
+ *
+ * @param { Object } [instance] The Source Editor instance
+ */
+ // eslint-disable-next-line class-methods-use-this,no-unused-vars
+ onUnuse(instance) {}
+
+ /**
+ * The public API of the extension: these are the methods that will be exposed
+ * to the end user
+ * @returns {Object}
+ */
+ provides() {
+ return {
+ basic: () => {
+ // The most basic method not depending on anything
+ // Use: instance.basic();
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return 'Foo Bar';
+ },
+ basicWithProp: () => {
+ // The methods with access to the props of the extension.
+ // The props can be either hardcoded (for example in `onSetup`), or
+ // can be dynamically passed as part of `setupOptions` object when
+ // using the extension.
+ // Use: instance.use({ definition: MyFancyExtension, setupOptions: { foo: 'bar' }});
+ return this.foo;
+ },
+ basicWithPropsAsList: (prop1, prop2) => {
+ // Just a simple method with local props
+ // The props are passed as usually.
+ // Use: instance.basicWithPropsAsList(prop1, prop2);
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `The prop1 is ${prop1}; the prop2 is ${prop2}`;
+ },
+ basicWithInstance: (instance) => {
+ // The method accessing the instance methods: either own or provided
+ // by previously-registered extensions
+ // `instance` is always supplied to all methods in provides() as THE LAST
+ // argument.
+ // You don't need to explicitly pass instance to this method:
+ // Use: instance.basicWithInstance();
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `We have access to the whole Instance! ${instance.alpha()}`;
+ },
+ advancedWithInstanceAndProps: ({ author, book } = {}, firstname, lastname, instance) => {
+ // Advanced method where
+ // { author, book } — are the props passed as an object
+ // prop1, prop2 — are the props passed as simple list
+ // instance — is automatically supplied, no need to pass it to
+ // the method explicitly
+ // Use: instance.advancedWithInstanceAndProps(
+ // {
+ // author: 'Franz Kafka',
+ // book: 'The Transformation'
+ // },
+ // 'Franz',
+ // 'Kafka'
+ // );
+ return `
+The author is ${author}; the book is ${book}
+The author's name is ${firstname}; the last name is ${lastname}
+We have access to the whole Instance! For example, 'instance.alpha()': ${instance.alpha()}`;
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
index 5fa01f03f7e..03c68fed3b1 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
@@ -36,12 +36,24 @@ export class SourceEditorExtension {
});
}
- static highlightLines(instance) {
- const { hash } = window.location;
- if (!hash) {
- return;
- }
- const [start, end] = hash.replace(hashRegexp, '').split('-');
+ static removeHighlights(instance) {
+ Object.assign(instance, {
+ lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []),
+ });
+ }
+
+ /**
+ * Returns a function that can only be invoked once between
+ * each browser screen repaint.
+ * @param {Object} instance - The Source Editor instance
+ * @param {Array} bounds - The [start, end] array with start
+ * and end coordinates for highlighting
+ */
+ static highlightLines(instance, bounds = null) {
+ const [start, end] =
+ bounds && Array.isArray(bounds)
+ ? bounds
+ : window.location.hash?.replace(hashRegexp, '').split('-');
let startLine = start ? parseInt(start, 10) : null;
let endLine = end ? parseInt(end, 10) : startLine;
if (endLine < startLine) {
@@ -51,15 +63,12 @@ export class SourceEditorExtension {
window.requestAnimationFrame(() => {
instance.revealLineInCenter(startLine);
Object.assign(instance, {
- lineDecorations: instance.deltaDecorations(
- [],
- [
- {
- range: new Range(startLine, 1, endLine, 1),
- options: { isWholeLine: true, className: 'active-line-text' },
- },
- ],
- ),
+ lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], [
+ {
+ range: new Range(startLine, 1, endLine, 1),
+ options: { isWholeLine: true, className: 'active-line-text' },
+ },
+ ]),
});
});
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
new file mode 100644
index 00000000000..212e09c8724
--- /dev/null
+++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
@@ -0,0 +1,293 @@
+import { toPath } from 'lodash';
+import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml';
+import { findPair } from 'yaml/util';
+import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
+
+export class YamlEditorExtension extends SourceEditorExtension {
+ /**
+ * Extends the source editor with capabilities for yaml files.
+ *
+ * @param { Instance } instance Source Editor Instance
+ * @param { boolean } enableComments Convert model nodes with the comment
+ * pattern to comments?
+ * @param { string } highlightPath Add a line highlight to the
+ * node specified by this e.g. `"foo.bar[0]"`
+ * @param { * } model Any JS Object that will be stringified and used as the
+ * editor's value. Equivalent to using `setDataModel()`
+ * @param options SourceEditorExtension Options
+ */
+ constructor({
+ instance,
+ enableComments = false,
+ highlightPath = null,
+ model = null,
+ ...options
+ } = {}) {
+ super({
+ instance,
+ options: {
+ ...options,
+ enableComments,
+ highlightPath,
+ },
+ });
+
+ if (model) {
+ YamlEditorExtension.initFromModel(instance, model);
+ }
+
+ instance.onDidChangeModelContent(() => instance.onUpdate());
+ }
+
+ /**
+ * @private
+ */
+ static initFromModel(instance, model) {
+ const doc = new Document(model);
+ if (instance.options.enableComments) {
+ YamlEditorExtension.transformComments(doc);
+ }
+ instance.setValue(doc.toString());
+ }
+
+ /**
+ * @private
+ * This wraps long comments to a maximum line length of 80 chars.
+ *
+ * The `yaml` package does not currently wrap comments. This function
+ * is a local workaround and should be deprecated if
+ * https://github.com/eemeli/yaml/issues/322
+ * is resolved.
+ */
+ static wrapCommentString(string, level = 0) {
+ if (!string) {
+ return null;
+ }
+ if (level < 0 || Number.isNaN(parseInt(level, 10))) {
+ throw Error(`Invalid value "${level}" for variable \`level\``);
+ }
+ const maxLineWidth = 80;
+ const indentWidth = 2;
+ const commentMarkerWidth = '# '.length;
+ const maxLength = maxLineWidth - commentMarkerWidth - level * indentWidth;
+ const lines = [[]];
+ string.split(' ').forEach((word) => {
+ const currentLine = lines.length - 1;
+ if ([...lines[currentLine], word].join(' ').length <= maxLength) {
+ lines[currentLine].push(word);
+ } else {
+ lines.push([word]);
+ }
+ });
+ return lines.map((line) => ` ${line.join(' ')}`).join('\n');
+ }
+
+ /**
+ * @private
+ *
+ * This utilizes `yaml`'s `visit` function to transform nodes with a
+ * comment key pattern to actual comments.
+ *
+ * In Objects, a key of '#' will be converted to a comment at the top of a
+ * property. Any key following the pattern `#|<some key>` will be placed
+ * right before `<some key>`.
+ *
+ * In Arrays, any string that starts with # (including the space), will
+ * be converted to a comment at the position it was in.
+ *
+ * @param { Document } doc
+ * @returns { Document }
+ */
+ static transformComments(doc) {
+ const getLevel = (path) => {
+ const { length } = path.filter((x) => isCollection(x));
+ return length ? length - 1 : 0;
+ };
+
+ visit(doc, {
+ Pair(_, pair, path) {
+ const key = pair.key.value;
+ // If the key is = '#', we add the value as a comment to the parent
+ // We can then remove the node.
+ if (key === '#') {
+ Object.assign(path[path.length - 1], {
+ commentBefore: YamlEditorExtension.wrapCommentString(pair.value.value, getLevel(path)),
+ });
+ return visit.REMOVE;
+ }
+ // If the key starts with `#|`, we want to add a comment to the
+ // corresponding property. We can then remove the node.
+ if (key.startsWith('#|')) {
+ const targetProperty = key.split('|')[1];
+ const target = findPair(path[path.length - 1].items, targetProperty);
+ if (target) {
+ target.key.commentBefore = YamlEditorExtension.wrapCommentString(
+ pair.value.value,
+ getLevel(path),
+ );
+ }
+ return visit.REMOVE;
+ }
+ return undefined; // If the node is not a comment, do nothing with it
+ },
+ // Sequence is basically an array
+ Seq(_, node, path) {
+ let comment = null;
+ const items = node.items.flatMap((child) => {
+ if (comment) {
+ Object.assign(child, { commentBefore: comment });
+ comment = null;
+ }
+ if (
+ isScalar(child) &&
+ child.value &&
+ child.value.startsWith &&
+ child.value.startsWith('#')
+ ) {
+ const commentValue = child.value.replace(/^#\s?/, '');
+ comment = YamlEditorExtension.wrapCommentString(commentValue, getLevel(path));
+ return [];
+ }
+ return child;
+ });
+ Object.assign(node, { items });
+ // Adding a comment in case the last one is a comment
+ if (comment) {
+ Object.assign(node, { comment });
+ }
+ },
+ });
+ return doc;
+ }
+
+ /**
+ * Get the editor's value parsed as a `Document` as defined by the `yaml`
+ * package
+ * @returns {Document}
+ */
+ getDoc() {
+ return parseDocument(this.getValue());
+ }
+
+ /**
+ * Accepts a `Document` as defined by the `yaml` package and
+ * sets the Editor's value to a stringified version of it.
+ * @param { Document } doc
+ */
+ setDoc(doc) {
+ if (this.options.enableComments) {
+ YamlEditorExtension.transformComments(doc);
+ }
+
+ if (!this.getValue()) {
+ this.setValue(doc.toString());
+ } else {
+ this.updateValue(doc.toString());
+ }
+ }
+
+ /**
+ * Returns the parsed value of the Editor's content as JS.
+ * @returns {*}
+ */
+ getDataModel() {
+ return this.getDoc().toJS();
+ }
+
+ /**
+ * Accepts any JS Object and sets the Editor's value to a stringified version
+ * of that value.
+ *
+ * @param value
+ */
+ setDataModel(value) {
+ this.setDoc(new Document(value));
+ }
+
+ /**
+ * Method to be executed when the Editor's <TextModel> was updated
+ */
+ onUpdate() {
+ if (this.options.highlightPath) {
+ this.highlight(this.options.highlightPath);
+ }
+ }
+
+ /**
+ * Set the editors content to the input without recreating the content model.
+ *
+ * @param blob
+ */
+ updateValue(blob) {
+ // Using applyEdits() instead of setValue() ensures that tokens such as
+ // highlighted lines aren't deleted/recreated which causes a flicker.
+ const model = this.getModel();
+ model.applyEdits([
+ {
+ // A nice improvement would be to replace getFullModelRange() with
+ // a range of the actual diff, avoiding re-formatting the document,
+ // but that's something for a later iteration.
+ range: model.getFullModelRange(),
+ text: blob,
+ },
+ ]);
+ }
+
+ /**
+ * Add a line highlight style to the node specified by the path.
+ *
+ * @param {string|null|false} path A path to a node of the Editor's value,
+ * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
+ * highlights.
+ */
+ highlight(path) {
+ if (this.options.highlightPath === path) return;
+ if (!path) {
+ SourceEditorExtension.removeHighlights(this);
+ } else {
+ const res = this.locate(path);
+ SourceEditorExtension.highlightLines(this, res);
+ }
+ this.options.highlightPath = path || null;
+ }
+
+ /**
+ * Return the line numbers of a certain node identified by `path` within
+ * the yaml.
+ *
+ * @param {string} path A path to a node, eg. `foo.bar[0]`
+ * @returns {number[]} Array following the schema `[firstLine, lastLine]`
+ * (both inclusive)
+ *
+ * @throws {Error} Will throw if the path is not found inside the document
+ */
+ locate(path) {
+ if (!path) throw Error(`No path provided.`);
+ const blob = this.getValue();
+ const doc = parseDocument(blob);
+ const pathArray = toPath(path);
+
+ if (!doc.getIn(pathArray)) {
+ throw Error(`The node ${path} could not be found inside the document.`);
+ }
+
+ const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1));
+ let startChar;
+ let endChar;
+ if (isMap(parentNode)) {
+ const node = parentNode.items.find(
+ (item) => item.key.value === pathArray[pathArray.length - 1],
+ );
+ [startChar] = node.key.range;
+ [, , endChar] = node.value.range;
+ } else {
+ const node = doc.getIn(pathArray);
+ [startChar, , endChar] = node.range;
+ }
+ const startSlice = blob.slice(0, startChar);
+ const endSlice = blob.slice(0, endChar);
+ const startLine = (startSlice.match(/\n/g) || []).length + 1;
+ const endLine = (endSlice.match(/\n/g) || []).length;
+ return [startLine, endLine];
+ }
+}
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 0052bc00406..f0db3e5594b 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -63,9 +63,9 @@
"items": {
"type": "object",
"properties": {
- "if": {
- "type": "string"
- },
+ "if": { "$ref": "#/definitions/if" },
+ "changes": { "$ref": "#/definitions/changes" },
+ "exists": { "$ref": "#/definitions/exists" },
"variables": { "$ref": "#/definitions/variables" },
"when": {
"type": "string",
@@ -497,24 +497,9 @@
"type": "object",
"additionalProperties": false,
"properties": {
- "if": {
- "type": "string",
- "description": "Expression to evaluate whether additional attributes should be provided to the job"
- },
- "changes": {
- "type": "array",
- "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file",
- "items": {
- "type": "string"
- }
- },
- "exists": {
- "type": "array",
- "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository",
- "items": {
- "type": "string"
- }
- },
+ "if": { "$ref": "#/definitions/if" },
+ "changes": { "$ref": "#/definitions/changes" },
+ "exists": { "$ref": "#/definitions/exists" },
"variables": { "$ref": "#/definitions/variables" },
"when": { "$ref": "#/definitions/when" },
"start_in": { "$ref": "#/definitions/start_in" },
@@ -541,6 +526,24 @@
]
}
},
+ "if": {
+ "type": "string",
+ "description": "Expression to evaluate whether additional attributes should be provided to the job"
+ },
+ "changes": {
+ "type": "array",
+ "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file",
+ "items": {
+ "type": "string"
+ }
+ },
+ "exists": {
+ "type": "array",
+ "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository",
+ "items": {
+ "type": "string"
+ }
+ },
"variables": {
"type": "object",
"description": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off.",
@@ -555,7 +558,7 @@
},
"start_in": {
"type": "string",
- "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job.",
+ "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job. e.g. '5', 5 seconds, 30 minutes, 1 week, etc. Read more: https://docs.gitlab.com/ee/ci/jobs/job_control.html#run-a-job-after-a-delay",
"minLength": 1
},
"allow_failure": {
@@ -939,7 +942,7 @@
"stage": {
"type": "string",
"description": "Define what stage the job will run in.",
- "default": "test"
+ "minLength": 1
},
"only": {
"$ref": "#/definitions/filter",
diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js
new file mode 100644
index 00000000000..f6bc62a1c09
--- /dev/null
+++ b/app/assets/javascripts/editor/source_editor_extension.js
@@ -0,0 +1,17 @@
+import { EDITOR_EXTENSION_DEFINITION_ERROR } from './constants';
+
+export default class EditorExtension {
+ constructor({ definition, setupOptions } = {}) {
+ if (typeof definition !== 'function') {
+ throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR);
+ }
+ this.name = definition.name; // both class- and fn-based extensions have a name
+ this.setupOptions = setupOptions;
+ // eslint-disable-next-line new-cap
+ this.obj = new definition();
+ }
+
+ get api() {
+ return this.obj.provides?.();
+ }
+}
diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js
new file mode 100644
index 00000000000..e0ca4ea518b
--- /dev/null
+++ b/app/assets/javascripts/editor/source_editor_instance.js
@@ -0,0 +1,271 @@
+/**
+ * @module source_editor_instance
+ */
+
+/**
+ * A Source Editor Extension definition
+ * @typedef {Object} SourceEditorExtensionDefinition
+ * @property {Object} definition
+ * @property {Object} setupOptions
+ */
+
+/**
+ * A Source Editor Extension
+ * @typedef {Object} SourceEditorExtension
+ * @property {Object} obj
+ * @property {string} name
+ * @property {Object} api
+ */
+
+import { isEqual } from 'lodash';
+import { editor as monacoEditor } from 'monaco-editor';
+import { getBlobLanguage } from '~/editor/utils';
+import { logError } from '~/lib/logger';
+import { sprintf } from '~/locale';
+import EditorExtension from './source_editor_extension';
+import {
+ EDITOR_EXTENSION_DEFINITION_TYPE_ERROR,
+ EDITOR_EXTENSION_NAMING_CONFLICT_ERROR,
+ EDITOR_EXTENSION_NO_DEFINITION_ERROR,
+ EDITOR_EXTENSION_NOT_REGISTERED_ERROR,
+ EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR,
+ EDITOR_EXTENSION_STORE_IS_MISSING_ERROR,
+} from './constants';
+
+const utils = {
+ removeExtFromMethod: (method, extensionName, container) => {
+ if (!container) {
+ return;
+ }
+ if (Object.prototype.hasOwnProperty.call(container, method)) {
+ // eslint-disable-next-line no-param-reassign
+ delete container[method];
+ }
+ },
+
+ getStoredExtension: (extensionsStore, name) => {
+ if (!extensionsStore) {
+ logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR);
+ return undefined;
+ }
+ return extensionsStore.get(name);
+ },
+};
+
+/** Class representing a Source Editor Instance */
+export default class EditorInstance {
+ /**
+ * Create a Source Editor Instance
+ * @param {Object} rootInstance - Monaco instance to build on top of
+ * @param {Map} extensionsStore - The global registry for the extension instances
+ * @returns {Object} - A Proxy returning props/methods from either registered extensions, or Source Editor instance, or underlying Monaco instance
+ */
+ constructor(rootInstance = {}, extensionsStore = new Map()) {
+ /** The methods provided by extensions. */
+ this.methods = {};
+
+ const seInstance = this;
+ const getHandler = {
+ get(target, prop, receiver) {
+ const methodExtension =
+ Object.prototype.hasOwnProperty.call(seInstance.methods, prop) &&
+ seInstance.methods[prop];
+ if (methodExtension) {
+ const extension = extensionsStore.get(methodExtension);
+
+ return (...args) => {
+ return extension.api[prop].call(seInstance, ...args, receiver);
+ };
+ }
+ return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver);
+ },
+ set(target, prop, value) {
+ Object.assign(seInstance, {
+ [prop]: value,
+ });
+ return true;
+ },
+ };
+ const instProxy = new Proxy(rootInstance, getHandler);
+
+ /**
+ * Main entry point to apply an extension to the instance
+ * @param {SourceEditorExtensionDefinition}
+ */
+ this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension);
+
+ /**
+ * Main entry point to un-use an extension and remove it from the instance
+ * @param {SourceEditorExtension}
+ */
+ this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension);
+
+ return instProxy;
+ }
+
+ /**
+ * A private dispatcher function for both `use` and `unuse`
+ * @param {Map} extensionsStore - The global registry for the extension instances
+ * @param {Function} fn - A function to route to. Either `this.useExtension` or `this.unuseExtension`
+ * @param {SourceEditorExtensionDefinition[]} extensions - The extensions to use/unuse.
+ * @returns {Function}
+ */
+ static useUnuse(extensionsStore, fn, extensions) {
+ if (Array.isArray(extensions)) {
+ /**
+ * We cut short if the Array is empty and let the destination function to throw
+ * Otherwise, we run the destination function on every entry of the Array
+ */
+ return extensions.length
+ ? extensions.map(fn.bind(this, extensionsStore))
+ : fn.call(this, extensionsStore);
+ }
+ return fn.call(this, extensionsStore, extensions);
+ }
+
+ //
+ // REGISTERING NEW EXTENSION
+ //
+
+ /**
+ * Run all registrations when using an extension
+ * @param {Map} extensionsStore - The global registry for the extension instances
+ * @param {SourceEditorExtensionDefinition} extension - The extension definition to use.
+ * @returns {EditorExtension|*}
+ */
+ useExtension(extensionsStore, extension = {}) {
+ const { definition } = extension;
+ if (!definition) {
+ throw new Error(EDITOR_EXTENSION_NO_DEFINITION_ERROR);
+ }
+ if (typeof definition !== 'function') {
+ throw new Error(EDITOR_EXTENSION_DEFINITION_TYPE_ERROR);
+ }
+
+ // Existing Extension Path
+ const existingExt = utils.getStoredExtension(extensionsStore, definition.name);
+ if (existingExt) {
+ if (isEqual(extension.setupOptions, existingExt.setupOptions)) {
+ return existingExt;
+ }
+ this.unuseExtension(extensionsStore, existingExt);
+ }
+
+ // New Extension Path
+ const extensionInstance = new EditorExtension(extension);
+ const { setupOptions, obj: extensionObj } = extensionInstance;
+ if (extensionObj.onSetup) {
+ extensionObj.onSetup(setupOptions, this);
+ }
+ if (extensionsStore) {
+ this.registerExtension(extensionInstance, extensionsStore);
+ }
+ this.registerExtensionMethods(extensionInstance);
+ return extensionInstance;
+ }
+
+ /**
+ * Register extension in the global extensions store
+ * @param {SourceEditorExtension} extension - Instance of Source Editor extension
+ * @param {Map} extensionsStore - The global registry for the extension instances
+ */
+ registerExtension(extension, extensionsStore) {
+ const { name } = extension;
+ const hasExtensionRegistered =
+ extensionsStore.has(name) &&
+ isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions);
+ if (hasExtensionRegistered) {
+ return;
+ }
+ extensionsStore.set(name, extension);
+ const { obj: extensionObj } = extension;
+ if (extensionObj.onUse) {
+ extensionObj.onUse(this);
+ }
+ }
+
+ /**
+ * Register extension methods in the registry on the instance
+ * @param {SourceEditorExtension} extension - Instance of Source Editor extension
+ */
+ registerExtensionMethods(extension) {
+ const { api, name } = extension;
+
+ if (!api) {
+ return;
+ }
+
+ Object.keys(api).forEach((prop) => {
+ if (this[prop]) {
+ logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop }));
+ } else {
+ this.methods[prop] = name;
+ }
+ }, this);
+ }
+
+ //
+ // UNREGISTERING AN EXTENSION
+ //
+
+ /**
+ * Unregister extension with the cleanup
+ * @param {Map} extensionsStore - The global registry for the extension instances
+ * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
+ */
+ unuseExtension(extensionsStore, extension) {
+ if (!extension) {
+ throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR);
+ }
+ const { name } = extension;
+ const existingExt = utils.getStoredExtension(extensionsStore, name);
+ if (!existingExt) {
+ throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name }));
+ }
+ const { obj: extensionObj } = existingExt;
+ if (extensionObj.onBeforeUnuse) {
+ extensionObj.onBeforeUnuse(this);
+ }
+ this.unregisterExtensionMethods(existingExt);
+ if (extensionObj.onUnuse) {
+ extensionObj.onUnuse(this);
+ }
+ }
+
+ /**
+ * Remove all methods associated with this extension from the registry on the instance
+ * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
+ */
+ unregisterExtensionMethods(extension) {
+ const { api, name } = extension;
+ if (!api) {
+ return;
+ }
+ Object.keys(api).forEach((method) => {
+ utils.removeExtFromMethod(method, name, this.methods);
+ });
+ }
+
+ /**
+ * PUBLIC API OF AN INSTANCE
+ */
+
+ /**
+ * Updates model language based on the path
+ * @param {String} path - blob path
+ */
+ updateModelLanguage(path) {
+ const lang = getBlobLanguage(path);
+ const model = this.getModel();
+ // return monacoEditor.setModelLanguage(model, lang);
+ monacoEditor.setModelLanguage(model, lang);
+ }
+
+ /**
+ * Get the methods returned by extensions.
+ * @returns {Array}
+ */
+ get extensionsAPI() {
+ return Object.keys(this.methods);
+ }
+}
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 7672151af2a..478e3f6aed9 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -9,6 +9,7 @@ let emojiMap = null;
let validEmojiNames = null;
export const FALLBACK_EMOJI_KEY = 'grey_question';
+// Keep the version in sync with `lib/gitlab/emoji.rb`
export const EMOJI_VERSION = '1';
const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index 2eb2be351b3..26ec882472b 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlModal } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
@@ -27,7 +27,7 @@ export default {
},
cancelProps() {
return {
- text: s__('Cancel'),
+ text: __('Cancel'),
};
},
confirmDeleteMessage() {
diff --git a/app/assets/javascripts/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/new_environment_folder.vue
new file mode 100644
index 00000000000..0615bdef537
--- /dev/null
+++ b/app/assets/javascripts/environments/components/new_environment_folder.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui';
+import folderQuery from '../graphql/queries/folder.query.graphql';
+
+export default {
+ components: {
+ GlCollapse,
+ GlIcon,
+ GlBadge,
+ GlLink,
+ },
+ props: {
+ nestedEnvironment: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return { visible: false };
+ },
+ apollo: {
+ folder: {
+ query: folderQuery,
+ variables() {
+ return { environment: this.nestedEnvironment.latest };
+ },
+ },
+ },
+ computed: {
+ icons() {
+ return this.visible
+ ? { caret: 'angle-down', folder: 'folder-open' }
+ : { caret: 'angle-right', folder: 'folder-o' };
+ },
+ count() {
+ return this.folder?.availableCount ?? 0;
+ },
+ folderClass() {
+ return { 'gl-font-weight-bold': this.visible };
+ },
+ folderPath() {
+ return this.nestedEnvironment.latest.folderPath;
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.visible = !this.visible;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-border-b-solid gl-border-gray-100 gl-border-1 gl-px-3 gl-pt-3 gl-pb-5">
+ <div class="gl-w-full gl-display-flex gl-align-items-center" @click="toggleCollapse">
+ <gl-icon
+ class="gl-mr-2 gl-fill-current-color gl-text-gray-500"
+ :name="icons.caret"
+ :size="12"
+ />
+ <gl-icon class="gl-mr-2 gl-fill-current-color gl-text-gray-500" :name="icons.folder" />
+ <div class="gl-mr-2 gl-text-gray-500" :class="folderClass">
+ {{ nestedEnvironment.name }}
+ </div>
+ <gl-badge size="sm" class="gl-mr-auto">{{ count }}</gl-badge>
+ <gl-link v-if="visible" :href="folderPath">{{ s__('Environments|Show all') }}</gl-link>
+ </div>
+ <gl-collapse :visible="visible" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue
new file mode 100644
index 00000000000..a5526f9cd71
--- /dev/null
+++ b/app/assets/javascripts/environments/components/new_environments_app.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
+import environmentAppQuery from '../graphql/queries/environmentApp.query.graphql';
+import EnvironmentFolder from './new_environment_folder.vue';
+
+export default {
+ components: {
+ EnvironmentFolder,
+ GlBadge,
+ GlTab,
+ GlTabs,
+ },
+ apollo: {
+ environmentApp: {
+ query: environmentAppQuery,
+ },
+ },
+ computed: {
+ folders() {
+ return this.environmentApp?.environments.filter((e) => e.size > 1) ?? [];
+ },
+ availableCount() {
+ return this.environmentApp?.availableCount;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-tabs>
+ <gl-tab>
+ <template #title>
+ <span>{{ __('Available') }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ availableCount }}
+ </gl-badge>
+ </template>
+ <environment-folder
+ v-for="folder in folders"
+ :key="folder.name"
+ class="gl-mb-3"
+ :nested-environment="folder"
+ />
+ </gl-tab>
+ </gl-tabs>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/rollback_modal_manager.vue b/app/assets/javascripts/environments/components/rollback_modal_manager.vue
index 6aa7d96fdfd..3a8b9ebcb84 100644
--- a/app/assets/javascripts/environments/components/rollback_modal_manager.vue
+++ b/app/assets/javascripts/environments/components/rollback_modal_manager.vue
@@ -15,7 +15,6 @@ export default {
data() {
return {
environment: null,
- retryPath: '',
visible: false,
};
},
@@ -35,9 +34,9 @@ export default {
name: environmentName,
commitShortSha,
commitUrl,
+ retryUrl: retryPath,
isLastDeployment: parseBoolean(isLastDeployment),
};
- this.retryPath = retryPath;
this.visible = true;
});
});
@@ -51,7 +50,5 @@ export default {
v-model="visible"
:environment="environment"
:has-multiple-commits="false"
- :retry-url="retryPath"
/>
- <div v-else></div>
</template>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index f248e9ec079..206381e0b7e 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -8,7 +8,7 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
export default () => {
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
new file mode 100644
index 00000000000..c734c2fba0c
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -0,0 +1,25 @@
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import environmentApp from './queries/environmentApp.query.graphql';
+import { resolvers } from './resolvers';
+import typeDefs from './typedefs.graphql';
+
+export const apolloProvider = (endpoint) => {
+ const defaultClient = createDefaultClient(resolvers(endpoint), {
+ typeDefs,
+ });
+ const { cache } = defaultClient;
+
+ cache.writeQuery({
+ query: environmentApp,
+ data: {
+ availableCount: 0,
+ environments: [],
+ reviewApp: {},
+ stoppedCount: 0,
+ },
+ });
+ return new VueApollo({
+ defaultClient,
+ });
+};
diff --git a/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql
new file mode 100644
index 00000000000..22dfb8a7a89
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql
@@ -0,0 +1,5 @@
+mutation cancelAutoStop($environment: LocalEnvironment) {
+ cancelAutoStop(environment: $environment) @client {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/mutations/delete_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/delete_environment.mutation.graphql
new file mode 100644
index 00000000000..9bb68857923
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/delete_environment.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deleteEnvironment($environment: LocalEnvironment) {
+ deleteEnvironment(environment: $environment) @client {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/mutations/rollback_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/rollback_environment.mutation.graphql
new file mode 100644
index 00000000000..3db4dc2b9a5
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/rollback_environment.mutation.graphql
@@ -0,0 +1,5 @@
+mutation rollbackEnvironment($environment: LocalEnvironment) {
+ rollbackEnvironment(environment: $environment) @client {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql
new file mode 100644
index 00000000000..7eae0ef4ce4
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql
@@ -0,0 +1,5 @@
+mutation stopEnvironment($environment: LocalEnvironment) {
+ stopEnvironment(environment: $environment) @client {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql
index 04ea5cbcaef..936bf49a1ac 100644
--- a/app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql
+++ b/app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql
@@ -1,4 +1,4 @@
-mutation($input: EnvironmentsCanaryIngressUpdateInput!) {
+mutation updateCanaryIngress($input: EnvironmentsCanaryIngressUpdateInput!) {
environmentsCanaryIngressUpdate(input: $input) {
errors
}
diff --git a/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql b/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql
new file mode 100644
index 00000000000..faa76c0a42c
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql
@@ -0,0 +1,8 @@
+query getEnvironmentApp {
+ environmentApp @client {
+ availableCount
+ environments
+ reviewApp
+ stoppedCount
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql
new file mode 100644
index 00000000000..3292c916b2e
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql
@@ -0,0 +1,7 @@
+query getEnvironmentFolder($environment: NestedLocalEnvironment) {
+ folder(environment: $environment) @client {
+ availableCount
+ environments
+ stoppedCount
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
new file mode 100644
index 00000000000..8322b806370
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -0,0 +1,50 @@
+import axios from '~/lib/utils/axios_utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+const mapNestedEnvironment = (env) => ({
+ ...convertObjectPropsToCamelCase(env, { deep: true }),
+ __typename: 'NestedLocalEnvironment',
+});
+const mapEnvironment = (env) => ({
+ ...convertObjectPropsToCamelCase(env),
+ __typename: 'LocalEnvironment',
+});
+
+export const resolvers = (endpoint) => ({
+ Query: {
+ environmentApp() {
+ return axios.get(endpoint, { params: { nested: true } }).then((res) => ({
+ availableCount: res.data.available_count,
+ environments: res.data.environments.map(mapNestedEnvironment),
+ reviewApp: {
+ ...convertObjectPropsToCamelCase(res.data.review_app),
+ __typename: 'ReviewApp',
+ },
+ stoppedCount: res.data.stopped_count,
+ __typename: 'LocalEnvironmentApp',
+ }));
+ },
+ folder(_, { environment: { folderPath } }) {
+ return axios.get(folderPath, { params: { per_page: 3 } }).then((res) => ({
+ availableCount: res.data.available_count,
+ environments: res.data.environments.map(mapEnvironment),
+ stoppedCount: res.data.stopped_count,
+ __typename: 'LocalEnvironmentFolder',
+ }));
+ },
+ },
+ Mutations: {
+ stopEnvironment(_, { environment: { stopPath } }) {
+ return axios.post(stopPath);
+ },
+ deleteEnvironment(_, { environment: { deletePath } }) {
+ return axios.delete(deletePath);
+ },
+ rollbackEnvironment(_, { environment: { retryUrl } }) {
+ return axios.post(retryUrl);
+ },
+ cancelAutoStop(_, { environment: { autoStopPath } }) {
+ return axios.post(autoStopPath);
+ },
+ },
+});
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
new file mode 100644
index 00000000000..49ea719449e
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -0,0 +1,35 @@
+type LocalEnvironment {
+ id: Int!
+ globalId: ID!
+ name: String!
+ folderPath: String
+ stopPath: String
+ deletePath: String
+ retryUrl: String
+ autoStopPath: String
+}
+
+type NestedLocalEnvironment {
+ name: String!
+ size: Int!
+ latest: LocalEnvironment!
+}
+
+type LocalEnvironmentFolder {
+ environments: [LocalEnvironment!]!
+ availableCount: Int!
+ stoppedCount: Int!
+}
+
+type ReviewApp {
+ canSetupReviewApp: Boolean!
+ allClustersEmpty: Boolean!
+ reviewSnippet: String
+}
+
+type LocalEnvironmentApp {
+ stoppedCount: Int!
+ availableCount: Int!
+ environments: [NestedLocalEnvironment!]!
+ reviewApp: ReviewApp!
+}
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 5e33923d518..3b1d35c1f22 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -9,40 +9,43 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
-export default () => {
- const el = document.getElementById('environments-list-view');
- return new Vue({
- el,
- components: {
- environmentsComponent,
- },
- apolloProvider,
- provide: {
- projectPath: el.dataset.projectPath,
- defaultBranchName: el.dataset.defaultBranchName,
- },
- data() {
- const environmentsData = el.dataset;
+export default (el) => {
+ if (el) {
+ return new Vue({
+ el,
+ components: {
+ environmentsComponent,
+ },
+ apolloProvider,
+ provide: {
+ projectPath: el.dataset.projectPath,
+ defaultBranchName: el.dataset.defaultBranchName,
+ },
+ data() {
+ const environmentsData = el.dataset;
- return {
- endpoint: environmentsData.environmentsDataEndpoint,
- newEnvironmentPath: environmentsData.newEnvironmentPath,
- helpPagePath: environmentsData.helpPagePath,
- canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
- };
- },
- render(createElement) {
- return createElement('environments-component', {
- props: {
- endpoint: this.endpoint,
- newEnvironmentPath: this.newEnvironmentPath,
- helpPagePath: this.helpPagePath,
- canCreateEnvironment: this.canCreateEnvironment,
- },
- });
- },
- });
+ return {
+ endpoint: environmentsData.environmentsDataEndpoint,
+ newEnvironmentPath: environmentsData.newEnvironmentPath,
+ helpPagePath: environmentsData.helpPagePath,
+ canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
+ };
+ },
+ render(createElement) {
+ return createElement('environments-component', {
+ props: {
+ endpoint: this.endpoint,
+ newEnvironmentPath: this.newEnvironmentPath,
+ helpPagePath: this.helpPagePath,
+ canCreateEnvironment: this.canCreateEnvironment,
+ },
+ });
+ },
+ });
+ }
+
+ return null;
};
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 85cff73cc3e..0f9741784d6 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -6,7 +6,7 @@ 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 { 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';
@@ -207,13 +207,13 @@ export default {
tabs() {
return [
{
- name: s__('Available'),
+ name: __('Available'),
scope: 'available',
count: this.state.availableCounter,
isActive: this.scope === 'available',
},
{
- name: s__('Stopped'),
+ name: __('Stopped'),
scope: 'stopped',
count: this.state.stoppedCounter,
isActive: this.scope === 'stopped',
diff --git a/app/assets/javascripts/environments/new_index.js b/app/assets/javascripts/environments/new_index.js
new file mode 100644
index 00000000000..dd5c709c75a
--- /dev/null
+++ b/app/assets/javascripts/environments/new_index.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { parseBoolean } from '../lib/utils/common_utils';
+import { apolloProvider } from './graphql/client';
+import EnvironmentsApp from './components/new_environments_app.vue';
+
+Vue.use(VueApollo);
+
+export default (el) => {
+ if (el) {
+ const {
+ canCreateEnvironment,
+ endpoint,
+ newEnvironmentPath,
+ helpPagePath,
+ projectPath,
+ defaultBranchName,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: apolloProvider(endpoint),
+ provide: {
+ projectPath,
+ defaultBranchName,
+ endpoint,
+ newEnvironmentPath,
+ helpPagePath,
+ canCreateEnvironment: parseBoolean(canCreateEnvironment),
+ },
+ render(h) {
+ return h(EnvironmentsApp);
+ },
+ });
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index 2b8a31da50f..34d01f21da2 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltip, GlSprintf, GlIcon } from '@gitlab/ui';
+import { GlTooltip, GlSprintf, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -12,6 +12,7 @@ export default {
},
directives: {
GlTooltip,
+ SafeHtml,
},
props: {
lines: {
@@ -129,9 +130,9 @@ export default {
{{ lineNum(line) }}
</td>
<td
+ v-safe-html="lineCode(line)"
class="line_content"
:class="{ old: isHighlighted(lineNum(line)) }"
- v-html="lineCode(line) /* eslint-disable-line vue/no-v-html */"
></td>
</tr>
</template>
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
index 624a04fd7c2..dcb6a8e20a3 100644
--- a/app/assets/javascripts/experimentation/utils.js
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -3,11 +3,24 @@ import { get } from 'lodash';
import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants';
function getExperimentsData() {
- return get(window, ['gon', 'experiment'], {});
+ // Pull from deprecated window.gon.experiment
+ const experimentsFromGon = get(window, ['gon', 'experiment'], {});
+ // Pull from preferred window.gl.experiments
+ const experimentsFromGl = get(window, ['gl', 'experiments'], {});
+
+ return { ...experimentsFromGon, ...experimentsFromGl };
}
function convertExperimentDataToExperimentContext(experimentData) {
- return { schema: TRACKING_CONTEXT_SCHEMA, data: experimentData };
+ // Bandaid to allow-list only the properties which the current gitlab_experiment context schema suppports.
+ // See TRACKING_CONTEXT_SCHEMA for current version (1-0-0)
+ // https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-0
+ const { experiment: experimentName, key, variant, migration_keys } = experimentData;
+
+ return {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data: { experiment: experimentName, key, variant, migration_keys },
+ };
}
export function getExperimentData(experimentName) {
@@ -26,14 +39,14 @@ export function getExperimentVariant(experimentName) {
return getExperimentData(experimentName)?.variant || DEFAULT_VARIANT;
}
-export function experiment(experimentName, variants) {
+export function experiment(experimentName, { use, control, candidate, ...variants }) {
const variant = getExperimentVariant(experimentName);
switch (variant) {
case DEFAULT_VARIANT:
- return variants.use.call();
+ return (use || control).call();
case CANDIDATE_VARIANT:
- return variants.try.call();
+ return (variants.try || candidate).call();
default:
return variants[variant].call();
}
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
index d86e13ce722..366ee6bb05b 100644
--- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -213,6 +213,7 @@ export default {
<div
v-if="hasRotateError"
class="gl-text-red-500 gl-display-flex gl-align-items-center gl-font-weight-normal gl-mb-3"
+ data-testid="rotate-error"
>
<gl-icon name="warning" class="gl-mr-2" />
<span>{{ $options.translations.instanceIdRegenerateError }}</span>
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 741171b185a..1287a7ed746 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -9,6 +9,8 @@ const FLASH_TYPES = {
WARNING: 'warning',
};
+const FLASH_CLOSED_EVENT = 'flashClosed';
+
const getCloseEl = (flashEl) => {
return flashEl.querySelector('.js-close-icon');
};
@@ -26,6 +28,7 @@ const hideFlash = (flashEl, fadeTransition = true) => {
() => {
flashEl.remove();
window.dispatchEvent(new Event('resize'));
+ flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT));
if (document.body.classList.contains('flash-shown'))
document.body.classList.remove('flash-shown');
},
@@ -132,4 +135,5 @@ export {
hideFlash,
removeFlashClickListener,
FLASH_TYPES,
+ FLASH_CLOSED_EVENT,
};
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 5dac315d345..1700437aa84 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/require-default-prop */
import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -23,6 +22,7 @@ export default {
matcher: {
type: String,
required: false,
+ default: '',
},
itemId: {
type: Number,
@@ -35,6 +35,7 @@ export default {
namespace: {
type: String,
required: false,
+ default: '',
},
webUrl: {
type: String,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index cb63c86a4fa..69331ff1a06 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import '~/lib/utils/jquery_at_who';
-import { escape as lodashEscape, sortBy, template } from 'lodash';
+import { escape as lodashEscape, sortBy, template, escapeRegExp } from 'lodash';
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
import { s__, __, sprintf } from '~/locale';
@@ -65,6 +65,17 @@ export function membersBeforeSave(members) {
});
}
+export const highlighter = (li, query) => {
+ // override default behaviour to escape dot character
+ // see https://github.com/ichord/At.js/pull/576
+ if (!query) {
+ return li;
+ }
+ const escapedQuery = escapeRegExp(query);
+ const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig');
+ return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`);
+};
+
export const defaultAutocompleteConfig = {
emojis: true,
members: true,
@@ -664,16 +675,7 @@ class GfmAutoComplete {
}
return null;
},
- highlighter(li, query) {
- // override default behaviour to escape dot character
- // see https://github.com/ichord/At.js/pull/576
- if (!query) {
- return li;
- }
- const escapedQuery = query.replace(/[.+]/, '\\$&');
- const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig');
- return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`);
- },
+ highlighter,
};
}
diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue
new file mode 100644
index 00000000000..1e5be9df019
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/app.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlTab, GlTabs } from '@gitlab/ui';
+import IncubationBanner from './incubation_banner.vue';
+import ServiceAccounts from './service_accounts.vue';
+
+export default {
+ components: { GlTab, GlTabs, IncubationBanner, ServiceAccounts },
+ props: {
+ serviceAccounts: {
+ type: Array,
+ required: true,
+ },
+ createServiceAccountUrl: {
+ type: String,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ feedbackUrl(template) {
+ return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new?issuable_template=${template}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner
+ :share-feedback-url="feedbackUrl('general_feedback')"
+ :report-bug-url="feedbackUrl('report_bug')"
+ :feature-request-url="feedbackUrl('feature_request')"
+ />
+ <gl-tabs>
+ <gl-tab :title="__('Configuration')">
+ <service-accounts
+ class="gl-mx-3"
+ :list="serviceAccounts"
+ :create-url="createServiceAccountUrl"
+ :empty-illustration-url="emptyIllustrationUrl"
+ />
+ </gl-tab>
+ <gl-tab :title="__('Deployments')" disabled />
+ <gl-tab :title="__('Services')" disabled />
+ </gl-tabs>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/incubation_banner.vue b/app/assets/javascripts/google_cloud/components/incubation_banner.vue
new file mode 100644
index 00000000000..652b8c1aecb
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/incubation_banner.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: { GlAlert, GlLink, GlSprintf },
+ props: {
+ shareFeedbackUrl: {
+ required: true,
+ type: String,
+ },
+ reportBugUrl: {
+ required: true,
+ type: String,
+ },
+ featureRequestUrl: {
+ required: true,
+ type: String,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert :dismissible="false" variant="info">
+ {{ __('This is an experimental feature developed by GitLab Incubation Engineering.') }}
+ <gl-sprintf
+ :message="
+ __(
+ 'We invite you to %{featureLinkStart}request a feature%{featureLinkEnd}, %{bugLinkStart}report a bug%{bugLinkEnd} or %{feedbackLinkStart}share feedback%{feedbackLinkEnd}',
+ )
+ "
+ >
+ <template #featureLink="{ content }">
+ <gl-link :href="featureRequestUrl">{{ content }}</gl-link>
+ </template>
+ <template #bugLink="{ content }">
+ <gl-link :href="reportBugUrl">{{ content }}</gl-link>
+ </template>
+ <template #feedbackLink="{ content }">
+ <gl-link :href="shareFeedbackUrl">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts.vue b/app/assets/javascripts/google_cloud/components/service_accounts.vue
new file mode 100644
index 00000000000..b70b25a5dc3
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/service_accounts.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: { GlButton, GlEmptyState, GlTable },
+ props: {
+ list: {
+ type: Array,
+ required: true,
+ },
+ createUrl: {
+ type: String,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tableFields: [
+ { key: 'environment', label: __('Environment'), sortable: true },
+ { key: 'gcp_project', label: __('Google Cloud Project'), sortable: true },
+ { key: 'service_account_exists', label: __('Service Account'), sortable: true },
+ { key: 'service_account_key_exists', label: __('Service Account Key'), sortable: true },
+ ],
+ };
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-empty-state
+ v-if="list.length === 0"
+ :title="__('No service accounts')"
+ :description="
+ __('Service Accounts keys authorize GitLab to deploy your Google Cloud project')
+ "
+ :primary-button-link="createUrl"
+ :primary-button-text="__('Create service account')"
+ :svg-path="emptyIllustrationUrl"
+ />
+
+ <div v-else>
+ <h2 class="gl-font-size-h2">{{ __('Service Accounts') }}</h2>
+ <p>{{ __('Service Accounts keys authorize GitLab to deploy your Google Cloud project') }}</p>
+
+ <gl-table :items="list" :fields="tableFields">
+ <template #cell(service_account_exists)="{ value }">
+ {{ value ? '✔' : __('Not found') }}
+ </template>
+ <template #cell(service_account_key_exists)="{ value }">
+ {{ value ? '✔' : __('Not found') }}
+ </template>
+ </gl-table>
+
+ <gl-button :href="createUrl" category="primary" variant="info">
+ {{ __('Create service account') }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js
new file mode 100644
index 00000000000..a156a632e9a
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import App from './components/app.vue';
+
+const elementRenderer = (element, props = {}) => (createElement) =>
+ createElement(element, { props });
+
+export default () => {
+ const root = document.querySelector('#js-google-cloud');
+ const props = JSON.parse(root.getAttribute('data'));
+ return new Vue({ el: root, render: elementRenderer(App, props) });
+};
diff --git a/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql
new file mode 100644
index 00000000000..78a368089a8
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Iteration on Iteration {
+ id
+ title
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql
index e345fe97281..c5f99a1657e 100644
--- a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql
@@ -1,9 +1,10 @@
#import "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
-query usersSearch($search: String!, $fullPath: ID!) {
+query groupUsersSearch($search: String!, $fullPath: ID!) {
workspace: group(fullPath: $fullPath) {
- users: groupMembers(search: $search, relations: [DIRECT, INHERITED]) {
+ id
+ users: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) {
nodes {
user {
...User
diff --git a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
index 1d9497d65ce..62ce27815c7 100644
--- a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
@@ -1,5 +1,6 @@
query searchProjectMembers($fullPath: ID!, $search: String) {
project(fullPath: $fullPath) {
+ id
projectMembers(search: $search) {
nodes {
user {
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index e18eea33041..d04a49f8b3a 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -1,7 +1,7 @@
#import "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
-query usersSearch($search: String!, $fullPath: ID!) {
+query projectUsersSearch($search: String!, $fullPath: ID!) {
workspace: project(fullPath: $fullPath) {
users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
nodes {
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 828ddd95ffc..8fb70eb59bd 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -15,6 +15,8 @@ export const isGid = (id) => {
return false;
};
+const parseGid = (gid) => parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10);
+
/**
* Ids generated by GraphQL endpoints are usually in the format
* gid://gitlab/Environments/123. This method extracts Id number
@@ -23,8 +25,10 @@ export const isGid = (id) => {
* @param {String} gid GraphQL global ID
* @returns {Number}
*/
-export const getIdFromGraphQLId = (gid = '') =>
- parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null;
+export const getIdFromGraphQLId = (gid = '') => {
+ const parsedGid = parseGid(gid);
+ return Number.isInteger(parsedGid) ? parsedGid : null;
+};
export const MutationOperationMode = {
Append: 'APPEND',
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index b6a1f41afb5..f255f8a084c 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,6 +1,6 @@
import createFlash from '~/flash';
import { __ } from '~/locale';
-import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
+import { getGroupPathAvailability } from '~/rest_api';
import { slugify } from './lib/utils/text_utility';
export default class Group {
@@ -51,7 +51,7 @@ export default class Group {
const slug = this.groupPaths[0]?.value || slugify(value);
if (!slug) return;
- fetchGroupPathAvailability(slug, this.parentId?.value)
+ getGroupPathAvailability(slug, this.parentId?.value)
.then(({ data }) => data)
.then(({ exists, suggests }) => {
if (exists && suggests.length) {
diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
index f61d96b3dfd..dcac337c6ef 100644
--- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -3,13 +3,7 @@ import { GlToggle, GlLoadingIcon, GlTooltip, GlAlert } from '@gitlab/ui';
import { debounce } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-import {
- DEBOUNCE_TOGGLE_DELAY,
- ERROR_MESSAGE,
- ENABLED,
- DISABLED,
- ALLOW_OVERRIDE,
-} from '../constants';
+import { DEBOUNCE_TOGGLE_DELAY, ERROR_MESSAGE } from '../constants';
export default {
components: {
@@ -18,21 +12,14 @@ export default {
GlTooltip,
GlAlert,
},
- props: {
- updatePath: {
- type: String,
- required: true,
- },
- sharedRunnersAvailability: {
- type: String,
- required: true,
- },
- parentSharedRunnersAvailability: {
- type: String,
- required: false,
- default: '',
- },
- },
+ inject: [
+ 'updatePath',
+ 'sharedRunnersAvailability',
+ 'parentSharedRunnersAvailability',
+ 'runnerEnabled',
+ 'runnerDisabled',
+ 'runnerAllowOverride',
+ ],
data() {
return {
isLoading: false,
@@ -43,21 +30,21 @@ export default {
},
computed: {
toggleDisabled() {
- return this.parentSharedRunnersAvailability === DISABLED || this.isLoading;
+ return this.parentSharedRunnersAvailability === this.runnerDisabled || this.isLoading;
},
enabledOrDisabledSetting() {
- return this.enabled ? ENABLED : DISABLED;
+ return this.enabled ? this.runnerEnabled : this.runnerDisabled;
},
disabledWithOverrideSetting() {
- return this.allowOverride ? ALLOW_OVERRIDE : DISABLED;
+ return this.allowOverride ? this.runnerAllowOverride : this.runnerDisabled;
},
},
created() {
- if (this.sharedRunnersAvailability !== ENABLED) {
+ if (this.sharedRunnersAvailability !== this.runnerEnabled) {
this.enabled = false;
}
- if (this.sharedRunnersAvailability === ALLOW_OVERRIDE) {
+ if (this.sharedRunnersAvailability === this.runnerAllowOverride) {
this.allowOverride = true;
}
},
diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js
index c7bb851c06b..4067b6b52a3 100644
--- a/app/assets/javascripts/group_settings/constants.js
+++ b/app/assets/javascripts/group_settings/constants.js
@@ -4,8 +4,3 @@ import { __ } from '~/locale';
export const DEBOUNCE_TOGGLE_DELAY = 1000;
export const ERROR_MESSAGE = __('Refresh the page and try again.');
-
-// runner setting options
-export const ENABLED = 'enabled';
-export const DISABLED = 'disabled_and_unoverridable';
-export const ALLOW_OVERRIDE = 'disabled_with_override';
diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js
index 44284204c41..21a2373e2b1 100644
--- a/app/assets/javascripts/group_settings/mount_shared_runners.js
+++ b/app/assets/javascripts/group_settings/mount_shared_runners.js
@@ -4,12 +4,27 @@ import UpdateSharedRunnersForm from './components/shared_runners_form.vue';
export default (containerId = 'update-shared-runners-form') => {
const containerEl = document.getElementById(containerId);
+ const {
+ updatePath,
+ sharedRunnersAvailability,
+ parentSharedRunnersAvailability,
+ runnerEnabled,
+ runnerDisabled,
+ runnerAllowOverride,
+ } = containerEl.dataset;
+
return new Vue({
el: containerEl,
+ provide: {
+ updatePath,
+ sharedRunnersAvailability,
+ parentSharedRunnersAvailability,
+ runnerEnabled,
+ runnerDisabled,
+ runnerAllowOverride,
+ },
render(createElement) {
- return createElement(UpdateSharedRunnersForm, {
- props: containerEl.dataset,
- });
+ return createElement(UpdateSharedRunnersForm);
},
});
};
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
index 9c379d7bf9b..a51edd385dd 100644
--- a/app/assets/javascripts/groups/components/item_caret.vue
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -22,6 +22,6 @@ export default {
<template>
<span class="folder-caret gl-mr-2">
- <gl-icon :size="10" :name="iconClass" use-deprecated-sizes />
+ <gl-icon :size="12" :name="iconClass" />
</span>
</template>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index 96cb4f3d495..55ae5501cdb 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import { throttle } from 'lodash';
import { mapActions, mapState } from 'vuex';
import { __ } from '../../../locale';
@@ -14,6 +14,7 @@ const scrollPositions = {
export default {
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
components: {
GlButton,
@@ -100,8 +101,8 @@ export default {
<pre ref="buildJobLog" class="build-log mb-0 h-100 mr-3" @scroll="scrollBuildLog">
<code
v-show="!detailJob.isLoading"
+ v-safe-html="jobOutput"
class="bash"
- v-html="jobOutput /* eslint-disable-line vue/no-v-html */"
>
</code>
<div
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index e8541d3a4c3..1c5a00568eb 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -2,7 +2,7 @@
import { GlModal, GlButton } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import createFlash from '~/flash';
-import { __, sprintf, s__ } from '~/locale';
+import { __, sprintf } from '~/locale';
import { modalTypes } from '../../constants';
import { trimPathComponents, getPathParent } from '../../utils';
@@ -58,7 +58,7 @@ export default {
if (this.modalType === modalTypes.rename) {
if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
createFlash({
- message: sprintf(s__('The name "%{name}" is already taken in this directory.'), {
+ message: sprintf(__('The name "%{name}" is already taken in this directory.'), {
name: this.entryName,
}),
fadeTransition: false,
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 907ac496982..e1caf1ba44a 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -87,7 +87,7 @@ export default {
v-if="!latestPipeline"
:empty-state-svg-path="pipelinesEmptyStateSvgPath"
:can-set-ci="true"
- class="mb-auto mt-auto"
+ class="gl-p-5"
/>
<gl-alert
v-else-if="latestPipeline.yamlError"
diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
new file mode 100644
index 00000000000..7fca7429ad7
--- /dev/null
+++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
@@ -0,0 +1,137 @@
+<script>
+import { GlIcon, GlPopover } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlPopover,
+ },
+ props: {
+ text: {
+ type: String,
+ required: true,
+ },
+ placeholder: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ scrollTop: 0,
+ isFocused: false,
+ };
+ },
+ computed: {
+ allLines() {
+ return this.text.split('\n').map((line, i) => ({
+ text: line.substr(0, this.getLineLength(i)) || ' ',
+ highlightedText: line.substr(this.getLineLength(i)),
+ }));
+ },
+ },
+ methods: {
+ handleScroll() {
+ if (this.$refs.textarea) {
+ this.$nextTick(() => {
+ this.scrollTop = this.$refs.textarea.scrollTop;
+ });
+ }
+ },
+ getLineLength(i) {
+ return i === 0 ? MAX_TITLE_LENGTH : MAX_BODY_LENGTH;
+ },
+ onInput(e) {
+ this.$emit('input', e.target.value);
+ },
+ onCtrlEnter() {
+ if (!this.isFocused) return;
+ this.$emit('submit');
+ },
+ updateIsFocused(isFocused) {
+ this.isFocused = isFocused;
+ },
+ },
+ popoverOptions: {
+ triggers: 'hover',
+ placement: 'top',
+ content: sprintf(
+ __(`
+ The character highlighter helps you keep the subject line to %{titleLength} characters
+ and wrap the body at %{bodyLength} so they are readable in git.
+ `),
+ { titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH },
+ ),
+ },
+};
+</script>
+
+<template>
+ <fieldset
+ class="gl-rounded-base gl-inset-border-1-gray-400 gl-py-4 gl-px-5"
+ :class="{
+ 'gl-outline-none! gl-focus-ring-border-1-gray-900!': isFocused,
+ }"
+ >
+ <div
+ v-once
+ class="gl-display-flex gl-align-items-center gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-mb-3"
+ >
+ <div>{{ __('Commit Message') }}</div>
+ <div id="commit-message-popover-container">
+ <span id="commit-message-question" class="gl-gray-700 gl-ml-3">
+ <gl-icon name="question" />
+ </span>
+ <gl-popover
+ target="commit-message-question"
+ container="commit-message-popover-container"
+ v-bind="$options.popoverOptions"
+ />
+ </div>
+ </div>
+ <div class="gl-relative gl-w-full gl-h-13 gl-overflow-hidden">
+ <div class="gl-absolute gl-z-index-1 gl-font-monospace gl-text-transparent">
+ <div
+ data-testid="highlights"
+ :style="{
+ transform: `translate3d(0, ${-scrollTop}px, 0)`,
+ }"
+ >
+ <div v-for="(line, index) in allLines" :key="index">
+ <span
+ data-testid="highlights-text"
+ class="gl-white-space-pre-wrap gl-word-break-word"
+ v-text="line.text"
+ >
+ </span
+ ><mark
+ v-show="line.highlightedText"
+ data-testid="highlights-mark"
+ class="gl-px-1 gl-py-0 gl-bg-orange-100 gl-text-transparent gl-white-space-pre-wrap gl-word-break-word"
+ v-text="line.highlightedText"
+ >
+ </mark>
+ </div>
+ </div>
+ </div>
+ <textarea
+ ref="textarea"
+ :placeholder="placeholder"
+ :value="text"
+ class="gl-absolute gl-w-full gl-h-full gl-z-index-2 gl-font-monospace p-0 gl-outline-0 gl-bg-transparent gl-border-0"
+ data-qa-selector="ide_commit_message_field"
+ dir="auto"
+ name="commit-message"
+ @scroll="handleScroll"
+ @input="onInput"
+ @focus="updateIsFocused(true)"
+ @blur="updateIsFocused(false)"
+ @keydown.ctrl.enter="onCtrlEnter"
+ @keydown.meta.enter="onCtrlEnter"
+ >
+ </textarea>
+ </div>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 4845b667b40..706d98fdb90 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -5,7 +5,7 @@ export const MAX_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72;
export const SIDEBAR_INIT_WIDTH = 340;
-export const SIDEBAR_MIN_WIDTH = 340;
+export const SIDEBAR_MIN_WIDTH = 260;
export const SIDEBAR_NAV_WIDTH = 60;
// File view modes
diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue
index 5ba910746ca..25d4037bbe5 100644
--- a/app/assets/javascripts/import_entities/components/group_dropdown.vue
+++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue
@@ -19,7 +19,7 @@ export default {
computed: {
filteredNamespaces() {
return this.namespaces.filter((ns) =>
- ns.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
},
},
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
index 104c84173fc..e004bc35087 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
@@ -1,7 +1,5 @@
<script>
import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
-import { joinPaths } from '~/lib/utils/url_utility';
-import { isFinished, isInvalid, isAvailableForImport } from '../utils';
export default {
components: {
@@ -12,32 +10,17 @@ export default {
GlTooltip,
},
props: {
- group: {
- type: Object,
+ isFinished: {
+ type: Boolean,
required: true,
},
- groupPathRegex: {
- type: RegExp,
+ isAvailableForImport: {
+ type: Boolean,
required: true,
},
- },
- computed: {
- fullLastImportPath() {
- return this.group.last_import_target
- ? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
- : null;
- },
- absoluteLastImportPath() {
- return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
- },
- isAvailableForImport() {
- return isAvailableForImport(this.group);
- },
- isFinished() {
- return isFinished(this.group);
- },
- isInvalid() {
- return isInvalid(this.group, this.groupPathRegex);
+ isInvalid: {
+ type: Boolean,
+ required: true,
},
},
};
@@ -56,7 +39,7 @@ export default {
{{ isFinished ? __('Re-import') : __('Import') }}
</gl-button>
<gl-icon
- v-if="isFinished"
+ v-if="isAvailableForImport && isFinished"
v-gl-tooltip
:size="16"
name="information-o"
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue
index 2de9bd4f868..cad1b983d61 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue
@@ -1,7 +1,6 @@
<script>
import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
-import { isFinished } from '../utils';
export default {
components: {
@@ -17,16 +16,13 @@ export default {
},
computed: {
fullLastImportPath() {
- return this.group.last_import_target
- ? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
+ return this.group.lastImportTarget
+ ? `${this.group.lastImportTarget.targetNamespace}/${this.group.lastImportTarget.newName}`
: null;
},
absoluteLastImportPath() {
return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
},
- isFinished() {
- return isFinished(this.group);
- },
},
};
</script>
@@ -34,13 +30,13 @@ export default {
<template>
<div>
<gl-link
- :href="group.web_url"
+ :href="group.webUrl"
target="_blank"
class="gl-display-inline-flex gl-align-items-center gl-h-7"
>
- {{ group.full_path }} <gl-icon name="external-link" />
+ {{ group.fullPath }} <gl-icon name="external-link" />
</gl-link>
- <div v-if="isFinished && fullLastImportPath" class="gl-font-sm">
+ <div v-if="group.flags.isFinished && fullLastImportPath" class="gl-font-sm">
<gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
<template #link>
<gl-link :href="absoluteLastImportPath" class="gl-font-sm" target="_blank">{{
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 04b037ecc2b..ec6025c84bb 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -12,18 +12,28 @@ import {
GlTable,
GlFormCheckbox,
} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import createFlash from '~/flash';
import { s__, __, n__ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
+import { getGroupPathAvailability } from '~/rest_api';
+import axios from '~/lib/utils/axios_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+
+import { STATUSES } from '../../constants';
import ImportStatusCell from '../../components/import_status.vue';
import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql';
-import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql';
+import updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
-import { isInvalid, isFinished, isAvailableForImport } from '../utils';
+import { NEW_NAME_FIELD, i18n } from '../constants';
+import { StatusPoller } from '../services/status_poller';
+import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils';
import ImportActionsCell from './import_actions_cell.vue';
import ImportSourceCell from './import_source_cell.vue';
import ImportTargetCell from './import_target_cell.vue';
+const VALIDATION_DEBOUNCE_TIME = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
const PAGE_SIZES = [20, 50, 100];
const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
const DEFAULT_TH_CLASSES =
@@ -59,7 +69,7 @@ export default {
type: RegExp,
required: true,
},
- groupUrlErrorMessage: {
+ jobsPath: {
type: String,
required: true,
},
@@ -70,7 +80,9 @@ export default {
filter: '',
page: 1,
perPage: DEFAULT_PAGE_SIZE,
- selectedGroups: [],
+ selectedGroupsIds: [],
+ pendingGroupsIds: [],
+ importTargets: {},
};
},
@@ -94,14 +106,14 @@ export default {
tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`,
},
{
- key: 'web_url',
+ key: 'webUrl',
label: s__('BulkImport|From source group'),
thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`,
// eslint-disable-next-line @gitlab/require-i18n-strings
tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`,
},
{
- key: 'import_target',
+ key: 'importTarget',
label: s__('BulkImport|To new group'),
thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`,
tdClass: DEFAULT_TD_CLASSES,
@@ -126,16 +138,39 @@ export default {
return this.bulkImportSourceGroups?.nodes ?? [];
},
+ groupsTableData() {
+ return this.groups.map((group) => {
+ const importTarget = this.getImportTarget(group);
+ const status = this.getStatus(group);
+
+ const flags = {
+ isInvalid: importTarget.validationErrors?.length > 0,
+ isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING,
+ isFinished: isFinished(group),
+ };
+
+ return {
+ ...group,
+ visibleStatus: status,
+ importTarget,
+ flags: {
+ ...flags,
+ isUnselectable: !flags.isAvailableForImport || flags.isInvalid,
+ },
+ };
+ });
+ },
+
hasSelectedGroups() {
- return this.selectedGroups.length > 0;
+ return this.selectedGroupsIds.length > 0;
},
hasAllAvailableGroupsSelected() {
- return this.selectedGroups.length === this.availableGroupsForImport.length;
+ return this.selectedGroupsIds.length === this.availableGroupsForImport.length;
},
availableGroupsForImport() {
- return this.groups.filter((g) => isAvailableForImport(g) && !this.isInvalid(g));
+ return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid);
},
humanizedTotal() {
@@ -175,25 +210,43 @@ export default {
filter() {
this.page = 1;
},
- groups() {
+
+ groupsTableData() {
const table = this.getTableRef();
- this.groups.forEach((g, idx) => {
- if (this.selectedGroups.includes(g)) {
+ const matches = new Set();
+ this.groupsTableData.forEach((g, idx) => {
+ if (this.selectedGroupsIds.includes(g.id)) {
+ matches.add(g.id);
this.$nextTick(() => {
table.selectRow(idx);
});
}
});
- this.selectedGroups = [];
+
+ this.selectedGroupsIds = this.selectedGroupsIds.filter((id) => matches.has(id));
},
},
- methods: {
- isUnselectable(group) {
- return !this.isAvailableForImport(group) || this.isInvalid(group);
- },
+ mounted() {
+ this.statusPoller = new StatusPoller({
+ pollPath: this.jobsPath,
+ updateImportStatus: (update) => {
+ this.$apollo.mutate({
+ mutation: updateImportStatusMutation,
+ variables: { id: update.id, status: update.status_name },
+ });
+ },
+ });
- rowClasses(group) {
+ this.statusPoller.startPolling();
+ },
+
+ beforeDestroy() {
+ this.statusPoller.stopPolling();
+ },
+
+ methods: {
+ rowClasses(groupTableItem) {
const DEFAULT_CLASSES = [
'gl-border-gray-200',
'gl-border-0',
@@ -201,7 +254,7 @@ export default {
'gl-border-solid',
];
const result = [...DEFAULT_CLASSES];
- if (this.isUnselectable(group)) {
+ if (groupTableItem.flags.isUnselectable) {
result.push('gl-cursor-default!');
}
return result;
@@ -211,19 +264,13 @@ export default {
if (type === 'row') {
return {
'data-qa-selector': 'import_item',
- 'data-qa-source-group': group.full_path,
+ 'data-qa-source-group': group.fullPath,
};
}
return {};
},
- isAvailableForImport,
- isFinished,
- isInvalid(group) {
- return isInvalid(group, this.groupPathRegex);
- },
-
groupsCount(count) {
return n__('%d group', '%d groups', count);
},
@@ -232,22 +279,64 @@ export default {
this.page = page;
},
- updateImportTarget(sourceGroupId, targetNamespace, newName) {
- this.$apollo.mutate({
- mutation: setImportTargetMutation,
- variables: { sourceGroupId, targetNamespace, newName },
- });
+ getStatus(group) {
+ if (this.pendingGroupsIds.includes(group.id)) {
+ return STATUSES.SCHEDULING;
+ }
+
+ return group.progress?.status || STATUSES.NONE;
},
- importGroups(sourceGroupIds) {
- this.$apollo.mutate({
- mutation: importGroupsMutation,
- variables: { sourceGroupIds },
+ updateImportTarget(group, changes) {
+ const newImportTarget = {
+ ...group.importTarget,
+ ...changes,
+ };
+ this.$set(this.importTargets, group.id, newImportTarget);
+ this.validateImportTarget(newImportTarget);
+ },
+
+ async importGroups(importRequests) {
+ const newPendingGroupsIds = importRequests.map((request) => request.sourceGroupId);
+ newPendingGroupsIds.forEach((id) => {
+ this.importTargets[id].validationErrors = [
+ { field: NEW_NAME_FIELD, message: i18n.ERROR_IMPORT_COMPLETED },
+ ];
+
+ if (!this.pendingGroupsIds.includes(id)) {
+ this.pendingGroupsIds.push(id);
+ }
});
+
+ try {
+ await this.$apollo.mutate({
+ mutation: importGroupsMutation,
+ variables: { importRequests },
+ });
+ } catch (error) {
+ const message = error?.networkError?.response?.data?.error ?? i18n.ERROR_IMPORT;
+ createFlash({
+ message,
+ captureError: true,
+ error,
+ });
+ } finally {
+ this.pendingGroupsIds = this.pendingGroupsIds.filter(
+ (id) => !newPendingGroupsIds.includes(id),
+ );
+ }
},
importSelectedGroups() {
- this.importGroups(this.selectedGroups.map((g) => g.id));
+ const importRequests = this.groupsTableData
+ .filter((group) => this.selectedGroupsIds.includes(group.id))
+ .map((group) => ({
+ sourceGroupId: group.id,
+ targetNamespace: group.importTarget.targetNamespace.fullPath,
+ newName: group.importTarget.newName,
+ }));
+
+ this.importGroups(importRequests);
},
setPageSize(size) {
@@ -263,16 +352,115 @@ export default {
preventSelectingAlreadyImportedGroups(updatedSelection) {
if (updatedSelection) {
- this.selectedGroups = updatedSelection;
+ this.selectedGroupsIds = updatedSelection.map((g) => g.id);
}
const table = this.getTableRef();
- this.groups.forEach((group, idx) => {
- if (table.isRowSelected(idx) && this.isUnselectable(group)) {
+ this.groupsTableData.forEach((group, idx) => {
+ if (table.isRowSelected(idx) && group.flags.isUnselectable) {
table.unselectRow(idx);
}
});
},
+
+ validateImportTarget: debounce(async function validate(importTarget) {
+ const newValidationErrors = [];
+ importTarget.cancellationToken?.cancel();
+ if (importTarget.newName === '') {
+ newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_REQUIRED });
+ } else if (!isNameValid(importTarget, this.groupPathRegex)) {
+ newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_INVALID_FORMAT });
+ } else if (Object.values(this.importTargets).find(isSameTarget(importTarget))) {
+ newValidationErrors.push({
+ field: NEW_NAME_FIELD,
+ message: i18n.ERROR_NAME_ALREADY_USED_IN_SUGGESTION,
+ });
+ } else {
+ try {
+ // eslint-disable-next-line no-param-reassign
+ importTarget.cancellationToken = axios.CancelToken.source();
+ const {
+ data: { exists },
+ } = await getGroupPathAvailability(
+ importTarget.newName,
+ importTarget.targetNamespace.id,
+ {
+ cancelToken: importTarget.cancellationToken?.token,
+ },
+ );
+
+ if (exists) {
+ newValidationErrors.push({
+ field: NEW_NAME_FIELD,
+ message: i18n.ERROR_NAME_ALREADY_EXISTS,
+ });
+ }
+ } catch (e) {
+ if (!axios.isCancel(e)) {
+ throw e;
+ }
+ }
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ importTarget.validationErrors = newValidationErrors;
+ }, VALIDATION_DEBOUNCE_TIME),
+
+ getImportTarget(group) {
+ if (this.importTargets[group.id]) {
+ return this.importTargets[group.id];
+ }
+
+ const defaultTargetNamespace = this.availableNamespaces[0] ?? { fullPath: '', id: null };
+ let importTarget;
+ if (group.lastImportTarget) {
+ const targetNamespace = this.availableNamespaces.find(
+ (ns) => ns.fullPath === group.lastImportTarget.targetNamespace,
+ );
+
+ importTarget = {
+ targetNamespace: targetNamespace ?? defaultTargetNamespace,
+ newName: group.lastImportTarget.newName,
+ };
+ } else {
+ importTarget = {
+ targetNamespace: defaultTargetNamespace,
+ newName: group.fullPath,
+ };
+ }
+
+ const cancellationToken = axios.CancelToken.source();
+ this.$set(this.importTargets, group.id, {
+ ...importTarget,
+ cancellationToken,
+ validationErrors: [],
+ });
+
+ getGroupPathAvailability(importTarget.newName, importTarget.targetNamespace.id, {
+ cancelToken: cancellationToken.token,
+ })
+ .then(({ data: { exists, suggests: suggestions } }) => {
+ if (!exists) return;
+
+ let currentSuggestion = suggestions[0] ?? importTarget.newName;
+ const existingTargets = Object.values(this.importTargets)
+ .filter((t) => t.targetNamespace.id === importTarget.targetNamespace.id)
+ .map((t) => t.newName.toLowerCase());
+
+ while (existingTargets.includes(currentSuggestion.toLowerCase())) {
+ currentSuggestion = `${currentSuggestion}-1`;
+ }
+
+ Object.assign(this.importTargets[group.id], {
+ targetNamespace: importTarget.targetNamespace,
+ newName: currentSuggestion,
+ });
+ })
+ .catch(() => {
+ // empty catch intended
+ });
+ return this.importTargets[group.id];
+ },
},
gitlabLogo: window.gon.gitlab_logo,
@@ -329,7 +517,7 @@ export default {
<gl-empty-state
v-else-if="!hasGroups"
:title="s__('BulkImport|You have no groups to import')"
- :description="s__('Check your source instance permissions.')"
+ :description="__('Check your source instance permissions.')"
/>
<template v-else>
<div
@@ -337,7 +525,7 @@ export default {
>
<gl-sprintf :message="__('%{count} selected')">
<template #count>
- {{ selectedGroups.length }}
+ {{ selectedGroupsIds.length }}
</template>
</gl-sprintf>
<gl-button
@@ -355,7 +543,7 @@ export default {
data-qa-selector="import_table"
:tbody-tr-class="rowClasses"
:tbody-tr-attr="qaRowAttributes"
- :items="groups"
+ :items="groupsTableData"
:fields="$options.fields"
selectable
select-mode="multi"
@@ -364,7 +552,7 @@ export default {
>
<template #head(selected)="{ selectAllRows, clearSelected }">
<gl-form-checkbox
- :key="`checkbox-${selectedGroups.length}`"
+ :key="`checkbox-${selectedGroupsIds.length}`"
class="gl-h-7 gl-pt-3"
:checked="hasSelectedGroups"
:indeterminate="hasSelectedGroups && !hasAllAvailableGroupsSelected"
@@ -375,35 +563,39 @@ export default {
<gl-form-checkbox
class="gl-h-7 gl-pt-3"
:checked="rowSelected"
- :disabled="!isAvailableForImport(group) || isInvalid(group)"
+ :disabled="group.flags.isUnselectable"
@change="rowSelected ? unselectRow() : selectRow()"
/>
</template>
- <template #cell(web_url)="{ item: group }">
+ <template #cell(webUrl)="{ item: group }">
<import-source-cell :group="group" />
</template>
- <template #cell(import_target)="{ item: group }">
+ <template #cell(importTarget)="{ item: group }">
<import-target-cell
:group="group"
:available-namespaces="availableNamespaces"
:group-path-regex="groupPathRegex"
- :group-url-error-message="groupUrlErrorMessage"
- @update-target-namespace="
- updateImportTarget(group.id, $event, group.import_target.new_name)
- "
- @update-new-name="
- updateImportTarget(group.id, group.import_target.target_namespace, $event)
- "
+ @update-target-namespace="updateImportTarget(group, { targetNamespace: $event })"
+ @update-new-name="updateImportTarget(group, { newName: $event })"
/>
</template>
- <template #cell(progress)="{ value: { status } }">
- <import-status-cell :status="status" class="gl-line-height-32" />
+ <template #cell(progress)="{ item: group }">
+ <import-status-cell :status="group.visibleStatus" class="gl-line-height-32" />
</template>
<template #cell(actions)="{ item: group }">
<import-actions-cell
- :group="group"
- :group-path-regex="groupPathRegex"
- @import-group="importGroups([group.id])"
+ :is-finished="group.flags.isFinished"
+ :is-available-for-import="group.flags.isAvailableForImport"
+ :is-invalid="group.flags.isInvalid"
+ @import-group="
+ importGroups([
+ {
+ sourceGroupId: group.id,
+ targetNamespace: group.importTarget.targetNamespace.fullPath,
+ newName: group.importTarget.newName,
+ },
+ ])
+ "
/>
</template>
</gl-table>
@@ -413,7 +605,7 @@ export default {
:page-info="bulkImportSourceGroups.pageInfo"
class="gl-m-0"
/>
- <gl-dropdown category="tertiary" class="gl-ml-auto">
+ <gl-dropdown category="tertiary" :aria-label="__('Page size')" class="gl-ml-auto">
<template #button-content>
<span class="font-weight-bold">
<gl-sprintf :message="__('%{count} items per page')">
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
index daced740c94..ca9ae9447d0 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
@@ -7,12 +7,7 @@ import {
} from '@gitlab/ui';
import { s__ } from '~/locale';
import ImportGroupDropdown from '../../components/group_dropdown.vue';
-import {
- isInvalid,
- getInvalidNameValidationMessage,
- isNameValid,
- isAvailableForImport,
-} from '../utils';
+import { getInvalidNameValidationMessage } from '../utils';
export default {
components: {
@@ -31,44 +26,15 @@ export default {
type: Array,
required: true,
},
- groupPathRegex: {
- type: RegExp,
- required: true,
- },
- groupUrlErrorMessage: {
- type: String,
- required: true,
- },
},
computed: {
- availableNamespaceNames() {
- return this.availableNamespaces.map((ns) => ns.full_path);
- },
-
- importTarget() {
- return this.group.import_target;
+ fullPath() {
+ return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent');
},
-
invalidNameValidationMessage() {
- return getInvalidNameValidationMessage(this.group);
+ return getInvalidNameValidationMessage(this.group.importTarget);
},
-
- isInvalid() {
- return isInvalid(this.group, this.groupPathRegex);
- },
-
- isNameValid() {
- return isNameValid(this.group, this.groupPathRegex);
- },
-
- isAvailableForImport() {
- return isAvailableForImport(this.group);
- },
- },
-
- i18n: {
- NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
},
};
</script>
@@ -77,14 +43,14 @@ export default {
<div class="gl-display-flex gl-align-items-stretch">
<import-group-dropdown
#default="{ namespaces }"
- :text="importTarget.target_namespace"
- :disabled="!isAvailableForImport"
- :namespaces="availableNamespaceNames"
+ :text="fullPath"
+ :disabled="!group.flags.isAvailableForImport"
+ :namespaces="availableNamespaces"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="gl-h-7 gl-flex-grow-1"
data-qa-selector="target_namespace_selector_dropdown"
>
- <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
+ <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{
s__('BulkImport|No parent')
}}</gl-dropdown-item>
<template v-if="namespaces.length">
@@ -94,20 +60,20 @@ export default {
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in namespaces"
- :key="ns"
+ :key="ns.fullPath"
data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns"
+ :data-qa-group-name="ns.fullPath"
@click="$emit('update-target-namespace', ns)"
>
- {{ ns }}
+ {{ ns.fullPath }}
</gl-dropdown-item>
</template>
</import-group-dropdown>
<div
class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
:class="{
- 'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport,
- 'gl-border-gray-200': isAvailableForImport,
+ 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport,
+ 'gl-border-gray-200': group.flags.isAvailableForImport,
}"
>
/
@@ -116,21 +82,21 @@ export default {
<gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{
- 'gl-inset-border-1-gray-200!': isAvailableForImport,
- 'gl-inset-border-1-gray-100!': !isAvailableForImport,
- 'is-invalid': isInvalid && isAvailableForImport,
+ 'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport,
+ 'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport,
+ 'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport,
}"
- :disabled="!isAvailableForImport"
- :value="importTarget.new_name"
+ debounce="500"
+ :disabled="!group.flags.isAvailableForImport"
+ :value="group.importTarget.newName"
+ :aria-label="__('New name')"
@input="$emit('update-new-name', $event)"
/>
- <p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
- <template v-if="!isNameValid">
- {{ groupUrlErrorMessage }}
- </template>
- <template v-else-if="invalidNameValidationMessage">
- {{ invalidNameValidationMessage }}
- </template>
+ <p
+ v-if="group.flags.isAvailableForImport && group.flags.isInvalid"
+ class="gl-text-red-500 gl-m-0 gl-mt-2"
+ >
+ {{ invalidNameValidationMessage }}
</p>
</div>
</div>
diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js
index b2c3d85e280..aa9cf3897e6 100644
--- a/app/assets/javascripts/import_entities/import_groups/constants.js
+++ b/app/assets/javascripts/import_entities/import_groups/constants.js
@@ -1,7 +1,16 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const i18n = {
- NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
+ ERROR_INVALID_FORMAT: s__(
+ 'GroupSettings|Please choose a group URL with no special characters or spaces.',
+ ),
+ ERROR_NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'),
+ ERROR_REQUIRED: __('This field is required.'),
+ ERROR_NAME_ALREADY_USED_IN_SUGGESTION: s__(
+ 'BulkImport|Name already used as a target for another group.',
+ ),
+ ERROR_IMPORT: s__('BulkImport|Importing the group failed.'),
+ ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'),
};
-export const NEW_NAME_FIELD = 'new_name';
+export const NEW_NAME_FIELD = 'newName';
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
index c08cf909a00..bce6e7bcb1f 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -1,23 +1,10 @@
-import createFlash from '~/flash';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
-import { s__ } from '~/locale';
import { STATUSES } from '../../constants';
-import { i18n, NEW_NAME_FIELD } from '../constants';
-import { isAvailableForImport } from '../utils';
import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql';
import bulkImportSourceGroupProgressFragment from './fragments/bulk_import_source_group_progress.fragment.graphql';
-import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql';
-import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql';
-import setImportProgressMutation from './mutations/set_import_progress.mutation.graphql';
-import setImportTargetMutation from './mutations/set_import_target.mutation.graphql';
-import updateImportStatusMutation from './mutations/update_import_status.mutation.graphql';
-import availableNamespacesQuery from './queries/available_namespaces.query.graphql';
-import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql';
-import groupAndProjectQuery from './queries/group_and_project.query.graphql';
-import { SourceGroupsManager } from './services/source_groups_manager';
-import { StatusPoller } from './services/status_poller';
+import { LocalStorageCache } from './services/local_storage_cache';
import typeDefs from './typedefs.graphql';
export const clientTypenames = {
@@ -27,221 +14,99 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo',
BulkImportTarget: 'ClientBulkImportTarget',
BulkImportProgress: 'ClientBulkImportProgress',
- BulkImportValidationError: 'ClientBulkImportValidationError',
};
-function makeGroup(data) {
- const result = {
- __typename: clientTypenames.BulkImportSourceGroup,
+function makeLastImportTarget(data) {
+ return {
+ __typename: clientTypenames.BulkImportTarget,
...data,
};
- const NESTED_OBJECT_FIELDS = {
- import_target: clientTypenames.BulkImportTarget,
- last_import_target: clientTypenames.BulkImportTarget,
- progress: clientTypenames.BulkImportProgress,
- };
-
- Object.entries(NESTED_OBJECT_FIELDS).forEach(([field, type]) => {
- if (!data[field]) {
- return;
- }
- result[field] = {
- __typename: type,
- ...data[field],
- };
- });
-
- return result;
}
-async function checkImportTargetIsValid({ client, newName, targetNamespace, sourceGroupId }) {
- const {
- data: { existingGroup, existingProject },
- } = await client.query({
- query: groupAndProjectQuery,
- fetchPolicy: 'no-cache',
- variables: {
- fullPath: `${targetNamespace}/${newName}`,
- },
- });
-
- const variables = {
- field: NEW_NAME_FIELD,
- sourceGroupId,
+function makeProgress(data) {
+ return {
+ __typename: clientTypenames.BulkImportProgress,
+ ...data,
};
-
- if (!existingGroup && !existingProject) {
- client.mutate({
- mutation: removeValidationErrorMutation,
- variables,
- });
- } else {
- client.mutate({
- mutation: addValidationErrorMutation,
- variables: {
- ...variables,
- message: i18n.NAME_ALREADY_EXISTS,
- },
- });
- }
}
-const localProgressId = (id) => `not-started-${id}`;
-const nextName = (name) => `${name}-1`;
+function makeGroup(data) {
+ return {
+ __typename: clientTypenames.BulkImportSourceGroup,
+ ...data,
+ progress: data.progress
+ ? makeProgress({
+ id: `LOCAL-PROGRESS-${data.id}`,
+ ...data.progress,
+ })
+ : null,
+ lastImportTarget: data.lastImportTarget
+ ? makeLastImportTarget({
+ id: data.id,
+ ...data.lastImportTarget,
+ })
+ : null,
+ };
+}
-export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
- const groupsManager = new GroupsManager({
- sourceUrl,
+function getGroupFromCache({ client, id, getCacheKey }) {
+ return client.readFragment({
+ fragment: bulkImportSourceGroupItemFragment,
+ fragmentName: 'BulkImportSourceGroupItem',
+ id: getCacheKey({
+ __typename: clientTypenames.BulkImportSourceGroup,
+ id,
+ }),
});
+}
- let statusPoller;
+export function createResolvers({ endpoints }) {
+ const localStorageCache = new LocalStorageCache();
return {
Query: {
- async bulkImportSourceGroup(_, { id }, { client, getCacheKey }) {
- return client.readFragment({
- fragment: bulkImportSourceGroupItemFragment,
- fragmentName: 'BulkImportSourceGroupItem',
- id: getCacheKey({
- __typename: clientTypenames.BulkImportSourceGroup,
- id,
- }),
+ async bulkImportSourceGroups(_, vars) {
+ const { headers, data } = await axios.get(endpoints.status, {
+ params: {
+ page: vars.page,
+ per_page: vars.perPage,
+ filter: vars.filter,
+ },
});
- },
- async bulkImportSourceGroups(_, vars, { client }) {
- if (!statusPoller) {
- statusPoller = new StatusPoller({
- updateImportStatus: ({ id, status_name: status }) =>
- client.mutate({
- mutation: updateImportStatusMutation,
- variables: { id, status },
- }),
- pollPath: endpoints.jobs,
- });
- statusPoller.startPolling();
- }
-
- return Promise.all([
- axios.get(endpoints.status, {
- params: {
- page: vars.page,
- per_page: vars.perPage,
- filter: vars.filter,
- },
- }),
- client.query({ query: availableNamespacesQuery }),
- ]).then(
- ([
- { headers, data },
- {
- data: { availableNamespaces },
- },
- ]) => {
- const pagination = parseIntPagination(normalizeHeaders(headers));
-
- const response = {
- __typename: clientTypenames.BulkImportSourceGroupConnection,
- nodes: data.importable_data.map((group) => {
- const { jobId, importState: cachedImportState } =
- groupsManager.getImportStateFromStorageByGroupId(group.id) ?? {};
-
- const status = cachedImportState?.status ?? STATUSES.NONE;
-
- const importTarget =
- status === STATUSES.FINISHED && cachedImportState.importTarget
- ? {
- target_namespace: cachedImportState.importTarget.target_namespace,
- new_name: nextName(cachedImportState.importTarget.new_name),
- }
- : cachedImportState?.importTarget ?? {
- new_name: group.full_path,
- target_namespace: availableNamespaces[0]?.full_path ?? '',
- };
-
- return makeGroup({
- ...group,
- validation_errors: [],
- progress: {
- id: jobId ?? localProgressId(group.id),
- status,
- },
- import_target: importTarget,
- last_import_target: cachedImportState?.importTarget ?? null,
- });
- }),
- pageInfo: {
- __typename: clientTypenames.BulkImportPageInfo,
- ...pagination,
- },
- };
-
- setTimeout(() => {
- response.nodes.forEach((group) => {
- if (isAvailableForImport(group)) {
- checkImportTargetIsValid({
- client,
- newName: group.import_target.new_name,
- targetNamespace: group.import_target.target_namespace,
- sourceGroupId: group.id,
- });
- }
- });
+ const pagination = parseIntPagination(normalizeHeaders(headers));
+
+ const response = {
+ __typename: clientTypenames.BulkImportSourceGroupConnection,
+ nodes: data.importable_data.map((group) => {
+ return makeGroup({
+ id: group.id,
+ webUrl: group.web_url,
+ fullPath: group.full_path,
+ fullName: group.full_name,
+ ...group,
+ ...localStorageCache.get(group.web_url),
});
-
- return response;
+ }),
+ pageInfo: {
+ __typename: clientTypenames.BulkImportPageInfo,
+ ...pagination,
},
- );
+ };
+ return response;
},
availableNamespaces: () =>
axios.get(endpoints.availableNamespaces).then(({ data }) =>
data.map((namespace) => ({
__typename: clientTypenames.AvailableNamespace,
- ...namespace,
+ id: namespace.id,
+ fullPath: namespace.full_path,
})),
),
},
Mutation: {
- setImportTarget(_, { targetNamespace, newName, sourceGroupId }, { client }) {
- checkImportTargetIsValid({
- client,
- sourceGroupId,
- targetNamespace,
- newName,
- });
-
- return makeGroup({
- id: sourceGroupId,
- import_target: {
- target_namespace: targetNamespace,
- new_name: newName,
- id: sourceGroupId,
- },
- });
- },
-
- async setImportProgress(_, { sourceGroupId, status, jobId, importTarget }) {
- if (jobId) {
- groupsManager.updateImportProgress(jobId, status);
- }
-
- return makeGroup({
- id: sourceGroupId,
- progress: {
- id: jobId ?? localProgressId(sourceGroupId),
- status,
- },
- last_import_target: {
- __typename: clientTypenames.BulkImportTarget,
- ...importTarget,
- },
- });
- },
-
async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) {
- groupsManager.updateImportProgress(id, newStatus);
-
const progressItem = client.readFragment({
fragment: bulkImportSourceGroupProgressFragment,
fragmentName: 'BulkImportSourceGroupProgress',
@@ -251,133 +116,62 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
}),
});
- const isInProgress = Boolean(progressItem);
- const { status: currentStatus } = progressItem ?? {};
- if (newStatus === STATUSES.FINISHED && isInProgress && currentStatus !== newStatus) {
- const groups = groupsManager.getImportedGroupsByJobId(id);
+ if (!progressItem) return null;
- groups.forEach(async ({ id: groupId, importTarget }) => {
- client.mutate({
- mutation: setImportTargetMutation,
- variables: {
- sourceGroupId: groupId,
- targetNamespace: importTarget.target_namespace,
- newName: nextName(importTarget.new_name),
- },
- });
- });
- }
+ localStorageCache.updateStatusByJobId(id, newStatus);
return {
__typename: clientTypenames.BulkImportProgress,
+ ...progressItem,
id,
status: newStatus,
};
},
- async addValidationError(_, { sourceGroupId, field, message }, { client }) {
- const {
- data: {
- bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
- },
- } = await client.query({
- query: bulkImportSourceGroupQuery,
- variables: { id: sourceGroupId },
- });
+ async importGroups(_, { importRequests }, { client, getCacheKey }) {
+ const importOperations = importRequests.map((importRequest) => {
+ const group = getGroupFromCache({
+ client,
+ getCacheKey,
+ id: importRequest.sourceGroupId,
+ });
- return {
- ...group,
- validation_errors: [
- ...validationErrors.filter(({ field: f }) => f !== field),
- {
- __typename: clientTypenames.BulkImportValidationError,
- field,
- message,
- },
- ],
- };
- },
+ return {
+ group,
+ ...importRequest,
+ };
+ });
- async removeValidationError(_, { sourceGroupId, field }, { client }) {
const {
- data: {
- bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
- },
- } = await client.query({
- query: bulkImportSourceGroupQuery,
- variables: { id: sourceGroupId },
+ data: { id: jobId },
+ } = await axios.post(endpoints.createBulkImport, {
+ bulk_import: importOperations.map((op) => ({
+ source_type: 'group_entity',
+ source_full_path: op.group.fullPath,
+ destination_namespace: op.targetNamespace,
+ destination_name: op.newName,
+ })),
});
- return {
- ...group,
- validation_errors: validationErrors.filter(({ field: f }) => f !== field),
- };
- },
-
- async importGroups(_, { sourceGroupIds }, { client }) {
- const groups = await Promise.all(
- sourceGroupIds.map((id) =>
- client
- .query({
- query: bulkImportSourceGroupQuery,
- variables: { id },
- })
- .then(({ data }) => data.bulkImportSourceGroup),
- ),
- );
+ return importOperations.map((op) => {
+ const lastImportTarget = {
+ targetNamespace: op.targetNamespace,
+ newName: op.newName,
+ };
- const GROUPS_BEING_SCHEDULED = sourceGroupIds.map((sourceGroupId) =>
- makeGroup({
- id: sourceGroupId,
- progress: {
- id: localProgressId(sourceGroupId),
- status: STATUSES.SCHEDULING,
- },
- }),
- );
-
- const defaultErrorMessage = s__('BulkImport|Importing the group failed');
- axios
- .post(endpoints.createBulkImport, {
- bulk_import: groups.map((group) => ({
- source_type: 'group_entity',
- source_full_path: group.full_path,
- destination_namespace: group.import_target.target_namespace,
- destination_name: group.import_target.new_name,
- })),
- })
- .then(({ data: { id: jobId } }) => {
- groupsManager.createImportState(jobId, {
- status: STATUSES.CREATED,
- groups,
- });
+ const progress = {
+ id: jobId,
+ status: STATUSES.CREATED,
+ };
- return { status: STATUSES.CREATED, jobId };
- })
- .catch((e) => {
- const message = e?.response?.data?.error ?? defaultErrorMessage;
- createFlash({ message });
- return { status: STATUSES.NONE };
- })
- .then((newStatus) =>
- sourceGroupIds.forEach((sourceGroupId, idx) =>
- client.mutate({
- mutation: setImportProgressMutation,
- variables: { sourceGroupId, ...newStatus, importTarget: groups[idx].import_target },
- }),
- ),
- )
- .catch(() => createFlash({ message: defaultErrorMessage }));
+ localStorageCache.set(op.group.webUrl, { progress, lastImportTarget });
- return GROUPS_BEING_SCHEDULED;
+ return makeGroup({ ...op.group, progress, lastImportTarget });
+ });
},
},
};
}
export const createApolloClient = ({ sourceUrl, endpoints }) =>
- createDefaultClient(
- createResolvers({ sourceUrl, endpoints }),
- { assumeImmutableResults: true },
- typeDefs,
- );
+ createDefaultClient(createResolvers({ sourceUrl, endpoints }), { typeDefs });
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
index 089340b3c48..0d83be7c0e8 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
@@ -2,22 +2,15 @@
fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
id
- web_url
- full_path
- full_name
+ webUrl
+ fullPath
+ fullName
+ lastImportTarget {
+ id
+ targetNamespace
+ newName
+ }
progress {
...BulkImportSourceGroupProgress
}
- import_target {
- target_namespace
- new_name
- }
- last_import_target {
- target_namespace
- new_name
- }
- validation_errors {
- field
- message
- }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql
deleted file mode 100644
index d95c460c046..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-mutation addValidationError($sourceGroupId: String!, $field: String!, $message: String!) {
- addValidationError(sourceGroupId: $sourceGroupId, field: $field, message: $message) @client {
- id
- validation_errors {
- field
- message
- }
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
index d8e46329e38..75215471d0f 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
@@ -1,6 +1,11 @@
-mutation importGroups($sourceGroupIds: [String!]!) {
- importGroups(sourceGroupIds: $sourceGroupIds) @client {
+mutation importGroups($importRequests: [ImportGroupInput!]!) {
+ importGroups(importRequests: $importRequests) @client {
id
+ lastImportTarget {
+ id
+ targetNamespace
+ newName
+ }
progress {
id
status
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql
deleted file mode 100644
index 940bf4dfaac..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-mutation removeValidationError($sourceGroupId: String!, $field: String!) {
- removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client {
- id
- validation_errors {
- field
- message
- }
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
deleted file mode 100644
index 43301554de3..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
+++ /dev/null
@@ -1,23 +0,0 @@
-mutation setImportProgress(
- $status: String!
- $sourceGroupId: String!
- $jobId: String
- $importTarget: ImportTargetInput!
-) {
- setImportProgress(
- status: $status
- sourceGroupId: $sourceGroupId
- jobId: $jobId
- importTarget: $importTarget
- ) @client {
- id
- progress {
- id
- status
- }
- last_import_target {
- target_namespace
- new_name
- }
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql
deleted file mode 100644
index 793b60ee378..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-mutation setImportTarget($newName: String!, $targetNamespace: String!, $sourceGroupId: String!) {
- setImportTarget(
- newName: $newName
- targetNamespace: $targetNamespace
- sourceGroupId: $sourceGroupId
- ) @client {
- id
- import_target {
- new_name
- target_namespace
- }
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql
index 5ab9796b50a..b0741dfbe5c 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql
@@ -1,6 +1,6 @@
query availableNamespaces {
availableNamespaces @client {
id
- full_path
+ fullPath
}
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql
deleted file mode 100644
index 0aff23af96d..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-#import "../fragments/bulk_import_source_group_item.fragment.graphql"
-
-query bulkImportSourceGroup($id: ID!) {
- bulkImportSourceGroup(id: $id) @client {
- ...BulkImportSourceGroupItem
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql
deleted file mode 100644
index d6124f84025..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-query groupAndProject($fullPath: ID!) {
- existingGroup: group(fullPath: $fullPath) {
- id
- }
-
- existingProject: project(fullPath: $fullPath) {
- id
- }
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js
new file mode 100644
index 00000000000..09bc7b33692
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js
@@ -0,0 +1,74 @@
+import { debounce, merge } from 'lodash';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+
+const OLD_KEY = 'gl-bulk-imports-import-state';
+export const KEY = 'gl-bulk-imports-import-state-v2';
+export const DEBOUNCE_INTERVAL = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
+
+export class LocalStorageCache {
+ constructor({ storage = window.localStorage } = {}) {
+ this.storage = storage;
+ this.cache = this.loadCacheFromStorage();
+ try {
+ // remove old storage data
+ this.storage.removeItem(OLD_KEY);
+ } catch {
+ // empty catch intended
+ }
+
+ // cache for searching data by jobid
+ this.jobsLookupCache = {};
+ }
+
+ loadCacheFromStorage() {
+ try {
+ return JSON.parse(this.storage.getItem(KEY)) ?? {};
+ } catch {
+ return {};
+ }
+ }
+
+ set(webUrl, data) {
+ this.cache[webUrl] = data;
+ this.saveCacheToStorage();
+ // There are changes to jobIds, drop cache
+ this.jobsLookupCache = {};
+ }
+
+ get(webUrl) {
+ return this.cache[webUrl];
+ }
+
+ getCacheKeysByJobId(jobId) {
+ // this is invoked by polling, so we would like to cache results
+ if (!this.jobsLookupCache[jobId]) {
+ this.jobsLookupCache[jobId] = Object.keys(this.cache).filter(
+ (url) => this.cache[url]?.progress.id === jobId,
+ );
+ }
+
+ return this.jobsLookupCache[jobId];
+ }
+
+ updateStatusByJobId(jobId, status) {
+ this.getCacheKeysByJobId(jobId).forEach((webUrl) =>
+ this.set(webUrl, {
+ ...(this.get(webUrl) ?? {}),
+ progress: {
+ id: jobId,
+ status,
+ },
+ }),
+ );
+ this.saveCacheToStorage();
+ }
+
+ saveCacheToStorage = debounce(() => {
+ try {
+ // storage might be changed in other tab so fetch first
+ this.storage.setItem(KEY, JSON.stringify(merge({}, this.loadCacheFromStorage(), this.cache)));
+ } catch {
+ // empty catch intentional: storage might be unavailable or full
+ }
+ }, DEBOUNCE_INTERVAL);
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
deleted file mode 100644
index 7caa37d9ad4..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import { debounce, merge } from 'lodash';
-
-export const KEY = 'gl-bulk-imports-import-state';
-export const DEBOUNCE_INTERVAL = 200;
-
-export class SourceGroupsManager {
- constructor({ sourceUrl, storage = window.localStorage }) {
- this.sourceUrl = sourceUrl;
-
- this.storage = storage;
- this.importStates = this.loadImportStatesFromStorage();
- }
-
- loadImportStatesFromStorage() {
- try {
- return Object.fromEntries(
- Object.entries(JSON.parse(this.storage.getItem(KEY)) ?? {}).map(([jobId, config]) => {
- // new format of storage
- if (config.groups) {
- return [jobId, config];
- }
-
- return [
- jobId,
- {
- status: config.status,
- groups: [{ id: config.id, importTarget: config.importTarget }],
- },
- ];
- }),
- );
- } catch {
- return {};
- }
- }
-
- createImportState(importId, jobConfig) {
- this.importStates[importId] = {
- status: jobConfig.status,
- groups: jobConfig.groups.map((g) => ({
- importTarget: { ...g.import_target },
- id: g.id,
- })),
- };
- this.saveImportStatesToStorage();
- }
-
- updateImportProgress(importId, status) {
- const currentState = this.importStates[importId];
- if (!currentState) {
- return;
- }
-
- currentState.status = status;
- this.saveImportStatesToStorage();
- }
-
- getImportedGroupsByJobId(jobId) {
- return this.importStates[jobId]?.groups ?? [];
- }
-
- getImportStateFromStorageByGroupId(groupId) {
- const [jobId, importState] =
- Object.entries(this.importStates)
- .reverse()
- .find(([, state]) => state.groups.some((g) => g.id === groupId)) ?? [];
-
- if (!jobId) {
- return null;
- }
-
- const group = importState.groups.find((g) => g.id === groupId);
- return { jobId, importState: { ...group, status: importState.status } };
- }
-
- saveImportStatesToStorage = debounce(() => {
- try {
- // storage might be changed in other tab so fetch first
- this.storage.setItem(
- KEY,
- JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)),
- );
- } catch {
- // empty catch intentional: storage might be unavailable or full
- }
- }, DEBOUNCE_INTERVAL);
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
index 6ef4bbafec0..b8dd79a5000 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
@@ -1,11 +1,11 @@
type ClientBulkImportAvailableNamespace {
id: ID!
- full_path: String!
+ fullPath: String!
}
type ClientBulkImportTarget {
- target_namespace: String!
- new_name: String!
+ targetNamespace: String!
+ newName: String!
}
type ClientBulkImportSourceGroupConnection {
@@ -14,7 +14,7 @@ type ClientBulkImportSourceGroupConnection {
}
type ClientBulkImportProgress {
- id: ID
+ id: ID!
status: String!
}
@@ -25,13 +25,11 @@ type ClientBulkImportValidationError {
type ClientBulkImportSourceGroup {
id: ID!
- web_url: String!
- full_path: String!
- full_name: String!
- progress: ClientBulkImportProgress!
- import_target: ClientBulkImportTarget!
- last_import_target: ClientBulkImportTarget
- validation_errors: [ClientBulkImportValidationError!]!
+ webUrl: String!
+ fullPath: String!
+ fullName: String!
+ lastImportTarget: ClientBulkImportTarget
+ progress: ClientBulkImportProgress
}
type ClientBulkImportPageInfo {
@@ -41,8 +39,13 @@ type ClientBulkImportPageInfo {
totalPages: Int!
}
+type ClientBulkImportNamespaceSuggestion {
+ id: ID!
+ exists: Boolean!
+ suggestions: [String!]!
+}
+
extend type Query {
- bulkImportSourceGroup(id: ID!): ClientBulkImportSourceGroup
bulkImportSourceGroups(
page: Int!
perPage: Int!
@@ -51,26 +54,13 @@ extend type Query {
availableNamespaces: [ClientBulkImportAvailableNamespace!]!
}
-input InputTargetInput {
- target_namespace: String!
- new_name: String!
+input ImportRequestInput {
+ sourceGroupId: ID!
+ targetNamespace: String!
+ newName: String!
}
extend type Mutation {
- setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
- setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
- importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
- setImportProgress(
- id: ID
- status: String!
- jobId: String
- importTarget: ImportTargetInput!
- ): ClientBulkImportSourceGroup!
- updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
- addValidationError(
- sourceGroupId: ID!
- field: String!
- message: String!
- ): ClientBulkImportSourceGroup!
- removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup!
+ importGroups(importRequests: [ImportRequestInput!]!): [ClientBulkImportSourceGroup!]!
+ updateImportStatus(id: ID, status: String!): ClientBulkImportProgress
}
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
index 07b839c5c82..67a7258d504 100644
--- a/app/assets/javascripts/import_entities/import_groups/index.js
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -17,7 +17,6 @@ export function mountImportGroupsApp(mountElement) {
jobsPath,
sourceUrl,
groupPathRegex,
- groupUrlErrorMessage,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
@@ -26,7 +25,6 @@ export function mountImportGroupsApp(mountElement) {
status: statusPath,
availableNamespaces: availableNamespacesPath,
createBulkImport: createBulkImportPath,
- jobs: jobsPath,
},
}),
});
@@ -38,8 +36,8 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, {
props: {
sourceUrl,
+ jobsPath,
groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
- groupUrlErrorMessage,
},
});
},
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
index 0297b3d3428..ba0f2bb947a 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
+++ b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
@@ -32,4 +32,8 @@ export class StatusPoller {
startPolling() {
this.eTagPoll.makeRequest();
}
+
+ stopPolling() {
+ this.eTagPoll.stop();
+ }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/utils.js b/app/assets/javascripts/import_entities/import_groups/utils.js
index a1baeaf39dd..1d0ab75e1cb 100644
--- a/app/assets/javascripts/import_entities/import_groups/utils.js
+++ b/app/assets/javascripts/import_entities/import_groups/utils.js
@@ -1,22 +1,25 @@
import { STATUSES } from '../constants';
import { NEW_NAME_FIELD } from './constants';
-export function isNameValid(group, validationRegex) {
- return validationRegex.test(group.import_target[NEW_NAME_FIELD]);
+export function isNameValid(importTarget, validationRegex) {
+ return validationRegex.test(importTarget[NEW_NAME_FIELD]);
}
-export function getInvalidNameValidationMessage(group) {
- return group.validation_errors.find(({ field }) => field === NEW_NAME_FIELD)?.message;
-}
-
-export function isInvalid(group, validationRegex) {
- return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group));
+export function getInvalidNameValidationMessage(importTarget) {
+ return importTarget.validationErrors?.find(({ field }) => field === NEW_NAME_FIELD)?.message;
}
export function isFinished(group) {
- return group.progress.status === STATUSES.FINISHED;
+ return [STATUSES.FINISHED, STATUSES.FAILED].includes(group.progress?.status);
}
export function isAvailableForImport(group) {
- return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status);
+ return !group.progress || isFinished(group);
+}
+
+export function isSameTarget(importTarget) {
+ return (target) =>
+ target !== importTarget &&
+ target.newName.toLowerCase() === importTarget.newName.toLowerCase() &&
+ target.targetNamespace.id === importTarget.targetNamespace.id;
}
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 0cd3519bcec..b9f0b5012ac 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
@@ -46,10 +46,6 @@ export default {
return `${this.filter}-${this.repositories.length}-${this.pageInfo.page}`;
},
- availableNamespaces() {
- return this.namespaces.map(({ fullPath }) => fullPath);
- },
-
importAllButtonText() {
if (this.isImportingAnyRepo) {
return n__('Importing %d repository', 'Importing %d repositories', this.importingRepoCount);
@@ -167,7 +163,7 @@ export default {
<provider-repo-table-row
:key="repo.importSource.providerLink"
:repo="repo"
- :available-namespaces="availableNamespaces"
+ :available-namespaces="namespaces"
:user-namespace="defaultTargetNamespace"
/>
</template>
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 a97af5367fb..c3d0ca4ed8c 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
@@ -128,17 +128,17 @@ export default {
<gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="ns in namespaces"
- :key="ns"
+ :key="ns.fullPath"
data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns"
- @click="updateImportTarget({ targetNamespace: ns })"
+ :data-qa-group-name="ns.fullPath"
+ @click="updateImportTarget({ targetNamespace: ns.fullPath })"
>
- {{ ns }}
+ {{ ns.fullPath }}
</gl-dropdown-item>
<gl-dropdown-divider />
</template>
<gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="updateImportTarget({ targetNamespace: ns })">{{
+ <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
userNamespace
}}</gl-dropdown-item>
</import-group-dropdown>
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 4d34daa43ba..37597da3c8e 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -125,6 +125,7 @@ export default {
'authorUsernameQuery',
'assigneeUsernameQuery',
'slaFeatureAvailable',
+ 'canCreateIncident',
],
apollo: {
incidents: {
@@ -230,13 +231,16 @@ export default {
},
emptyStateData() {
const {
- emptyState: { title, emptyClosedTabTitle, description },
+ emptyState: { title, emptyClosedTabTitle, description, cannotCreateIncidentDescription },
createIncidentBtnLabel,
} = this.$options.i18n;
if (this.activeClosedTabHasNoIncidents) {
return { title: emptyClosedTabTitle };
}
+ if (!this.canCreateIncident) {
+ return { title, description: cannotCreateIncidentDescription };
+ }
return {
title,
description,
@@ -244,6 +248,9 @@ export default {
btnText: createIncidentBtnLabel,
};
},
+ isHeaderButtonVisible() {
+ return this.canCreateIncident && (!this.isEmpty || this.activeClosedTabHasNoIncidents);
+ },
},
methods: {
hasAssignees(assignees) {
@@ -311,7 +318,7 @@ export default {
>
<template #header-actions>
<gl-button
- v-if="!isEmpty || activeClosedTabHasNoIncidents"
+ v-if="isHeaderButtonVisible"
class="gl-my-3 gl-mr-5 create-incident-button"
data-testid="createIncidentBtn"
data-qa-selector="create_incident_button"
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index b82980b5628..23909ae3b6c 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -11,7 +11,10 @@ export const I18N = {
title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
description: s__(
- 'IncidentManagement|All alerts promoted to incidents will automatically be displayed within the list. You can also create a new incident using the button below.',
+ 'IncidentManagement|All alerts promoted to incidents are automatically displayed within the list. You can also create a new incident using the button below.',
+ ),
+ cannotCreateIncidentDescription: s__(
+ 'IncidentManagement|All alerts promoted to incidents are automatically displayed within the list.',
),
},
};
diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js
index 6e6461cd7a9..1d40f1093a4 100644
--- a/app/assets/javascripts/incidents/list.js
+++ b/app/assets/javascripts/incidents/list.js
@@ -21,10 +21,11 @@ export default () => {
authorUsernameQuery,
assigneeUsernameQuery,
slaFeatureAvailable,
+ canCreateIncident,
} = domEl.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
return new Vue({
@@ -44,6 +45,7 @@ export default () => {
authorUsernameQuery,
assigneeUsernameQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
+ canCreateIncident: parseBoolean(canCreateIncident),
},
apolloProvider,
render(createElement) {
diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js
new file mode 100644
index 00000000000..d3d32c8be54
--- /dev/null
+++ b/app/assets/javascripts/init_confirm_danger.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import { parseBoolean } from './lib/utils/common_utils';
+import ConfirmDanger from './vue_shared/components/confirm_danger/confirm_danger.vue';
+
+export default () => {
+ const el = document.querySelector('.js-confirm-danger');
+ if (!el) return null;
+
+ const {
+ removeFormId = null,
+ phrase,
+ buttonText,
+ buttonTestid = null,
+ confirmDangerMessage,
+ disabled = false,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ confirmDangerMessage,
+ },
+ render: (createElement) =>
+ createElement(ConfirmDanger, {
+ props: {
+ phrase,
+ buttonText,
+ buttonTestid,
+ disabled: parseBoolean(disabled),
+ },
+ on: {
+ confirm: () => {
+ if (removeFormId) document.getElementById(removeFormId)?.submit();
+ },
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 8a8d38b295c..d214ee4ded6 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const TEST_INTEGRATION_EVENT = 'testIntegration';
export const SAVE_INTEGRATION_EVENT = 'saveIntegration';
@@ -21,3 +21,9 @@ export const overrideDropdownDescriptions = {
'Integrations|Default settings are inherited from the instance level.',
),
};
+
+export const I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE = s__(
+ 'Integrations|Connection failed. Please check your settings.',
+);
+export const I18N_DEFAULT_ERROR_MESSAGE = __('Something went wrong on our end.');
+export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection successful.');
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index f30298676df..258cd1bf365 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -62,6 +62,14 @@ export default {
required: false,
default: null,
},
+ /**
+ * The label that is displayed inline with the checkbox.
+ */
+ checkboxLabel: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -152,7 +160,7 @@ export default {
<template v-if="isCheckbox">
<input :name="fieldName" type="hidden" :value="model || false" />
<gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting">
- {{ humanizedTitle }}
+ {{ checkboxLabel || humanizedTitle }}
</gl-form-checkbox>
</template>
<template v-else-if="isSelect">
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 0521e1eeea5..7cbfb35aeaa 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -5,6 +5,7 @@ import {
VALIDATE_INTEGRATION_FORM_EVENT,
GET_JIRA_ISSUE_TYPES_EVENT,
} from '~/integrations/constants';
+import { s__, __ } from '~/locale';
import eventHub from '../event_hub';
import JiraUpgradeCta from './jira_upgrade_cta.vue';
@@ -94,33 +95,38 @@ export default {
eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
},
},
+ i18n: {
+ sectionTitle: s__('JiraService|View Jira issues in GitLab'),
+ sectionDescription: s__(
+ 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.',
+ ),
+ enableCheckboxLabel: s__('JiraService|Enable Jira issues'),
+ enableCheckboxHelp: s__(
+ 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.',
+ ),
+ 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 the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.',
+ ),
+ },
};
</script>
<template>
<div>
- <gl-form-group
- :label="s__('JiraService|View Jira issues in GitLab')"
- label-for="jira-issue-settings"
- >
+ <gl-form-group :label="$options.i18n.sectionTitle" label-for="jira-issue-settings">
<div id="jira-issue-settings">
<p>
- {{
- s__(
- 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.',
- )
- }}
+ {{ $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">
- {{ s__('JiraService|Enable Jira issues') }}
+ {{ $options.i18n.enableCheckboxLabel }}
<template #help>
- {{
- s__(
- 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.',
- )
- }}
+ {{ $options.i18n.enableCheckboxHelp }}
</template>
</gl-form-checkbox>
<template v-if="enableJiraIssues">
@@ -152,30 +158,25 @@ export default {
</gl-form-group>
<template v-if="showJiraIssuesIntegration">
<gl-form-group
- :label="s__('JiraService|Jira project key')"
+ :label="$options.i18n.projectKeyLabel"
label-for="service_project_key"
- :invalid-feedback="__('This field is required.')"
+ :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]"
- :placeholder="s__('JiraService|For example, AB')"
+ :placeholder="$options.i18n.projectKeyPlaceholder"
:required="enableJiraIssues"
:state="validProjectKey"
:disabled="!enableJiraIssues"
:readonly="isInheriting"
/>
</gl-form-group>
- <p v-if="gitlabIssuesEnabled">
- <gl-sprintf
- :message="
- s__(
- 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.',
- )
- "
- >
+ <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>
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index f33364d5545..f519fc87c46 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -1,5 +1,4 @@
import { delay } from 'lodash';
-import { __, s__ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import axios from '../lib/utils/axios_utils';
import initForm from './edit';
@@ -10,6 +9,9 @@ import {
GET_JIRA_ISSUE_TYPES_EVENT,
TOGGLE_INTEGRATION_EVENT,
VALIDATE_INTEGRATION_FORM_EVENT,
+ I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
+ I18N_DEFAULT_ERROR_MESSAGE,
+ I18N_SUCCESSFUL_CONNECTION_MESSAGE,
} from './constants';
export default class IntegrationSettingsForm {
@@ -104,11 +106,7 @@ export default class IntegrationSettingsForm {
return this.fetchTestSettings(formData)
.then(
({
- data: {
- issuetypes,
- error,
- message = s__('Integrations|Connection failed. Please check your settings.'),
- },
+ data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE },
}) => {
if (error || !issuetypes?.length) {
eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
@@ -118,7 +116,7 @@ export default class IntegrationSettingsForm {
dispatch('receiveJiraIssueTypesSuccess', issuetypes);
},
)
- .catch(({ message = __('Something went wrong on our end.') }) => {
+ .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => {
dispatch('receiveJiraIssueTypesError', message);
});
}
@@ -140,11 +138,11 @@ export default class IntegrationSettingsForm {
toast(`${data.message} ${data.service_response}`);
} else {
this.vue.$store.dispatch('receiveJiraIssueTypesSuccess', data.issuetypes);
- toast(s__('Integrations|Connection successful.'));
+ toast(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
}
})
.catch(() => {
- toast(__('Something went wrong on our end.'));
+ toast(I18N_DEFAULT_ERROR_MESSAGE);
})
.finally(() => {
this.vue.$store.dispatch('setIsTesting', false);
diff --git a/app/assets/javascripts/invite_members/components/confetti.vue b/app/assets/javascripts/invite_members/components/confetti.vue
new file mode 100644
index 00000000000..2e5744afcd4
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/confetti.vue
@@ -0,0 +1,33 @@
+<script>
+import confetti from 'canvas-confetti';
+
+export default {
+ mounted() {
+ confetti.create(this.$refs.canvas, {
+ resize: true,
+ useWorker: true,
+ disableForReducedMotion: true,
+ });
+
+ this.basicCannon();
+ },
+ methods: {
+ basicCannon() {
+ confetti({
+ particleCount: 100,
+ spread: 70,
+ origin: { y: 0.2 },
+ scalar: 2,
+ shapes: ['square'],
+ colors: ['#FC6D26', '#6B4FBB', '#FDB997'],
+ zIndex: 1045,
+ gravity: 1.5,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <canvas ref="canvas" width="0" height="0"></canvas>
+</template>
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 cd0b413265b..cf4f434a7a8 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlAlert,
GlFormGroup,
GlModal,
GlDropdown,
@@ -11,29 +12,34 @@ import {
GlFormInput,
GlFormCheckboxGroup,
} from '@gitlab/ui';
-import { partition, isString, unescape } from 'lodash';
+import { partition, isString, unescape, uniqueId } from 'lodash';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { sanitize } from '~/lib/dompurify';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
-import { s__, sprintf } from '~/locale';
+import { getParameterValues } from '~/lib/utils/url_utility';
+import { sprintf } from '~/locale';
import {
INVITE_MEMBERS_IN_COMMENT,
GROUP_FILTERS,
USERS_FILTER_ALL,
MEMBER_AREAS_OF_FOCUS,
+ INVITE_MEMBERS_FOR_TASK,
+ MODAL_LABELS,
} from '../constants';
import eventHub from '../event_hub';
import {
responseMessageFromError,
responseMessageFromSuccess,
} from '../utils/response_message_parser';
+import ModalConfetti from './confetti.vue';
import GroupSelect from './group_select.vue';
import MembersTokenSelect from './members_token_select.vue';
export default {
name: 'InviteMembersModal',
components: {
+ GlAlert,
GlFormGroup,
GlDatepicker,
GlLink,
@@ -46,7 +52,9 @@ export default {
GlFormCheckboxGroup,
MembersTokenSelect,
GroupSelect,
+ ModalConfetti,
},
+ inject: ['newProjectPath'],
props: {
id: {
type: String,
@@ -100,36 +108,54 @@ export default {
type: Array,
required: true,
},
+ tasksToBeDoneOptions: {
+ type: Array,
+ required: true,
+ },
+ projects: {
+ type: Array,
+ required: true,
+ },
},
data() {
return {
visible: true,
- modalId: 'invite-members-modal',
+ modalId: uniqueId('invite-members-modal-'),
selectedAccessLevel: this.defaultAccessLevel,
inviteeType: 'members',
newUsersToInvite: [],
selectedDate: undefined,
selectedAreasOfFocus: [],
+ selectedTasksToBeDone: [],
+ selectedTaskProject: this.projects[0],
groupToBeSharedWith: {},
source: 'unknown',
invalidFeedbackMessage: '',
isLoading: false,
+ mode: 'default',
};
},
computed: {
+ isCelebration() {
+ return this.mode === 'celebrate';
+ },
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
isInviteGroup() {
return this.inviteeType === 'group';
},
+ modalTitle() {
+ return this.$options.labels[this.inviteeType].modal[this.mode].title;
+ },
introText() {
- const inviteTo = this.isProject ? 'toProject' : 'toGroup';
-
- return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, {
+ return sprintf(this.$options.labels[this.inviteeType][this.inviteTo][this.mode].introText, {
name: this.name,
});
},
+ inviteTo() {
+ return this.isProject ? 'toProject' : 'toGroup';
+ },
toastOptions() {
return {
onComplete: () => {
@@ -156,7 +182,7 @@ export default {
);
},
areasOfFocusEnabled() {
- return this.areasOfFocusOptions.length !== 0;
+ return !this.tasksToBeDoneEnabled && this.areasOfFocusOptions.length !== 0;
},
areasOfFocusForPost() {
if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) {
@@ -172,12 +198,40 @@ export default {
return this.$options.labels[this.inviteeType].placeHolder;
},
+ tasksToBeDoneEnabled() {
+ return (
+ getParameterValues('open_modal')[0] === 'invite_members_for_task' &&
+ this.tasksToBeDoneOptions.length
+ );
+ },
+ showTasksToBeDone() {
+ return (
+ this.tasksToBeDoneEnabled &&
+ this.selectedAccessLevel >= INVITE_MEMBERS_FOR_TASK.minimum_access_level
+ );
+ },
+ showTaskProjects() {
+ return !this.isProject && this.selectedTasksToBeDone.length;
+ },
+ tasksToBeDoneForPost() {
+ return this.showTasksToBeDone ? this.selectedTasksToBeDone : [];
+ },
+ tasksProjectForPost() {
+ return this.showTasksToBeDone && this.selectedTasksToBeDone.length
+ ? this.selectedTaskProject.id
+ : '';
+ },
},
mounted() {
eventHub.$on('openModal', (options) => {
this.openModal(options);
this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view);
});
+
+ if (this.tasksToBeDoneEnabled) {
+ this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' });
+ this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
+ }
},
methods: {
partitionNewUsersToInvite() {
@@ -191,7 +245,8 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal({ inviteeType, source }) {
+ openModal({ mode = 'default', inviteeType, source }) {
+ this.mode = mode;
this.inviteeType = inviteeType;
this.source = source;
@@ -219,6 +274,12 @@ export default {
this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit);
},
+ trackinviteMembersForTask() {
+ const label = 'selected_tasks_to_be_done';
+ const property = this.selectedTasksToBeDone.join(',');
+ const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
+ tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
+ },
resetFields() {
this.isLoading = false;
this.selectedAccessLevel = this.defaultAccessLevel;
@@ -227,10 +288,15 @@ export default {
this.groupToBeSharedWith = {};
this.invalidFeedbackMessage = '';
this.selectedAreasOfFocus = [];
+ this.selectedTasksToBeDone = [];
+ [this.selectedTaskProject] = this.projects;
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
+ changeSelectedTaskProject(project) {
+ this.selectedTaskProject = project;
+ },
submitShareWithGroup() {
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
@@ -263,6 +329,7 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
this.trackInvite();
+ this.trackinviteMembersForTask();
Promise.all(promises)
.then(this.conditionallyShowToastSuccess)
@@ -275,6 +342,8 @@ export default {
access_level: this.selectedAccessLevel,
invite_source: this.source,
areas_of_focus: this.areasOfFocusForPost,
+ tasks_to_be_done: this.tasksToBeDoneForPost,
+ tasks_project_id: this.tasksProjectForPost,
};
},
addByUserIdPostData(usersToAddById) {
@@ -284,6 +353,8 @@ export default {
access_level: this.selectedAccessLevel,
invite_source: this.source,
areas_of_focus: this.areasOfFocusForPost,
+ tasks_to_be_done: this.tasksToBeDoneForPost,
+ tasks_project_id: this.tasksProjectForPost,
};
},
shareWithGroupPostData(groupToBeSharedWith) {
@@ -322,49 +393,7 @@ export default {
return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
},
},
- labels: {
- members: {
- modalTitle: s__('InviteMembersModal|Invite members'),
- searchField: s__('InviteMembersModal|GitLab member or email address'),
- placeHolder: s__('InviteMembersModal|Select members or type email addresses'),
- toGroup: {
- introText: s__(
- "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.",
- ),
- },
- toProject: {
- introText: s__(
- "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.",
- ),
- },
- },
- group: {
- modalTitle: s__('InviteMembersModal|Invite a group'),
- searchField: s__('InviteMembersModal|Select a group to invite'),
- placeHolder: s__('InviteMembersModal|Search for a group to invite'),
- toGroup: {
- introText: s__(
- "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.",
- ),
- },
- toProject: {
- introText: s__(
- "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.",
- ),
- },
- },
- accessLevel: s__('InviteMembersModal|Select a role'),
- accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
- toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
- invalidFeedbackMessageDefault: s__('InviteMembersModal|Something went wrong'),
- readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
- inviteButtonText: s__('InviteMembersModal|Invite'),
- cancelButtonText: s__('InviteMembersModal|Cancel'),
- headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
- areasOfFocusLabel: s__(
- 'InviteMembersModal|What would you like new member(s) to focus on? (optional)',
- ),
- },
+ labels: MODAL_LABELS,
membersTokenSelectLabelId: 'invite-members-input',
};
</script>
@@ -374,20 +403,29 @@ export default {
:modal-id="modalId"
size="sm"
data-qa-selector="invite_members_modal_content"
- :title="$options.labels[inviteeType].modalTitle"
+ data-testid="invite-members-modal"
+ :title="modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
@hidden="resetFields"
@close="resetFields"
@hide="resetFields"
>
<div>
- <p ref="introText">
- <gl-sprintf :message="introText">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
+ <div class="gl-display-flex">
+ <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
+ <div>
+ <p ref="introText">
+ <gl-sprintf :message="introText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <br />
+ <span v-if="isCelebration">{{ $options.labels.members.modal.celebrate.intro }} </span>
+ <modal-confetti v-if="isCelebration" />
+ </p>
+ </div>
+ </div>
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
@@ -476,24 +514,70 @@ export default {
data-testid="area-of-focus-checks"
/>
</div>
+ <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done">
+ <label class="gl-mt-5">
+ {{ $options.labels.members.tasksToBeDone.title }}
+ </label>
+ <template v-if="projects.length">
+ <gl-form-checkbox-group
+ v-model="selectedTasksToBeDone"
+ :options="tasksToBeDoneOptions"
+ data-testid="invite-members-modal-tasks"
+ />
+ <template v-if="showTaskProjects">
+ <label class="gl-mt-5 gl-display-block">
+ {{ $options.labels.members.tasksProject.title }}
+ </label>
+ <gl-dropdown
+ class="gl-w-half gl-xs-w-full"
+ :text="selectedTaskProject.title"
+ data-testid="invite-members-modal-project-select"
+ >
+ <template v-for="project in projects">
+ <gl-dropdown-item
+ :key="project.id"
+ active-class="is-active"
+ is-check-item
+ :is-checked="project.id === selectedTaskProject.id"
+ @click="changeSelectedTaskProject(project)"
+ >
+ {{ project.title }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </template>
+ </template>
+ <gl-alert
+ v-else-if="tasksToBeDoneEnabled"
+ variant="tip"
+ :dismissible="false"
+ data-testid="invite-members-modal-no-projects-alert"
+ >
+ <gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects">
+ <template #link="{ content }">
+ <gl-link :href="newProjectPath" target="_blank" class="gl-label-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </div>
</div>
<template #modal-footer>
- <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0">
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.labels.cancelButtonText }}
- </gl-button>
- <div class="gl-mr-3"></div>
- <gl-button
- :disabled="inviteDisabled"
- :loading="isLoading"
- variant="success"
- data-qa-selector="invite_button"
- data-testid="invite-button"
- @click="sendInvite"
- >{{ $options.labels.inviteButtonText }}</gl-button
- >
- </div>
+ <gl-button data-testid="cancel-button" @click="closeModal">
+ {{ $options.labels.cancelButtonText }}
+ </gl-button>
+ <gl-button
+ :disabled="inviteDisabled"
+ :loading="isLoading"
+ variant="success"
+ data-qa-selector="invite_button"
+ data-testid="invite-button"
+ @click="sendInvite"
+ >
+ {{ $options.labels.inviteButtonText }}
+ </gl-button>
</template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 05be427742c..bf3250f63a5 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,11 +1,12 @@
<script>
-import { GlButton, GlLink } from '@gitlab/ui';
+import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
+import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants';
export default {
- components: { GlButton, GlLink },
+ components: { GlButton, GlLink, GlIcon },
props: {
displayText: {
type: String,
@@ -53,13 +54,11 @@ export default {
},
},
computed: {
- isButton() {
- return this.triggerElement === 'button';
- },
componentAttributes() {
const baseAttributes = {
class: this.classes,
'data-qa-selector': 'invite_members_button',
+ 'data-test-id': 'invite-members-button',
};
if (this.event && this.label) {
@@ -77,6 +76,9 @@ export default {
this.trackExperimentOnShow();
},
methods: {
+ checkTrigger(targetTriggerElement) {
+ return this.triggerElement === targetTriggerElement;
+ },
openModal() {
eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
},
@@ -87,12 +89,14 @@ export default {
}
},
},
+ TRIGGER_ELEMENT_BUTTON,
+ TRIGGER_ELEMENT_SIDE_NAV,
};
</script>
<template>
<gl-button
- v-if="isButton"
+ v-if="checkTrigger($options.TRIGGER_ELEMENT_BUTTON)"
v-bind="componentAttributes"
:variant="variant"
:icon="icon"
@@ -100,6 +104,17 @@ export default {
>
{{ displayText }}
</gl-button>
+ <gl-link
+ v-else-if="checkTrigger($options.TRIGGER_ELEMENT_SIDE_NAV)"
+ v-bind="componentAttributes"
+ data-is-link="true"
+ @click="openModal"
+ >
+ <span class="nav-icon-container">
+ <gl-icon :name="icon" />
+ </span>
+ <span class="nav-item-name"> {{ displayText }} </span>
+ </gl-link>
<gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
{{ displayText }}
</gl-link>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index d7daf83e26b..59d4c2f3077 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,4 +1,4 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
export const SEARCH_DELAY = 200;
@@ -8,6 +8,12 @@ export const MEMBER_AREAS_OF_FOCUS = {
view: 'view',
submit: 'submit',
};
+export const INVITE_MEMBERS_FOR_TASK = {
+ minimum_access_level: 30,
+ name: 'invite_members_for_task',
+ view: 'modal_opened_from_email',
+ submit: 'submit',
+};
export const GROUP_FILTERS = {
ALL: 'all',
@@ -19,3 +25,122 @@ export const API_MESSAGES = {
};
export const USERS_FILTER_ALL = 'all';
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
+export const TRIGGER_ELEMENT_BUTTON = 'button';
+export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav';
+export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members');
+export const MEMBERS_MODAL_CELEBRATE_TITLE = s__(
+ 'InviteMembersModal|GitLab is better with colleagues!',
+);
+export const MEMBERS_MODAL_CELEBRATE_INTRO = s__(
+ 'InviteMembersModal|How about inviting a colleague or two to join you?',
+);
+export const MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT = s__(
+ "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.",
+);
+
+export const MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT = s__(
+ "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.",
+);
+export const MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT = s__(
+ "InviteMembersModal|Congratulations on creating your project, you're almost there!",
+);
+export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|GitLab member or email address');
+export const MEMBERS_PLACEHOLDER = s__('InviteMembersModal|Select members or type email addresses');
+export const MEMBERS_TASKS_TO_BE_DONE_TITLE = s__(
+ 'InviteMembersModal|Create issues for your new team member to work on (optional)',
+);
+export const MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS = s__(
+ 'InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}',
+);
+export const MEMBERS_TASKS_PROJECTS_TITLE = s__(
+ 'InviteMembersModal|Choose a project for the issues',
+);
+
+export const GROUP_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite a group');
+export const GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT = s__(
+ "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.",
+);
+export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__(
+ "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.",
+);
+
+export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite');
+export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite');
+
+export const ACCESS_LEVEL = s__('InviteMembersModal|Select a role');
+export const ACCESS_EXPIRE_DATE = s__('InviteMembersModal|Access expiration date (optional)');
+export const TOAST_MESSAGE_SUCCESSFUL = s__('InviteMembersModal|Members were successfully added');
+export const INVALID_FEEDBACK_MESSAGE_DEFAULT = s__('InviteMembersModal|Something went wrong');
+export const READ_MORE_TEXT = s__(
+ `InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`,
+);
+export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite');
+export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
+export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
+export const AREAS_OF_FOCUS_LABEL = s__(
+ 'InviteMembersModal|What would you like new member(s) to focus on? (optional)',
+);
+
+export const MODAL_LABELS = {
+ members: {
+ modal: {
+ default: {
+ title: MEMBERS_MODAL_DEFAULT_TITLE,
+ },
+ celebrate: {
+ title: MEMBERS_MODAL_CELEBRATE_TITLE,
+ intro: MEMBERS_MODAL_CELEBRATE_INTRO,
+ },
+ },
+ toGroup: {
+ default: {
+ introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT,
+ },
+ },
+ toProject: {
+ default: {
+ introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
+ },
+ celebrate: {
+ introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
+ },
+ },
+ searchField: MEMBERS_SEARCH_FIELD,
+ placeHolder: MEMBERS_PLACEHOLDER,
+ tasksToBeDone: {
+ title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
+ noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
+ },
+ tasksProject: {
+ title: MEMBERS_TASKS_PROJECTS_TITLE,
+ },
+ },
+ group: {
+ modal: {
+ default: {
+ title: GROUP_MODAL_DEFAULT_TITLE,
+ },
+ },
+ toGroup: {
+ default: {
+ introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
+ },
+ },
+ toProject: {
+ default: {
+ introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
+ },
+ },
+ searchField: GROUP_SEARCH_FIELD,
+ placeHolder: GROUP_PLACEHOLDER,
+ },
+ accessLevel: ACCESS_LEVEL,
+ accessExpireDate: ACCESS_EXPIRE_DATE,
+ toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
+ invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT,
+ readMoreText: READ_MORE_TEXT,
+ inviteButtonText: INVITE_BUTTON_TEXT,
+ cancelButtonText: CANCEL_BUTTON_TEXT,
+ headerCloseLabel: HEADER_CLOSE_LABEL,
+ areasOfFocusLabel: AREAS_OF_FOCUS_LABEL,
+};
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 c1dfaa25dc7..fc657a064dd 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -5,15 +5,32 @@ import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(GlToast);
+let initedInviteMembersModal;
+
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;
+ }
+
+ // 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;
}
+ initedInviteMembersModal = true;
+
return new Vue({
el,
+ provide: {
+ newProjectPath: el.dataset.newProjectPath,
+ },
render: (createElement) =>
createElement(InviteMembersModal, {
props: {
@@ -24,6 +41,8 @@ export default function initInviteMembersModal() {
groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10),
areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions),
+ tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
+ projects: JSON.parse(el.dataset.projects || '[]'),
noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue
index 6e300831e00..799d2bdc9e2 100644
--- a/app/assets/javascripts/issuable/components/issuable_by_email.vue
+++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue
@@ -166,7 +166,7 @@ export default {
</gl-sprintf>
</p>
<template #modal-footer>
- <gl-button category="secondary" @click="cancelHandler">{{ s__('Cancel') }}</gl-button>
+ <gl-button category="secondary" @click="cancelHandler">{{ __('Cancel') }}</gl-button>
</template>
</gl-modal>
</div>
diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js
index 22a99a17741..8f7f317d6b4 100644
--- a/app/assets/javascripts/issuable_suggestions/index.js
+++ b/app/assets/javascripts/issuable_suggestions/index.js
@@ -10,12 +10,7 @@ export default function initIssuableSuggestions() {
const issueTitle = document.getElementById('issue_title');
const { projectPath } = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
return new Vue({
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 2c9a512acdb..d3b58ed3012 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -4,7 +4,7 @@ import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
-import { __, s__, sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import {
IssuableStatus,
IssuableStatusText,
@@ -12,6 +12,7 @@ import {
IssueTypePath,
IncidentTypePath,
IncidentType,
+ POLLING_DELAY,
} from '../constants';
import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
@@ -249,7 +250,7 @@ export default {
return false;
},
defaultErrorMessage() {
- return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
+ return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType });
},
isClosed() {
return this.issuableStatus === IssuableStatus.Closed;
@@ -282,7 +283,7 @@ export default {
});
if (!Visibility.hidden()) {
- this.poll.makeDelayedRequest(2000);
+ this.poll.makeDelayedRequest(POLLING_DELAY);
}
Visibility.change(() => {
@@ -436,7 +437,7 @@ export default {
})
.catch(() => {
createFlash({
- message: sprintf(s__('Error deleting %{issuableType}'), {
+ message: sprintf(__('Error deleting %{issuableType}'), {
issuableType: this.issuableType,
}),
});
@@ -457,6 +458,22 @@ export default {
this.flashContainer = null;
}
},
+
+ taskListUpdateStarted() {
+ this.poll.stop();
+ },
+
+ taskListUpdateSucceeded() {
+ this.poll.enable();
+ this.poll.makeDelayedRequest(POLLING_DELAY);
+ },
+
+ taskListUpdateFailed() {
+ this.poll.enable();
+ this.poll.makeDelayedRequest(POLLING_DELAY);
+
+ this.updateStoreState();
+ },
},
};
</script>
@@ -552,7 +569,9 @@ export default {
:issuable-type="issuableType"
:update-url="updateEndpoint"
:lock-version="state.lock_version"
- @taskListUpdateFailed="updateStoreState"
+ @taskListUpdateStarted="taskListUpdateStarted"
+ @taskListUpdateSucceeded="taskListUpdateSucceeded"
+ @taskListUpdateFailed="taskListUpdateFailed"
/>
<edited-component
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 4c6a1478e95..9dc122d426c 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -2,7 +2,7 @@
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import createFlash from '~/flash';
-import { s__, sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import TaskList from '../../task_list';
import animateMixin from '../mixins/animate';
@@ -86,15 +86,25 @@ export default {
fieldName: 'description',
lockVersion: this.lockVersion,
selector: '.detail-page-description',
+ onUpdate: this.taskListUpdateStarted.bind(this),
+ onSuccess: this.taskListUpdateSuccess.bind(this),
onError: this.taskListUpdateError.bind(this),
});
}
},
+ taskListUpdateStarted() {
+ this.$emit('taskListUpdateStarted');
+ },
+
+ taskListUpdateSuccess() {
+ this.$emit('taskListUpdateSucceeded');
+ },
+
taskListUpdateError() {
createFlash({
message: sprintf(
- s__(
+ __(
'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.',
),
{
diff --git a/app/assets/javascripts/issue_show/components/fields/type.vue b/app/assets/javascripts/issue_show/components/fields/type.vue
index 3eac448c637..9110a6924b4 100644
--- a/app/assets/javascripts/issue_show/components/fields/type.vue
+++ b/app/assets/javascripts/issue_show/components/fields/type.vue
@@ -2,7 +2,7 @@
import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { capitalize } from 'lodash';
import { __ } from '~/locale';
-import { IssuableTypes } from '../../constants';
+import { IssuableTypes, IncidentType } from '../../constants';
import getIssueStateQuery from '../../queries/get_issue_state.query.graphql';
import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql';
@@ -19,6 +19,14 @@ export default {
GlDropdown,
GlDropdownItem,
},
+ inject: {
+ canCreateIncident: {
+ default: false,
+ },
+ issueType: {
+ default: 'issue',
+ },
+ },
data() {
return {
issueState: {},
@@ -36,6 +44,9 @@ export default {
} = this;
return capitalize(issueType);
},
+ shouldShowIncident() {
+ return this.issueType === IncidentType || this.canCreateIncident;
+ },
},
methods: {
updateIssueType(issueType) {
@@ -47,6 +58,9 @@ export default {
},
});
},
+ isShown(type) {
+ return type.value !== IncidentType || this.shouldShowIncident;
+ },
},
};
</script>
@@ -68,6 +82,7 @@ export default {
>
<gl-dropdown-item
v-for="type in $options.IssuableTypes"
+ v-show="isShown(type)"
:key="type.value"
:is-checked="issueState.issueType === type.value"
is-check-item
diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue
index 2bddbe4faa0..2c314ce1c3f 100644
--- a/app/assets/javascripts/issue_show/components/header_actions.vue
+++ b/app/assets/javascripts/issue_show/components/header_actions.vue
@@ -192,9 +192,14 @@ export default {
class="gl-sm-display-none! w-100"
block
:text="dropdownText"
+ data-qa-selector="issue_actions_dropdown"
:loading="isToggleStateButtonLoading"
>
- <gl-dropdown-item v-if="showToggleIssueStateButton" @click="toggleIssueState">
+ <gl-dropdown-item
+ v-if="showToggleIssueStateButton"
+ :data-qa-selector="`mobile_${qaSelector}`"
+ @click="toggleIssueState"
+ >
{{ buttonText }}
</gl-dropdown-item>
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js
index 64d39a79821..ef9699deb42 100644
--- a/app/assets/javascripts/issue_show/constants.js
+++ b/app/assets/javascripts/issue_show/constants.js
@@ -37,3 +37,10 @@ export const IncidentTypePath = 'issues/incident';
export const IncidentType = 'incident';
export const issueState = { issueType: undefined, isDirty: false };
+
+export const POLLING_DELAY = 2000;
+
+export const WorkspaceType = {
+ project: 'project',
+ group: 'group',
+};
diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issue_show/incident.js
index df986195656..3aff2d9c54a 100644
--- a/app/assets/javascripts/issue_show/incident.js
+++ b/app/assets/javascripts/issue_show/incident.js
@@ -2,25 +2,31 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import issuableApp from './components/app.vue';
import incidentTabs from './components/incidents/incident_tabs.vue';
-import { issueState } from './constants';
+import { issueState, IncidentType } from './constants';
import apolloProvider from './graphql';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
+import HeaderActions from './components/header_actions.vue';
-export default function initIssuableApp(issuableData = {}) {
+const bootstrapApollo = (state = {}) => {
+ return apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: getIssueStateQuery,
+ data: {
+ issueState: state,
+ },
+ });
+};
+
+export function initIncidentApp(issuableData = {}) {
const el = document.getElementById('js-issuable-app');
if (!el) {
return undefined;
}
- apolloProvider.clients.defaultClient.cache.writeQuery({
- query: getIssueStateQuery,
- data: {
- issueState: { ...issueState, issueType: el.dataset.issueType },
- },
- });
+ bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
const {
+ canCreateIncident,
canUpdate,
iid,
projectNamespace,
@@ -39,6 +45,8 @@ export default function initIssuableApp(issuableData = {}) {
issuableApp,
},
provide: {
+ issueType: IncidentType,
+ canCreateIncident,
canUpdate,
fullPath,
iid,
@@ -57,3 +65,35 @@ export default function initIssuableApp(issuableData = {}) {
},
});
}
+
+export function initIncidentHeaderActions(store) {
+ const el = document.querySelector('.js-issue-header-actions');
+
+ if (!el) {
+ return undefined;
+ }
+
+ bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ store,
+ provide: {
+ canCreateIssue: parseBoolean(el.dataset.canCreateIncident),
+ canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
+ canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
+ canReportSpam: parseBoolean(el.dataset.canReportSpam),
+ canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
+ iid: el.dataset.iid,
+ isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
+ issueType: el.dataset.issueType,
+ newIssuePath: el.dataset.newIssuePath,
+ projectPath: el.dataset.projectPath,
+ projectId: el.dataset.projectId,
+ reportAbusePath: el.dataset.reportAbusePath,
+ submitAsSpamPath: el.dataset.submitAsSpamPath,
+ },
+ render: (createElement) => createElement(HeaderActions),
+ });
+}
diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issue_show/issue.js
index 4374dba6eb7..25cc51478ff 100644
--- a/app/assets/javascripts/issue_show/issue.js
+++ b/app/assets/javascripts/issue_show/issue.js
@@ -25,17 +25,22 @@ export function initIssuableApp(issuableData, store) {
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
+ const { canCreateIncident, ...issuableProps } = issuableData;
+
return new Vue({
el,
apolloProvider,
store,
+ provide: {
+ canCreateIncident,
+ },
computed: {
...mapGetters(['getNoteableData']),
},
render(createElement) {
return createElement(IssuableApp, {
props: {
- ...issuableData,
+ ...issuableProps,
isConfidential: this.getNoteableData?.confidential,
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
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 7b51f6ee46a..7f2082e5b90 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -36,6 +36,7 @@ import {
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
TOKEN_TYPE_WEIGHT,
UPDATED_DESC,
@@ -65,6 +66,7 @@ import {
TOKEN_TITLE_LABEL,
TOKEN_TITLE_MILESTONE,
TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_RELEASE,
TOKEN_TITLE_TYPE,
TOKEN_TITLE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
@@ -88,6 +90,8 @@ const LabelToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue');
const MilestoneToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
+const ReleaseToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue');
const WeightToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue');
@@ -165,6 +169,9 @@ export default {
newIssuePath: {
default: '',
},
+ releasesPath: {
+ default: '',
+ },
rssPath: {
default: '',
},
@@ -288,6 +295,7 @@ export default {
avatar_url: gon.current_user_avatar_url,
});
}
+
const tokens = [
{
type: TOKEN_TYPE_AUTHOR,
@@ -297,7 +305,6 @@ export default {
dataType: 'user',
unique: true,
defaultAuthors: [],
- operators: OPERATOR_IS_ONLY,
fetchAuthors: this.fetchUsers,
preloadedAuthors,
},
@@ -317,7 +324,6 @@ export default {
title: TOKEN_TITLE_MILESTONE,
icon: 'clock',
token: MilestoneToken,
- unique: true,
fetchMilestones: this.fetchMilestones,
},
{
@@ -333,7 +339,6 @@ export default {
title: TOKEN_TITLE_TYPE,
icon: 'issues',
token: GlFilteredSearchToken,
- operators: OPERATOR_IS_ONLY,
options: [
{ icon: 'issue-type-issue', title: 'issue', value: 'issue' },
{ icon: 'issue-type-incident', title: 'incident', value: 'incident' },
@@ -342,6 +347,16 @@ export default {
},
];
+ if (this.isProject) {
+ tokens.push({
+ type: TOKEN_TYPE_RELEASE,
+ title: TOKEN_TITLE_RELEASE,
+ icon: 'rocket',
+ token: ReleaseToken,
+ fetchReleases: this.fetchReleases,
+ });
+ }
+
if (this.isSignedIn) {
tokens.push({
type: TOKEN_TYPE_MY_REACTION,
@@ -349,7 +364,6 @@ export default {
icon: 'thumb-up',
token: EmojiToken,
unique: true,
- operators: OPERATOR_IS_ONLY,
fetchEmojis: this.fetchEmojis,
});
@@ -373,7 +387,6 @@ export default {
title: TOKEN_TITLE_ITERATION,
icon: 'iteration',
token: IterationToken,
- unique: true,
fetchIterations: this.fetchIterations,
});
}
@@ -459,6 +472,9 @@ export default {
fetchEmojis(search) {
return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
},
+ fetchReleases(search) {
+ return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search);
+ },
fetchLabels(search) {
return this.$apollo
.query({
diff --git a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue
index 037fd9be542..e749579af80 100644
--- a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue
+++ b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue
@@ -71,8 +71,11 @@ export default {
hasSelectedProject() {
return this.selectedProject.id;
},
+ projectsWithIssuesEnabled() {
+ return this.projects.filter((project) => project.issuesEnabled);
+ },
showNoSearchResultsText() {
- return !this.projects.length && this.search;
+ return !this.projectsWithIssuesEnabled.length && this.search;
},
},
methods: {
@@ -110,7 +113,7 @@ export default {
<gl-loading-icon v-if="$apollo.queries.projects.loading" />
<template v-else>
<gl-dropdown-item
- v-for="project of projects"
+ v-for="project of projectsWithIssuesEnabled"
:key="project.id"
@click="selectProject(project)"
>
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index 5bdc1bd9f90..da9b96d0e22 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -166,6 +166,7 @@ const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc';
const POPULARITY_ASC_SORT = 'popularity_asc';
const WEIGHT_DESC_SORT = 'weight_desc';
const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc';
+const TITLE_ASC_SORT = 'title_asc';
const TITLE_DESC_SORT = 'title_desc';
export const urlSortParams = {
@@ -187,7 +188,7 @@ export const urlSortParams = {
[WEIGHT_ASC]: WEIGHT,
[WEIGHT_DESC]: WEIGHT_DESC_SORT,
[BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT,
- [TITLE_ASC]: TITLE,
+ [TITLE_ASC]: TITLE_ASC_SORT,
[TITLE_DESC]: TITLE_DESC_SORT,
};
@@ -211,6 +212,7 @@ export const TOKEN_TYPE_ASSIGNEE = 'assignee_username';
export const TOKEN_TYPE_MILESTONE = 'milestone';
export const TOKEN_TYPE_LABEL = 'labels';
export const TOKEN_TYPE_TYPE = 'type';
+export const TOKEN_TYPE_RELEASE = 'release';
export const TOKEN_TYPE_MY_REACTION = 'my_reaction_emoji';
export const TOKEN_TYPE_CONFIDENTIAL = 'confidential';
export const TOKEN_TYPE_ITERATION = 'iteration';
@@ -271,6 +273,7 @@ export const filters = {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'label_name[]',
[SPECIAL_FILTER]: 'label_name[]',
+ [ALTERNATIVE_FILTER]: 'label_name',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[label_name][]',
@@ -280,12 +283,28 @@ export const filters = {
[TOKEN_TYPE_TYPE]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'types',
- [SPECIAL_FILTER]: 'types',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'type[]',
- [SPECIAL_FILTER]: 'type[]',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[type][]',
+ },
+ },
+ },
+ [TOKEN_TYPE_RELEASE]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'releaseTag',
+ [SPECIAL_FILTER]: 'releaseTagWildcardId',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'release_tag',
+ [SPECIAL_FILTER]: 'release_tag',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[release_tag]',
},
},
},
@@ -299,6 +318,9 @@ export const filters = {
[NORMAL_FILTER]: 'my_reaction_emoji',
[SPECIAL_FILTER]: 'my_reaction_emoji',
},
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[my_reaction_emoji]',
+ },
},
},
[TOKEN_TYPE_CONFIDENTIAL]: {
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 47af20f5271..59034964afb 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -24,7 +24,7 @@ export function mountJiraIssuesListApp() {
}
Vue.use(VueApollo);
- const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
+ const defaultClient = createDefaultClient();
const apolloProvider = new VueApollo({
defaultClient,
});
@@ -103,7 +103,7 @@ export function mountIssuesListApp() {
},
};
- const defaultClient = createDefaultClient(resolvers, { assumeImmutableResults: true });
+ const defaultClient = createDefaultClient(resolvers);
const apolloProvider = new VueApollo({
defaultClient,
});
@@ -137,6 +137,7 @@ export function mountIssuesListApp() {
newIssuePath,
projectImportJiraPath,
quickActionsHelpPath,
+ releasesPath,
resetPath,
rssPath,
showNewIssueLink,
@@ -164,6 +165,7 @@ export function mountIssuesListApp() {
isSignedIn: parseBoolean(isSignedIn),
jiraIntegrationPath,
newIssuePath,
+ releasesPath,
rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink),
signInPath,
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 6df72cf6596..9866efbcecc 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
@@ -11,9 +11,13 @@ query getIssues(
$assigneeId: String
$assigneeUsernames: [String!]
$authorUsername: String
+ $confidential: Boolean
$labelName: [String]
$milestoneTitle: [String]
$milestoneWildcardId: MilestoneWildcardId
+ $myReactionEmoji: String
+ $releaseTag: [String!]
+ $releaseTagWildcardId: ReleaseTagWildcardId
$types: [IssueType!]
$not: NegatedIssueFilterInput
$beforeCursor: String
@@ -30,9 +34,11 @@ query getIssues(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
types: $types
not: $not
before: $beforeCursor
@@ -57,9 +63,13 @@ query getIssues(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
types: $types
not: $not
before: $beforeCursor
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql
index 7bcdbbb28fc..5e755ec5870 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql
@@ -5,9 +5,13 @@ query getIssuesCount(
$assigneeId: String
$assigneeUsernames: [String!]
$authorUsername: String
+ $confidential: Boolean
$labelName: [String]
$milestoneTitle: [String]
$milestoneWildcardId: MilestoneWildcardId
+ $myReactionEmoji: String
+ $releaseTag: [String!]
+ $releaseTagWildcardId: ReleaseTagWildcardId
$types: [IssueType!]
$not: NegatedIssueFilterInput
) {
@@ -19,9 +23,11 @@ query getIssuesCount(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
types: $types
not: $not
) {
@@ -34,9 +40,11 @@ query getIssuesCount(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
types: $types
not: $not
) {
@@ -49,9 +57,11 @@ query getIssuesCount(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
types: $types
not: $not
) {
@@ -65,9 +75,13 @@ query getIssuesCount(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
types: $types
not: $not
) {
@@ -79,9 +93,13 @@ query getIssuesCount(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
types: $types
not: $not
) {
@@ -93,9 +111,13 @@ query getIssuesCount(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
types: $types
not: $not
) {
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
index 8f9b888d19b..8c95e6114d3 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
@@ -1,4 +1,4 @@
-query($fullPath: ID!) {
+query getIssuesListDetails($fullPath: ID!) {
project(fullPath: $fullPath) {
issues {
nodes {
diff --git a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql
index 78a368089a8..4f7217be7f7 100644
--- a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql
+++ b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql
@@ -1,4 +1,10 @@
fragment Iteration on Iteration {
id
title
+ startDate
+ dueDate
+ iterationCadence {
+ id
+ title
+ }
}
diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
index df1f330139a..75463f643a2 100644
--- a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
@@ -3,6 +3,7 @@ query searchProjects($fullPath: ID!, $search: String) {
projects(search: $search, includeSubgroups: true) {
nodes {
id
+ issuesEnabled
name
nameWithNamespace
webUrl
diff --git a/app/assets/javascripts/issues_list/service_desk_helper.js b/app/assets/javascripts/issues_list/service_desk_helper.js
index f96567ef53b..815f338f1a0 100644
--- a/app/assets/javascripts/issues_list/service_desk_helper.js
+++ b/app/assets/javascripts/issues_list/service_desk_helper.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
/**
* Generates empty state messages for Service Desk issues list.
@@ -20,12 +20,12 @@ export function generateMessages(emptyStateMeta) {
);
const serviceDeskSupportedMessage = s__(
- 'ServiceDesk|Issues created from Service Desk emails appear here. Each comment becomes part of the email conversation.',
+ 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.',
);
const commonDescription = `
<span>${serviceDeskSupportedMessage}</span>
- <a href="${serviceDeskHelpPage}">${s__('Learn more.')}</a>`;
+ <a href="${serviceDeskHelpPage}">${__('Learn more.')}</a>`;
return {
serviceDeskEnabledAndCanEditProjectSettings: {
@@ -60,7 +60,7 @@ export function generateMessages(emptyStateMeta) {
'ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email.',
),
primaryLink: incomingEmailHelpPage,
- primaryText: s__('Learn more.'),
+ primaryText: __('Learn more.'),
},
serviceDeskIsNotEnabled: {
title: s__('ServiceDesk|Service Desk is not enabled'),
diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js
index 1d3d07475af..0e57e2bff83 100644
--- a/app/assets/javascripts/issues_list/utils.js
+++ b/app/assets/javascripts/issues_list/utils.js
@@ -21,9 +21,13 @@ import {
RELATIVE_POSITION_ASC,
SPECIAL_FILTER,
SPECIAL_FILTER_VALUES,
+ TITLE_ASC,
+ TITLE_DESC,
TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_CONFIDENTIAL,
TOKEN_TYPE_ITERATION,
TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
UPDATED_ASC,
UPDATED_DESC,
@@ -113,11 +117,19 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
descending: RELATIVE_POSITION_ASC,
},
},
+ {
+ id: 9,
+ title: __('Title'),
+ sortDirection: {
+ ascending: TITLE_ASC,
+ descending: TITLE_DESC,
+ },
+ },
];
if (hasIssueWeightsFeature) {
sortOptions.push({
- id: 9,
+ id: sortOptions.length + 1,
title: __('Weight'),
sortDirection: {
ascending: WEIGHT_ASC,
@@ -128,7 +140,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
if (hasBlockedIssuesFeature) {
sortOptions.push({
- id: 10,
+ id: sortOptions.length + 1,
title: __('Blocking'),
sortDirection: {
ascending: BLOCKING_ISSUES_DESC,
@@ -193,17 +205,23 @@ const getFilterType = (data, tokenType = '') =>
? SPECIAL_FILTER
: NORMAL_FILTER;
+const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_RELEASE];
+
const isWildcardValue = (tokenType, value) =>
- (tokenType === TOKEN_TYPE_ITERATION || tokenType === TOKEN_TYPE_MILESTONE) &&
- SPECIAL_FILTER_VALUES.includes(value);
+ wildcardTokens.includes(tokenType) && SPECIAL_FILTER_VALUES.includes(value);
const requiresUpperCaseValue = (tokenType, value) =>
tokenType === TOKEN_TYPE_TYPE || isWildcardValue(tokenType, value);
-const formatData = (token) =>
- requiresUpperCaseValue(token.type, token.value.data)
- ? token.value.data.toUpperCase()
- : token.value.data;
+const formatData = (token) => {
+ if (requiresUpperCaseValue(token.type, token.value.data)) {
+ return token.value.data.toUpperCase();
+ }
+ if (token.type === TOKEN_TYPE_CONFIDENTIAL) {
+ return token.value.data === 'yes';
+ }
+ return token.value.data;
+};
export const convertToApiParams = (filterTokens) => {
const params = {};
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 e768154e210..32fbc1113bc 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,6 +1,6 @@
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-query getProjects(
+query jiraGetProjects(
$search: String!
$after: String = ""
$first: Int!
diff --git a/app/assets/javascripts/jira_connect/branches/index.js b/app/assets/javascripts/jira_connect/branches/index.js
index 95bd4f5c675..04510fcff4b 100644
--- a/app/assets/javascripts/jira_connect/branches/index.js
+++ b/app/assets/javascripts/jira_connect/branches/index.js
@@ -14,12 +14,7 @@ export default async function initJiraConnectBranches() {
const { initialBranchName, successStateSvgPath } = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
return new Vue({
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue
new file mode 100644
index 00000000000..0b286bc903f
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue
@@ -0,0 +1,24 @@
+<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { ADD_NAMESPACE_MODAL_ID } from '../constants';
+import AddNamespaceModal from './add_namespace_modal/add_namespace_modal.vue';
+
+export default {
+ components: {
+ GlButton,
+ AddNamespaceModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ ADD_NAMESPACE_MODAL_ID,
+};
+</script>
+<template>
+ <div>
+ <gl-button v-gl-modal="$options.ADD_NAMESPACE_MODAL_ID" category="primary" variant="info">
+ {{ s__('Integrations|Add namespace') }}
+ </gl-button>
+ <add-namespace-modal />
+ </div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue
new file mode 100644
index 00000000000..0e209a09b16
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { ADD_NAMESPACE_MODAL_ID } from '../../constants';
+import GroupsList from './groups_list.vue';
+
+export default {
+ components: { GlModal, GroupsList },
+ modal: {
+ id: ADD_NAMESPACE_MODAL_ID,
+ title: s__('Integrations|Link namespaces'),
+ cancelProps: {
+ text: __('Cancel'),
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="$options.modal.id"
+ :title="$options.modal.title"
+ :action-cancel="$options.modal.cancelProps"
+ >
+ <groups-list />
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
index 5a49d7c1a90..5a49d7c1a90 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
index ed7585e8a88..005c3bcd0e3 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
@@ -4,7 +4,7 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import { addSubscription } from '~/jira_connect/subscriptions/api';
import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils';
import { s__ } from '~/locale';
-import GroupItemName from './group_item_name.vue';
+import GroupItemName from '../group_item_name.vue';
export default {
components: {
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 413424be28d..c0504cbb645 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -1,65 +1,51 @@
<script>
-import { GlAlert, GlButton, GlLink, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlLink, GlSprintf, GlEmptyState } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex';
-import { retrieveAlert, getLocation } from '~/jira_connect/subscriptions/utils';
-import { __ } from '~/locale';
+import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import { SET_ALERT } from '../store/mutation_types';
-import GroupsList from './groups_list.vue';
import SubscriptionsList from './subscriptions_list.vue';
+import AddNamespaceButton from './add_namespace_button.vue';
+import SignInButton from './sign_in_button.vue';
export default {
name: 'JiraConnectApp',
components: {
GlAlert,
- GlButton,
GlLink,
- GlModal,
GlSprintf,
- GroupsList,
+ GlEmptyState,
SubscriptionsList,
- },
- directives: {
- GlModalDirective,
+ AddNamespaceButton,
+ SignInButton,
},
inject: {
usersPath: {
default: '',
},
- },
- data() {
- return {
- location: '',
- };
+ subscriptions: {
+ default: [],
+ },
},
computed: {
...mapState(['alert']),
- usersPathWithReturnTo() {
- if (this.location) {
- return `${this.usersPath}?return_to=${this.location}`;
- }
-
- return this.usersPath;
- },
shouldShowAlert() {
return Boolean(this.alert?.message);
},
- },
- modal: {
- cancelProps: {
- text: __('Cancel'),
+ hasSubscriptions() {
+ return !isEmpty(this.subscriptions);
+ },
+ userSignedIn() {
+ return Boolean(!this.usersPath);
},
},
created() {
this.setInitialAlert();
- this.setLocation();
},
methods: {
...mapMutations({
setAlert: SET_ALERT,
}),
- async setLocation() {
- this.location = await getLocation();
- },
setInitialAlert() {
const { linkUrl, title, message, variant } = retrieveAlert() || {};
this.setAlert({ linkUrl, title, message, variant });
@@ -88,38 +74,44 @@ export default {
</template>
</gl-alert>
- <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
+ <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
+ <div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7">
+ <template v-if="hasSubscriptions">
+ <div class="gl-display-flex gl-justify-content-end">
+ <sign-in-button v-if="!userSignedIn" :users-path="usersPath" />
+ <add-namespace-button v-else />
+ </div>
- <div class="jira-connect-app-body gl-my-7 gl-px-5 gl-pb-4">
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- v-if="usersPath"
- category="primary"
- variant="info"
- class="gl-align-self-center"
- :href="usersPathWithReturnTo"
- target="_blank"
- >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button
+ <subscriptions-list />
+ </template>
+ <template v-else>
+ <div v-if="!userSignedIn" class="gl-text-center">
+ <p class="gl-mb-7">{{ s__('JiraService|Sign in to GitLab.com to get started.') }}</p>
+ <sign-in-button class="gl-mb-7" :users-path="usersPath">
+ {{ __('Sign in to GitLab') }}
+ </sign-in-button>
+ <p>
+ {{
+ s__(
+ 'Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).',
+ )
+ }}
+ </p>
+ </div>
+ <gl-empty-state
+ v-else
+ :title="s__('Integrations|No linked namespaces')"
+ :description="
+ s__(
+ 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
+ )
+ "
>
- <template v-else>
- <gl-button
- v-gl-modal-directive="'add-namespace-modal'"
- category="primary"
- variant="info"
- class="gl-align-self-center"
- >{{ s__('Integrations|Add namespace') }}</gl-button
- >
- <gl-modal
- modal-id="add-namespace-modal"
- :title="s__('Integrations|Link namespaces')"
- :action-cancel="$options.modal.cancelProps"
- >
- <groups-list />
- </gl-modal>
- </template>
- </div>
-
- <subscriptions-list />
+ <template #actions>
+ <add-namespace-button />
+ </template>
+ </gl-empty-state>
+ </template>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
new file mode 100644
index 00000000000..dc0a77e99c2
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
@@ -0,0 +1,36 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ usersPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ signInURL: '',
+ };
+ },
+ created() {
+ this.setSignInURL();
+ },
+ methods: {
+ async setSignInURL() {
+ this.signInURL = await getGitlabSignInURL(this.usersPath);
+ },
+ },
+};
+</script>
+<template>
+ <gl-button category="primary" variant="info" :href="signInURL" target="_blank">
+ <slot>
+ {{ s__('Integrations|Sign in to add namespaces') }}
+ </slot>
+ </gl-button>
+</template>
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 7062fb370ed..33126040c16 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, GlEmptyState, GlTable } from '@gitlab/ui';
+import { GlButton, GlTable } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapMutations } from 'vuex';
import { removeSubscription } from '~/jira_connect/subscriptions/api';
@@ -12,7 +12,6 @@ import GroupItemName from './group_item_name.vue';
export default {
components: {
GlButton,
- GlEmptyState,
GlTable,
GroupItemName,
TimeagoTooltip,
@@ -44,17 +43,15 @@ export default {
},
],
i18n: {
- emptyTitle: s__('Integrations|No linked namespaces'),
- emptyDescription: s__(
- 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
- ),
unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'),
},
methods: {
...mapMutations({
setAlert: SET_ALERT,
}),
- isEmpty,
+ isUnlinkButtonDisabled(item) {
+ return !isEmpty(item);
+ },
isLoadingItem(item) {
return this.loadingItem === item;
},
@@ -81,29 +78,22 @@ export default {
</script>
<template>
- <div>
- <gl-empty-state
- v-if="isEmpty(subscriptions)"
- :title="$options.i18n.emptyTitle"
- :description="$options.i18n.emptyDescription"
- />
- <gl-table v-else :items="subscriptions" :fields="$options.fields">
- <template #cell(name)="{ item }">
- <group-item-name :group="item.group" />
- </template>
- <template #cell(created_at)="{ item }">
- <timeago-tooltip :time="item.created_at" />
- </template>
- <template #cell(actions)="{ item }">
- <gl-button
- :class="unlinkBtnClass(item)"
- category="secondary"
- :loading="isLoadingItem(item)"
- :disabled="!isEmpty(loadingItem)"
- @click.prevent="onClick(item)"
- >{{ __('Unlink') }}</gl-button
- >
- </template>
- </gl-table>
- </div>
+ <gl-table :items="subscriptions" :fields="$options.fields">
+ <template #cell(name)="{ item }">
+ <group-item-name :group="item.group" />
+ </template>
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
+ <template #cell(actions)="{ item }">
+ <gl-button
+ :class="unlinkBtnClass(item)"
+ category="secondary"
+ :loading="isLoadingItem(item)"
+ :disabled="isUnlinkButtonDisabled(loadingItem)"
+ @click.prevent="onClick(item)"
+ >{{ __('Unlink') }}</gl-button
+ >
+ </template>
+ </gl-table>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index 8dff83eabb5..2a65b7bc1fa 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -1,3 +1,5 @@
export const DEFAULT_GROUPS_PER_PAGE = 10;
export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
export const MINIMUM_SEARCH_TERM_LENGTH = 3;
+
+export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js
index f1262be0174..8a7a80d885d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/index.js
@@ -7,16 +7,20 @@ import Translate from '~/vue_shared/translate';
import JiraConnectApp from './components/app.vue';
import createStore from './store';
-import { getLocation, sizeToParent } from './utils';
+import { getGitlabSignInURL, sizeToParent } from './utils';
const store = createStore();
+/**
+ * Add `return_to` query param to all HAML-defined GitLab sign in links.
+ */
const updateSignInLinks = async () => {
- const location = await getLocation();
- Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => {
- const updatedLink = `${el.getAttribute('href')}?return_to=${location}`;
- el.setAttribute('href', updatedLink);
- });
+ await Promise.all(
+ Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).map(async (el) => {
+ const updatedLink = await getGitlabSignInURL(el.getAttribute('href'));
+ el.setAttribute('href', updatedLink);
+ }),
+ );
};
export async function initJiraConnect() {
diff --git a/app/assets/javascripts/jira_connect/subscriptions/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js
index ed7a9484a81..b2d03a1fbba 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/utils.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js
@@ -1,4 +1,5 @@
import AccessorUtilities from '~/lib/utils/accessor';
+import { objectToQuery } from '~/lib/utils/url_utility';
import { ALERT_LOCALSTORAGE_KEY } from './constants';
const isFunction = (fn) => typeof fn === 'function';
@@ -71,3 +72,17 @@ export const sizeToParent = () => {
AP.sizeToParent();
}
};
+
+export const getGitlabSignInURL = async (signInURL) => {
+ const location = await getLocation();
+
+ if (location) {
+ const queryParams = {
+ return_to: location,
+ };
+
+ return `${signInURL}?${objectToQuery(queryParams)}`;
+ }
+
+ return signInURL;
+};
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 003f3c7107e..695a237bf50 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -6,7 +6,7 @@ import App from './components/jira_import_app.vue';
Vue.use(VueApollo);
-const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
+const defaultClient = createDefaultClient();
const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
index 2aacc5cf668..6fec07cc6f8 100644
--- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
+++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
@@ -1,6 +1,6 @@
#import "./jira_import.fragment.graphql"
-query($fullPath: ID!) {
+query getJiraImportDetails($fullPath: ID!) {
project(fullPath: $fullPath) {
jiraImportStatus
jiraImports {
diff --git a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql
index cca33af342c..7af30ffb869 100644
--- a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql
+++ b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql
@@ -1,4 +1,4 @@
-mutation($input: JiraImportUsersInput!) {
+mutation getJiraUserMapping($input: JiraImportUsersInput!) {
jiraImportUsers(input: $input) {
jiraUsers {
jiraAccountId
diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
index 807374bf06c..29f8428fbcf 100644
--- a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
+++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
@@ -1,6 +1,6 @@
#import "./jira_import.fragment.graphql"
-mutation($input: JiraImportStartInput!) {
+mutation initiateJiraImport($input: JiraImportStartInput!) {
jiraImportStart(input: $input) {
jiraImport {
...JiraImport
diff --git a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql
index 06f119e75ed..6ea8963e6a6 100644
--- a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql
+++ b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql
@@ -1,4 +1,4 @@
-query searchProjectMembers($fullPath: ID!, $search: String) {
+query jiraSearchProjectMembers($fullPath: ID!, $search: String) {
project(fullPath: $fullPath) {
projectMembers(search: $search) {
nodes {
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue
index 269551ff9aa..7a52a1b0d6b 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue
@@ -1,5 +1,12 @@
<script>
-import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlButton,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { mapActions } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -8,6 +15,9 @@ import { s__ } from '~/locale';
export default {
name: 'ManualVariablesForm',
components: {
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
GlButton,
GlLink,
GlSprintf,
@@ -32,6 +42,9 @@ export default {
value: 'value',
},
i18n: {
+ header: s__('CiVariables|Variables'),
+ keyLabel: s__('CiVariables|Key'),
+ valueLabel: s__('CiVariables|Value'),
keyPlaceholder: s__('CiVariables|Input variable key'),
valuePlaceholder: s__('CiVariables|Input variable value'),
formHelpText: s__(
@@ -40,9 +53,13 @@ export default {
},
data() {
return {
- variables: [],
- key: '',
- secretValue: '',
+ variables: [
+ {
+ key: '',
+ secretValue: '',
+ id: uniqueId(),
+ },
+ ],
triggerBtnDisabled: false,
};
},
@@ -50,40 +67,32 @@ export default {
variableSettings() {
return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' });
},
- },
- watch: {
- key(newVal) {
- this.handleValueChange(newVal, this.$options.inputTypes.key);
- },
- secretValue(newVal) {
- this.handleValueChange(newVal, this.$options.inputTypes.value);
+ preparedVariables() {
+ // we need to ensure no empty variables are passed to the API
+ // and secretValue should be snake_case when passed to the API
+ return this.variables
+ .filter((variable) => variable.key !== '')
+ .map(({ key, secretValue }) => ({ key, secret_value: secretValue }));
},
},
methods: {
...mapActions(['triggerManualJob']),
- handleValueChange(newValue, type) {
- if (newValue !== '') {
- this.createNewVariable(type);
- this.resetForm();
- }
+ canRemove(index) {
+ return index < this.variables.length - 1;
},
- createNewVariable(type) {
- const newVariable = {
- key: this.key,
- secret_value: this.secretValue,
- id: uniqueId(),
- };
+ addEmptyVariable() {
+ const lastVar = this.variables[this.variables.length - 1];
- this.variables.push(newVariable);
+ if (lastVar.key === '') {
+ return;
+ }
- return this.$nextTick().then(() => {
- this.$refs[`${this.$options.inputTypes[type]}-${newVariable.id}`][0].focus();
+ this.variables.push({
+ key: '',
+ secret_value: '',
+ id: uniqueId(),
});
},
- resetForm() {
- this.key = '';
- this.secretValue = '';
- },
deleteVariable(id) {
this.variables.splice(
this.variables.findIndex((el) => el.id === id),
@@ -93,112 +102,92 @@ export default {
trigger() {
this.triggerBtnDisabled = true;
- this.triggerManualJob(this.variables);
+ this.triggerManualJob(this.preparedVariables);
},
},
};
</script>
<template>
- <div class="col-12" data-testid="manual-vars-form">
- <label>{{ s__('CiVariables|Variables') }}</label>
-
- <div class="ci-table">
- <div class="gl-responsive-table-row table-row-header pb-0 pt-0 border-0" role="row">
- <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Key') }}</div>
- <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Value') }}</div>
- </div>
+ <div class="row gl-justify-content-center">
+ <div class="col-10" data-testid="manual-vars-form">
+ <label>{{ $options.i18n.header }}</label>
<div
- v-for="variable in variables"
+ v-for="(variable, index) in variables"
:key="variable.id"
- class="gl-responsive-table-row"
+ class="gl-display-flex gl-align-items-center gl-mb-4"
data-testid="ci-variable-row"
>
- <div class="table-section section-50">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
- <div class="table-mobile-content gl-mr-3">
- <input
- :ref="`${$options.inputTypes.key}-${variable.id}`"
- v-model="variable.key"
- :placeholder="$options.i18n.keyPlaceholder"
- class="ci-variable-body-item form-control"
- data-testid="ci-variable-key"
- />
- </div>
- </div>
+ <gl-form-input-group class="gl-mr-4 gl-flex-grow-1">
+ <template #prepend>
+ <gl-input-group-text>
+ {{ $options.i18n.keyLabel }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-input
+ :ref="`${$options.inputTypes.key}-${variable.id}`"
+ v-model="variable.key"
+ :placeholder="$options.i18n.keyPlaceholder"
+ data-testid="ci-variable-key"
+ @change="addEmptyVariable"
+ />
+ </gl-form-input-group>
- <div class="table-section section-50">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div>
- <div class="table-mobile-content gl-mr-3">
- <input
- :ref="`${$options.inputTypes.value}-${variable.id}`"
- v-model="variable.secret_value"
- :placeholder="$options.i18n.valuePlaceholder"
- class="ci-variable-body-item form-control"
- data-testid="ci-variable-value"
- />
- </div>
- </div>
+ <gl-form-input-group class="gl-flex-grow-2">
+ <template #prepend>
+ <gl-input-group-text>
+ {{ $options.i18n.valueLabel }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-input
+ :ref="`${$options.inputTypes.value}-${variable.id}`"
+ v-model="variable.secretValue"
+ :placeholder="$options.i18n.valuePlaceholder"
+ data-testid="ci-variable-value"
+ />
+ </gl-form-input-group>
- <div class="table-section section-10">
- <div class="table-mobile-header" role="rowheader"></div>
- <div class="table-mobile-content justify-content-end">
- <gl-button
- category="tertiary"
- icon="clear"
- :aria-label="__('Delete variable')"
- data-testid="delete-variable-btn"
- @click="deleteVariable(variable.id)"
- />
- </div>
- </div>
+ <!-- delete variable button placeholder to not break flex layout -->
+ <div
+ v-if="!canRemove(index)"
+ class="gl-w-7 gl-mr-3"
+ data-testid="delete-variable-btn-placeholder"
+ ></div>
+
+ <gl-button
+ v-if="canRemove(index)"
+ class="gl-flex-grow-0 gl-flex-basis-0"
+ category="tertiary"
+ variant="danger"
+ icon="clear"
+ :aria-label="__('Delete variable')"
+ data-testid="delete-variable-btn"
+ @click="deleteVariable(variable.id)"
+ />
</div>
- <div class="gl-responsive-table-row">
- <div class="table-section section-50">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
- <div class="table-mobile-content gl-mr-3">
- <input
- ref="inputKey"
- v-model="key"
- class="js-input-key form-control"
- :placeholder="$options.i18n.keyPlaceholder"
- />
- </div>
- </div>
- <div class="table-section section-50">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div>
- <div class="table-mobile-content gl-mr-3">
- <input
- ref="inputSecretValue"
- v-model="secretValue"
- class="ci-variable-body-item form-control"
- :placeholder="$options.i18n.valuePlaceholder"
- />
- </div>
- </div>
+ <div class="gl-text-center gl-mt-5">
+ <gl-sprintf :message="$options.i18n.formHelpText">
+ <template #link="{ content }">
+ <gl-link :href="variableSettings" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-button
+ class="gl-mt-5"
+ variant="info"
+ category="primary"
+ :aria-label="__('Trigger manual job')"
+ :disabled="triggerBtnDisabled"
+ data-testid="trigger-manual-job-btn"
+ @click="trigger"
+ >
+ {{ action.button_title }}
+ </gl-button>
</div>
- </div>
- <div class="gl-text-center gl-mt-3">
- <gl-sprintf :message="$options.i18n.formHelpText">
- <template #link="{ content }">
- <gl-link :href="variableSettings" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </div>
- <div class="d-flex justify-content-center">
- <gl-button
- variant="info"
- category="primary"
- :aria-label="__('Trigger manual job')"
- :disabled="triggerBtnDisabled"
- data-testid="trigger-manual-job-btn"
- @click="trigger"
- >
- {{ action.button_title }}
- </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index fef5b37015c..b1ddede8fe8 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -84,9 +84,13 @@ export default {
>
</p>
- <gl-table :items="trigger.variables" :fields="$options.fields" small bordered>
+ <gl-table :items="trigger.variables" :fields="$options.fields" small bordered fixed>
+ <template #cell(key)="{ item }">
+ <span class="gl-overflow-break-word">{{ item.key }}</span>
+ </template>
+
<template #cell(value)="data">
- {{ getDisplayValue(data.value) }}
+ <span class="gl-overflow-break-word">{{ getDisplayValue(data.value) }}</span>
</template>
</gl-table>
</template>
diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
index ad92bd4de42..9b7901685b6 100644
--- a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
+++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
@@ -9,10 +9,6 @@ import { isNavigatingAway } from '~/lib/utils/is_navigating_away';
* @returns {ApolloLink|null}
*/
export const getSuppressNetworkErrorsDuringNavigationLink = () => {
- if (!gon.features?.suppressApolloErrorsDuringNavigation) {
- return null;
- }
-
return onError(({ networkError }) => {
if (networkError && isNavigatingAway()) {
// Return an observable that will never notify any subscribers with any
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 39bf804b54e..df2e85afe24 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -48,7 +48,6 @@ export const stripWhitespaceFromQuery = (url, path) => {
export default (resolvers = {}, config = {}) => {
const {
- assumeImmutableResults,
baseUrl,
batchMax = 10,
cacheConfig,
@@ -161,10 +160,10 @@ export default (resolvers = {}, config = {}) => {
link: appLink,
cache: new InMemoryCache({
...cacheConfig,
- freezeResults: assumeImmutableResults,
+ freezeResults: true,
}),
resolvers,
- assumeImmutableResults,
+ assumeImmutableResults: true,
defaultOptions: {
query: {
fetchPolicy,
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 813fd3dbb1e..a82dad7e2c9 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -220,16 +220,16 @@ export const scrollToElement = (element, options = {}) => {
// In the previous implementation, jQuery naturally deferred this scrolling.
// Unfortunately, we're quite coupled to this implementation detail now.
defer(() => {
- const { duration = 200, offset = 0 } = options;
+ const { duration = 200, offset = 0, behavior = duration ? 'smooth' : 'auto' } = options;
const y = el.getBoundingClientRect().top + window.pageYOffset + offset - contentTop();
- window.scrollTo({ top: y, behavior: duration ? 'smooth' : 'auto' });
+ window.scrollTo({ top: y, behavior });
});
}
};
-export const scrollToElementWithContext = (element) => {
+export const scrollToElementWithContext = (element, options) => {
const offsetMultiplier = -0.1;
- return scrollToElement(element, { offset: window.innerHeight * offsetMultiplier });
+ return scrollToElement(element, { ...options, offset: window.innerHeight * offsetMultiplier });
};
/**
@@ -688,17 +688,20 @@ export const searchBy = (query = '', searchSpace = {}) => {
*/
export const isScopedLabel = ({ title = '' } = {}) => title.includes(SCOPED_LABEL_DELIMITER);
+const scopedLabelRegex = new RegExp(`(.*)${SCOPED_LABEL_DELIMITER}.*`);
+
/**
- * Returns the base value of the scoped label
- *
- * Expected Label to be an Object with `title` as a key:
- * { title: 'LabelTitle', ...otherProperties };
+ * Returns the key of a scoped label.
+ * For example:
+ * - returns `scoped` if the label is `scoped::value`.
+ * - returns `scoped::label` if the label is `scoped::label::value`.
*
- * @param {Object} label
- * @returns String
+ * @param {Object} label object containing `title` property
+ * @returns String scoped label key, or full label if it is not a scoped label
*/
-export const scopedLabelKey = ({ title = '' }) =>
- isScopedLabel({ title }) && title.split(SCOPED_LABEL_DELIMITER)[0];
+export const scopedLabelKey = ({ title = '' }) => {
+ return title.replace(scopedLabelRegex, '$1');
+};
// Methods to set and get Cookie
export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
new file mode 100644
index 00000000000..733d0f69f5d
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ cancelAction: { text: __('Cancel') },
+ components: {
+ GlModal,
+ },
+ props: {
+ primaryText: {
+ type: String,
+ required: false,
+ default: __('OK'),
+ },
+ primaryVariant: {
+ type: String,
+ required: false,
+ default: 'confirm',
+ },
+ },
+ computed: {
+ primaryAction() {
+ return { text: this.primaryText, attributes: { variant: this.primaryVariant } };
+ },
+ },
+ mounted() {
+ this.$refs.modal.show();
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ modal-id="confirmationModal"
+ body-class="gl-display-flex"
+ :action-primary="primaryAction"
+ :action-cancel="$options.cancelAction"
+ hide-header
+ @primary="$emit('confirmed')"
+ @hidden="$emit('closed')"
+ >
+ <div class="gl-align-self-center"><slot></slot></div>
+ </gl-modal>
+</template>
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
new file mode 100644
index 00000000000..fdd0e045d07
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+
+export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {}) {
+ return new Promise((resolve) => {
+ let confirmed = false;
+
+ const component = new Vue({
+ components: {
+ ConfirmModal: () => import('./confirm_modal.vue'),
+ },
+ render(h) {
+ return h(
+ 'confirm-modal',
+ {
+ props: {
+ primaryVariant: primaryBtnVariant,
+ primaryText: primaryBtnText,
+ },
+ on: {
+ confirmed() {
+ confirmed = true;
+ },
+ closed() {
+ component.$destroy();
+ resolve(confirmed);
+ },
+ },
+ },
+ [message],
+ );
+ },
+ }).$mount();
+ });
+}
+
+export function confirmViaGlModal(message, element) {
+ const primaryBtnConfig = {};
+
+ const confirmBtnVariant = element.getAttribute('data-confirm-btn-variant');
+
+ if (confirmBtnVariant) {
+ primaryBtnConfig.primaryBtnVariant = confirmBtnVariant;
+ }
+
+ const screenReaderText =
+ element.querySelector('.gl-sr-only')?.textContent ||
+ element.querySelector('.sr-only')?.textContent ||
+ element.getAttribute('aria-label');
+
+ if (screenReaderText) {
+ primaryBtnConfig.primaryBtnText = screenReaderText;
+ }
+
+ return confirmAction(message, primaryBtnConfig);
+}
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 0e5a23a5cbb..36c6545164e 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -24,3 +24,5 @@ export const DEFAULT_TH_CLASSES =
// We set the drawer's z-index to 252 to clear flash messages that might
// be displayed in the page and that have a z-index of 251.
export const DRAWER_Z_INDEX = 252;
+
+export const MIN_USERNAME_LENGTH = 2;
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 3c446c21865..7bff2bf3e47 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -14,33 +14,33 @@ import { s__, n__, __, sprintf } from '../../../locale';
export const getMonthNames = (abbreviated) => {
if (abbreviated) {
return [
- s__('Jan'),
- s__('Feb'),
- s__('Mar'),
- s__('Apr'),
- s__('May'),
- s__('Jun'),
- s__('Jul'),
- s__('Aug'),
- s__('Sep'),
- s__('Oct'),
- s__('Nov'),
- s__('Dec'),
+ __('Jan'),
+ __('Feb'),
+ __('Mar'),
+ __('Apr'),
+ __('May'),
+ __('Jun'),
+ __('Jul'),
+ __('Aug'),
+ __('Sep'),
+ __('Oct'),
+ __('Nov'),
+ __('Dec'),
];
}
return [
- s__('January'),
- s__('February'),
- s__('March'),
- s__('April'),
- s__('May'),
- s__('June'),
- s__('July'),
- s__('August'),
- s__('September'),
- s__('October'),
- s__('November'),
- s__('December'),
+ __('January'),
+ __('February'),
+ __('March'),
+ __('April'),
+ __('May'),
+ __('June'),
+ __('July'),
+ __('August'),
+ __('September'),
+ __('October'),
+ __('November'),
+ __('December'),
];
};
diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js
index b8b63bf58d4..f99a4927338 100644
--- a/app/assets/javascripts/lib/utils/file_upload.js
+++ b/app/assets/javascripts/lib/utils/file_upload.js
@@ -15,13 +15,17 @@ export default (buttonSelector, fileSelector) => {
});
};
-export const getFilename = ({ clipboardData }) => {
- let value;
- if (window.clipboardData && window.clipboardData.getData) {
- value = window.clipboardData.getData('Text');
- } else if (clipboardData && clipboardData.getData) {
- value = clipboardData.getData('text/plain');
+export const getFilename = (file) => {
+ let fileName;
+ if (file) {
+ fileName = file.name;
}
- value = value.split('\r');
- return value[0];
+
+ return fileName;
+};
+
+export const validateImageName = (file) => {
+ const fileName = file.name ? file.name : 'image.png';
+ const legalImageRegex = /^[\w.\-+]+\.(png|jpg|jpeg|gif|bmp|tiff|ico|webp)$/;
+ return legalImageRegex.test(fileName) ? fileName : 'image.png';
};
diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js
index 8b40cc7bd11..6b1985a23ba 100644
--- a/app/assets/javascripts/lib/utils/rails_ujs.js
+++ b/app/assets/javascripts/lib/utils/rails_ujs.js
@@ -1,4 +1,42 @@
import Rails from '@rails/ujs';
+import { confirmViaGlModal } from './confirm_via_gl_modal/confirm_via_gl_modal';
+
+function monkeyPatchConfirmModal() {
+ /**
+ * This function is used to replace the `Rails.confirm` which uses `window.confirm`
+ *
+ * This function opens a confirmation modal which will resolve in a promise.
+ * Because the `Rails.confirm` API is synchronous, we go with a little hack here:
+ *
+ * 1. User clicks on something with `data-confirm`
+ * 2. We open the modal and return `false`, ending the "Rails" event chain
+ * 3. If the modal is closed and the user "confirmed" the action
+ * 1. replace the `Rails.confirm` with a function that always returns `true`
+ * 2. click the same element programmatically
+ *
+ * @param message {String} Message to be shown in the modal
+ * @param element {HTMLElement} Element that was clicked on
+ * @returns {boolean}
+ */
+ function confirmViaModal(message, element) {
+ confirmViaGlModal(message, element)
+ .then((confirmed) => {
+ if (confirmed) {
+ Rails.confirm = () => true;
+ element.click();
+ Rails.confirm = confirmViaModal;
+ }
+ })
+ .catch(() => {});
+ return false;
+ }
+
+ Rails.confirm = confirmViaModal;
+}
+
+if (gon?.features?.bootstrapConfirmationModals) {
+ monkeyPatchConfirmModal();
+}
export const initRails = () => {
// eslint-disable-next-line no-underscore-dangle
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 0804d792631..40dd29bea76 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -233,7 +233,7 @@ export function insertMarkdownText({
}
} else if (tag.indexOf(textPlaceholder) > -1) {
textToInsert = tag.replace(textPlaceholder, () =>
- selected.replace(/\\n/g, '\n').replace('%br', '\\n'),
+ selected.replace(/\\n/g, '\n').replace(/%br/g, '\\n'),
);
} else {
textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index c70d23d06ec..e53a39cde06 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -5,6 +5,12 @@ const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
const SHA_REGEX = /[\da-f]{40}/gi;
+// About GitLab default host (overwrite in jh)
+export const PROMO_HOST = 'about.gitlab.com';
+
+// About Gitlab default url (overwrite in jh)
+export const PROMO_URL = `https://${PROMO_HOST}`;
+
// Reset the cursor in a Regex so that multiple uses before a recompile don't fail
function resetRegExp(regex) {
regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index 2a60825a427..c9e7b034950 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -130,7 +130,7 @@ export default {
}}
<a :href="clusterApplicationsDocumentationPath">
<strong>
- {{ s__('View Documentation') }}
+ {{ __('View Documentation') }}
</strong>
</a>
</gl-alert>
diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue
index 0ec39f58930..c5083bc4826 100644
--- a/app/assets/javascripts/members/components/app.vue
+++ b/app/assets/javascripts/members/components/app.vue
@@ -11,7 +11,10 @@ export default {
components: { MembersTable, FilterSortContainer, GlAlert },
provide() {
return {
- namespace: this.namespace,
+ // We can't use this.namespace due to bug in vue-apollo when
+ // provide is called in beforeCreate
+ // See https://github.com/vuejs/vue-apollo/pull/1153 for details
+ namespace: this.$options.propsData.namespace,
};
},
props: {
diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue
deleted file mode 100644
index e5d7e2ea2eb..00000000000
--- a/app/assets/javascripts/monitoring/components/alert_widget.vue
+++ /dev/null
@@ -1,285 +0,0 @@
-<script>
-import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
-import { values, get } from 'lodash';
-import createFlash from '~/flash';
-import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { s__ } from '~/locale';
-import { OPERATORS } from '../constants';
-import AlertsService from '../services/alerts_service';
-import { alertsValidator, queriesValidator } from '../validators';
-import AlertWidgetForm from './alert_widget_form.vue';
-
-export default {
- components: {
- AlertWidgetForm,
- GlBadge,
- GlLoadingIcon,
- GlIcon,
- GlTooltip,
- GlSprintf,
- },
- directives: {
- GlModal: GlModalDirective,
- },
- props: {
- alertsEndpoint: {
- type: String,
- required: true,
- },
- showLoadingState: {
- type: Boolean,
- required: false,
- default: true,
- },
- // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls.
- // Includes only the metrics/alerts to be managed by this widget.
- alertsToManage: {
- type: Object,
- required: false,
- default: () => ({}),
- validator: alertsValidator,
- },
- // [{ metric+query_attributes }]. Represents queries (and alerts) we know about
- // on intial fetch. Essentially used for reference.
- relevantQueries: {
- type: Array,
- required: true,
- validator: queriesValidator,
- },
- modalId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- service: null,
- errorMessage: null,
- isLoading: false,
- apiAction: 'create',
- };
- },
- i18n: {
- alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'),
- singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'),
- multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'),
- firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'),
- },
- computed: {
- singleAlertSummary() {
- return {
- message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0],
- alert: this.thresholds[0],
- };
- },
- multipleAlertsSummary() {
- return {
- message: this.isFiring
- ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}`
- : this.$options.i18n.alertsCountMsg,
- count: this.thresholds.length,
- firingCount: this.firingAlerts.length,
- };
- },
- shouldShowLoadingIcon() {
- return this.showLoadingState && this.isLoading;
- },
- thresholds() {
- const alertsToManage = Object.keys(this.alertsToManage);
- return alertsToManage.map(this.formatAlertSummary);
- },
- hasAlerts() {
- return Boolean(Object.keys(this.alertsToManage).length);
- },
- hasMultipleAlerts() {
- return this.thresholds.length > 1;
- },
- isFiring() {
- return Boolean(this.firingAlerts.length);
- },
- firingAlerts() {
- return values(this.alertsToManage).filter((alert) =>
- this.passedAlertThreshold(this.getQueryData(alert), alert),
- );
- },
- formattedFiringAlerts() {
- return this.firingAlerts.map((alert) => this.formatAlertSummary(alert.alert_path));
- },
- configuredAlert() {
- return this.hasAlerts ? values(this.alertsToManage)[0].metricId : '';
- },
- },
- created() {
- this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint });
- this.fetchAlertData();
- },
- methods: {
- fetchAlertData() {
- this.isLoading = true;
-
- const queriesWithAlerts = this.relevantQueries.filter((query) => query.alert_path);
-
- return Promise.all(
- queriesWithAlerts.map((query) =>
- this.service
- .readAlert(query.alert_path)
- .then((alertAttributes) => this.setAlert(alertAttributes, query.metricId)),
- ),
- )
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- createFlash({
- message: s__('PrometheusAlerts|Error fetching alert'),
- });
- this.isLoading = false;
- });
- },
- setAlert(alertAttributes, metricId) {
- this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId });
- },
- removeAlert(alertPath) {
- this.$emit('setAlerts', alertPath, null);
- },
- formatAlertSummary(alertPath) {
- const alert = this.alertsToManage[alertPath];
- const alertQuery = this.relevantQueries.find((query) => query.metricId === alert.metricId);
-
- return `${alertQuery.label} ${alert.operator} ${alert.threshold}`;
- },
- passedAlertThreshold(data, alert) {
- const { threshold, operator } = alert;
-
- switch (operator) {
- case OPERATORS.greaterThan:
- return data.some((value) => value > threshold);
- case OPERATORS.lessThan:
- return data.some((value) => value < threshold);
- case OPERATORS.equalTo:
- return data.some((value) => value === threshold);
- default:
- return false;
- }
- },
- getQueryData(alert) {
- const alertQuery = this.relevantQueries.find((query) => query.metricId === alert.metricId);
-
- return get(alertQuery, 'result[0].values', []).map((value) => get(value, '[1]', null));
- },
- showModal() {
- this.$root.$emit(BV_SHOW_MODAL, this.modalId);
- },
- hideModal() {
- this.errorMessage = null;
- this.$root.$emit(BV_HIDE_MODAL, this.modalId);
- },
- handleSetApiAction(apiAction) {
- this.apiAction = apiAction;
- },
- handleCreate({ operator, threshold, prometheus_metric_id, runbookUrl }) {
- const newAlert = { operator, threshold, prometheus_metric_id, runbookUrl };
- this.isLoading = true;
- this.service
- .createAlert(newAlert)
- .then((alertAttributes) => {
- this.setAlert(alertAttributes, prometheus_metric_id);
- this.isLoading = false;
- this.hideModal();
- })
- .catch(() => {
- this.errorMessage = s__('PrometheusAlerts|Error creating alert');
- this.isLoading = false;
- });
- },
- handleUpdate({ alert, operator, threshold, runbookUrl }) {
- const updatedAlert = { operator, threshold, runbookUrl };
- this.isLoading = true;
- this.service
- .updateAlert(alert, updatedAlert)
- .then((alertAttributes) => {
- this.setAlert(alertAttributes, this.alertsToManage[alert].metricId);
- this.isLoading = false;
- this.hideModal();
- })
- .catch(() => {
- this.errorMessage = s__('PrometheusAlerts|Error saving alert');
- this.isLoading = false;
- });
- },
- handleDelete({ alert }) {
- this.isLoading = true;
- this.service
- .deleteAlert(alert)
- .then(() => {
- this.removeAlert(alert);
- this.isLoading = false;
- this.hideModal();
- })
- .catch(() => {
- this.errorMessage = s__('PrometheusAlerts|Error deleting alert');
- this.isLoading = false;
- });
- },
- },
-};
-</script>
-
-<template>
- <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden">
- <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" size="sm" />
- <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{
- errorMessage
- }}</span>
- <span
- v-else-if="hasAlerts"
- ref="alertCurrentSetting"
- class="alert-current-setting cursor-pointer d-flex"
- @click="showModal"
- >
- <gl-badge :variant="isFiring ? 'danger' : 'neutral'" class="d-flex-center text-truncate">
- <gl-icon name="warning" :size="16" class="flex-shrink-0" />
- <span class="text-truncate gl-pl-2">
- <gl-sprintf
- :message="
- hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message
- "
- >
- <template #alert>
- {{ singleAlertSummary.alert }}
- </template>
- <template #count>
- {{ multipleAlertsSummary.count }}
- </template>
- <template #firingCount>
- {{ multipleAlertsSummary.firingCount }}
- </template>
- </gl-sprintf>
- </span>
- </gl-badge>
- <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting">
- <gl-sprintf :message="$options.i18n.firingAlertsTooltip">
- <template #alerts>
- <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path">
- {{ alert }}
- </div>
- </template>
- </gl-sprintf>
- </gl-tooltip>
- </span>
- <alert-widget-form
- ref="widgetForm"
- :disabled="isLoading"
- :alerts-to-manage="alertsToManage"
- :relevant-queries="relevantQueries"
- :error-message="errorMessage"
- :configured-alert="configuredAlert"
- :modal-id="modalId"
- @create="handleCreate"
- @update="handleUpdate"
- @delete="handleDelete"
- @cancel="hideModal"
- @setAction="handleSetApiAction"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
deleted file mode 100644
index 68fd3e256ec..00000000000
--- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue
+++ /dev/null
@@ -1,324 +0,0 @@
-<script>
-import {
- GlLink,
- GlButton,
- GlButtonGroup,
- GlFormGroup,
- GlFormInput,
- GlDropdown,
- GlDropdownItem,
- GlModal,
- GlTooltipDirective,
- GlIcon,
-} from '@gitlab/ui';
-import { isEmpty, findKey } from 'lodash';
-import Vue from 'vue';
-import { __, s__ } from '~/locale';
-import TrackEventDirective from '~/vue_shared/directives/track_event';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import Translate from '~/vue_shared/translate';
-import { OPERATORS } from '../constants';
-import { alertsValidator, queriesValidator } from '../validators';
-
-Vue.use(Translate);
-
-const SUBMIT_ACTION_TEXT = {
- create: __('Add'),
- update: __('Save'),
- delete: __('Delete'),
-};
-
-const SUBMIT_BUTTON_CLASS = {
- create: 'btn-success',
- update: 'btn-success',
- delete: 'btn-danger',
-};
-
-export default {
- components: {
- GlButton,
- GlButtonGroup,
- GlFormGroup,
- GlFormInput,
- GlDropdown,
- GlDropdownItem,
- GlModal,
- GlLink,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- TrackEvent: TrackEventDirective,
- },
- mixins: [glFeatureFlagsMixin()],
- props: {
- disabled: {
- type: Boolean,
- required: true,
- },
- errorMessage: {
- type: String,
- required: false,
- default: '',
- },
- configuredAlert: {
- type: String,
- required: false,
- default: '',
- },
- alertsToManage: {
- type: Object,
- required: false,
- default: () => ({}),
- validator: alertsValidator,
- },
- relevantQueries: {
- type: Array,
- required: true,
- validator: queriesValidator,
- },
- modalId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- operators: OPERATORS,
- operator: null,
- threshold: null,
- prometheusMetricId: null,
- runbookUrl: null,
- selectedAlert: {},
- alertQuery: '',
- };
- },
- computed: {
- isValidQuery() {
- // TODO: Add query validation check (most likely via http request)
- return this.alertQuery.length ? true : null;
- },
- currentQuery() {
- return this.relevantQueries.find((query) => query.metricId === this.prometheusMetricId) || {};
- },
- formDisabled() {
- // We need a prometheusMetricId to determine whether we're
- // creating/updating/deleting
- return this.disabled || !(this.prometheusMetricId || this.isValidQuery);
- },
- supportsComputedAlerts() {
- return this.glFeatures.prometheusComputedAlerts;
- },
- queryDropdownLabel() {
- return this.currentQuery.label || s__('PrometheusAlerts|Select query');
- },
- haveValuesChanged() {
- return (
- this.operator &&
- this.threshold === Number(this.threshold) &&
- (this.operator !== this.selectedAlert.operator ||
- this.threshold !== this.selectedAlert.threshold ||
- this.runbookUrl !== this.selectedAlert.runbookUrl)
- );
- },
- submitAction() {
- if (isEmpty(this.selectedAlert)) return 'create';
- if (this.haveValuesChanged) return 'update';
- return 'delete';
- },
- submitActionText() {
- return SUBMIT_ACTION_TEXT[this.submitAction];
- },
- submitButtonClass() {
- return SUBMIT_BUTTON_CLASS[this.submitAction];
- },
- isSubmitDisabled() {
- return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged);
- },
- dropdownTitle() {
- return this.submitAction === 'create'
- ? s__('PrometheusAlerts|Add alert')
- : s__('PrometheusAlerts|Edit alert');
- },
- },
- watch: {
- alertsToManage() {
- this.resetAlertData();
- },
- submitAction() {
- this.$emit('setAction', this.submitAction);
- },
- },
- methods: {
- selectQuery(queryId) {
- const existingAlertPath = findKey(this.alertsToManage, (alert) => alert.metricId === queryId);
- const existingAlert = this.alertsToManage[existingAlertPath];
-
- if (existingAlert) {
- const { operator, threshold, runbookUrl } = existingAlert;
-
- this.selectedAlert = existingAlert;
- this.operator = operator;
- this.threshold = threshold;
- this.runbookUrl = runbookUrl;
- } else {
- this.selectedAlert = {};
- this.operator = this.operators.greaterThan;
- this.threshold = null;
- this.runbookUrl = null;
- }
-
- this.prometheusMetricId = queryId;
- },
- handleHidden() {
- this.resetAlertData();
- this.$emit('cancel');
- },
- handleSubmit() {
- this.$emit(this.submitAction, {
- alert: this.selectedAlert.alert_path,
- operator: this.operator,
- threshold: this.threshold,
- prometheus_metric_id: this.prometheusMetricId,
- runbookUrl: this.runbookUrl,
- });
- },
- handleShown() {
- if (this.configuredAlert) {
- this.selectQuery(this.configuredAlert);
- } else if (this.relevantQueries.length === 1) {
- this.selectQuery(this.relevantQueries[0].metricId);
- }
- },
- resetAlertData() {
- this.operator = null;
- this.threshold = null;
- this.prometheusMetricId = null;
- this.selectedAlert = {};
- this.runbookUrl = null;
- },
- getAlertFormActionTrackingOption() {
- const label = `${this.submitAction}_alert`;
- return {
- category: document.body.dataset.page,
- action: 'click_button',
- label,
- };
- },
- },
- alertQueryText: {
- label: __('Query'),
- validFeedback: __('Query is valid'),
- invalidFeedback: __('Invalid query'),
- descriptionTooltip: __(
- 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.',
- ),
- },
-};
-</script>
-
-<template>
- <gl-modal
- ref="alertModal"
- :title="dropdownTitle"
- :modal-id="modalId"
- :ok-variant="submitAction === 'delete' ? 'danger' : 'success'"
- :ok-disabled="formDisabled"
- @ok.prevent="handleSubmit"
- @hidden="handleHidden"
- @shown="handleShown"
- >
- <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div>
- <div class="alert-form">
- <gl-form-group
- v-if="supportsComputedAlerts"
- :label="$options.alertQueryText.label"
- label-for="alert-query-input"
- :valid-feedback="$options.alertQueryText.validFeedback"
- :invalid-feedback="$options.alertQueryText.invalidFeedback"
- :state="isValidQuery"
- >
- <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" />
- <template #description>
- <div class="d-flex align-items-center">
- {{ __('Single or combined queries') }}
- <gl-icon
- v-gl-tooltip="$options.alertQueryText.descriptionTooltip"
- name="question"
- class="gl-ml-2"
- />
- </div>
- </template>
- </gl-form-group>
- <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label">
- <gl-dropdown
- id="alert-query-dropdown"
- :text="queryDropdownLabel"
- toggle-class="dropdown-menu-toggle gl-border-1! qa-alert-query-dropdown"
- >
- <gl-dropdown-item
- v-for="query in relevantQueries"
- :key="query.metricId"
- data-qa-selector="alert_query_option"
- @click="selectQuery(query.metricId)"
- >
- {{ query.label }}
- </gl-dropdown-item>
- </gl-dropdown>
- </gl-form-group>
- <gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')">
- <gl-button
- :class="{ active: operator === operators.greaterThan }"
- :disabled="formDisabled"
- @click="operator = operators.greaterThan"
- >
- {{ operators.greaterThan }}
- </gl-button>
- <gl-button
- :class="{ active: operator === operators.equalTo }"
- :disabled="formDisabled"
- @click="operator = operators.equalTo"
- >
- {{ operators.equalTo }}
- </gl-button>
- <gl-button
- :class="{ active: operator === operators.lessThan }"
- :disabled="formDisabled"
- @click="operator = operators.lessThan"
- >
- {{ operators.lessThan }}
- </gl-button>
- </gl-button-group>
- <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold">
- <gl-form-input
- id="alerts-threshold"
- v-model.number="threshold"
- :disabled="formDisabled"
- type="number"
- data-qa-selector="alert_threshold_field"
- />
- </gl-form-group>
- <gl-form-group
- :label="s__('PrometheusAlerts|Runbook URL (optional)')"
- label-for="alert-runbook"
- >
- <gl-form-input
- id="alert-runbook"
- v-model="runbookUrl"
- :disabled="formDisabled"
- data-testid="alertRunbookField"
- type="text"
- :placeholder="s__('PrometheusAlerts|https://gitlab.com/gitlab-com/runbooks')"
- />
- </gl-form-group>
- </div>
- <template #modal-ok>
- <gl-link
- v-track-event="getAlertFormActionTrackingOption()"
- class="text-reset text-decoration-none"
- >
- {{ submitActionText }}
- </gl-link>
- </template>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
index 4b54cffe231..ae079da0b0b 100644
--- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
+++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
@@ -1,8 +1,12 @@
<script>
import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg';
+import { GlSafeHtmlDirective } from '@gitlab/ui';
import { chartHeight } from '../../constants';
export default {
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
data() {
return {
height: chartHeight,
@@ -18,14 +22,15 @@ export default {
created() {
this.chartEmptyStateIllustration = chartEmptyStateIllustration;
},
+ safeHtmlConfig: { ADD_TAGS: ['use'] },
};
</script>
<template>
<div class="d-flex flex-column justify-content-center">
<div
+ v-safe-html:[$options.safeHtmlConfig]="chartEmptyStateIllustration"
class="gl-mt-3 svg-w-100 d-flex align-items-center"
:style="svgContainerStyle"
- v-html="chartEmptyStateIllustration /* eslint-disable-line vue/no-v-html */"
></div>
<h5 class="text-center gl-mt-3">{{ __('No data to display') }}</h5>
</div>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 12f5e7efc96..5529a94874b 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -73,11 +73,6 @@ export default {
required: false,
default: chartHeight,
},
- thresholds: {
- type: Array,
- required: false,
- default: () => [],
- },
legendLayout: {
type: String,
required: false,
@@ -391,7 +386,6 @@ export default {
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
:format-annotations-tooltip-text="formatAnnotationsTooltipText"
- :thresholds="thresholds"
:width="width"
:height="height"
:legend-layout="legendLayout"
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index be9f104b81e..c9767330b73 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -8,10 +8,8 @@ import invalidUrl from '~/lib/utils/invalid_url';
import { ESC_KEY } from '~/lib/utils/keys';
import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
-import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue';
import { defaultTimeRange } from '~/vue_shared/constants';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { metricStates, keyboardShortcutKeys } from '../constants';
import {
timeRangeFromUrl,
@@ -30,7 +28,6 @@ import VariablesSection from './variables_section.vue';
export default {
components: {
- AlertsDeprecationWarning,
VueDraggable,
DashboardHeader,
DashboardPanel,
@@ -47,7 +44,6 @@ export default {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
- mixins: [glFeatureFlagMixin()],
props: {
hasMetrics: {
type: Boolean,
@@ -399,8 +395,6 @@ export default {
<template>
<div class="prometheus-graphs" data-qa-selector="prometheus_graphs">
- <alerts-deprecation-warning v-if="!glFeatures.managedAlertsDeprecation" />
-
<dashboard-header
v-if="showHeader"
ref="prometheusGraphsHeader"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 446c6b52602..78e3b15913a 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -13,20 +13,16 @@ import {
GlTooltip,
GlTooltipDirective,
} from '@gitlab/ui';
-import { mapValues, pickBy } from 'lodash';
import { mapState } from 'vuex';
-import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import invalidUrl from '~/lib/utils/invalid_url';
import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility';
import { __, n__ } from '~/locale';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { panelTypes } from '../constants';
import { graphDataToCsv } from '../csv_export';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
-import AlertWidget from './alert_widget.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorBarChart from './charts/bar.vue';
import MonitorColumnChart from './charts/column.vue';
@@ -45,7 +41,6 @@ const events = {
export default {
components: {
MonitorEmptyChart,
- AlertWidget,
GlIcon,
GlLink,
GlLoadingIcon,
@@ -62,7 +57,6 @@ export default {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
- mixins: [glFeatureFlagMixin()],
props: {
clipboardText: {
type: String,
@@ -84,16 +78,6 @@ export default {
required: false,
default: 'monitoringDashboard',
},
- alertsEndpoint: {
- type: String,
- required: false,
- default: null,
- },
- prometheusAlertsAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
settingsPath: {
type: String,
required: false,
@@ -104,7 +88,6 @@ export default {
return {
showTitleTooltip: false,
zoomedTimeRange: null,
- allAlerts: {},
expandBtnAvailable: Boolean(this.$listeners[events.expand]),
};
},
@@ -211,7 +194,7 @@ export default {
/**
* In monitoring, Time Series charts typically support
* a larger feature set like "annotations", "deployment
- * data", alert "thresholds" and "datazoom".
+ * data" and "datazoom".
*
* This is intentional as Time Series are more frequently
* used.
@@ -252,34 +235,11 @@ export default {
const { metrics = [] } = this.graphData;
return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId));
},
- alertWidgetAvailable() {
- const supportsAlerts =
- this.isPanelType(panelTypes.AREA_CHART) || this.isPanelType(panelTypes.LINE_CHART);
- return (
- supportsAlerts &&
- this.prometheusAlertsAvailable &&
- this.alertsEndpoint &&
- this.graphData &&
- this.hasMetricsInDb &&
- !this.glFeatures.managedAlertsDeprecation
- );
- },
- alertModalId() {
- return `alert-modal-${this.graphData.id}`;
- },
},
mounted() {
this.refreshTitleTooltip();
},
methods: {
- getGraphAlerts(queries) {
- if (!this.allAlerts) return {};
- const metricIdsForChart = queries.map((q) => q.metricId);
- return pickBy(this.allAlerts, (alert) => metricIdsForChart.includes(alert.metricId));
- },
- getGraphAlertValues(queries) {
- return Object.values(this.getGraphAlerts(queries));
- },
isPanelType(type) {
return this.graphData?.type === type;
},
@@ -310,24 +270,9 @@ export default {
this.onExpand();
}
},
- setAlerts(alertPath, alertAttributes) {
- if (alertAttributes) {
- this.$set(this.allAlerts, alertPath, alertAttributes);
- } else {
- this.$delete(this.allAlerts, alertPath);
- }
- },
safeUrl(url) {
return isSafeURL(url) ? url : '#';
},
- showAlertModal() {
- this.$root.$emit(BV_SHOW_MODAL, this.alertModalId);
- },
- showAlertModalFromKeyboardShortcut() {
- if (this.isContextualMenuShown) {
- this.showAlertModal();
- }
- },
visitLogsPage() {
if (this.logsPathWithTimeRange) {
visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL()));
@@ -348,19 +293,6 @@ export default {
this.$refs.copyChartLink.$el.firstChild.click();
}
},
- getAlertRunbooks(queries) {
- const hasRunbook = (alert) => Boolean(alert.runbookUrl);
- const graphAlertsWithRunbooks = pickBy(this.getGraphAlerts(queries), hasRunbook);
- const alertToRunbookTransform = (alert) => {
- const alertQuery = queries.find((query) => query.metricId === alert.metricId);
- return {
- key: alert.metricId,
- href: alert.runbookUrl,
- label: alertQuery.label,
- };
- };
- return mapValues(graphAlertsWithRunbooks, alertToRunbookTransform);
- },
},
panelTypes,
};
@@ -378,15 +310,6 @@ export default {
<gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip">
{{ title }}
</gl-tooltip>
- <alert-widget
- v-if="isContextualMenuShown && alertWidgetAvailable"
- class="mx-1"
- :modal-id="alertModalId"
- :alerts-endpoint="alertsEndpoint"
- :relevant-queries="graphData.metrics"
- :alerts-to-manage="getGraphAlerts(graphData.metrics)"
- @setAlerts="setAlerts"
- />
<div class="flex-grow-1"></div>
<div v-if="graphDataIsLoading" class="mx-1 mt-1">
<gl-loading-icon size="sm" />
@@ -450,32 +373,6 @@ export default {
>
{{ __('Copy link to chart') }}
</gl-dropdown-item>
- <gl-dropdown-item
- v-if="alertWidgetAvailable"
- v-gl-modal="alertModalId"
- data-qa-selector="alert_widget_menu_item"
- >
- {{ __('Alerts') }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-for="runbook in getAlertRunbooks(graphData.metrics)"
- :key="runbook.key"
- :href="safeUrl(runbook.href)"
- data-testid="runbookLink"
- target="_blank"
- rel="noopener noreferrer"
- >
- <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
- <span>
- <gl-sprintf :message="s__('Metrics|View runbook - %{label}')">
- <template #label>
- {{ runbook.label }}
- </template>
- </gl-sprintf>
- </span>
- <gl-icon name="external-link" />
- </span>
- </gl-dropdown-item>
<template v-if="graphData.links && graphData.links.length">
<gl-dropdown-divider />
@@ -515,7 +412,6 @@ export default {
:deployment-data="deploymentData"
:annotations="annotations"
:project-path="projectPath"
- :thresholds="getGraphAlertValues(graphData.metrics)"
:group-id="groupId"
:timezone="dashboardTimezone"
:time-range="fixedCurrentTimeRange"
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
index 1765a2f3d5d..a63008aa382 100644
--- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
+++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
@@ -63,7 +63,7 @@ export default {
return !(this.form.fileName && !this.form.fileName.endsWith('.yml'));
},
fileNameFeedback() {
- return !this.fileNameState ? s__('The file name should have a .yml extension') : '';
+ return !this.fileNameState ? __('The file name should have a .yml extension') : '';
},
},
mounted() {
diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js
index cf79e71b9e0..ee67e5dd827 100644
--- a/app/assets/javascripts/monitoring/monitoring_app.js
+++ b/app/assets/javascripts/monitoring/monitoring_app.js
@@ -12,10 +12,7 @@ export default (props = {}) => {
if (el && el.dataset) {
const { metricsDashboardBasePath, ...dataset } = el.dataset;
- const {
- initState,
- dataProps: { hasManagedPrometheus, ...dataProps },
- } = stateAndPropsFromDataset(dataset);
+ const { initState, dataProps } = stateAndPropsFromDataset(dataset);
const store = createStore(initState);
const router = createRouter(metricsDashboardBasePath);
@@ -24,7 +21,6 @@ export default (props = {}) => {
el,
store,
router,
- provide: { hasManagedPrometheus },
data() {
return {
dashboardProps: { ...dataProps, ...props },
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 74b777d7b44..336b613b620 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -41,7 +41,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => {
dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics);
dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable);
dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable);
- dataProps.hasManagedPrometheus = parseBoolean(dataProps.hasManagedPrometheus);
return {
initState: {
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index ea3e4e5604c..a1377415efe 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -23,8 +23,8 @@ export default function initMrNotes() {
initNotesApp();
document.addEventListener('merged:UpdateActions', () => {
- initRevertCommitModal();
- initCherryPickCommitModal();
+ initRevertCommitModal('i_code_review_post_merge_submit_revert_modal');
+ initCherryPickCommitModal('i_code_review_post_merge_submit_cherry_pick_modal');
});
requestIdleCallback(() => {
diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js
index 6e46c5d3c1f..714cf67e0bd 100644
--- a/app/assets/javascripts/mr_popover/index.js
+++ b/app/assets/javascripts/mr_popover/index.js
@@ -48,12 +48,7 @@ export default (elements) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
const listenerAddedAttr = 'data-mr-listener-added';
diff --git a/app/assets/javascripts/nav/components/responsive_home.vue b/app/assets/javascripts/nav/components/responsive_home.vue
index c8f2f0bfb10..a80fda96363 100644
--- a/app/assets/javascripts/nav/components/responsive_home.vue
+++ b/app/assets/javascripts/nav/components/responsive_home.vue
@@ -55,6 +55,7 @@ export default {
v-gl-tooltip="{ title: newDropdownViewModel.title }"
:view-model="newDropdownViewModel"
class="gl-ml-3"
+ data-qa-selector="mobile_new_dropdown"
/>
</header>
<top-nav-menu-sections class="gl-h-full" :sections="menuSections" v-on="$listeners" />
diff --git a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
index 154bed81854..bfcdcfc7292 100644
--- a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
+++ b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
@@ -46,6 +46,7 @@ export default {
link-class="top-nav-menu-item"
:href="menuItem.href"
data-testid="item"
+ :data-qa-selector="`${menuItem.title.toLowerCase().replace(' ', '_')}_mobile_button`"
>
{{ menuItem.title }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index 6ee5d85a09f..54fe9d19002 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -272,6 +272,8 @@ export default class BranchGraph {
return r
.text(this.offsetX + this.unitSpace * this.mspace + 35, y, commit.message.split('\n')[0])
.attr({
+ fill: 'currentColor',
+ class: 'gl-text-body',
'text-anchor': 'start',
font: '14px Monaco, monospace',
});
diff --git a/app/assets/javascripts/notebook/cells/output/latex.vue b/app/assets/javascripts/notebook/cells/output/latex.vue
index db9e61dce82..d0ed963b55d 100644
--- a/app/assets/javascripts/notebook/cells/output/latex.vue
+++ b/app/assets/javascripts/notebook/cells/output/latex.vue
@@ -1,4 +1,5 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import 'mathjax/es5/tex-svg';
import Prompt from '../prompt.vue';
@@ -7,6 +8,9 @@ export default {
components: {
Prompt,
},
+ directives: {
+ SafeHtml,
+ },
props: {
count: {
type: Number,
@@ -33,13 +37,16 @@ export default {
return svg.outerHTML;
},
},
+ safeHtmlConfig: {
+ // to support SVGs and custom tags for mathjax
+ ADD_TAGS: ['use', 'mjx-container', 'mjx-tool', 'mjx-status', 'mjx-tip'],
+ },
};
</script>
<template>
<div class="output">
<prompt type="Out" :count="count" :show-output="index === 0" />
- <!-- eslint-disable -->
- <div ref="maths" v-html="code"></div>
+ <div ref="maths" v-safe-html:[$options.safeHtmlConfig]="code"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 831e6dd8f92..33819c78c0f 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -78,8 +78,8 @@ export default {
v-if="resolveAllDiscussionsIssuePath && !allResolved"
v-gl-tooltip
:href="resolveAllDiscussionsIssuePath"
- :title="s__('Create issue to resolve all threads')"
- :aria-label="s__('Create issue to resolve all threads')"
+ :title="__('Create issue to resolve all threads')"
+ :aria-label="__('Create issue to resolve all threads')"
class="new-issue-for-discussion discussion-create-issue-btn"
icon="issue-new"
/>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 6fcfa66ea49..d1df4eb848b 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -1,5 +1,6 @@
<script>
import { mapGetters, mapActions } from 'vuex';
+import { GlIntersectionObserver } from '@gitlab/ui';
import { __ } from '~/locale';
import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
@@ -16,7 +17,9 @@ export default {
ToggleRepliesWidget,
NoteEditedText,
DiscussionNotesRepliesWrapper,
+ GlIntersectionObserver,
},
+ inject: ['discussionObserverHandler'],
props: {
discussion: {
type: Object,
@@ -54,7 +57,11 @@ export default {
},
},
computed: {
- ...mapGetters(['userCanReply']),
+ ...mapGetters([
+ 'userCanReply',
+ 'previousUnresolvedDiscussionId',
+ 'firstUnresolvedDiscussionId',
+ ]),
hasReplies() {
return Boolean(this.replies.length);
},
@@ -77,9 +84,20 @@ export default {
url: this.discussion.discussion_path,
};
},
+ isFirstUnresolved() {
+ return this.firstUnresolvedDiscussionId === this.discussion.id;
+ },
+ },
+ observerOptions: {
+ threshold: 0,
+ rootMargin: '0px 0px -50% 0px',
},
methods: {
- ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']),
+ ...mapActions([
+ 'toggleDiscussion',
+ 'setSelectedCommentPositionHover',
+ 'setCurrentDiscussionId',
+ ]),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
@@ -110,6 +128,18 @@ export default {
this.setSelectedCommentPositionHover();
}
},
+ observerTriggered(entry) {
+ this.discussionObserverHandler({
+ entry,
+ isFirstUnresolved: this.isFirstUnresolved,
+ currentDiscussion: { ...this.discussion },
+ isDiffsPage: !this.isOverviewTab,
+ functions: {
+ setCurrentDiscussionId: this.setCurrentDiscussionId,
+ getPreviousUnresolvedDiscussionId: this.previousUnresolvedDiscussionId,
+ },
+ });
+ },
},
};
</script>
@@ -122,33 +152,35 @@ export default {
@mouseleave="handleMouseLeave(discussion)"
>
<template v-if="shouldGroupReplies">
- <component
- :is="componentName(firstNote)"
- :note="componentData(firstNote)"
- :line="line || diffLine"
- :discussion-file="discussion.diff_file"
- :commit="commit"
- :help-page-path="helpPagePath"
- :show-reply-button="userCanReply"
- :discussion-root="true"
- :discussion-resolve-path="discussion.resolve_path"
- :is-overview-tab="isOverviewTab"
- @handleDeleteNote="$emit('deleteNote')"
- @startReplying="$emit('startReplying')"
- >
- <template #discussion-resolved-text>
- <note-edited-text
- v-if="discussion.resolved"
- :edited-at="discussion.resolved_at"
- :edited-by="discussion.resolved_by"
- :action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
- />
- </template>
- <template #avatar-badge>
- <slot name="avatar-badge"></slot>
- </template>
- </component>
+ <gl-intersection-observer :options="$options.observerOptions" @update="observerTriggered">
+ <component
+ :is="componentName(firstNote)"
+ :note="componentData(firstNote)"
+ :line="line || diffLine"
+ :discussion-file="discussion.diff_file"
+ :commit="commit"
+ :help-page-path="helpPagePath"
+ :show-reply-button="userCanReply"
+ :discussion-root="true"
+ :discussion-resolve-path="discussion.resolve_path"
+ :is-overview-tab="isOverviewTab"
+ @handleDeleteNote="$emit('deleteNote')"
+ @startReplying="$emit('startReplying')"
+ >
+ <template #discussion-resolved-text>
+ <note-edited-text
+ v-if="discussion.resolved"
+ :edited-at="discussion.resolved_at"
+ :edited-by="discussion.resolved_by"
+ :action-text="resolvedText"
+ class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
+ />
+ </template>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
+ </component>
+ </gl-intersection-observer>
<discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion">
<toggle-replies-widget
v-if="hasReplies"
diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue
index 6ad565567be..1633b79c3be 100644
--- a/app/assets/javascripts/notes/components/multiline_comment_form.vue
+++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue
@@ -1,6 +1,6 @@
<script>
import { GlFormSelect, GlSprintf } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions } from 'vuex';
import { getSymbol, getLineClasses } from './multiline_comment_utils';
export default {
@@ -27,13 +27,12 @@ export default {
};
},
computed: {
- ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition }),
lineNumber() {
return this.commentLineOptions[this.commentLineOptions.length - 1].text;
},
},
created() {
- const line = this.selectedCommentPosition?.start || this.lineRange?.start || this.line;
+ const line = this.lineRange?.start || this.line;
this.commentLineStart = {
line_code: line.line_code,
@@ -42,7 +41,6 @@ export default {
new_line: line.new_line,
};
- if (this.selectedCommentPosition) return;
this.highlightSelection();
},
destroyed() {
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 1ce1696e332..c09582d6287 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,5 +1,6 @@
<script>
import $ from 'jquery';
+import { GlSafeHtmlDirective } from '@gitlab/ui';
import { escape } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
@@ -19,6 +20,9 @@ export default {
noteForm,
Suggestions,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
mixins: [autosave],
props: {
note: {
@@ -144,6 +148,9 @@ export default {
this.removeSuggestionInfoFromBatch(suggestionId);
},
},
+ safeHtmlConfig: {
+ ADD_TAGS: ['use', 'gl-emoji'],
+ },
};
</script>
@@ -163,11 +170,7 @@ export default {
@addToBatch="addSuggestionToBatch"
@removeFromBatch="removeSuggestionFromBatch"
/>
- <div
- v-else
- class="note-text md"
- v-html="note.note_html /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <div v-else v-safe-html:[$options.safeHtmlConfig]="note.note_html" class="note-text md"></div>
<note-form
v-if="isEditing"
ref="noteForm"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index b05643e5e13..d6b65ed0e8b 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,9 +1,9 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
@@ -17,6 +17,8 @@ export default {
markdownField,
CommentFieldLayout,
GlButton,
+ GlSprintf,
+ GlLink,
},
mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable],
props: {
@@ -203,16 +205,12 @@ export default {
);
},
changedCommentText() {
- return sprintf(
- __(
+ return {
+ text: __(
'This comment changed after you started editing it. Review the %{startTag}updated comment%{endTag} to ensure information is not lost.',
),
- {
- startTag: `<a href="${this.noteHash}" target="_blank" rel="noopener noreferrer">`,
- endTag: '</a>',
- },
- false,
- );
+ placeholder: { link: ['startTag', 'endTag'] },
+ };
},
},
watch: {
@@ -318,11 +316,13 @@ export default {
<template>
<div ref="editNoteForm" class="note-edit-form current-note-edit-form js-discussion-note-form">
- <div
- v-if="conflictWhileEditing"
- class="js-conflict-edit-warning alert alert-danger"
- v-html="changedCommentText /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger">
+ <gl-sprintf :message="changedCommentText.text" :placeholders="changedCommentText.placeholder">
+ <template #link="{ content }">
+ <gl-link :href="noteHash" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
<div class="flash-container timeline-content"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout
@@ -334,13 +334,13 @@ export default {
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
+ :lines="lines"
:note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
:textarea-value="updatedNoteBody"
- :lines="lines"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 58570e76795..3ab3e7a20d4 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -8,6 +8,7 @@ import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item
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 { discussionIntersectionObserverHandlerFactory } from '../../diffs/utils/discussions';
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';
@@ -38,6 +39,9 @@ export default {
TimelineEntryItem,
},
mixins: [glFeatureFlagsMixin()],
+ provide: {
+ discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
+ },
props: {
noteableData: {
type: Object,
@@ -94,15 +98,17 @@ export default {
return this.noteableData.noteableType;
},
allDiscussions() {
+ let skeletonNotes = [];
+
if (this.renderSkeleton || this.isLoading) {
const prerenderedNotesCount = parseInt(this.notesData.prerenderedNotesCount, 10) || 0;
- return new Array(prerenderedNotesCount).fill({
+ skeletonNotes = new Array(prerenderedNotesCount).fill({
isSkeletonNote: true,
});
}
- return this.discussions;
+ return this.discussions.concat(skeletonNotes);
},
canReply() {
return this.userCanReply && !this.commentsDisabled && !this.timelineEnabled;
@@ -258,7 +264,13 @@ export default {
getFetchDiscussionsConfig() {
const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') };
- if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) {
+ const currentFilter =
+ this.getNotesDataByProp('notesFilter') || constants.DISCUSSION_FILTERS_DEFAULT_VALUE;
+
+ if (
+ doesHashExistInUrl(constants.NOTE_UNDERSCORE) &&
+ currentFilter !== constants.DISCUSSION_FILTERS_DEFAULT_VALUE
+ ) {
return {
...defaultConfig,
filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE,
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 96974c4fa2d..ad529eb99b6 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,7 +1,10 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils';
+import { updateHistory } from '../../lib/utils/url_utility';
import eventHub from '../event_hub';
+const isDiffsVirtualScrollingEnabled = () => window.gon?.features?.diffsVirtualScrolling;
+
/**
* @param {string} selector
* @returns {boolean}
@@ -11,20 +14,52 @@ function scrollTo(selector, { withoutContext = false } = {}) {
const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext;
if (el) {
- scrollFunction(el);
+ scrollFunction(el, {
+ behavior: isDiffsVirtualScrollingEnabled() ? 'auto' : 'smooth',
+ });
return true;
}
return false;
}
+function updateUrlWithNoteId(noteId) {
+ const newHistoryEntry = {
+ state: null,
+ title: window.title,
+ url: `#note_${noteId}`,
+ replace: true,
+ };
+
+ if (noteId && isDiffsVirtualScrollingEnabled()) {
+ // Temporarily mask the ID to avoid the browser default
+ // scrolling taking over which is broken with virtual
+ // scrolling enabled.
+ const note = document.querySelector(`#note_${noteId}`);
+ note?.setAttribute('id', `masked::${note.id}`);
+
+ // Update the hash now that the ID "doesn't exist" in the page
+ updateHistory(newHistoryEntry);
+
+ // Unmask the note's ID
+ note?.setAttribute('id', `note_${noteId}`);
+ } else if (noteId) {
+ updateHistory(newHistoryEntry);
+ }
+}
+
/**
* @param {object} self Component instance with mixin applied
* @param {string} id Discussion id we are jumping to
*/
-function diffsJump({ expandDiscussion }, id) {
+function diffsJump({ expandDiscussion }, id, firstNoteId) {
const selector = `ul.notes[data-discussion-id="${id}"]`;
- eventHub.$once('scrollToDiscussion', () => scrollTo(selector));
+
+ eventHub.$once('scrollToDiscussion', () => {
+ scrollTo(selector);
+ // Wait for the discussion scroll before updating to the more specific ID
+ setTimeout(() => updateUrlWithNoteId(firstNoteId), 0);
+ });
expandDiscussion({ discussionId: id });
}
@@ -56,12 +91,13 @@ function switchToDiscussionsTabAndJumpTo(self, id) {
* @param {object} discussion Discussion we are jumping to
*/
function jumpToDiscussion(self, discussion) {
- const { id, diff_discussion: isDiffDiscussion } = discussion;
+ const { id, diff_discussion: isDiffDiscussion, notes } = discussion;
+ const firstNoteId = notes?.[0]?.id;
if (id) {
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'diffs' && isDiffDiscussion) {
- diffsJump(self, id);
+ diffsJump(self, id, firstNoteId);
} else if (activeTab === 'show') {
discussionJump(self, id);
} else {
@@ -79,10 +115,18 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId)
const isDiffView = window.mrTabs.currentAction === 'diffs';
const targetId = fn(discussionId, isDiffView);
const discussion = self.getDiscussion(targetId);
+ const setHash = !isDiffView && !isDiffsVirtualScrollingEnabled();
const discussionFilePath = discussion?.diff_file?.file_path;
+ if (isDiffsVirtualScrollingEnabled()) {
+ window.location.hash = '';
+ }
+
if (discussionFilePath) {
- self.scrollToFile(discussionFilePath);
+ self.scrollToFile({
+ path: discussionFilePath,
+ setHash,
+ });
}
self.$nextTick(() => {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 7eb10f647a0..c862a29ad9c 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import $ from 'jquery';
import Visibility from 'visibilityjs';
import Vue from 'vue';
@@ -71,7 +70,7 @@ export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, dat
export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
export const setInitialNotes = ({ commit }, discussions) =>
- commit(types.SET_INITIAL_DISCUSSIONS, discussions);
+ commit(types.ADD_OR_UPDATE_DISCUSSIONS, discussions);
export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data);
@@ -90,14 +89,51 @@ export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFi
? { params: { notes_filter: filter, persist_filter: persistFilter } }
: null;
+ if (window.gon?.features?.paginatedIssueDiscussions) {
+ return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 });
+ }
+
return axios.get(path, config).then(({ data }) => {
- commit(types.SET_INITIAL_DISCUSSIONS, data);
+ commit(types.ADD_OR_UPDATE_DISCUSSIONS, data);
commit(types.SET_FETCHING_DISCUSSIONS, false);
dispatch('updateResolvableDiscussionsCounts');
});
};
+export const fetchDiscussionsBatch = ({ commit, dispatch }, { path, config, cursor, perPage }) => {
+ const params = { ...config?.params, per_page: perPage };
+
+ if (cursor) {
+ params.cursor = cursor;
+ }
+
+ return axios.get(path, { params }).then(({ data, headers }) => {
+ commit(types.ADD_OR_UPDATE_DISCUSSIONS, data);
+
+ if (headers['x-next-page-cursor']) {
+ const nextConfig = { ...config };
+
+ if (config?.params?.persist_filter) {
+ delete nextConfig.params.notes_filter;
+ delete nextConfig.params.persist_filter;
+ }
+
+ return dispatch('fetchDiscussionsBatch', {
+ path,
+ config: nextConfig,
+ cursor: headers['x-next-page-cursor'],
+ perPage: Math.min(Math.round(perPage * 1.5), 100),
+ });
+ }
+
+ commit(types.SET_FETCHING_DISCUSSIONS, false);
+ dispatch('updateResolvableDiscussionsCounts');
+
+ return undefined;
+ });
+};
+
export const updateDiscussion = ({ commit, state }, discussion) => {
commit(types.UPDATE_DISCUSSION, discussion);
@@ -621,7 +657,7 @@ export const submitSuggestion = (
const flashMessage = errorMessage || defaultMessage;
createFlash({
- message: __(flashMessage),
+ message: flashMessage,
parent: flashContainer,
});
})
@@ -657,7 +693,7 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, fl
const flashMessage = errorMessage || defaultMessage;
createFlash({
- message: __(flashMessage),
+ message: flashMessage,
parent: flashContainer,
});
})
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 2e8b728e013..fcd2846ff0d 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -1,11 +1,11 @@
export const ADD_NEW_NOTE = 'ADD_NEW_NOTE';
export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
+export const ADD_OR_UPDATE_DISCUSSIONS = 'ADD_OR_UPDATE_DISCUSSIONS';
export const DELETE_NOTE = 'DELETE_NOTE';
export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
export const SET_NOTES_DATA = 'SET_NOTES_DATA';
export const SET_NOTEABLE_DATA = 'SET_NOTEABLE_DATA';
export const SET_USER_DATA = 'SET_USER_DATA';
-export const SET_INITIAL_DISCUSSIONS = 'SET_INITIAL_DISCUSSIONS';
export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT';
export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH';
export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index c5fa34dfedd..1a99750ddb3 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -129,8 +129,8 @@ export default {
Object.assign(state, { userData: data });
},
- [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) {
- const discussions = discussionsData.reduce((acc, d) => {
+ [types.ADD_OR_UPDATE_DISCUSSIONS](state, discussionsData) {
+ discussionsData.forEach((d) => {
const discussion = { ...d };
const diffData = {};
@@ -145,27 +145,38 @@ export default {
// To support legacy notes, should be very rare case.
if (discussion.individual_note && discussion.notes.length > 1) {
discussion.notes.forEach((n) => {
- acc.push({
+ const newDiscussion = {
...discussion,
...diffData,
notes: [n], // override notes array to only have one item to mimick individual_note
- });
+ };
+ const oldDiscussion = state.discussions.find(
+ (existingDiscussion) =>
+ existingDiscussion.id === discussion.id && existingDiscussion.notes[0].id === n.id,
+ );
+
+ if (oldDiscussion) {
+ state.discussions.splice(state.discussions.indexOf(oldDiscussion), 1, newDiscussion);
+ } else {
+ state.discussions.push(newDiscussion);
+ }
});
} else {
- const oldNote = utils.findNoteObjectById(state.discussions, discussion.id);
+ const oldDiscussion = utils.findNoteObjectById(state.discussions, discussion.id);
- acc.push({
- ...discussion,
- ...diffData,
- expanded: oldNote ? oldNote.expanded : discussion.expanded,
- });
+ if (oldDiscussion) {
+ state.discussions.splice(state.discussions.indexOf(oldDiscussion), 1, {
+ ...discussion,
+ ...diffData,
+ expanded: oldDiscussion.expanded,
+ });
+ } else {
+ state.discussions.push({ ...discussion, ...diffData });
+ }
}
-
- return acc;
- }, []);
-
- Object.assign(state, { discussions });
+ });
},
+
[types.SET_LAST_FETCHED_AT](state, fetchedAt) {
Object.assign(state, { lastFetchedAt: fetchedAt });
},
diff --git a/app/assets/javascripts/packages/list/components/package_search.vue b/app/assets/javascripts/packages/list/components/package_search.vue
deleted file mode 100644
index 869a2c2f641..00000000000
--- a/app/assets/javascripts/packages/list/components/package_search.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<script>
-import { mapState, mapActions } from 'vuex';
-import { s__ } from '~/locale';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
-import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
-import UrlSync from '~/vue_shared/components/url_sync.vue';
-import { sortableFields } from '../utils';
-import PackageTypeToken from './tokens/package_type_token.vue';
-
-export default {
- tokens: [
- {
- type: 'type',
- icon: 'package',
- title: s__('PackageRegistry|Type'),
- unique: true,
- token: PackageTypeToken,
- operators: OPERATOR_IS_ONLY,
- },
- ],
- components: { RegistrySearch, UrlSync },
- computed: {
- ...mapState({
- isGroupPage: (state) => state.config.isGroupPage,
- sorting: (state) => state.sorting,
- filter: (state) => state.filter,
- }),
- sortableFields() {
- return sortableFields(this.isGroupPage);
- },
- },
- methods: {
- ...mapActions(['setSorting', 'setFilter']),
- updateSorting(newValue) {
- this.setSorting(newValue);
- this.$emit('update');
- },
- },
-};
-</script>
-
-<template>
- <url-sync>
- <template #default="{ updateQuery }">
- <registry-search
- :filter="filter"
- :sorting="sorting"
- :tokens="$options.tokens"
- :sortable-fields="sortableFields"
- @sorting:changed="updateSorting"
- @filter:changed="setFilter"
- @filter:submit="$emit('update')"
- @query:changed="updateQuery"
- />
- </template>
- </url-sync>
-</template>
diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue
deleted file mode 100644
index 426ad150ea9..00000000000
--- a/app/assets/javascripts/packages/list/components/package_title.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<script>
-import { n__ } from '~/locale';
-import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
-import TitleArea from '~/vue_shared/components/registry/title_area.vue';
-import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '../constants';
-
-export default {
- name: 'PackageTitle',
- components: {
- TitleArea,
- MetadataItem,
- },
- props: {
- count: {
- type: Number,
- required: false,
- default: null,
- },
- helpUrl: {
- type: String,
- required: true,
- },
- },
- computed: {
- showPackageCount() {
- return Number.isInteger(this.count);
- },
- packageAmountText() {
- return n__(`%d Package`, `%d Packages`, this.count);
- },
- infoMessages() {
- return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }];
- },
- },
- i18n: {
- LIST_TITLE_TEXT,
- },
-};
-</script>
-
-<template>
- <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
- <template #metadata-amount>
- <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" />
- </template>
- </title-area>
-</template>
diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue
index 4c5fb0ee7c9..31d90fa4dee 100644
--- a/app/assets/javascripts/packages/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue
@@ -7,6 +7,8 @@ import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
+import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue';
+import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackageList from './packages_list.vue';
@@ -16,28 +18,10 @@ export default {
GlLink,
GlSprintf,
PackageList,
- PackageTitle: () =>
- import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'),
- PackageSearch: () =>
- import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'),
- InfrastructureTitle: () =>
- import(
- /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'
- ),
- InfrastructureSearch: () =>
- import(
- /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'
- ),
+ InfrastructureTitle,
+ InfrastructureSearch,
},
inject: {
- titleComponent: {
- from: 'titleComponent',
- default: 'PackageTitle',
- },
- searchComponent: {
- from: 'searchComponent',
- default: 'PackageSearch',
- },
emptyPageTitle: {
from: 'emptyPageTitle',
default: s__('PackageRegistry|There are no packages yet'),
@@ -111,8 +95,8 @@ export default {
<template>
<div>
- <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" />
- <component :is="searchComponent" @update="requestPackagesList" />
+ <infrastructure-title :help-url="packageHelpUrl" :count="packagesCount" />
+ <infrastructure-search @update="requestPackagesList" />
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state>
diff --git a/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue b/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue
deleted file mode 100644
index 74b6774712e..00000000000
--- a/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { PACKAGE_TYPES } from '../../constants';
-
-export default {
- components: {
- GlFilteredSearchToken,
- GlFilteredSearchSuggestion,
- },
- PACKAGE_TYPES,
-};
-</script>
-
-<template>
- <gl-filtered-search-token v-bind="{ ...$attrs }" v-on="$listeners">
- <template #suggestions>
- <gl-filtered-search-suggestion
- v-for="(type, index) in $options.PACKAGE_TYPES"
- :key="index"
- :value="type.type"
- >
- {{ type.title }}
- </gl-filtered-search-suggestion>
- </template>
- </gl-filtered-search-token>
-</template>
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index 2c6fd94024e..4f5071e784b 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -96,10 +96,4 @@ export const PACKAGE_TYPES = [
},
];
-export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry');
-
-export const LIST_INTRO_TEXT = s__(
- 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}',
-);
-
export const TERRAFORM_SEARCH_TYPE = Object.freeze({ value: { data: 'terraform_module' } });
diff --git a/app/assets/javascripts/registry/explorer/components/delete_button.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue
index e4a1a1a8266..e4a1a1a8266 100644
--- a/app/assets/javascripts/registry/explorer/components/delete_button.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue
diff --git a/app/assets/javascripts/registry/explorer/components/delete_image.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue
index a313854f5e4..a313854f5e4 100644
--- a/app/assets/javascripts/registry/explorer/components/delete_image.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
index 56d2ff86fb7..56d2ff86fb7 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
index f857c96c9d1..f857c96c9d1 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
index e9e36151fe6..e9e36151fe6 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue
index a16d95a6b30..a16d95a6b30 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/empty_state.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue
index 12095655126..12095655126 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/status_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue
index fc1504f6c31..fc1504f6c31 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/status_alert.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
index 3e19a646f53..3e19a646f53 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
index 0556fd298aa..0556fd298aa 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue
index b7afa5fba33..b7afa5fba33 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
index 1f52e319ad0..1f52e319ad0 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue
index 07ee3c6083b..07ee3c6083b 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue
index a68c4de5aa6..a68c4de5aa6 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue
index 5bd13322ebb..5bd13322ebb 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue
diff --git a/app/assets/javascripts/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..c1ec523574a 100644
--- a/app/assets/javascripts/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
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue
index 5aa04419ca0..5aa04419ca0 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue
diff --git a/app/assets/javascripts/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..6d2ff9ea7b6 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
diff --git a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue
index e77eda31596..e77eda31596 100644
--- a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue
diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
index f7beec2c935..f7beec2c935 100644
--- a/app/assets/javascripts/registry/explorer/constants/common.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
index 19e1a75fb2f..19e1a75fb2f 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
index 40f9b09a982..40f9b09a982 100644
--- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js
index 6886356d8e2..6886356d8e2 100644
--- a/app/assets/javascripts/registry/explorer/constants/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js
diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
index d21a154d1b8..d21a154d1b8 100644
--- a/app/assets/javascripts/registry/explorer/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
diff --git a/app/assets/javascripts/registry/explorer/constants/quick_start.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js
index 6a39c07eba2..6a39c07eba2 100644
--- a/app/assets/javascripts/registry/explorer/constants/quick_start.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js
diff --git a/app/assets/javascripts/registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
index d934bcc7419..9694bfd4e77 100644
--- a/app/assets/javascripts/registry/explorer/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
@@ -9,7 +9,6 @@ export const apolloProvider = new VueApollo({
{},
{
batchMax: 1,
- assumeImmutableResults: true,
},
),
});
diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql
index 4c88b726ee5..4c88b726ee5 100644
--- a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql
diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql
index a31f2829e13..a31f2829e13 100644
--- a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql
index 01cb7fa1cab..01cb7fa1cab 100644
--- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
index b5a99fd9ac1..b5a99fd9ac1 100644
--- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
diff --git a/app/assets/javascripts/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 a703c2dd0ac..a703c2dd0ac 100644
--- a/app/assets/javascripts/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
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
index 9092a71edb0..9092a71edb0 100644
--- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
index 246a6768593..246a6768593 100644
--- a/app/assets/javascripts/registry/explorer/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
index feabc4f770b..feabc4f770b 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue
index dca63e1a569..dca63e1a569 100644
--- a/app/assets/javascripts/registry/explorer/pages/index.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index 73b957f42f2..73b957f42f2 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js
index a0c4417d549..a0c4417d549 100644
--- a/app/assets/javascripts/registry/explorer/router.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js
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 73fb3656af1..71e8cf4f634 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -1,32 +1,49 @@
<script>
-import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
-import { __ } from '~/locale';
+import {
+ GlAlert,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlSkeletonLoader,
+ GlSprintf,
+ GlEmptyState,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
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';
import {
DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
DEPENDENCY_PROXY_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
+import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants';
import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql';
export default {
components: {
- GlFormGroup,
GlAlert,
+ GlEmptyState,
+ GlFormGroup,
GlFormInputGroup,
+ GlSkeletonLoader,
GlSprintf,
ClipboardButton,
TitleArea,
- GlSkeletonLoader,
+ ManifestsList,
},
- inject: ['groupPath', 'dependencyProxyAvailable'],
+ inject: ['groupPath', 'dependencyProxyAvailable', 'noManifestsIllustration'],
i18n: {
- proxyNotAvailableText: __('Dependency Proxy feature is limited to public groups for now.'),
- proxyDisabledText: __('Dependency Proxy disabled. To enable it, contact the group owner.'),
- proxyImagePrefix: __('Dependency Proxy image prefix'),
- copyImagePrefixText: __('Copy prefix'),
- blobCountAndSize: __('Contains %{count} blobs of images (%{size})'),
+ proxyNotAvailableText: s__(
+ 'DependencyProxy|Dependency Proxy feature is limited to public groups for now.',
+ ),
+ proxyDisabledText: s__(
+ 'DependencyProxy|Dependency Proxy disabled. To enable it, contact the group owner.',
+ ),
+ proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'),
+ copyImagePrefixText: s__('DependencyProxy|Copy prefix'),
+ blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'),
+ pageTitle: s__('DependencyProxy|Dependency Proxy'),
+ noManifestTitle: s__('DependencyProxy|There are no images in the cache'),
},
data() {
return {
@@ -40,7 +57,7 @@ export default {
return !this.dependencyProxyAvailable;
},
variables() {
- return { fullPath: this.groupPath };
+ return this.queryVariables;
},
},
},
@@ -56,13 +73,45 @@ export default {
dependencyProxyEnabled() {
return this.group?.dependencyProxySetting?.enabled;
},
+ queryVariables() {
+ return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE };
+ },
+ pageInfo() {
+ return this.group.dependencyProxyManifests.pageInfo;
+ },
+ manifests() {
+ return this.group.dependencyProxyManifests.nodes;
+ },
+ },
+ methods: {
+ fetchNextPage() {
+ this.fetchMore({
+ first: GRAPHQL_PAGE_SIZE,
+ after: this.pageInfo?.endCursor,
+ });
+ },
+ fetchPreviousPage() {
+ this.fetchMore({
+ first: null,
+ last: GRAPHQL_PAGE_SIZE,
+ before: this.pageInfo?.startCursor,
+ });
+ },
+ fetchMore(variables) {
+ this.$apollo.queries.group.fetchMore({
+ variables: { ...this.queryVariables, ...variables },
+ updateQuery(_, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ },
},
};
</script>
<template>
<div>
- <title-area :title="__('Dependency Proxy')" :info-messages="infoMessages" />
+ <title-area :title="$options.i18n.pageTitle" :info-messages="infoMessages" />
<gl-alert
v-if="!dependencyProxyAvailable"
:dismissible="false"
@@ -97,6 +146,20 @@ export default {
</span>
</template>
</gl-form-group>
+
+ <manifests-list
+ v-if="manifests && manifests.length"
+ :manifests="manifests"
+ :pagination="pageInfo"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ />
+
+ <gl-empty-state
+ v-else
+ :svg-path="noManifestsIllustration"
+ :title="$options.i18n.noManifestTitle"
+ />
</div>
<gl-alert v-else :dismissible="false" data-testid="proxy-disabled">
{{ $options.i18n.proxyDisabledText }}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
new file mode 100644
index 00000000000..78880b6e3f4
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'ManifestRow',
+ components: {
+ GlSprintf,
+ ListItem,
+ TimeagoTooltip,
+ },
+ props: {
+ manifest: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ name() {
+ return this.manifest?.imageName.split(':')[0];
+ },
+ version() {
+ return this.manifest?.imageName.split(':')[1];
+ },
+ },
+ i18n: {
+ cachedAgoMessage: s__('DependencyProxy|Cached %{time}'),
+ },
+};
+</script>
+
+<template>
+ <list-item>
+ <template #left-primary> {{ name }} </template>
+ <template #left-secondary> {{ version }} </template>
+ <template #right-primary> &nbsp; </template>
+ <template #right-secondary>
+ <timeago-tooltip :time="manifest.createdAt" data-testid="cached-message">
+ <template #default="{ timeAgo }">
+ <gl-sprintf :message="$options.i18n.cachedAgoMessage">
+ <template #time>{{ timeAgo }}</template>
+ </gl-sprintf>
+ </template>
+ </timeago-tooltip>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
new file mode 100644
index 00000000000..005c8feea3a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlKeysetPagination } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
+
+export default {
+ name: 'ManifestsLists',
+ components: {
+ ManifestRow,
+ GlKeysetPagination,
+ },
+ props: {
+ manifests: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ pagination: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ listTitle: s__('DependencyProxy|Image list'),
+ },
+ computed: {
+ showPagination() {
+ return this.pagination.hasNextPage || this.pagination.hasPreviousPage;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mt-6">
+ <h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3>
+ <div
+ class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
+ >
+ <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" />
+ </div>
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pagination"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js
new file mode 100644
index 00000000000..3c6ede6fdce
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js
@@ -0,0 +1 @@
+export const GRAPHQL_PAGE_SIZE = 20;
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
index 16152eb81f6..56f95fa2c1f 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
@@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
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 9058d349bf3..63d5469c955 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,12 @@
-query getDependencyProxyDetails($fullPath: ID!) {
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getDependencyProxyDetails(
+ $fullPath: ID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
group(fullPath: $fullPath) {
dependencyProxyBlobCount
dependencyProxyTotalSize
@@ -6,5 +14,14 @@ query getDependencyProxyDetails($fullPath: ID!) {
dependencyProxySetting {
enabled
}
+ dependencyProxyManifests(after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ createdAt
+ imageName
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
}
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
index 3d3fa62fd43..bcbeec72961 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
@@ -23,6 +23,7 @@ import PackageFiles from '~/packages_and_registries/package_registry/components/
import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
+import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import {
PACKAGE_TYPE_NUGET,
PACKAGE_TYPE_COMPOSER,
@@ -35,12 +36,10 @@ import {
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
- DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
} from '~/packages_and_registries/package_registry/constants';
-import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import Tracking from '~/tracking';
@@ -62,6 +61,7 @@ export default {
AdditionalMetadata,
InstallationCommands,
PackageFiles,
+ DeletePackage,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -148,40 +148,15 @@ export default {
formatSize(size) {
return numberToHumanSize(size);
},
- async deletePackage() {
- const { data } = await this.$apollo.mutate({
- mutation: destroyPackageMutation,
- variables: {
- id: this.packageEntity.id,
- },
- });
+ navigateToListWithSuccessModal() {
+ const returnTo =
+ !this.groupListUrl || document.referrer.includes(this.projectName)
+ ? this.projectListUrl
+ : this.groupListUrl; // to avoid security issue url are supplied from backend
- if (data?.destroyPackage?.errors[0]) {
- throw data.destroyPackage.errors[0];
- }
- },
- async confirmPackageDeletion() {
- this.track(DELETE_PACKAGE_TRACKING_ACTION);
-
- try {
- await this.deletePackage();
-
- const returnTo =
- !this.groupListUrl || document.referrer.includes(this.projectName)
- ? this.projectListUrl
- : this.groupListUrl; // to avoid security issue url are supplied from backend
-
- const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
+ const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
- window.location.replace(`${returnTo}?${modalQuery}`);
- } catch (error) {
- createFlash({
- message: DELETE_PACKAGE_ERROR_MESSAGE,
- type: 'warning',
- captureError: true,
- error,
- });
- }
+ window.location.replace(`${returnTo}?${modalQuery}`);
},
async deletePackageFile(id) {
try {
@@ -322,26 +297,33 @@ export default {
</gl-tab>
</gl-tabs>
- <gl-modal
- ref="deleteModal"
- modal-id="delete-modal"
- data-testid="delete-modal"
- :action-primary="$options.modal.packageDeletePrimaryAction"
- :action-cancel="$options.modal.cancelAction"
- @primary="confirmPackageDeletion"
- @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
+ <delete-package
+ @start="track($options.trackingActions.DELETE_PACKAGE_TRACKING_ACTION)"
+ @end="navigateToListWithSuccessModal"
>
- <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
- <gl-sprintf :message="$options.i18n.deleteModalContent">
- <template #version>
- <strong>{{ packageEntity.version }}</strong>
- </template>
+ <template #default="{ deletePackage }">
+ <gl-modal
+ ref="deleteModal"
+ modal-id="delete-modal"
+ data-testid="delete-modal"
+ :action-primary="$options.modal.packageDeletePrimaryAction"
+ :action-cancel="$options.modal.cancelAction"
+ @primary="deletePackage(packageEntity)"
+ @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
+ >
+ <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
+ <gl-sprintf :message="$options.i18n.deleteModalContent">
+ <template #version>
+ <strong>{{ packageEntity.version }}</strong>
+ </template>
- <template #name>
- <strong>{{ packageEntity.name }}</strong>
- </template>
- </gl-sprintf>
- </gl-modal>
+ <template #name>
+ <strong>{{ packageEntity.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ </template>
+ </delete-package>
<gl-modal
ref="deleteFileModal"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue
new file mode 100644
index 00000000000..7a85fd3052e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue
@@ -0,0 +1,62 @@
+<script>
+import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/package_registry/constants';
+
+export default {
+ props: {
+ refetchQueries: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ showSuccessAlert: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ i18n: {
+ errorMessage: s__('PackageRegistry|Something went wrong while deleting the package.'),
+ successMessage: DELETE_PACKAGE_SUCCESS_MESSAGE,
+ },
+ methods: {
+ async deletePackage(packageEntity) {
+ try {
+ this.$emit('start');
+ const { data } = await this.$apollo.mutate({
+ mutation: destroyPackageMutation,
+ variables: {
+ id: packageEntity.id,
+ },
+ awaitRefetchQueries: Boolean(this.refetchQueries),
+ refetchQueries: this.refetchQueries,
+ });
+
+ if (data?.destroyPackage?.errors[0]) {
+ throw data.destroyPackage.errors[0];
+ }
+ if (this.showSuccessAlert) {
+ createFlash({
+ message: this.$options.i18n.successMessage,
+ type: 'success',
+ });
+ }
+ } catch (error) {
+ createFlash({
+ message: this.$options.i18n.errorMessage,
+ type: 'warning',
+ captureError: true,
+ error,
+ });
+ }
+ this.$emit('end');
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({ deletePackage: this.deletePackage });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
index 08481ac5655..11eeaf933ff 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
@@ -1,33 +1,31 @@
<script>
-/*
- * The following component has several commented lines, this is because we are refactoring them piece by piece on several mrs
- * For a complete overview of the plan please check: https://gitlab.com/gitlab-org/gitlab/-/issues/330846
- * This work is behind feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/341136
- */
-// import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
-import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
- LIST_QUERY_DEBOUNCE_TIME,
+ GRAPHQL_PAGE_SIZE,
+ DELETE_PACKAGE_SUCCESS_MESSAGE,
} from '~/packages_and_registries/package_registry/constants';
+import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
+
+import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import PackageTitle from './package_title.vue';
import PackageSearch from './package_search.vue';
-// import PackageList from './packages_list.vue';
+import PackageList from './packages_list.vue';
export default {
components: {
- // GlEmptyState,
- // GlLink,
- // GlSprintf,
- // PackageList,
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
+ PackageList,
PackageTitle,
PackageSearch,
+ DeletePackage,
},
inject: [
'packageHelpUrl',
@@ -41,6 +39,7 @@ export default {
packages: {},
sort: '',
filters: {},
+ mutationLoading: false,
};
},
apollo: {
@@ -52,7 +51,9 @@ export default {
update(data) {
return data[this.graphqlResource].packages;
},
- debounce: LIST_QUERY_DEBOUNCE_TIME,
+ skip() {
+ return !this.sort;
+ },
},
},
computed: {
@@ -64,22 +65,40 @@ export default {
groupSort: this.isGroupPage ? this.sort : undefined,
packageName: this.filters?.packageName,
packageType: this.filters?.packageType,
+ first: GRAPHQL_PAGE_SIZE,
};
},
graphqlResource() {
return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE;
},
+ pageInfo() {
+ return this.packages?.pageInfo ?? {};
+ },
packagesCount() {
return this.packages?.count;
},
hasFilters() {
return this.filters.packageName && this.filters.packageType;
},
+ emptySearch() {
+ return !this.filters.packageName && !this.filters.packageType;
+ },
emptyStateTitle() {
return this.emptySearch
? this.$options.i18n.emptyPageTitle
: this.$options.i18n.noResultsTitle;
},
+ isLoading() {
+ return this.$apollo.queries.packages.loading || this.mutationLoading;
+ },
+ refetchQueriesData() {
+ return [
+ {
+ query: getPackagesQuery,
+ variables: this.queryVariables,
+ },
+ ];
+ },
},
mounted() {
this.checkDeleteAlert();
@@ -99,6 +118,35 @@ export default {
this.sort = sort;
this.filters = { ...filters };
},
+ updateQuery(_, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ fetchNextPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: GRAPHQL_PAGE_SIZE,
+ last: null,
+ after: this.pageInfo?.endCursor,
+ };
+
+ this.$apollo.queries.packages.fetchMore({
+ variables,
+ updateQuery: this.updateQuery,
+ });
+ },
+ fetchPreviousPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: null,
+ last: GRAPHQL_PAGE_SIZE,
+ before: this.pageInfo?.startCursor,
+ };
+
+ this.$apollo.queries.packages.fetchMore({
+ variables,
+ updateQuery: this.updateQuery,
+ });
+ },
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
@@ -116,19 +164,35 @@ export default {
<package-title :help-url="packageHelpUrl" :count="packagesCount" />
<package-search @update="handleSearchUpdate" />
- <!-- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
- <template #empty-state>
- <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
- <template #description>
- <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
- <gl-sprintf v-else :message="$options.i18n.noResultsText">
- <template #noPackagesLink="{ content }">
- <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ <delete-package
+ :refetch-queries="refetchQueriesData"
+ show-success-alert
+ @start="mutationLoading = true"
+ @end="mutationLoading = false"
+ >
+ <template #default="{ deletePackage }">
+ <package-list
+ :list="packages.nodes"
+ :is-loading="isLoading"
+ :page-info="pageInfo"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ @package:delete="deletePackage"
+ >
+ <template #empty-state>
+ <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
+ <template #description>
+ <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
+ <gl-sprintf v-else :message="$options.i18n.noResultsText">
+ <template #noPackagesLink="{ content }">
+ <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
- </gl-sprintf>
+ </gl-empty-state>
</template>
- </gl-empty-state>
+ </package-list>
</template>
- </package-list> -->
+ </delete-package>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
index 836df59ca58..3483d23e251 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
@@ -1,6 +1,6 @@
<script>
import { s__ } from '~/locale';
-import { sortableFields } from '~/packages/list/utils';
+import { sortableFields } from '~/packages_and_registries/package_registry/utils';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
index 6e00a48586e..bf41c36e09b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
@@ -1,6 +1,5 @@
<script>
-import { n__ } from '~/locale';
-import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants';
+import { n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
@@ -29,11 +28,14 @@ export default {
return n__(`%d Package`, `%d Packages`, this.count);
},
infoMessages() {
- return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }];
+ return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }];
},
},
i18n: {
- LIST_TITLE_TEXT,
+ LIST_TITLE_TEXT: s__('PackageRegistry|Package Registry'),
+ LIST_INTRO_TEXT: s__(
+ 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}',
+ ),
},
};
</script>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index 25bac687dbf..2a946544c2f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -1,82 +1,94 @@
<script>
-import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui';
-import { mapState, mapGetters } from 'vuex';
+import { GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui';
import { s__ } from '~/locale';
-import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
+import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
-import { TrackingActions } from '~/packages/shared/constants';
-import { packageTypeToTrackCategory } from '~/packages/shared/utils';
+import {
+ DELETE_PACKAGE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
+} from '~/packages_and_registries/package_registry/constants';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import Tracking from '~/tracking';
export default {
components: {
- GlPagination,
+ GlKeysetPagination,
GlModal,
GlSprintf,
PackagesListLoader,
PackagesListRow,
},
mixins: [Tracking.mixin()],
+ props: {
+ list: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+
data() {
return {
itemToBeDeleted: null,
};
},
computed: {
- ...mapState({
- perPage: (state) => state.pagination.perPage,
- totalItems: (state) => state.pagination.total,
- page: (state) => state.pagination.page,
- isGroupPage: (state) => state.config.isGroupPage,
- isLoading: 'isLoading',
- }),
- ...mapGetters({ list: 'getList' }),
- currentPage: {
- get() {
- return this.page;
- },
- set(value) {
- this.$emit('page:changed', value);
- },
- },
isListEmpty() {
return !this.list || this.list.length === 0;
},
- modalAction() {
- return s__('PackageRegistry|Delete package');
- },
deletePackageName() {
return this.itemToBeDeleted?.name ?? '';
},
tracking() {
const category = this.itemToBeDeleted
- ? packageTypeToTrackCategory(this.itemToBeDeleted.package_type)
+ ? packageTypeToTrackCategory(this.itemToBeDeleted.packageType)
: undefined;
return {
category,
};
},
+ showPagination() {
+ return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
+ },
+ showDeleteModal: {
+ get() {
+ return Boolean(this.itemToBeDeleted);
+ },
+ set(value) {
+ if (!value) {
+ this.itemToBeDeleted = null;
+ }
+ },
+ },
},
methods: {
setItemToBeDeleted(item) {
this.itemToBeDeleted = { ...item };
- this.track(TrackingActions.REQUEST_DELETE_PACKAGE);
- this.$refs.packageListDeleteModal.show();
+ this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
},
deleteItemConfirmation() {
this.$emit('package:delete', this.itemToBeDeleted);
- this.track(TrackingActions.DELETE_PACKAGE);
- this.itemToBeDeleted = null;
+ this.track(DELETE_PACKAGE_TRACKING_ACTION);
},
deleteItemCanceled() {
- this.track(TrackingActions.CANCEL_DELETE_PACKAGE);
- this.itemToBeDeleted = null;
+ this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
},
},
i18n: {
deleteModalContent: s__(
'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?',
),
+ modalAction: s__('PackageRegistry|Delete package'),
},
};
</script>
@@ -95,29 +107,29 @@ export default {
v-for="packageEntity in list"
:key="packageEntity.id"
:package-entity="packageEntity"
- :package-link="packageEntity._links.web_path"
- :is-group="isGroupPage"
@packageToDelete="setItemToBeDeleted"
/>
</div>
- <gl-pagination
- v-model="currentPage"
- :per-page="perPage"
- :total-items="totalItems"
- align="center"
- class="gl-w-full gl-mt-3"
- />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pageInfo"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
<gl-modal
- ref="packageListDeleteModal"
+ v-model="showDeleteModal"
modal-id="confirm-delete-pacakge"
ok-variant="danger"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
>
- <template #modal-title>{{ modalAction }}</template>
- <template #modal-ok>{{ modalAction }}</template>
+ <template #modal-title>{{ $options.i18n.modalAction }}</template>
+ <template #modal-ok>{{ $options.i18n.modalAction }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #name>
<strong>{{ deletePackageName }}</strong>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue
index 529a7893dfc..59354e77ee9 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { PACKAGE_TYPES } from '~/packages/list/constants';
+import { PACKAGE_TYPES } from '~/packages_and_registries/package_registry/constants';
export default {
components: {
@@ -17,9 +17,9 @@ export default {
<gl-filtered-search-suggestion
v-for="(type, index) in $options.PACKAGE_TYPES"
:key="index"
- :value="type.type"
+ :value="type"
>
- {{ type.title }}
+ {{ type }}
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index 6a88880fa90..9fd8880861c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const PACKAGE_TYPE_CONAN = 'CONAN';
export const PACKAGE_TYPE_MAVEN = 'MAVEN';
@@ -59,16 +59,7 @@ export const TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND =
export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND =
'copy_composer_package_include_command';
-export const TrackingCategories = {
- [PACKAGE_TYPE_MAVEN]: 'MavenPackages',
- [PACKAGE_TYPE_NPM]: 'NpmPackages',
- [PACKAGE_TYPE_CONAN]: 'ConanPackages',
-};
-
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
-export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
- 'PackageRegistry|Something went wrong while deleting the package.',
-);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package file.',
);
@@ -79,6 +70,8 @@ export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__(
'PackageRegistry|Failed to load the package data',
);
+export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
+
export const PACKAGE_ERROR_STATUS = 'ERROR';
export const PACKAGE_DEFAULT_STATUS = 'DEFAULT';
export const PACKAGE_HIDDEN_STATUS = 'HIDDEN';
@@ -92,4 +85,52 @@ export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance';
export const PROJECT_RESOURCE_TYPE = 'project';
export const GROUP_RESOURCE_TYPE = 'group';
-export const LIST_QUERY_DEBOUNCE_TIME = 50;
+export const GRAPHQL_PAGE_SIZE = 20;
+
+export const LIST_KEY_NAME = 'name';
+export const LIST_KEY_PROJECT = 'project_path';
+export const LIST_KEY_VERSION = 'version';
+export const LIST_KEY_PACKAGE_TYPE = 'type';
+export const LIST_KEY_CREATED_AT = 'created_at';
+
+export const LIST_LABEL_NAME = __('Name');
+export const LIST_LABEL_PROJECT = __('Project');
+export const LIST_LABEL_VERSION = __('Version');
+export const LIST_LABEL_PACKAGE_TYPE = __('Type');
+export const LIST_LABEL_CREATED_AT = __('Published');
+
+export const SORT_FIELDS = [
+ {
+ orderBy: LIST_KEY_NAME,
+ label: LIST_LABEL_NAME,
+ },
+ {
+ orderBy: LIST_KEY_PROJECT,
+ label: LIST_LABEL_PROJECT,
+ },
+ {
+ orderBy: LIST_KEY_VERSION,
+ label: LIST_LABEL_VERSION,
+ },
+ {
+ orderBy: LIST_KEY_PACKAGE_TYPE,
+ label: LIST_LABEL_PACKAGE_TYPE,
+ },
+ {
+ orderBy: LIST_KEY_CREATED_AT,
+ label: LIST_LABEL_CREATED_AT,
+ },
+];
+
+export const PACKAGE_TYPES = [
+ s__('PackageRegistry|Composer'),
+ s__('PackageRegistry|Conan'),
+ s__('PackageRegistry|Generic'),
+ s__('PackageRegistry|Maven'),
+ s__('PackageRegistry|npm'),
+ s__('PackageRegistry|NuGet'),
+ s__('PackageRegistry|PyPI'),
+ s__('PackageRegistry|RubyGems'),
+ s__('PackageRegistry|Debian'),
+ s__('PackageRegistry|Helm'),
+];
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
index f8cb5c516e2..21d6fbc9e1f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
@@ -17,7 +17,6 @@ export const apolloProvider = new VueApollo({
cacheConfig: {
fragmentMatcher,
},
- assumeImmutableResults: true,
},
),
});
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 74e6de87866..e3115365f8b 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,4 +1,5 @@
#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getPackages(
$fullPath: ID!
@@ -7,21 +8,47 @@ query getPackages(
$groupSort: PackageGroupSort
$packageName: String
$packageType: PackageTypeEnum
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
- packages(sort: $sort, packageName: $packageName, packageType: $packageType) {
+ packages(
+ sort: $sort
+ packageName: $packageName
+ packageType: $packageType
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ ) {
count
nodes {
...PackageData
}
+ pageInfo {
+ ...PageInfo
+ }
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
- packages(sort: $groupSort, packageName: $packageName, packageType: $packageType) {
+ packages(
+ sort: $groupSort
+ packageName: $packageName
+ packageType: $packageType
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ ) {
count
nodes {
...PackageData
}
+ pageInfo {
+ ...PageInfo
+ }
}
}
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/utils.js b/app/assets/javascripts/packages_and_registries/package_registry/utils.js
index ae886952c3e..4ff8edb8f66 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/utils.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/utils.js
@@ -1,3 +1,4 @@
+import { capitalize } from 'lodash';
import { s__ } from '~/locale';
import {
PACKAGE_TYPE_CONAN,
@@ -10,6 +11,8 @@ import {
PACKAGE_TYPE_GENERIC,
PACKAGE_TYPE_DEBIAN,
PACKAGE_TYPE_HELM,
+ LIST_KEY_PROJECT,
+ SORT_FIELDS,
} from './constants';
export const getPackageTypeLabel = (packageType) => {
@@ -38,3 +41,8 @@ export const getPackageTypeLabel = (packageType) => {
return null;
}
};
+
+export const packageTypeToTrackCategory = (type) => `UI::${capitalize(type)}Packages`;
+
+export const sortableFields = (isGroupPage) =>
+ SORT_FIELDS.filter((f) => f.orderBy !== LIST_KEY_PROJECT || isGroupPage);
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
index 2dbe36def0e..5815c6393a7 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
@@ -103,6 +103,7 @@ export default {
:disabled="isLoading"
:label="$options.i18n.label"
data-qa-selector="dependency_proxy_setting_toggle"
+ data-testid="dependency-proxy-setting-toggle"
/>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js
index 16152eb81f6..56f95fa2c1f 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js
@@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
index 16152eb81f6..56f95fa2c1f 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
@@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/pages/admin/deploy_keys/index/index.js b/app/assets/javascripts/pages/admin/deploy_keys/index/index.js
new file mode 100644
index 00000000000..1e52aa3efd8
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/deploy_keys/index/index.js
@@ -0,0 +1,3 @@
+import { initAdminDeployKeysTable } from '~/admin/deploy_keys';
+
+initAdminDeployKeysTable();
diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
index a94a60af7ff..4cad87492cf 100644
--- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js
+++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
@@ -1,5 +1,5 @@
-import initDevOpsScore from '~/analytics/devops_report/devops_score';
-import initDevOpsScoreDisabledServicePing from '~/analytics/devops_report/devops_score_disabled_service_ping';
+import initDevOpsScore from '~/analytics/devops_reports/devops_score';
+import initDevOpsScoreDisabledServicePing from '~/analytics/devops_reports/devops_score_disabled_service_ping';
initDevOpsScoreDisabledServicePing();
initDevOpsScore();
diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
index 055d6f40c14..b06c804f3ca 100644
--- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -46,7 +46,7 @@ export default {
return sprintf(
s__(`AdminProjects|
You’re about to permanently delete the project %{projectName}, its repository,
- and all related resources, including issues and merge requests. Once you confirm and press
+ and all related resources, including issues and merge requests. After you confirm and press
%{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`),
{
projectName: `<strong>${escape(this.projectName)}</strong>`,
@@ -70,7 +70,7 @@ export default {
},
primaryProps() {
return {
- text: s__('Delete project'),
+ text: __('Delete project'),
attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.canSubmit }],
};
},
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index 946076cfb29..a1e7eb5d3de 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -1,4 +1,4 @@
-/* eslint-disable class-methods-use-this, no-unneeded-ternary */
+/* eslint-disable class-methods-use-this */
import $ from 'jquery';
import { getGroups } from '~/api/groups_api';
@@ -78,7 +78,7 @@ export default class Todos {
initDeprecatedJQueryDropdown($dropdown, {
fieldName,
selectable: true,
- filterable: searchFields ? true : false,
+ filterable: Boolean(searchFields),
search: { fields: searchFields },
data: $dropdown.data('data'),
clicked: () => {
@@ -172,8 +172,8 @@ export default class Todos {
updateBadges(data) {
$(document).trigger('todo:toggle', data.count);
- document.querySelector('.todos-pending .badge').innerHTML = addDelimiter(data.count);
- document.querySelector('.todos-done .badge').innerHTML = addDelimiter(data.done_count);
+ document.querySelector('.js-todos-pending .badge').innerHTML = addDelimiter(data.count);
+ document.querySelector('.js-todos-done .badge').innerHTML = addDelimiter(data.done_count);
}
goToTodoUrl(e) {
diff --git a/app/assets/javascripts/pages/groups/crm/contacts/index.js b/app/assets/javascripts/pages/groups/crm/contacts/index.js
new file mode 100644
index 00000000000..a595246957f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/crm/contacts/index.js
@@ -0,0 +1,3 @@
+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
new file mode 100644
index 00000000000..16479b43d52
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/crm/organizations/index.js
@@ -0,0 +1,3 @@
+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 49b9822795c..604da77f60c 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -10,10 +10,12 @@ import projectSelect from '~/project_select';
import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import setupTransferEdit from '~/transfer_edit';
+import initConfirmDanger from '~/init_confirm_danger';
document.addEventListener('DOMContentLoaded', () => {
initFilePickers();
initConfirmDangerModal();
+ initConfirmDanger();
initSettingsPanels();
dirtySubmitFactory(
document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'),
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index 9aac364d20e..c3ac074cd7a 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -47,7 +47,7 @@ export default {
<template>
<new-namespace-page
:jump-to-last-persisted-panel="hasErrors"
- :initial-breadcrumb="s__('New group')"
+ :initial-breadcrumb="__('New group')"
:panels="$options.PANELS"
:title="s__('GroupsNew|Create new group')"
persistence-key="new_group_last_active_tab"
diff --git a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
index ea08a0821a8..35193171fb8 100644
--- a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
+++ b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
@@ -20,7 +20,7 @@ export default {
<gl-sprintf
:message="
s__(
- 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects. Groups can also be nested by creating subgroups.',
+ 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.',
)
"
>
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index c58be202043..8ce73be6e74 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -3,7 +3,7 @@ import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
-import fetchGroupPathAvailability from './fetch_group_path_availability';
+import { getGroupPathAvailability } from '~/rest_api';
const debounceTimeoutDuration = 1000;
const invalidInputClass = 'gl-field-error-outline';
@@ -45,7 +45,7 @@ export default class GroupPathValidator extends InputValidator {
if (inputDomElement.checkValidity() && groupPath.length > 1) {
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
- fetchGroupPathAvailability(groupPath, parentId)
+ getGroupPathAvailability(groupPath, parentId)
.then(({ data }) => data)
.then((data) => {
GroupPathValidator.setInputState(inputDomElement, !data.exists);
diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js
index 95522573b53..f9eecff4ac4 100644
--- a/app/assets/javascripts/pages/groups/packages/index/index.js
+++ b/app/assets/javascripts/pages/groups/packages/index/index.js
@@ -1,10 +1,3 @@
-(async function packageApp() {
- if (window.gon.features.packageListApollo) {
- const newPackageList = await import('~/packages_and_registries/package_registry/pages/list');
+import packageList from '~/packages_and_registries/package_registry/pages/list';
- newPackageList.default();
- } else {
- const packageList = await import('~/packages/list/packages_list_app_bundle');
- packageList.default();
- }
-})();
+packageList();
diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js
index 6fd32321568..44579ee1217 100644
--- a/app/assets/javascripts/pages/groups/registry/repositories/index.js
+++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js
@@ -1,4 +1,4 @@
-import registryExplorer from '~/registry/explorer/index';
+import registryExplorer from '~/packages_and_registries/container_registry/explorer/index';
const explorer = registryExplorer();
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
index e42e89ce021..b41611001ab 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -3,7 +3,7 @@ import { GlModal } from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
export default {
components: {
@@ -83,7 +83,7 @@ export default {
attributes: [{ variant: 'warning' }],
},
cancelAction: {
- text: s__('Cancel'),
+ text: __('Cancel'),
attributes: [],
},
};
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 80bcbefab46..b365e039191 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -14,7 +14,7 @@ import '~/sourcegraph/load';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
const viewBlobEl = document.querySelector('#js-view-blob-app');
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index 335d8d481fc..f4beefea90c 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -1,5 +1,5 @@
import { PROJECT_BADGE } from '~/badges/constants';
-import initConfirmDangerModal from '~/confirm_danger_modal';
+import initLegacyConfirmDangerModal from '~/confirm_danger_modal';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
@@ -9,11 +9,12 @@ import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import setupTransferEdit from '~/transfer_edit';
import UserCallout from '~/user_callout';
+import initTopicsTokenSelector from '~/projects/settings/topics';
import initProjectPermissionsSettings from '../shared/permissions';
import initProjectLoadingSpinner from '../shared/save_project_loader';
initFilePickers();
-initConfirmDangerModal();
+initLegacyConfirmDangerModal();
initSettingsPanels();
initProjectDeleteButton();
mountBadgeSettings(PROJECT_BADGE);
@@ -28,3 +29,4 @@ setupTransferEdit('.js-project-transfer-form', 'select.select2');
dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form'));
initSearchSettings();
+initTopicsTokenSelector();
diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js
index 554ed4f9786..f0554d64ddc 100644
--- a/app/assets/javascripts/pages/projects/environments/index/index.js
+++ b/app/assets/javascripts/pages/projects/environments/index/index.js
@@ -1,3 +1,11 @@
import initEnvironments from '~/environments/';
+import initNewEnvironments from '~/environments/new_index';
-initEnvironments();
+let el = document.getElementById('environments-list-view');
+
+if (el) {
+ initEnvironments(el);
+} else {
+ el = document.getElementById('environments-table');
+ initNewEnvironments(el);
+}
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 795ae713c08..25b62e6c971 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -382,7 +382,11 @@ export default {
:data-testid="`radio-${value}`"
>
<div>
- <gl-icon :name="icon" />
+ <gl-icon
+ data-qa-selector="fork_privacy_button"
+ :name="icon"
+ :data-qa-privacy-level="`${value}`"
+ />
<span>{{ text }}</span>
</div>
<template #help>{{ help }}</template>
diff --git a/app/assets/javascripts/pages/projects/google_cloud/index.js b/app/assets/javascripts/pages/projects/google_cloud/index.js
new file mode 100644
index 00000000000..4506ea8efd1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/index.js
@@ -0,0 +1,3 @@
+import initGoogleCloud from '~/google_cloud/index';
+
+initGoogleCloud();
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 62aa5df888f..24aa2f0da13 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -3,7 +3,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import initIssuableSidebar from '~/init_issuable_sidebar';
import { IssuableType } from '~/issuable_show/constants';
import Issue from '~/issue';
-import initIncidentApp from '~/issue_show/incident';
+import { initIncidentApp, initIncidentHeaderActions } from '~/issue_show/incident';
import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
import initNotesApp from '~/notes';
@@ -22,16 +22,18 @@ export default function initShowIssue() {
switch (issueType) {
case IssuableType.Incident:
initIncidentApp(issuableData);
+ initIncidentHeaderActions(store);
break;
case IssuableType.Issue:
initIssuableApp(issuableData, store);
+ initIssueHeaderActions(store);
break;
default:
+ initIssueHeaderActions(store);
break;
}
initIssuableHeaderWarning(store);
- initIssueHeaderActions(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
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 51980b2d971..95afcb6bda8 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
@@ -1,5 +1,6 @@
<script>
import { GlProgressBar, GlSprintf } from '@gitlab/ui';
+import eventHub from '~/invite_members/event_hub';
import { s__ } from '~/locale';
import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
import LearnGitlabSectionCard from './learn_gitlab_section_card.vue';
@@ -22,6 +23,11 @@ export default {
required: true,
type: Object,
},
+ inviteMembersOpen: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
maxValue: Object.keys(ACTION_LABELS).length,
actionSections: Object.keys(ACTION_SECTIONS),
@@ -33,7 +39,15 @@ export default {
return Math.round((this.progressValue / this.$options.maxValue) * 100);
},
},
+ mounted() {
+ if (this.inviteMembersOpen) {
+ this.openInviteMembersModal('celebrate');
+ }
+ },
methods: {
+ openInviteMembersModal(mode) {
+ eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' });
+ },
actionsFor(section) {
const actions = Object.fromEntries(
Object.entries(this.actions).filter(
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 69fb5878f5c..0995947f3e7 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
@@ -40,6 +40,7 @@ export default {
data-track-action="click_link"
:data-track-label="$options.i18n.ACTION_LABELS[action].title"
data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ data-track-experiment="change_continuous_onboarding_link_urls"
>
{{ $options.i18n.ACTION_LABELS[action].title }}
</gl-link>
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 6da0a8fd212..ea9eec2595f 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import LearnGitlab from '../components/learn_gitlab.vue';
@@ -11,15 +12,17 @@ function initLearnGitlab() {
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
+ const { inviteMembersOpen } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(LearnGitlab, {
- props: { actions, sections },
+ props: { actions, sections, inviteMembersOpen },
});
},
});
}
+initInviteMembersModal();
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 dadf0988582..99094617b0a 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
@@ -28,7 +28,6 @@ export default function initMergeRequestShow() {
const el = document.querySelector('.js-mr-status-box');
const apolloProvider = new VueApollo({
- assumeImmutableResults: true,
defaultClient: createDefaultClient(),
});
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
index 95522573b53..f9eecff4ac4 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
@@ -1,10 +1,3 @@
-(async function packageApp() {
- if (window.gon.features.packageListApollo) {
- const newPackageList = await import('~/packages_and_registries/package_registry/pages/list');
+import packageList from '~/packages_and_registries/package_registry/pages/list';
- newPackageList.default();
- } else {
- const packageList = await import('~/packages/list/packages_list_app_bundle');
- packageList.default();
- }
-})();
+packageList();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 0e646e8c505..85443843684 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -72,18 +72,18 @@ export default {
return [
{
value: KEY_EVERY_DAY,
- text: sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }),
+ text: sprintf(__(`Every day (at %{time})`), { time: this.formattedTime }),
},
{
value: KEY_EVERY_WEEK,
- text: sprintf(s__('Every week (%{weekday} at %{time})'), {
+ text: sprintf(__('Every week (%{weekday} at %{time})'), {
weekday: this.weekday,
time: this.formattedTime,
}),
},
{
value: KEY_EVERY_MONTH,
- text: sprintf(s__('Every month (Day %{day} at %{time})'), {
+ text: sprintf(__('Every month (Day %{day} at %{time})'), {
day: this.randomDay,
time: this.formattedTime,
}),
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 9e93f709937..a26aeeb6db4 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -23,14 +23,14 @@ export default class Project {
});
}
- $('.hide-no-ssh-message').on('click', function (e) {
+ $('.js-hide-no-ssh-message').on('click', function (e) {
Cookies.set('hide_no_ssh_message', 'false');
- $(this).parents('.no-ssh-key-message').remove();
+ $(this).parents('.js-no-ssh-key-message').remove();
return e.preventDefault();
});
- $('.hide-no-password-message').on('click', function (e) {
+ $('.js-hide-no-password-message').on('click', function (e) {
Cookies.set('hide_no_password_message', 'false');
- $(this).parents('.no-password-message').remove();
+ $(this).parents('.js-no-password-message').remove();
return e.preventDefault();
});
$('.hide-auto-devops-implicitly-enabled-banner').on('click', function (e) {
diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js
index 6fd32321568..44579ee1217 100644
--- a/app/assets/javascripts/pages/projects/registry/repositories/index.js
+++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js
@@ -1,4 +1,4 @@
-import registryExplorer from '~/registry/explorer/index';
+import registryExplorer from '~/packages_and_registries/container_registry/explorer/index';
const explorer = registryExplorer();
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
index b7546a6bed7..cc92a8cd476 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
@@ -1,10 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
-
export default {
- components: {
- GlIcon,
- },
props: {
label: {
type: String,
@@ -29,10 +24,14 @@ export default {
<div class="project-feature-row">
<label v-if="label" class="label-bold">
{{ label }}
- <a v-if="helpPath" :href="helpPath" target="_blank">
- <gl-icon name="question-o" />
- </a>
</label>
- <span v-if="helpText" class="form-text text-muted"> {{ helpText }} </span> <slot></slot>
+ <div>
+ <span v-if="helpText" class="text-muted"> {{ helpText }} </span>
+ <span v-if="helpPath"
+ ><a :href="helpPath" target="_blank">{{ __('Learn more') }}</a
+ >.</span
+ >
+ </div>
+ <slot></slot>
</div>
</template>
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 c53d367ed71..384ee1f5034 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
@@ -2,7 +2,7 @@
import { GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle } from '@gitlab/ui';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import {
visibilityOptions,
visibilityLevelDescriptions,
@@ -31,7 +31,7 @@ export default {
operationsLabel: s__('ProjectSettings|Operations'),
packagesLabel: s__('ProjectSettings|Packages'),
pagesLabel: s__('ProjectSettings|Pages'),
- ciCdLabel: s__('CI/CD'),
+ ciCdLabel: __('CI/CD'),
repositoryLabel: s__('ProjectSettings|Repository'),
requirementsLabel: s__('ProjectSettings|Requirements'),
securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'),
@@ -400,6 +400,9 @@ export default {
ref="project-visibility-settings"
:help-path="visibilityHelpPath"
:label="s__('ProjectSettings|Project visibility')"
+ :help-text="
+ s__('ProjectSettings|Manage who can see the project in the public access directory.')
+ "
>
<div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0">
<div class="select-wrapper gl-flex-grow-1">
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 78b3f2f1b30..31d69a731fe 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -7,7 +7,7 @@ import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import leaveByUrl from '~/namespaces/leave_by_url';
import initVueNotificationsDropdown from '~/notifications';
-import { initUploadFileTrigger } from '~/projects/upload_file_experiment';
+import { initUploadFileTrigger } from '~/projects/upload_file';
import initReadMore from '~/read_more';
import UserCallout from '~/user_callout';
import Star from '../../../star';
diff --git a/app/assets/javascripts/pages/projects/work_items/index/index.js b/app/assets/javascripts/pages/projects/work_items/index.js
index 11c257611f0..11c257611f0 100644
--- a/app/assets/javascripts/pages/projects/work_items/index/index.js
+++ b/app/assets/javascripts/pages/projects/work_items/index.js
diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
index 1cb7259be64..e83c73edfde 100644
--- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js
+++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
@@ -9,12 +9,7 @@ export function initInstallRunner(componentId = 'js-install-runner') {
const installRunnerEl = document.getElementById(componentId);
if (installRunnerEl) {
- const defaultClient = createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- );
+ const defaultClient = createDefaultClient();
const apolloProvider = new VueApollo({
defaultClient,
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 a8ec731e105..6f19a9f4379 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -12,13 +12,15 @@ import {
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 {
- WIKI_CONTENT_EDITOR_TRACKING_LABEL,
CONTENT_EDITOR_LOADED_ACTION,
SAVED_USING_CONTENT_EDITOR_ACTION,
+ WIKI_CONTENT_EDITOR_TRACKING_LABEL,
+ WIKI_FORMAT_LABEL,
+ WIKI_FORMAT_UPDATED_ACTION,
} from '../constants';
const trackingMixin = Tracking.mixin({
@@ -83,7 +85,7 @@ export default {
),
},
},
- feedbackTip: s__(
+ feedbackTip: __(
'Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}.',
),
},
@@ -219,6 +221,8 @@ export default {
this.trackFormSubmit();
}
+ this.trackWikiFormat();
+
// Wait until form field values are refreshed
await this.$nextTick();
@@ -304,6 +308,14 @@ export default {
}
},
+ trackWikiFormat() {
+ this.track(WIKI_FORMAT_UPDATED_ACTION, {
+ label: WIKI_FORMAT_LABEL,
+ value: this.format,
+ extra: { project_path: this.pageInfo.path, old_format: this.pageInfo.format },
+ });
+ },
+
dismissContentEditorAlert() {
this.isContentEditorAlertDismissed = true;
},
diff --git a/app/assets/javascripts/pages/shared/wikis/constants.js b/app/assets/javascripts/pages/shared/wikis/constants.js
index b358ac9cf52..94d086158f1 100644
--- a/app/assets/javascripts/pages/shared/wikis/constants.js
+++ b/app/assets/javascripts/pages/shared/wikis/constants.js
@@ -1,4 +1,5 @@
-export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor';
-
export const CONTENT_EDITOR_LOADED_ACTION = 'content_editor_loaded';
export const SAVED_USING_CONTENT_EDITOR_ACTION = 'saved_using_content_editor';
+export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor';
+export const WIKI_FORMAT_LABEL = 'wiki_format';
+export const WIKI_FORMAT_UPDATED_ACTION = 'wiki_format_updated';
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 0fab4678bc3..7f4e79976bc 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -5,6 +5,7 @@ import { last } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
+import { formatDate } from '~/lib/utils/datetime/date_format_utility';
import { n__, s__, __ } from '~/locale';
const d3 = { select };
@@ -294,7 +295,15 @@ export default class ActivityCalendar {
},
responseType: 'text',
})
- .then(({ data }) => $(this.activitiesContainer).html(data))
+ .then(({ data }) => {
+ $(this.activitiesContainer).html(data);
+ document
+ .querySelector(this.activitiesContainer)
+ .querySelectorAll('.js-localtime')
+ .forEach((el) => {
+ el.setAttribute('title', formatDate(el.getAttribute('data-datetime')));
+ });
+ })
.catch(() =>
createFlash({
message: __('An error occurred while retrieving calendar activity'),
diff --git a/app/assets/javascripts/pages/users/terms/index/index.js b/app/assets/javascripts/pages/users/terms/index/index.js
new file mode 100644
index 00000000000..29ddde6da94
--- /dev/null
+++ b/app/assets/javascripts/pages/users/terms/index/index.js
@@ -0,0 +1,4 @@
+import { initTermsApp } from '~/terms';
+import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
+
+waitForCSSLoaded(initTermsApp);
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index f163a7c3a8e..1bb82e1d8e6 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlModal, GlModalDirective, GlSegmentedControl } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import { sortOrders, sortOrderOptions } from '../constants';
import RequestWarning from './request_warning.vue';
@@ -55,7 +55,7 @@ export default {
const summary = {};
if (!this.metricDetails.summaryOptions?.hideTotal) {
- summary[s__('Total')] = this.metricDetails.calls;
+ summary[__('Total')] = this.metricDetails.calls;
}
if (!this.metricDetails.summaryOptions?.hideDuration) {
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index f1fe8cf10fd..905a5f2d271 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -36,6 +36,11 @@ export default {
required: false,
default: false,
},
+ scrollToCommitForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -52,6 +57,13 @@ export default {
return !(this.message && this.targetBranch);
},
},
+ watch: {
+ scrollToCommitForm(flag) {
+ if (flag) {
+ this.scrollIntoView();
+ }
+ },
+ },
methods: {
onSubmit() {
this.$emit('submit', {
@@ -63,6 +75,10 @@ export default {
onReset() {
this.$emit('cancel');
},
+ scrollIntoView() {
+ this.$el.scrollIntoView({ behavior: 'smooth' });
+ this.$emit('scrolled-to-commit-form');
+ },
},
i18n: {
commitMessage: __('Commit message'),
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
index 0308cd9c565..14c11099756 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -10,9 +10,8 @@ import {
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import updateCurrentBranchMutation from '../../graphql/mutations/update_current_branch.mutation.graphql';
import updateLastCommitBranchMutation from '../../graphql/mutations/update_last_commit_branch.mutation.graphql';
+import updatePipelineEtag from '../../graphql/mutations/update_pipeline_etag.mutation.graphql';
import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql';
-import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql';
-import getPipelineEtag from '../../graphql/queries/client/pipeline_etag.graphql';
import CommitForm from './commit_form.vue';
@@ -41,18 +40,24 @@ export default {
required: false,
default: '',
},
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scrollToCommitForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
commit: {},
- isNewCiConfigFile: false,
isSaving: false,
};
},
apollo: {
- isNewCiConfigFile: {
- query: getIsNewCiConfigFile,
- },
currentBranch: {
query: getCurrentBranch,
},
@@ -96,10 +101,10 @@ export default {
content: this.ciFileContent,
lastCommitId: this.commitSha,
},
- update(store, { data }) {
+ update(_, { data }) {
const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath;
if (pipelineEtag) {
- store.writeQuery({ query: getPipelineEtag, data: { pipelineEtag } });
+ this.$apollo.mutate({ mutation: updatePipelineEtag, variables: pipelineEtag });
}
},
});
@@ -146,6 +151,8 @@ export default {
:current-branch="currentBranch"
:default-message="defaultCommitMessage"
:is-saving="isSaving"
+ :scroll-to-commit-form="scrollToCommitForm"
+ v-on="$listeners"
@cancel="onCommitCancel"
@submit="onCommitSubmit"
/>
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 ff1e0b6388f..d7594fb318a 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
@@ -2,6 +2,7 @@
import { GlButton, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { experiment } from '~/experimentation/utils';
import { DRAWER_EXPANDED_KEY } from '../../constants';
import FirstPipelineCard from './cards/first_pipeline_card.vue';
import GettingStartedCard from './cards/getting_started_card.vue';
@@ -53,12 +54,23 @@ export default {
},
methods: {
setInitialExpandState() {
+ let isExpanded;
+
+ experiment('pipeline_editor_walkthrough', {
+ control: () => {
+ isExpanded = true;
+ },
+ candidate: () => {
+ isExpanded = false;
+ },
+ });
+
// We check in the local storage and if no value is defined, we want the default
// to be true. We want to explicitly set it to true here so that the drawer
// animates to open on load.
const localValue = localStorage.getItem(this.$options.localDrawerKey);
if (localValue === null) {
- this.isExpanded = true;
+ this.isExpanded = isExpanded;
}
},
setTopPosition() {
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index 68065cc3c73..baf1d17b233 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -12,7 +12,7 @@ import { produce } from 'immer';
import { fetchPolicies } from '~/lib/graphql';
import { historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
+import { __ } from '~/locale';
import {
BRANCH_PAGINATION_LIMIT,
BRANCH_SEARCH_DEBOUNCE,
@@ -25,9 +25,9 @@ import getLastCommitBranchQuery from '~/pipeline_editor/graphql/queries/client/l
export default {
i18n: {
- dropdownHeader: s__('Switch branch'),
- title: s__('Branches'),
- fetchError: s__('Unable to fetch branch list for this project.'),
+ dropdownHeader: __('Switch branch'),
+ title: __('Branches'),
+ fetchError: __('Unable to fetch branch list for this project.'),
},
inputDebounce: BRANCH_SEARCH_DEBOUNCE,
components: {
@@ -43,14 +43,25 @@ export default {
},
inject: ['projectFullPath', 'totalBranches'],
props: {
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
paginationLimit: {
type: Number,
required: false,
default: BRANCH_PAGINATION_LIMIT,
},
+ shouldLoadNewBranch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
+ branchSelected: null,
availableBranches: [],
filteredBranches: [],
isSearchingBranches: false,
@@ -101,10 +112,17 @@ export default {
isBranchesLoading() {
return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches;
},
- showBranchSwitcher() {
+ enableBranchSwitcher() {
return this.branches.length > 0 || this.searchTerm.length > 0;
},
},
+ watch: {
+ shouldLoadNewBranch(flag) {
+ if (flag) {
+ this.changeBranch(this.branchSelected);
+ }
+ },
+ },
methods: {
availableBranchesQueryVars(varsOverride = {}) {
if (this.searchTerm.length > 0) {
@@ -149,11 +167,7 @@ export default {
})
.catch(this.showFetchError);
},
- async selectBranch(newBranch) {
- if (newBranch === this.currentBranch) {
- return;
- }
-
+ async changeBranch(newBranch) {
this.updateCurrentBranch(newBranch);
const updatedPath = setUrlParams({ branch_name: newBranch });
historyPushState(updatedPath);
@@ -164,6 +178,19 @@ export default {
await this.$nextTick();
this.$emit('refetchContent');
},
+ selectBranch(newBranch) {
+ if (newBranch !== this.currentBranch) {
+ // If there are unsaved changes, we want to show the user
+ // a modal to confirm what to do with these before changing
+ // branches.
+ if (this.hasUnsavedChanges) {
+ this.branchSelected = newBranch;
+ this.$emit('select-branch', newBranch);
+ } else {
+ this.changeBranch(newBranch);
+ }
+ }
+ },
async setSearchTerm(newSearchTerm) {
this.pageCounter = 0;
this.searchTerm = newSearchTerm.trim();
@@ -203,11 +230,11 @@ export default {
<template>
<gl-dropdown
- v-if="showBranchSwitcher"
v-gl-tooltip.hover
:title="$options.i18n.dropdownHeader"
:header-text="$options.i18n.dropdownHeader"
:text="currentBranch"
+ :disabled="!enableBranchSwitcher"
icon="branch"
data-qa-selector="branch_selector_button"
data-testid="branch-selector"
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
index 551a0430fbf..83b074dd55c 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
@@ -5,10 +5,26 @@ export default {
components: {
BranchSwitcher,
},
+ props: {
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ shouldLoadNewBranch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
};
</script>
<template>
<div class="gl-mb-4">
- <branch-switcher v-on="$listeners" />
+ <branch-switcher
+ :has-unsaved-changes="hasUnsavedChanges"
+ :should-load-new-branch="shouldLoadNewBranch"
+ v-on="$listeners"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
index fcc31f087ff..ec6ee52b6b2 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
@@ -63,6 +63,7 @@ export default {
v-if="showPipelineStatus"
:commit-sha="commitSha"
:class="$options.pipelineStatusClasses"
+ v-on="$listeners"
/>
<validation-segment :class="validationStyling" :ci-config="ciConfigData" />
</div>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
index 75b1398a3c2..25a78aab933 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -1,17 +1,52 @@
<script>
+import { __ } from '~/locale';
import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
+import { PIPELINE_FAILURE } from '../../constants';
export default {
+ i18n: {
+ linkedPipelinesFetchError: __('Unable to fetch upstream and downstream pipelines.'),
+ },
components: {
PipelineMiniGraph,
+ LinkedPipelinesMiniList: () =>
+ import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
+ inject: ['projectFullPath'],
props: {
pipeline: {
type: Object,
required: true,
},
},
+ apollo: {
+ linkedPipelines: {
+ query: getLinkedPipelinesQuery,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ iid: this.pipeline.iid,
+ };
+ },
+ skip() {
+ return !this.pipeline.iid;
+ },
+ update({ project }) {
+ return project?.pipeline;
+ },
+ error() {
+ this.$emit('showError', {
+ type: PIPELINE_FAILURE,
+ reasons: [this.$options.i18n.linkedPipelinesFetchError],
+ });
+ },
+ },
+ },
computed: {
+ downstreamPipelines() {
+ return this.linkedPipelines?.downstream?.nodes || [];
+ },
pipelinePath() {
return this.pipeline.detailedStatus?.detailsPath || '';
},
@@ -38,12 +73,29 @@ export default {
};
});
},
+ showDownstreamPipelines() {
+ return this.downstreamPipelines.length > 0;
+ },
+ upstreamPipeline() {
+ return this.linkedPipelines?.upstream;
+ },
},
};
</script>
<template>
<div v-if="pipelineStages.length > 0" class="stage-cell gl-mr-5">
+ <linked-pipelines-mini-list
+ v-if="upstreamPipeline"
+ :triggered-by="[upstreamPipeline]"
+ data-testid="pipeline-editor-mini-graph-upstream"
+ />
<pipeline-mini-graph class="gl-display-inline" :stages="pipelineStages" />
+ <linked-pipelines-mini-list
+ v-if="showDownstreamPipelines"
+ :triggered="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ data-testid="pipeline-editor-mini-graph-downstream"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index a1fa2147994..6fe1459c80c 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
@@ -10,7 +10,6 @@ import {
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue';
const POLL_INTERVAL = 10000;
@@ -21,6 +20,10 @@ export const i18n = {
`Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`,
),
viewBtn: s__('Pipeline|View pipeline'),
+ viewCommit: s__('Pipeline|View commit'),
+ pipelineNotTriggeredMsg: s__(
+ 'Pipeline|No pipeline was triggered for the latest changes due to the current CI/CD configuration.',
+ ),
};
export default {
@@ -34,7 +37,9 @@ export default {
GlSprintf,
PipelineEditorMiniGraph,
},
- mixins: [glFeatureFlagMixin()],
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
inject: ['projectFullPath'],
props: {
commitSha: {
@@ -59,12 +64,13 @@ export default {
};
},
update(data) {
- const { id, commitPath = '', detailedStatus = {}, stages, status } =
+ const { id, iid, commit = {}, detailedStatus = {}, stages, status } =
data.project?.pipeline || {};
return {
id,
- commitPath,
+ iid,
+ commit,
detailedStatus,
stages,
status,
@@ -73,20 +79,36 @@ export default {
result(res) {
if (res.data?.project?.pipeline) {
this.hasError = false;
+ } else {
+ this.hasError = true;
+ this.pipelineNotTriggered = true;
}
},
error() {
this.hasError = true;
+ this.networkError = true;
},
pollInterval: POLL_INTERVAL,
},
},
data() {
return {
+ networkError: false,
+ pipelineNotTriggered: false,
hasError: false,
};
},
computed: {
+ commitText() {
+ const shortSha = truncateSha(this.commitSha);
+ const commitTitle = this.pipeline.commit.title || '';
+
+ if (commitTitle.length > 0) {
+ return `${shortSha}: ${commitTitle}`;
+ }
+
+ return shortSha;
+ },
hasPipelineData() {
return Boolean(this.pipeline?.id);
},
@@ -126,13 +148,19 @@ export default {
</div>
</template>
<template v-else-if="hasError">
- <div>
+ <div v-if="networkError">
<gl-icon class="gl-mr-auto" name="warning-solid" />
<span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
</div>
+ <div v-else>
+ <gl-icon class="gl-mr-auto" name="information-o" />
+ <span data-testid="pipeline-not-triggered-error-msg">
+ {{ $options.i18n.pipelineNotTriggeredMsg }}
+ </span>
+ </div>
</template>
<template v-else>
- <div>
+ <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1">
<a :href="status.detailsPath" class="gl-mr-auto">
<ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" />
</a>
@@ -144,25 +172,21 @@ export default {
<template #status>{{ status.text }}</template>
<template #commit>
<gl-link
- :href="pipeline.commitPath"
- class="commit-sha gl-font-weight-normal"
- target="_blank"
+ v-gl-tooltip.hover
+ :href="pipeline.commit.webPath"
+ :title="$options.i18n.viewCommit"
data-testid="pipeline-commit"
>
- {{ shortSha }}
+ {{ commitText }}
</gl-link>
</template>
</gl-sprintf>
</span>
</div>
<div class="gl-display-flex gl-flex-wrap">
- <pipeline-editor-mini-graph
- v-if="glFeatures.pipelineEditorMiniGraph"
- :pipeline="pipeline"
- />
+ <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" />
<gl-button
class="gl-mt-2 gl-md-mt-0"
- target="_blank"
category="secondary"
variant="confirm"
:href="status.detailsPath"
diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
index 8bffd893473..611b78b3c5e 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -75,7 +75,7 @@ export default {
return this.$options.i18n.valid;
default:
// Only display first error as a reason
- return this.ciConfig?.errors.length > 0
+ return this.ciConfig?.errors?.length > 0
? sprintf(this.$options.i18n.invalidWithReason, { reason }, false)
: this.$options.i18n.invalid;
}
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 f7c9f10ea46..0cd0d17d944 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -3,15 +3,18 @@ import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import {
CREATE_TAB,
EDITOR_APP_STATUS_EMPTY,
- EDITOR_APP_STATUS_ERROR,
EDITOR_APP_STATUS_INVALID,
EDITOR_APP_STATUS_LOADING,
EDITOR_APP_STATUS_VALID,
LINT_TAB,
MERGED_TAB,
+ TAB_QUERY_PARAM,
+ TABS_INDEX,
VISUALIZE_TAB,
} from '../constants';
import getAppStatus from '../graphql/queries/client/app_status.graphql';
@@ -20,6 +23,7 @@ import CiEditorHeader from './editor/ci_editor_header.vue';
import TextEditor from './editor/text_editor.vue';
import CiLint from './lint/ci_lint.vue';
import EditorTab from './ui/editor_tab.vue';
+import WalkthroughPopover from './walkthrough_popover.vue';
export default {
i18n: {
@@ -42,6 +46,9 @@ export default {
errorTexts: {
loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
},
+ query: {
+ TAB_QUERY_PARAM,
+ },
tabConstants: {
CREATE_TAB,
LINT_TAB,
@@ -58,6 +65,8 @@ export default {
GlTabs,
PipelineGraph,
TextEditor,
+ GitlabExperiment,
+ WalkthroughPopover,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -74,6 +83,10 @@ export default {
required: false,
default: '',
},
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: true,
+ },
},
apollo: {
appStatus: {
@@ -81,9 +94,8 @@ export default {
},
},
computed: {
- hasAppError() {
- // Not an invalid config and with `mergedYaml` data missing
- return this.appStatus === EDITOR_APP_STATUS_ERROR;
+ isMergedYamlAvailable() {
+ return this.ciConfigData?.mergedYaml;
},
isEmpty() {
return this.appStatus === EDITOR_APP_STATUS_EMPTY;
@@ -98,22 +110,51 @@ export default {
return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
},
+ created() {
+ const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM);
+
+ if (tabQueryParam && TABS_INDEX[tabQueryParam]) {
+ this.setDefaultTab(tabQueryParam);
+ }
+ },
methods: {
setCurrentTab(tabName) {
this.$emit('set-current-tab', tabName);
},
+ setDefaultTab(tabName) {
+ // We associate tab name with the index so that we can use tab name
+ // in other part of the app and load the corresponding tab closer to the
+ // actual component using a hash that binds the name to the indexes.
+ // This also means that if we ever changed tab order, we would justs need to
+ // update `TABS_INDEX` hash instead of all the instances in the app
+ // where we used the individual indexes
+ const newUrl = setUrlParams({ [TAB_QUERY_PARAM]: TABS_INDEX[tabName] });
+
+ this.setCurrentTab(tabName);
+ updateHistory({ url: newUrl, title: document.title, replace: true });
+ },
},
};
</script>
<template>
- <gl-tabs class="file-editor gl-mb-3">
+ <gl-tabs
+ class="file-editor gl-mb-3"
+ :query-param-name="$options.query.TAB_QUERY_PARAM"
+ sync-active-tab-with-query-params
+ >
<editor-tab
class="gl-mb-3"
+ title-link-class="js-walkthrough-popover-target"
:title="$options.i18n.tabEdit"
lazy
data-testid="editor-tab"
@click="setCurrentTab($options.tabConstants.CREATE_TAB)"
>
+ <gitlab-experiment name="pipeline_editor_walkthrough">
+ <template #candidate>
+ <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
+ </template>
+ </gitlab-experiment>
<ci-editor-header />
<text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
</editor-tab>
@@ -154,7 +195,7 @@ export default {
@click="setCurrentTab($options.tabConstants.MERGED_TAB)"
>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
- <gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false">
+ <gl-alert v-else-if="!isMergedYamlAvailable" variant="danger" :dismissible="false">
{{ $options.errorTexts.loadMergedYaml }}
</gl-alert>
<ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" />
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
index 091b202e10b..7206f19d060 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
@@ -8,6 +8,7 @@ import {
DEFAULT_FAILURE,
DEFAULT_SUCCESS,
LOAD_FAILURE_UNKNOWN,
+ PIPELINE_FAILURE,
} from '../../constants';
import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue';
import {
@@ -24,6 +25,7 @@ export default {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[DEFAULT_FAILURE]: __('Something went wrong on our end.'),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
+ [PIPELINE_FAILURE]: s__('Pipelines|There was a problem with loading the pipeline data.'),
},
successTexts: {
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
@@ -74,6 +76,11 @@ export default {
text: this.$options.errorTexts[COMMIT_FAILURE],
variant: 'danger',
};
+ case PIPELINE_FAILURE:
+ return {
+ text: this.$options.errorTexts[PIPELINE_FAILURE],
+ variant: 'danger',
+ };
default:
return {
text: this.$options.errorTexts[DEFAULT_FAILURE],
diff --git a/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue b/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue
new file mode 100644
index 00000000000..5742b11b841
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlButton, GlPopover, GlSprintf, GlOutsideDirective as Outside } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ directives: { Outside },
+ i18n: {
+ title: s__('pipelineEditorWalkthrough|See how GitLab pipelines work'),
+ description: s__(
+ 'pipelineEditorWalkthrough|This %{codeStart}.gitlab-ci.yml%{codeEnd} file creates a simple test pipeline.',
+ ),
+ instruction: s__(
+ 'pipelineEditorWalkthrough|Use the %{boldStart}commit changes%{boldEnd} button at the bottom of the page to run the pipeline.',
+ ),
+ ctaText: s__("pipelineEditorWalkthrough|Let's do this!"),
+ },
+ components: {
+ GlButton,
+ GlPopover,
+ GlSprintf,
+ },
+ data() {
+ return {
+ show: true,
+ };
+ },
+ computed: {
+ targetElement() {
+ return document.querySelector('.js-walkthrough-popover-target');
+ },
+ },
+ methods: {
+ close() {
+ this.show = false;
+ },
+ handleClickCta() {
+ this.close();
+ this.$emit('walkthrough-popover-cta-clicked');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover
+ :show.sync="show"
+ :title="$options.i18n.title"
+ :target="targetElement"
+ placement="right"
+ triggers="focus"
+ >
+ <div v-outside="close" class="gl-display-flex gl-flex-direction-column">
+ <p>
+ <gl-sprintf :message="$options.i18n.description">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.instruction">
+ <template #bold="{ content }">
+ <strong>
+ {{ content }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-button
+ class="gl-align-self-end"
+ category="tertiary"
+ data-testid="ctaBtn"
+ variant="confirm"
+ @click="handleClickCta"
+ >
+ <gl-emoji data-name="rocket" />
+ {{ this.$options.i18n.ctaText }}
+ </gl-button>
+ </div>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index bb03fa126a5..a2eaeeef286 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -5,24 +5,37 @@ export const CI_CONFIG_STATUS_VALID = 'VALID';
// Values for EDITOR_APP_STATUS_* are frontend specifics and
// represent the global state of the pipeline editor app.
export const EDITOR_APP_STATUS_EMPTY = 'EMPTY';
-export const EDITOR_APP_STATUS_ERROR = 'ERROR';
export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID;
export const EDITOR_APP_STATUS_LOADING = 'LOADING';
export const EDITOR_APP_STATUS_VALID = CI_CONFIG_STATUS_VALID;
+export const EDITOR_APP_VALID_STATUSES = [
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_INVALID,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
+];
+
export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS';
export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
+export const PIPELINE_FAILURE = 'PIPELINE_FAILURE';
export const CREATE_TAB = 'CREATE_TAB';
export const LINT_TAB = 'LINT_TAB';
export const MERGED_TAB = 'MERGED_TAB';
export const VISUALIZE_TAB = 'VISUALIZE_TAB';
-export const TABS_WITH_COMMIT_FORM = [CREATE_TAB, LINT_TAB, VISUALIZE_TAB];
+export const TABS_INDEX = {
+ [CREATE_TAB]: '0',
+ [VISUALIZE_TAB]: '1',
+ [LINT_TAB]: '2',
+ [MERGED_TAB]: '3',
+};
+export const TAB_QUERY_PARAM = 'tab';
export const COMMIT_ACTION_CREATE = 'CREATE';
export const COMMIT_ACTION_UPDATE = 'UPDATE';
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql
new file mode 100644
index 00000000000..7487e328668
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateAppStatus($appStatus: String) {
+ updateAppStatus(appStatus: $appStatus) @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql
new file mode 100644
index 00000000000..9025f00b343
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updatePipelineEtag($pipelineEtag: String) {
+ updatePipelineEtag(pipelineEtag: $pipelineEtag) @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql
deleted file mode 100644
index 8c2ca276f50..00000000000
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-query getIsNewCiConfigFile {
- isNewCiConfigFile @client
-}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
index 0c3653a2880..34e98ae3eb3 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
@@ -1,10 +1,13 @@
query getPipeline($fullPath: ID!, $sha: String!) {
project(fullPath: $fullPath) {
pipeline(sha: $sha) {
- commitPath
id
iid
status
+ commit {
+ title
+ webPath
+ }
detailedStatus {
detailsPath
icon
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index a34652b1495..e4965e00af3 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -1,7 +1,8 @@
-import produce from 'immer';
import axios from '~/lib/utils/axios_utils';
+import getAppStatus from './queries/client/app_status.graphql';
import getCurrentBranchQuery from './queries/client/current_branch.graphql';
import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql';
+import getPipelineEtag from './queries/client/pipeline_etag.graphql';
export const resolvers = {
Mutation: {
@@ -31,20 +32,28 @@ export const resolvers = {
__typename: 'CiLintContent',
}));
},
+ updateAppStatus: (_, { appStatus }, { cache }) => {
+ cache.writeQuery({
+ query: getAppStatus,
+ data: { appStatus },
+ });
+ },
updateCurrentBranch: (_, { currentBranch }, { cache }) => {
cache.writeQuery({
query: getCurrentBranchQuery,
- data: produce(cache.readQuery({ query: getCurrentBranchQuery }), (draftData) => {
- draftData.currentBranch = currentBranch;
- }),
+ data: { currentBranch },
});
},
updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => {
cache.writeQuery({
query: getLastCommitBranchQuery,
- data: produce(cache.readQuery({ query: getLastCommitBranchQuery }), (draftData) => {
- draftData.lastCommitBranch = lastCommitBranch;
- }),
+ data: { lastCommitBranch },
+ });
+ },
+ updatePipelineEtag: (_, { pipelineEtag }, { cache }) => {
+ cache.writeQuery({
+ query: getPipelineEtag,
+ data: { pipelineEtag },
});
},
},
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 89b9091e6f9..4f7f2743aca 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -3,8 +3,10 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
+import { EDITOR_APP_STATUS_LOADING } from './constants';
import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
+import getAppStatus from './graphql/queries/client/app_status.graphql';
import getLastCommitBranchQuery from './graphql/queries/client/last_commit_branch.query.graphql';
import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql';
import { resolvers } from './graphql/resolvers';
@@ -59,12 +61,18 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
defaultClient: createDefaultClient(resolvers, {
typeDefs,
useGet: true,
- assumeImmutableResults: true,
}),
});
const { cache } = apolloProvider.clients.defaultClient;
cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ appStatus: EDITOR_APP_STATUS_LOADING,
+ },
+ });
+
+ cache.writeQuery({
query: getCurrentBranch,
data: {
currentBranch: initialBranchName || defaultBranch,
@@ -93,6 +101,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ciExamplesHelpPagePath,
ciHelpPagePath,
configurationPaths,
+ dataMethod: 'graphql',
defaultBranch,
emptyStateIllustrationPath,
helpPaths,
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index e70417145ab..68db5d8078f 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -12,16 +12,16 @@ import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue
import {
COMMIT_SHA_POLL_INTERVAL,
EDITOR_APP_STATUS_EMPTY,
- EDITOR_APP_STATUS_ERROR,
+ EDITOR_APP_VALID_STATUSES,
EDITOR_APP_STATUS_LOADING,
LOAD_FAILURE_UNKNOWN,
STARTER_TEMPLATE_NAME,
} from './constants';
+import updateAppStatus from './graphql/mutations/update_app_status.mutation.graphql';
import getBlobContent from './graphql/queries/blob_content.graphql';
import getCiConfigData from './graphql/queries/ci_config.graphql';
import getAppStatus from './graphql/queries/client/app_status.graphql';
import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
-import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql';
import getTemplate from './graphql/queries/get_starter_template.query.graphql';
import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
@@ -44,23 +44,23 @@ export default {
},
data() {
return {
- starterTemplateName: STARTER_TEMPLATE_NAME,
ciConfigData: {},
+ currentCiFileContent: '',
failureType: null,
failureReasons: [],
initialCiFileContent: '',
isFetchingCommitSha: false,
isNewCiConfigFile: false,
lastCommittedContent: '',
- currentCiFileContent: '',
- successType: null,
+ shouldSkipStartScreen: false,
+ showFailure: false,
showStartScreen: false,
showSuccess: false,
- showFailure: false,
starterTemplate: '',
+ starterTemplateName: STARTER_TEMPLATE_NAME,
+ successType: null,
};
},
-
apollo: {
initialCiFileContent: {
fetchPolicy: fetchPolicies.NETWORK_ONLY,
@@ -103,7 +103,11 @@ export default {
}
if (!hasCIFile) {
- this.showStartScreen = true;
+ if (this.shouldSkipStartScreen) {
+ this.setNewEmptyCiConfigFile();
+ } else {
+ this.showStartScreen = true;
+ }
} else if (fileContent.length) {
// If the file content is > 0, then we make sure to reset the
// start screen flag during a refetch
@@ -141,10 +145,10 @@ export default {
return { ...ciConfig, stages };
},
result({ data }) {
- this.setAppStatus(data?.ciConfig?.status || EDITOR_APP_STATUS_ERROR);
+ this.setAppStatus(data?.ciConfig?.status);
},
- error() {
- this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ error(err) {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN, [String(err)]);
},
watchLoading(isLoading) {
if (isLoading) {
@@ -179,9 +183,6 @@ export default {
currentBranch: {
query: getCurrentBranch,
},
- isNewCiConfigFile: {
- query: getIsNewCiConfigFile,
- },
starterTemplate: {
query: getTemplate,
variables() {
@@ -232,6 +233,7 @@ export default {
},
mounted() {
this.loadTemplateFromURL();
+ this.checkShouldSkipStartScreen();
},
methods: {
hideFailure() {
@@ -245,8 +247,6 @@ export default {
await this.$apollo.queries.initialCiFileContent.refetch();
},
reportFailure(type, reasons = []) {
- this.setAppStatus(EDITOR_APP_STATUS_ERROR);
-
window.scrollTo({ top: 0, behavior: 'smooth' });
this.showFailure = true;
this.failureType = type;
@@ -261,12 +261,12 @@ export default {
this.currentCiFileContent = this.lastCommittedContent;
},
setAppStatus(appStatus) {
- this.$apollo.getClient().writeQuery({ query: getAppStatus, data: { appStatus } });
+ if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) {
+ this.$apollo.mutate({ mutation: updateAppStatus, variables: { appStatus } });
+ }
},
setNewEmptyCiConfigFile() {
- this.$apollo
- .getClient()
- .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: true } });
+ this.isNewCiConfigFile = true;
this.showStartScreen = false;
},
showErrorAlert({ type, reasons = [] }) {
@@ -283,9 +283,7 @@ export default {
this.reportSuccess(type);
if (this.isNewCiConfigFile) {
- this.$apollo
- .getClient()
- .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: false } });
+ this.isNewCiConfigFile = false;
}
// Keep track of the latest committed content to know
@@ -300,6 +298,10 @@ export default {
this.setNewEmptyCiConfigFile();
}
},
+ checkShouldSkipStartScreen() {
+ const params = queryToObject(window.location.search);
+ this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
+ },
},
};
</script>
@@ -325,8 +327,9 @@ export default {
<pipeline-editor-home
:ci-config-data="ciConfigData"
:ci-file-content="currentCiFileContent"
- :is-new-ci-config-file="isNewCiConfigFile"
:commit-sha="commitSha"
+ :has-unsaved-changes="hasUnsavedChanges"
+ :is-new-ci-config-file="isNewCiConfigFile"
@commit="updateOnCommit"
@resetContent="resetContent"
@showError="showErrorAlert"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index ba567023946..8e8f31a4acc 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -1,14 +1,31 @@
<script>
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
import CommitSection from './components/commit/commit_section.vue';
import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
-import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
+import { CREATE_TAB } from './constants';
export default {
+ commitSectionRef: 'commitSectionRef',
+ modal: {
+ switchBranch: {
+ title: __('You have unsaved changes'),
+ body: __('Uncommitted changes will be lost if you change branches. Do you want to continue?'),
+ actionPrimary: {
+ text: __('Switch Branches'),
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: { variant: 'default' },
+ },
+ },
+ },
components: {
CommitSection,
+ GlModal,
PipelineEditorDrawer,
PipelineEditorFileNav,
PipelineEditorHeader,
@@ -28,6 +45,11 @@ export default {
required: false,
default: '',
},
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
isNewCiConfigFile: {
type: Boolean,
required: true,
@@ -36,40 +58,82 @@ export default {
data() {
return {
currentTab: CREATE_TAB,
+ scrollToCommitForm: false,
+ shouldLoadNewBranch: false,
+ showSwitchBranchModal: false,
};
},
computed: {
showCommitForm() {
- return TABS_WITH_COMMIT_FORM.includes(this.currentTab);
+ return this.currentTab === CREATE_TAB;
},
},
methods: {
+ closeBranchModal() {
+ this.showSwitchBranchModal = false;
+ },
+ handleConfirmSwitchBranch() {
+ this.showSwitchBranchModal = true;
+ },
+ switchBranch() {
+ this.showSwitchBranchModal = false;
+ this.shouldLoadNewBranch = true;
+ },
setCurrentTab(tabName) {
this.currentTab = tabName;
},
+ setScrollToCommitForm(newValue = true) {
+ this.scrollToCommitForm = newValue;
+ },
},
};
</script>
<template>
<div class="gl-pr-9 gl-transition-medium gl-w-full">
- <pipeline-editor-file-nav v-on="$listeners" />
+ <gl-modal
+ v-if="showSwitchBranchModal"
+ visible
+ modal-id="switchBranchModal"
+ :title="$options.modal.switchBranch.title"
+ :action-primary="$options.modal.switchBranch.actionPrimary"
+ :action-secondary="$options.modal.switchBranch.actionSecondary"
+ @primary="switchBranch"
+ @secondary="closeBranchModal"
+ @cancel="closeBranchModal"
+ @hide="closeBranchModal"
+ >
+ {{ $options.modal.switchBranch.body }}
+ </gl-modal>
+ <pipeline-editor-file-nav
+ :has-unsaved-changes="hasUnsavedChanges"
+ :should-load-new-branch="shouldLoadNewBranch"
+ @select-branch="handleConfirmSwitchBranch"
+ v-on="$listeners"
+ />
<pipeline-editor-header
:ci-config-data="ciConfigData"
:commit-sha="commitSha"
:is-new-ci-config-file="isNewCiConfigFile"
+ v-on="$listeners"
/>
<pipeline-editor-tabs
:ci-config-data="ciConfigData"
:ci-file-content="ciFileContent"
:commit-sha="commitSha"
+ :is-new-ci-config-file="isNewCiConfigFile"
v-on="$listeners"
@set-current-tab="setCurrentTab"
+ @walkthrough-popover-cta-clicked="setScrollToCommitForm"
/>
<commit-section
v-if="showCommitForm"
+ :ref="$options.commitSectionRef"
:ci-file-content="ciFileContent"
:commit-sha="commitSha"
+ :is-new-ci-config-file="isNewCiConfigFile"
+ :scroll-to-commit-form="scrollToCommitForm"
+ @scrolled-to-commit-form="setScrollToCommitForm(false)"
v-on="$listeners"
/>
<pipeline-editor-drawer />
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
index b778fe28e59..9725e882d5e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -95,9 +95,9 @@ export default {
{{ $options.i18n.artifactsFetchErrorMessage }}
</gl-alert>
- <gl-loading-icon v-if="isLoading" size="sm" />
+ <gl-loading-icon v-else-if="isLoading" size="sm" />
- <gl-dropdown-item v-if="!artifacts.length && !isLoading" data-testid="artifacts-empty-message">
+ <gl-dropdown-item v-else-if="!artifacts.length" data-testid="artifacts-empty-message">
{{ $options.i18n.emptyArtifactsMessage }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index 1c7c4d7c704..7d0cea67099 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -1,20 +1,15 @@
<script>
import {
- GlAlert,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
- GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
export const i18n = {
artifacts: __('Artifacts'),
artifactSectionHeader: __('Download artifacts'),
- artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
- noArtifacts: s__('Pipelines|No artifacts available'),
};
export default {
@@ -23,11 +18,9 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- GlAlert,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
- GlLoadingIcon,
},
inject: {
artifactsEndpoint: {
@@ -42,44 +35,22 @@ export default {
type: Number,
required: true,
},
- },
- data() {
- return {
- artifacts: [],
- hasError: false,
- isLoading: false,
- };
- },
- computed: {
- hasArtifacts() {
- return Boolean(this.artifacts.length);
+ artifacts: {
+ type: Array,
+ required: false,
+ default: () => [],
},
},
- methods: {
- fetchArtifacts() {
- this.isLoading = true;
- // Replace the placeholder with the ID of the pipeline we are viewing
- const endpoint = this.artifactsEndpoint.replace(
- this.artifactsEndpointPlaceholder,
- this.pipelineId,
- );
- return axios
- .get(endpoint)
- .then(({ data }) => {
- this.artifacts = data.artifacts;
- })
- .catch(() => {
- this.hasError = true;
- })
- .finally(() => {
- this.isLoading = false;
- });
+ computed: {
+ shouldShowDropdown() {
+ return this.artifacts?.length;
},
},
};
</script>
<template>
<gl-dropdown
+ v-if="shouldShowDropdown"
v-gl-tooltip
class="build-artifacts js-pipeline-dropdown-download"
:title="$options.i18n.artifacts"
@@ -89,22 +60,11 @@ export default {
right
lazy
text-sr-only
- @show.once="fetchArtifacts"
>
<gl-dropdown-section-header>{{
$options.i18n.artifactSectionHeader
}}</gl-dropdown-section-header>
- <gl-alert v-if="hasError" variant="danger" :dismissible="false">
- {{ $options.i18n.artifactsFetchErrorMessage }}
- </gl-alert>
-
- <gl-loading-icon v-if="isLoading" size="sm" />
-
- <gl-alert v-else-if="!hasArtifacts" variant="info" :dismissible="false">
- {{ $options.i18n.noArtifacts }}
- </gl-alert>
-
<gl-dropdown-item
v-for="(artifact, i) in artifacts"
:key="i"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
index c6c81d5253b..83f6356f31a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
@@ -76,7 +76,7 @@ export default {
</p>
<div class="row gl-mb-8">
- <div class="col-lg-3">
+ <div class="col-12">
<gl-card>
<div class="gl-flex-direction-row">
<div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
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 12ee82f0390..d64decc81ec 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../../event_hub';
import PipelineMiniGraph from './pipeline_mini_graph.vue';
@@ -18,7 +18,7 @@ const DEFAULT_TH_CLASSES =
export default {
components: {
- GlTable,
+ GlTableLite,
LinkedPipelinesMiniList: () =>
import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
PipelinesCommit,
@@ -156,7 +156,7 @@ export default {
</script>
<template>
<div class="ci-table">
- <gl-table
+ <gl-table-lite
:fields="tableFields"
:items="pipelines"
tbody-tr-class="commit"
@@ -225,7 +225,7 @@ export default {
<template #cell(actions)="{ item }">
<pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" />
</template>
- </gl-table>
+ </gl-table-lite>
<pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" />
</div>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 5678b613ec6..d123f7a203c 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -49,3 +49,5 @@ export const PipelineKeyOptions = [
key: 'iid',
},
];
+
+export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.');
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
index de8de651eea..8fcae9dbad8 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
@@ -18,8 +18,11 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
}
createdAt
user {
+ id
name
+ username
webPath
+ webUrl
email
avatarUrl
status {
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index 082d67c938c..3201f88a9e3 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -4,7 +4,7 @@ import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/commo
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import { validateParams } from '~/pipelines/utils';
-import { CANCEL_REQUEST } from '../constants';
+import { CANCEL_REQUEST, TOAST_MESSAGE } from '../constants';
import eventHub from '../event_hub';
export default {
@@ -191,7 +191,10 @@ export default {
this.service
.runMRPipeline(options)
- .then(() => this.updateTable())
+ .then(() => {
+ this.$toast.show(TOAST_MESSAGE);
+ this.updateTable();
+ })
.catch(() => {
createFlash({
message: __(
diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js
index 7a922acd0b3..c3be487caae 100644
--- a/app/assets/javascripts/pipelines/pipeline_shared_client.js
+++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js
@@ -5,7 +5,6 @@ export const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
- assumeImmutableResults: true,
useGet: true,
},
),
diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
index 17cbcabeedb..3cb2dce87d3 100644
--- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -37,7 +37,7 @@ export default {
},
primaryProps() {
return {
- text: s__('Delete account'),
+ text: __('Delete account'),
attributes: [
{ variant: 'danger', 'data-qa-selector': 'confirm_delete_account_button' },
{ category: 'primary' },
@@ -47,7 +47,7 @@ export default {
},
cancelProps() {
return {
- text: s__('Cancel'),
+ text: __('Cancel'),
};
},
canSubmit() {
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index 7917a9a75e0..45a6130826d 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -3,7 +3,7 @@ import { GlSafeHtmlDirective as SafeHtml, GlButton, GlModal, GlModalDirective }
import { escape } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
export default {
components: {
@@ -58,7 +58,7 @@ Please update your Git repository remotes as soon as possible.`),
},
primaryProps() {
return {
- text: s__('Update username'),
+ text: __('Update username'),
attributes: [
{ variant: 'warning' },
{ category: 'primary' },
@@ -68,7 +68,7 @@ Please update your Git repository remotes as soon as possible.`),
},
cancelProps() {
return {
- text: s__('Cancel'),
+ text: __('Cancel'),
};
},
},
diff --git a/app/assets/javascripts/project_visibility.js b/app/assets/javascripts/project_visibility.js
index e3868e2925d..1b57a69d464 100644
--- a/app/assets/javascripts/project_visibility.js
+++ b/app/assets/javascripts/project_visibility.js
@@ -1,42 +1,58 @@
import $ from 'jquery';
+import eventHub from '~/projects/new/event_hub';
-function setVisibilityOptions(namespaceSelector) {
- if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) {
- return;
- }
- const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex];
- const { name, visibility, visibilityLevel, showPath, editPath } = selectedNamespace.dataset;
+// Values are from lib/gitlab/visibility_level.rb
+const visibilityLevel = {
+ private: 0,
+ internal: 10,
+ public: 20,
+};
+function setVisibilityOptions({ name, visibility, showPath, editPath }) {
document.querySelectorAll('.visibility-level-setting .form-check').forEach((option) => {
+ // Don't change anything if the option is restricted by admin
+ if (option.classList.contains('restricted')) {
+ return;
+ }
+
const optionInput = option.querySelector('input[type=radio]');
- const optionValue = optionInput ? optionInput.value : 0;
- const optionTitle = option.querySelector('.option-title');
- const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : '';
+ const optionValue = optionInput ? parseInt(optionInput.value, 10) : 0;
- // don't change anything if the option is restricted by admin
- if (!option.classList.contains('restricted')) {
- if (visibilityLevel < optionValue) {
- option.classList.add('disabled');
- optionInput.disabled = true;
- const reason = option.querySelector('.option-disabled-reason');
- if (reason) {
- reason.innerHTML = `This project cannot be ${optionName} because the visibility of
+ if (visibilityLevel[visibility] < optionValue) {
+ option.classList.add('disabled');
+ optionInput.disabled = true;
+ const reason = option.querySelector('.option-disabled-reason');
+ if (reason) {
+ const optionTitle = option.querySelector('.option-title');
+ const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : '';
+ reason.innerHTML = `This project cannot be ${optionName} because the visibility of
<a href="${showPath}">${name}</a> is ${visibility}. To make this project
${optionName}, you must first <a href="${editPath}">change the visibility</a>
of the parent group.`;
- }
- } else {
- option.classList.remove('disabled');
- optionInput.disabled = false;
}
+ } else {
+ option.classList.remove('disabled');
+ optionInput.disabled = false;
}
});
}
+function handleSelect2DropdownChange(namespaceSelector) {
+ if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) {
+ return;
+ }
+ const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex];
+ setVisibilityOptions(selectedNamespace.dataset);
+}
+
export default function initProjectVisibilitySelector() {
+ eventHub.$on('update-visibility', setVisibilityOptions);
+
const namespaceSelector = document.querySelector('select.js-select-namespace');
if (namespaceSelector) {
- $('.select2.js-select-namespace').on('change', () => setVisibilityOptions(namespaceSelector));
- setVisibilityOptions(namespaceSelector);
+ $('.select2.js-select-namespace').on('change', () =>
+ handleSelect2DropdownChange(namespaceSelector),
+ );
+ handleSelect2DropdownChange(namespaceSelector);
}
}
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index ec7d37644a8..f9dd72119d1 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -1,6 +1,7 @@
<script>
import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
+import api from '~/api';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import csrf from '~/lib/utils/csrf';
import eventHub from '../event_hub';
@@ -40,6 +41,11 @@ export default {
required: false,
default: false,
},
+ primaryActionEventName: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -83,6 +89,10 @@ export default {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
handlePrimary() {
+ if (this.primaryActionEventName) {
+ api.trackRedisHllUserEvent(this.primaryActionEventName);
+ }
+
this.$refs.form.$el.submit();
},
resetModalHandler() {
diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
index 47ee8237fea..b21fd1a74de 100644
--- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
+++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
@@ -9,7 +9,7 @@ import {
} from './constants';
import createStore from './store';
-export default function initInviteMembersModal() {
+export default function initInviteMembersModal(primaryActionEventName) {
const el = document.querySelector('.js-cherry-pick-commit-modal');
if (!el) {
return false;
@@ -52,6 +52,7 @@ export default function initInviteMembersModal() {
openModal: OPEN_CHERRY_PICK_MODAL,
modalId: CHERRY_PICK_MODAL_ID,
isCherryPick: true,
+ primaryActionEventName,
},
}),
});
diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
index df26aa3c830..849b2f4858c 100644
--- a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
+++ b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
@@ -10,7 +10,7 @@ import {
} from './constants';
import createStore from './store';
-export default function initInviteMembersModal() {
+export default function initInviteMembersModal(primaryActionEventName) {
const el = document.querySelector('.js-revert-commit-modal');
if (!el) {
return false;
@@ -49,6 +49,7 @@ export default function initInviteMembersModal() {
i18n: { ...I18N_REVERT_MODAL, ...I18N_MODAL },
openModal: OPEN_REVERT_MODAL,
modalId: REVERT_MODAL_ID,
+ primaryActionEventName,
},
}),
});
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 2505c47147f..1d4ec4c110b 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,12 +5,7 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => {
diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue
index 06711e4025a..eaf93e2da4f 100644
--- a/app/assets/javascripts/projects/components/project_delete_button.vue
+++ b/app/assets/javascripts/projects/components/project_delete_button.vue
@@ -18,12 +18,36 @@ export default {
type: String,
required: true,
},
+ isFork: {
+ type: Boolean,
+ required: true,
+ },
+ issuesCount: {
+ type: Number,
+ required: true,
+ },
+ mergeRequestsCount: {
+ type: Number,
+ required: true,
+ },
+ forksCount: {
+ type: Number,
+ required: true,
+ },
+ starsCount: {
+ type: Number,
+ required: true,
+ },
},
strings: {
alertTitle: __('You are about to permanently delete this project'),
alertBody: __(
- 'Once a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.',
+ 'After a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.',
+ ),
+ isNotForkMessage: __(
+ 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:',
),
+ isForkMessage: __('This forked project has the following:'),
},
};
</script>
@@ -37,6 +61,38 @@ export default {
:title="$options.strings.alertTitle"
:dismissible="false"
>
+ <p>
+ <gl-sprintf v-if="isFork" :message="$options.strings.isForkMessage" />
+ <gl-sprintf v-else :message="$options.strings.isNotForkMessage">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <ul>
+ <li>
+ <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)">
+ <template #issuesCount>{{ issuesCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf
+ :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)"
+ >
+ <template #mergeRequestsCount>{{ mergeRequestsCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)">
+ <template #forksCount>{{ forksCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="n__('%d star', '%d stars', starsCount)">
+ <template #starsCount>{{ starsCount }}</template>
+ </gl-sprintf>
+ </li>
+ </ul>
<gl-sprintf :message="$options.strings.alertBody">
<template #strong="{ content }">
<strong>{{ content }}</strong>
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index 2da9449d24e..0393d82ca36 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -93,6 +93,10 @@ export default {
text: s__('ProjectTemplates|Serverless Framework/JS'),
icon: '.template-option .icon-serverless_framework',
},
+ tencent_serverless_framework: {
+ text: s__('ProjectTemplates|Tencent Serverless Framework/NextjsSSR'),
+ icon: '.template-option .icon-tencent_serverless_framework',
+ },
cluster_management: {
text: s__('ProjectTemplates|GitLab Cluster Management'),
icon: '.template-option .icon-cluster_management',
diff --git a/app/assets/javascripts/projects/details/upload_button.vue b/app/assets/javascripts/projects/details/upload_button.vue
index 5b19f15c233..e1c8c66a214 100644
--- a/app/assets/javascripts/projects/details/upload_button.vue
+++ b/app/assets/javascripts/projects/details/upload_button.vue
@@ -1,7 +1,6 @@
<script>
import { GlButton, GlModalDirective } from '@gitlab/ui';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
-import { trackFileUploadEvent } from '../upload_file_experiment_tracking';
const UPLOAD_BLOB_MODAL_ID = 'details-modal-upload-blob';
@@ -30,11 +29,6 @@ export default {
default: '',
},
},
- methods: {
- trackOpenModal() {
- trackFileUploadEvent('click_upload_modal_trigger');
- },
- },
uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
};
</script>
@@ -44,7 +38,6 @@ export default {
v-gl-modal="$options.uploadBlobModalId"
icon="upload"
data-testid="upload-file-button"
- @click="trackOpenModal"
>{{ __('Upload File') }}</gl-button
>
<upload-blob-modal
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 6e9efc50be8..476d6466cbb 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -95,7 +95,7 @@ export default {
<template>
<new-namespace-page
- :initial-breadcrumb="s__('New project')"
+ :initial-breadcrumb="__('New project')"
:panels="availablePanels"
:jump-to-last-persisted-panel="hasErrors"
:title="s__('ProjectsNew|Create new project')"
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 bf44ff70562..e0ba60074af 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
@@ -6,9 +6,9 @@ import {
GlDropdownItem,
GlDropdownText,
GlDropdownSectionHeader,
- GlLoadingIcon,
GlSearchBoxByType,
} from '@gitlab/ui';
+import { joinPaths } from '~/lib/utils/url_utility';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
@@ -24,7 +24,6 @@ export default {
GlDropdownItem,
GlDropdownText,
GlDropdownSectionHeader,
- GlLoadingIcon,
GlSearchBoxByType,
},
mixins: [Tracking.mixin()],
@@ -103,6 +102,15 @@ export default {
focusInput() {
this.$refs.search.focusInput();
},
+ handleDropdownItemClick(namespace) {
+ eventHub.$emit('update-visibility', {
+ name: namespace.name,
+ visibility: namespace.visibility,
+ showPath: namespace.webUrl,
+ editPath: joinPaths(namespace.webUrl, '-', 'edit'),
+ });
+ this.setNamespace(namespace);
+ },
handleSelectTemplate(groupId) {
this.groupToFilterBy = this.userGroups.find(
(group) => getIdFromGraphQLId(group.id) === groupId,
@@ -134,23 +142,23 @@ export default {
<gl-search-box-by-type
ref="search"
v-model.trim="search"
+ :is-loading="$apollo.queries.currentUser.loading"
data-qa-selector="select_namespace_dropdown_search_field"
/>
- <gl-loading-icon v-if="$apollo.queries.currentUser.loading" />
- <template v-else>
+ <template v-if="!$apollo.queries.currentUser.loading">
<template v-if="hasGroupMatches">
<gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="group of filteredGroups"
:key="group.id"
- @click="setNamespace(group)"
+ @click="handleDropdownItemClick(group)"
>
{{ group.fullPath }}
</gl-dropdown-item>
</template>
<template v-if="hasNamespaceMatches">
<gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="setNamespace(userNamespace)">
+ <gl-dropdown-item @click="handleDropdownItemClick(userNamespace)">
{{ userNamespace.fullPath }}
</gl-dropdown-item>
</template>
@@ -158,6 +166,11 @@ export default {
</template>
</gl-dropdown>
- <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" />
+ <input
+ id="project_namespace_id"
+ type="hidden"
+ name="project[namespace_id]"
+ :value="selectedNamespace.id"
+ />
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
index 572d3276e4f..010c6a29ae3 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -50,7 +50,7 @@ export function initNewProjectUrlSelect() {
new Vue({
el,
apolloProvider: new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
}),
provide: {
namespaceFullPath: el.dataset.namespaceFullPath,
diff --git a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
index e16fe5dde49..74febec5a51 100644
--- a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
+++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
@@ -4,6 +4,9 @@ query searchNamespacesWhereUserCanCreateProjects($search: String) {
nodes {
id
fullPath
+ name
+ visibility
+ webUrl
}
}
namespace {
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 25bacc1cc4a..7379d5caed7 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -11,12 +11,17 @@ export default {
DeploymentFrequencyCharts: () =>
import('ee_component/dora/components/deployment_frequency_charts.vue'),
LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'),
+ ProjectQualitySummary: () => import('ee_component/project_quality_summary/app.vue'),
},
inject: {
shouldRenderDoraCharts: {
type: Boolean,
default: false,
},
+ shouldRenderQualitySummary: {
+ type: Boolean,
+ default: false,
+ },
},
data() {
return {
@@ -31,6 +36,10 @@ export default {
chartsToShow.push('deployment-frequency', 'lead-time');
}
+ if (this.shouldRenderQualitySummary) {
+ chartsToShow.push('project-quality');
+ }
+
return chartsToShow;
},
},
@@ -68,6 +77,9 @@ export default {
<lead-time-charts />
</gl-tab>
</template>
+ <gl-tab v-if="shouldRenderQualitySummary" :title="s__('QualitySummary|Project quality')">
+ <project-quality-summary />
+ </gl-tab>
</gl-tabs>
<pipeline-charts v-else />
</div>
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index f7ea89068a0..003b61d94b1 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -7,13 +7,14 @@ import ProjectPipelinesCharts from './components/app.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
const mountPipelineChartsApp = (el) => {
const { projectPath } = el.dataset;
const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
+ const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary);
return new Vue({
el,
@@ -25,6 +26,7 @@ const mountPipelineChartsApp = (el) => {
provide: {
projectPath,
shouldRenderDoraCharts,
+ shouldRenderQualitySummary,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/projects/project_delete_button.js b/app/assets/javascripts/projects/project_delete_button.js
index aa7fc31d307..b4d388eda3a 100644
--- a/app/assets/javascripts/projects/project_delete_button.js
+++ b/app/assets/javascripts/projects/project_delete_button.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import ProjectDeleteButton from './components/project_delete_button.vue';
export default (selector = '#js-project-delete-button') => {
@@ -6,7 +7,15 @@ export default (selector = '#js-project-delete-button') => {
if (!el) return;
- const { confirmPhrase, formPath } = el.dataset;
+ const {
+ confirmPhrase,
+ formPath,
+ isFork,
+ issuesCount,
+ mergeRequestsCount,
+ forksCount,
+ starsCount,
+ } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
@@ -16,6 +25,11 @@ export default (selector = '#js-project-delete-button') => {
props: {
confirmPhrase,
formPath,
+ isFork: parseBoolean(isFork),
+ issuesCount: parseInt(issuesCount, 10),
+ mergeRequestsCount: parseInt(mergeRequestsCount, 10),
+ forksCount: parseInt(forksCount, 10),
+ starsCount: parseInt(starsCount, 10),
},
});
},
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index b350db0c838..8d71a3dab68 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -43,6 +43,8 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr
};
const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
+ const specialRepo = document.querySelector('.js-user-readme-repo');
+
// eslint-disable-next-line @gitlab/no-global-event-off
$projectNameInput.off('keyup change').on('keyup change', () => {
onProjectNameChange($projectNameInput, $projectPathInput);
@@ -54,6 +56,11 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
$projectPathInput.off('keyup change').on('keyup change', () => {
onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName);
hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
+
+ specialRepo.classList.toggle(
+ 'gl-display-none',
+ $projectPathInput.val() !== $projectPathInput.data('username'),
+ );
});
};
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
new file mode 100644
index 00000000000..e8b0e95b142
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
@@ -0,0 +1,92 @@
+<script>
+import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import searchProjectTopics from '../queries/project_topics_search.query.graphql';
+
+export default {
+ components: {
+ GlTokenSelector,
+ GlAvatarLabeled,
+ },
+ i18n: {
+ placeholder: s__('ProjectSettings|Search for topic'),
+ },
+ props: {
+ selected: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ apollo: {
+ topics: {
+ query: searchProjectTopics,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ update(data) {
+ return (
+ data.topics?.nodes.filter(
+ (topic) => !this.selectedTokens.some((token) => token.name === topic.name),
+ ) || []
+ );
+ },
+ debounce: 250,
+ },
+ },
+ data() {
+ return {
+ topics: [],
+ selectedTokens: this.selected,
+ search: '',
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.topics.loading;
+ },
+ placeholderText() {
+ return this.selectedTokens.length ? '' : this.$options.i18n.placeholder;
+ },
+ },
+ methods: {
+ handleEnter(event) {
+ // Prevent form from submitting when adding a token
+ if (event.target.value !== '') {
+ event.preventDefault();
+ }
+ },
+ filterTopics(searchTerm) {
+ this.search = searchTerm;
+ },
+ onTokensUpdate(tokens) {
+ this.$emit('update', tokens);
+ },
+ },
+};
+</script>
+<template>
+ <gl-token-selector
+ ref="tokenSelector"
+ v-model="selectedTokens"
+ :dropdown-items="topics"
+ :loading="loading"
+ allow-user-defined-tokens
+ :placeholder="placeholderText"
+ @keydown.enter="handleEnter"
+ @text-input="filterTopics"
+ @input="onTokensUpdate"
+ >
+ <template #dropdown-item-content="{ dropdownItem }">
+ <gl-avatar-labeled
+ :src="dropdownItem.avatarUrl"
+ :entity-name="dropdownItem.name"
+ :label="dropdownItem.name"
+ :size="32"
+ shape="rect"
+ />
+ </template>
+ </gl-token-selector>
+</template>
diff --git a/app/assets/javascripts/projects/settings/topics/index.js b/app/assets/javascripts/projects/settings/topics/index.js
new file mode 100644
index 00000000000..3fbd1a61abe
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/topics/index.js
@@ -0,0 +1,51 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import TopicsTokenSelector from './components/topics_token_selector.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export default () => {
+ const el = document.querySelector('.js-topics-selector');
+
+ if (!el) return null;
+
+ const { hiddenInputId } = el.dataset;
+ const hiddenInput = document.getElementById(hiddenInputId);
+
+ const selected = hiddenInput.value
+ ? hiddenInput.value.split(/,\s*/).map((token, index) => ({
+ id: index,
+ name: token,
+ }))
+ : [];
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(TopicsTokenSelector, {
+ props: {
+ selected,
+ },
+ on: {
+ update(tokens) {
+ const value = tokens.map(({ name }) => name).join(', ');
+ hiddenInput.value = value;
+ // Dispatch `input` event so form submit button becomes active
+ hiddenInput.dispatchEvent(
+ new Event('input', {
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql b/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql
new file mode 100644
index 00000000000..b193165062a
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql
@@ -0,0 +1,9 @@
+query searchProjectTopics($search: String) {
+ topics(search: $search) {
+ nodes {
+ id
+ name
+ avatarUrl
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index 4c083ed5496..14c8c53dd19 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -31,6 +31,9 @@ export default {
selectedTemplate: {
default: '',
},
+ selectedFileTemplateProjectId: {
+ default: null,
+ },
outgoingName: {
default: '',
},
@@ -80,7 +83,7 @@ export default {
});
},
- onSaveTemplate({ selectedTemplate, outgoingName, projectKey }) {
+ onSaveTemplate({ selectedTemplate, fileTemplateProjectId, outgoingName, projectKey }) {
this.isTemplateSaving = true;
const body = {
@@ -88,6 +91,7 @@ export default {
outgoing_name: outgoingName,
project_key: projectKey,
service_desk_enabled: this.isEnabled,
+ file_template_project_id: fileTemplateProjectId,
};
return axios
@@ -132,6 +136,7 @@ export default {
:custom-email="updatedCustomEmail"
:custom-email-enabled="customEmailEnabled"
:initial-selected-template="selectedTemplate"
+ :initial-selected-file-template-project-id="selectedFileTemplateProjectId"
:initial-outgoing-name="outgoingName"
:initial-project-key="projectKey"
:templates="templates"
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index fe2d376f1da..b8053bf9ab5 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -1,15 +1,8 @@
<script>
-import {
- GlButton,
- GlFormSelect,
- GlToggle,
- GlLoadingIcon,
- GlSprintf,
- GlFormInput,
- GlLink,
-} from '@gitlab/ui';
+import { GlButton, GlToggle, GlLoadingIcon, GlSprintf, GlFormInput, GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue';
export default {
i18n: {
@@ -18,12 +11,12 @@ export default {
components: {
ClipboardButton,
GlButton,
- GlFormSelect,
GlToggle,
GlLoadingIcon,
GlSprintf,
GlFormInput,
GlLink,
+ ServiceDeskTemplateDropdown,
},
props: {
isEnabled: {
@@ -49,6 +42,11 @@ export default {
required: false,
default: '',
},
+ initialSelectedFileTemplateProjectId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
initialOutgoingName: {
type: String,
required: false,
@@ -73,14 +71,14 @@ export default {
data() {
return {
selectedTemplate: this.initialSelectedTemplate,
+ selectedFileTemplateProjectId: this.initialSelectedFileTemplateProjectId,
outgoingName: this.initialOutgoingName || __('GitLab Support Bot'),
projectKey: this.initialProjectKey,
+ searchTerm: '',
+ projectKeyError: null,
};
},
computed: {
- templateOptions() {
- return [''].concat(this.templates);
- },
hasProjectKeySupport() {
return Boolean(this.customEmailEnabled);
},
@@ -100,8 +98,21 @@ export default {
selectedTemplate: this.selectedTemplate,
outgoingName: this.outgoingName,
projectKey: this.projectKey,
+ fileTemplateProjectId: this.selectedFileTemplateProjectId,
});
},
+ templateChange({ selectedFileTemplateProjectId, selectedTemplate }) {
+ this.selectedFileTemplateProjectId = selectedFileTemplateProjectId;
+ this.selectedTemplate = selectedTemplate;
+ },
+ validateProjectKey() {
+ if (this.projectKey && !new RegExp(/^[a-z0-9_]+$/).test(this.projectKey)) {
+ this.projectKeyError = __('Only use lowercase letters, numbers, and underscores.');
+ return;
+ }
+
+ this.projectKeyError = null;
+ },
},
};
</script>
@@ -167,8 +178,17 @@ export default {
v-model.trim="projectKey"
data-testid="project-suffix"
class="form-control"
+ :state="!projectKeyError"
+ @blur="validateProjectKey"
/>
- <span v-if="hasProjectKeySupport" class="form-text text-muted">
+ <span v-if="hasProjectKeySupport && projectKeyError" class="form-text text-danger">
+ {{ projectKeyError }}
+ </span>
+ <span
+ v-if="hasProjectKeySupport"
+ class="form-text text-muted"
+ :class="{ 'gl-mt-2!': hasProjectKeySupport && projectKeyError }"
+ >
{{ __('A string appended to the project path to form the Service Desk email address.') }}
</span>
<span v-else class="form-text text-muted">
@@ -193,12 +213,13 @@ export default {
<label for="service-desk-template-select" class="mt-3">
{{ __('Template to append to all Service Desk issues') }}
</label>
- <gl-form-select
- id="service-desk-template-select"
- v-model="selectedTemplate"
- data-qa-selector="service_desk_template_dropdown"
- :options="templateOptions"
+ <service-desk-template-dropdown
+ :selected-template="selectedTemplate"
+ :selected-file-template-project-id="selectedFileTemplateProjectId"
+ :templates="templates"
+ @change="templateChange"
/>
+
<label for="service-desk-email-from-name" class="mt-3">
{{ __('Email display name') }}
</label>
@@ -210,6 +231,7 @@ export default {
<gl-button
variant="success"
class="gl-mt-5"
+ data-testid="save_service_desk_settings_button"
data-qa-selector="save_service_desk_settings_button"
:disabled="isTemplateSaving"
@click="onSaveTemplate"
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue
new file mode 100644
index 00000000000..bdd9f940d79
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue
@@ -0,0 +1,115 @@
+<script>
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ },
+ props: {
+ selectedTemplate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ templates: {
+ type: Array,
+ required: true,
+ },
+ selectedFileTemplateProjectId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ };
+ },
+ computed: {
+ templateOptions() {
+ if (this.searchTerm) {
+ const filteredTemplates = [];
+ for (let i = 0; i < this.templates.length; i += 2) {
+ const sectionName = this.templates[i];
+ const availableTemplates = this.templates[i + 1];
+
+ const matchedTemplates = fuzzaldrinPlus.filter(availableTemplates, this.searchTerm, {
+ key: 'name',
+ });
+
+ if (matchedTemplates.length > 0) {
+ filteredTemplates.push(sectionName, matchedTemplates);
+ }
+ }
+
+ return filteredTemplates;
+ }
+
+ return this.templates;
+ },
+ },
+ methods: {
+ templateClick(template) {
+ // Clicking on the same template should unselect it
+ if (
+ template.name === this.selectedTemplate &&
+ template.project_id === this.selectedFileTemplateProjectId
+ ) {
+ this.$emit('change', {
+ selectedFileTemplateProjectId: null,
+ selectedTemplate: null,
+ });
+ return;
+ }
+
+ this.$emit('change', {
+ selectedFileTemplateProjectId: template.project_id,
+ selectedTemplate: template.key,
+ });
+ },
+ },
+ i18n: {
+ defaultDropdownText: __('Choose a template'),
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ id="service-desk-template-select"
+ :text="selectedTemplate || $options.i18n.defaultDropdownText"
+ :header-text="$options.i18n.defaultDropdownText"
+ data-qa-selector="service_desk_template_dropdown"
+ :block="true"
+ class="service-desk-template-select"
+ toggle-class="gl-m-0"
+ >
+ <template #header>
+ <gl-search-box-by-type v-model.trim="searchTerm" />
+ </template>
+ <template v-for="item in templateOptions">
+ <gl-dropdown-section-header v-if="!Array.isArray(item)" :key="item">
+ {{ item }}
+ </gl-dropdown-section-header>
+ <template v-else>
+ <gl-dropdown-item
+ v-for="template in item"
+ :key="template.key"
+ :is-check-item="true"
+ :is-checked="
+ template.project_id === selectedFileTemplateProjectId &&
+ template.name === selectedTemplate
+ "
+ @click="() => templateClick(template)"
+ >
+ {{ template.name }}
+ </gl-dropdown-item>
+ </template>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index f842ffaaa2b..e14cdee17ce 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -18,6 +18,7 @@ export default () => {
outgoingName,
projectKey,
selectedTemplate,
+ selectedFileTemplateProjectId,
templates,
} = el.dataset;
@@ -32,6 +33,7 @@ export default () => {
outgoingName,
projectKey,
selectedTemplate,
+ selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null,
templates: JSON.parse(templates),
},
render: (createElement) => createElement(ServiceDeskRoot),
diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue
index 7047fd925fb..a42a9711572 100644
--- a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue
+++ b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue
@@ -1,9 +1,10 @@
<script>
-import { GlLink, GlIcon, GlTable, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlIcon, GlTableLite as GlTable, GlSprintf } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { thWidthClass } from '~/lib/utils/table_utility';
import { sprintf } from '~/locale';
import { PROJECT_TABLE_LABELS, HELP_LINK_ARIA_LABEL } from '../constants';
+import StorageTypeIcon from './storage_type_icon.vue';
export default {
name: 'StorageTable',
@@ -12,6 +13,7 @@ export default {
GlIcon,
GlTable,
GlSprintf,
+ StorageTypeIcon,
},
props: {
storageTypes: {
@@ -48,31 +50,39 @@ export default {
<template>
<gl-table :items="storageTypes" :fields="$options.projectTableFields">
<template #cell(storageType)="{ item }">
- <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`">
- {{ item.storageType.name }}
- <gl-link
- v-if="item.storageType.helpPath"
- :href="item.storageType.helpPath"
- target="_blank"
- :aria-label="helpLinkAriaLabel(item.storageType.name)"
- :data-testid="`${item.storageType.id}-help-link`"
- >
- <gl-icon name="question" :size="12" />
- </gl-link>
- </p>
- <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
- {{ item.storageType.description }}
- </p>
- <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm">
- <gl-icon name="warning" :size="12" />
- <gl-sprintf :message="item.storageType.warningMessage">
- <template #warningLink="{ content }">
- <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
+ <div class="gl-display-flex gl-flex-direction-row">
+ <storage-type-icon
+ :name="item.storageType.id"
+ :data-testid="`${item.storageType.id}-icon`"
+ />
+ <div>
+ <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`">
+ {{ item.storageType.name }}
+ <gl-link
+ v-if="item.storageType.helpPath"
+ :href="item.storageType.helpPath"
+ target="_blank"
+ :aria-label="helpLinkAriaLabel(item.storageType.name)"
+ :data-testid="`${item.storageType.id}-help-link`"
+ >
+ <gl-icon name="question" :size="12" />
+ </gl-link>
+ </p>
+ <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
+ {{ item.storageType.description }}
+ </p>
+ <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm">
+ <gl-icon name="warning" :size="12" />
+ <gl-sprintf :message="item.storageType.warningMessage">
+ <template #warningLink="{ content }">
+ <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </div>
</template>
</gl-table>
</template>
diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue b/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue
new file mode 100644
index 00000000000..bc7cd42df1e
--- /dev/null
+++ b/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: { GlIcon },
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ iconName(storageTypeName) {
+ const defaultStorageTypeIcon = 'disk';
+ const storageTypeIconMap = {
+ lfsObjectsSize: 'doc-image',
+ snippetsSize: 'snippet',
+ uploadsSize: 'upload',
+ repositorySize: 'infrastructure-registry',
+ packagesSize: 'package',
+ };
+
+ return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon;
+ },
+ },
+};
+</script>
+<template>
+ <span
+ class="gl-display-inline-flex gl-align-items-flex-start gl-justify-content-center gl-min-w-8 gl-pr-2 gl-pt-1"
+ >
+ <gl-icon :name="iconName(name)" :size="16" class="gl-mt-1" />
+ </span>
+</template>
diff --git a/app/assets/javascripts/projects/storage_counter/constants.js b/app/assets/javascripts/projects/storage_counter/constants.js
index d9b28abfbe7..df4b1800dff 100644
--- a/app/assets/javascripts/projects/storage_counter/constants.js
+++ b/app/assets/javascripts/projects/storage_counter/constants.js
@@ -6,13 +6,13 @@ export const PROJECT_STORAGE_TYPES = [
name: s__('UsageQuota|Artifacts'),
description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'),
warningMessage: s__(
- 'UsageQuota|There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.',
+ 'UsageQuota|Because of a known issue, the artifact total for some projects may be incorrect. For more details, read %{warningLinkStart}the epic%{warningLinkEnd}.',
),
warningLink: 'https://gitlab.com/groups/gitlab-org/-/epics/5380',
},
{
id: 'lfsObjectsSize',
- name: s__('UsageQuota|LFS Storage'),
+ name: s__('UsageQuota|LFS storage'),
description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
},
{
@@ -23,7 +23,7 @@ export const PROJECT_STORAGE_TYPES = [
{
id: 'repositorySize',
name: s__('UsageQuota|Repository'),
- description: s__('UsageQuota|Git repository, managed by the Gitaly service.'),
+ description: s__('UsageQuota|Git repository.'),
},
{
id: 'snippetsSize',
@@ -51,11 +51,11 @@ export const ERROR_MESSAGE = s__(
'UsageQuota|Something went wrong while fetching project storage statistics',
);
-export const LEARN_MORE_LABEL = s__('Learn more.');
+export const LEARN_MORE_LABEL = __('Learn more.');
export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas');
export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
export const TOTAL_USAGE_DEFAULT_TEXT = __('N/A');
-export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage Breakdown');
+export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown');
export const TOTAL_USAGE_SUBTITLE = s__(
- 'UsageQuota|Includes project registry, artifacts, packages, wiki, uploads and other items.',
+ 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.',
);
diff --git a/app/assets/javascripts/projects/storage_counter/index.js b/app/assets/javascripts/projects/storage_counter/index.js
index 10668f08402..15796bc1870 100644
--- a/app/assets/javascripts/projects/storage_counter/index.js
+++ b/app/assets/javascripts/projects/storage_counter/index.js
@@ -25,7 +25,7 @@ export default (containerId = 'js-project-storage-count-app') => {
} = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
return new Vue({
diff --git a/app/assets/javascripts/projects/storage_counter/utils.js b/app/assets/javascripts/projects/storage_counter/utils.js
index cb26603fff5..9fca9d88f46 100644
--- a/app/assets/javascripts/projects/storage_counter/utils.js
+++ b/app/assets/javascripts/projects/storage_counter/utils.js
@@ -14,10 +14,6 @@ export const parseGetProjectStorageResults = (data, helpLinks) => {
}
const { storageSize, ...storageStatistics } = projectStatistics;
const storageTypes = PROJECT_STORAGE_TYPES.reduce((types, currentType) => {
- if (!storageStatistics[currentType.id]) {
- return types;
- }
-
const helpPathKey = currentType.id.replace(`Size`, `HelpPagePath`);
const helpPath = helpLinks[helpPathKey];
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index f6f409873c8..a79da00de43 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -58,7 +58,7 @@ export default {
};
this.isLoading = false;
createFlash({
- message: s__('Something went wrong on our end'),
+ message: __('Something went wrong on our end'),
});
},
initPolling() {
diff --git a/app/assets/javascripts/projects/upload_file_experiment.js b/app/assets/javascripts/projects/upload_file.js
index a7519f2bce8..597965eabfc 100644
--- a/app/assets/javascripts/projects/upload_file_experiment.js
+++ b/app/assets/javascripts/projects/upload_file.js
@@ -4,7 +4,7 @@ import createRouter from '~/repository/router';
import UploadButton from './details/upload_button.vue';
export const initUploadFileTrigger = () => {
- const uploadFileTriggerEl = document.querySelector('.js-upload-file-experiment-trigger');
+ const uploadFileTriggerEl = document.querySelector('.js-upload-file-trigger');
if (!uploadFileTriggerEl) return false;
diff --git a/app/assets/javascripts/projects/upload_file_experiment_tracking.js b/app/assets/javascripts/projects/upload_file_experiment_tracking.js
deleted file mode 100644
index c5e93f19b32..00000000000
--- a/app/assets/javascripts/projects/upload_file_experiment_tracking.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import ExperimentTracking from '~/experimentation/experiment_tracking';
-
-export const trackFileUploadEvent = (eventName) => {
- const isEmpty = Boolean(document.querySelector('.project-home-panel.empty-project'));
- const property = isEmpty ? 'empty' : 'nonempty';
- const label = 'blob-upload-modal';
- const FileUploadTracking = new ExperimentTracking('empty_repo_upload', { label, property });
- FileUploadTracking.event(eventName);
-};
diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js
index 1cef986a83d..397e3ed2ac8 100644
--- a/app/assets/javascripts/ref/constants.js
+++ b/app/assets/javascripts/ref/constants.js
@@ -15,9 +15,9 @@ export const DEFAULT_I18N = Object.freeze({
searchPlaceholder: __('Search by Git revision'),
noResultsWithQuery: __('No matching results for "%{query}"'),
noResults: __('No matching results'),
- branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'),
+ branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'),
tagsErrorMessage: __('An error occurred while fetching tags. Retry the search.'),
- commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'),
+ commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'),
branches: __('Branches'),
tags: __('Tags'),
commits: __('Commits'),
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
index 05858c7469d..50835142d28 100644
--- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -1,7 +1,7 @@
<script>
import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { sprintf, n__, s__ } from '~/locale';
+import { sprintf, __, n__ } from '~/locale';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
import { parseIssuableData } from '../../issue_show/utils/parse_data';
@@ -40,7 +40,7 @@ export default {
this.totalCount,
);
- return sprintf(s__('%{mrText}, this issue will be closed automatically.'), { mrText });
+ return sprintf(__('%{mrText}, this issue will be closed automatically.'), { mrText });
},
},
mounted() {
@@ -64,58 +64,51 @@ export default {
</script>
<template>
- <div
- v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"
- id="related-merge-requests"
- >
- <div id="merge-requests" class="card card-slim mt-3">
+ <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
+ <div class="card card-slim gl-mt-5">
<div class="card-header">
- <div class="card-title mt-0 mb-0 h5 merge-requests-title position-relative">
+ <div
+ class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
+ >
<gl-link
- id="user-content-related-merge-requests"
- class="anchor position-absolute text-decoration-none"
+ class="anchor gl-absolute gl-text-decoration-none"
href="#related-merge-requests"
- aria-hidden="true"
+ aria-labelledby="related-merge-requests"
/>
- <span class="mr-1">
+ <h3 id="related-merge-requests" class="gl-font-base gl-m-0">
{{ __('Related merge requests') }}
- </span>
- <div v-if="totalCount" class="d-inline-flex lh-100 align-middle">
- <div
- class="mr-count-badge gl-display-inline-flex gl-align-items-center gl-py-2 gl-px-3"
- >
- <svg class="s16 mr-1 text-secondary">
- <gl-icon name="merge-request" class="mr-1 text-secondary" />
- </svg>
- <span class="js-items-count">{{ totalCount }}</span>
- </div>
- </div>
+ </h3>
+ <template v-if="totalCount">
+ <gl-icon name="merge-request" class="gl-ml-5 gl-mr-2 gl-text-gray-500" />
+ <span data-testid="count">{{ totalCount }}</span>
+ </template>
</div>
</div>
- <div>
- <div v-if="isFetchingMergeRequests" class="qa-related-merge-requests-loading-icon">
- <gl-loading-icon size="sm" label="Fetching related merge requests" class="py-2" />
- </div>
- <ul v-else class="content-list related-items-list">
- <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0">
- <related-issuable-item
- :id-key="mr.id"
- :display-reference="mr.reference"
- :title="mr.title"
- :milestone="mr.milestone"
- :assignees="getAssignees(mr)"
- :created-at="mr.created_at"
- :closed-at="mr.closed_at"
- :merged-at="mr.merged_at"
- :path="mr.web_url"
- :state="mr.state"
- :is-merge-request="true"
- :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
- path-id-separator="!"
- />
- </li>
- </ul>
- </div>
+ <gl-loading-icon
+ v-if="isFetchingMergeRequests"
+ size="sm"
+ label="Fetching related merge requests"
+ class="gl-py-3"
+ />
+ <ul v-else class="content-list related-items-list">
+ <li v-for="mr in mergeRequests" :key="mr.id" class="list-item gl-m-0! gl-p-0!">
+ <related-issuable-item
+ :id-key="mr.id"
+ :display-reference="mr.reference"
+ :title="mr.title"
+ :milestone="mr.milestone"
+ :assignees="getAssignees(mr)"
+ :created-at="mr.created_at"
+ :closed-at="mr.closed_at"
+ :merged-at="mr.merged_at"
+ :path="mr.web_url"
+ :state="mr.state"
+ :is-merge-request="true"
+ :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
+ path-id-separator="!"
+ />
+ </li>
+ </ul>
</div>
<div
v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js
index 652d03a0fd0..94abb50de89 100644
--- a/app/assets/javascripts/related_merge_requests/store/actions.js
+++ b/app/assets/javascripts/related_merge_requests/store/actions.js
@@ -1,7 +1,7 @@
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { normalizeHeaders } from '~/lib/utils/common_utils';
-import { s__ } from '~/locale';
+import { __ } from '~/locale';
import * as types from './mutation_types';
const REQUEST_PAGE_COUNT = 100;
@@ -30,7 +30,7 @@ export const fetchMergeRequests = ({ state, dispatch }) => {
.catch(() => {
dispatch('receiveDataError');
createFlash({
- message: s__('Something went wrong while fetching related merge requests.'),
+ message: __('Something went wrong while fetching related merge requests.'),
});
});
};
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 80f59485426..9e05d00a98d 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -95,6 +95,8 @@ export default {
noRefSelected: __('No tag selected'),
dropdownHeader: __('Tag name'),
searchPlaceholder: __('Search or create tag'),
+ label: __('Tag name'),
+ labelDescription: __('*Required'),
},
createFrom: {
noRefSelected: __('No source selected'),
@@ -108,11 +110,12 @@ export default {
<template>
<div>
<gl-form-group
- :label="__('Tag name')"
- :label-for="tagNameInputId"
data-testid="tag-name-field"
:state="!showTagNameValidationError"
:invalid-feedback="__('Tag name is required')"
+ :label="$options.translations.tagName.label"
+ :label-for="tagNameInputId"
+ :label-description="$options.translations.tagName.labelDescription"
>
<form-field-container>
<ref-selector
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 f2d89dbe682..c69481150e0 100644
--- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -6,7 +6,7 @@
# When the `releases_index_apollo_client` feature flag is
# removed, this query should be removed entirely.
-query allReleases(
+query allReleasesDeprecated(
$fullPath: ID!
$first: Int
$last: Int
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index 59f6ebfc928..86fa72d1496 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -24,7 +24,6 @@ export default () => {
// the purpose of making separate requests. So we explicitly
// disable batching on this page.
batchMax: 1,
- assumeImmutableResults: true,
},
),
});
diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js
index 686f9e294b7..7272880197a 100644
--- a/app/assets/javascripts/releases/mount_show.js
+++ b/app/assets/javascripts/releases/mount_show.js
@@ -6,12 +6,7 @@ import ReleaseShowApp from './components/app_show.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
export default () => {
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 7ad9fb56972..2cc5a8a79d2 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
@@ -17,12 +17,16 @@ import ForkSuggestion from './fork_suggestion.vue';
import { loadViewer, viewerProps } from './blob_viewers';
export default {
+ i18n: {
+ pipelineEditor: __('Pipeline Editor'),
+ },
components: {
BlobHeader,
BlobEdit,
BlobButtonGroup,
BlobContent,
GlLoadingIcon,
+ GlButton,
ForkSuggestion,
},
mixins: [getRefMixin],
@@ -105,6 +109,7 @@ export default {
rawPath: '',
externalStorageUrl: '',
replacePath: '',
+ pipelineEditorPath: '',
deletePath: '',
simpleViewer: {},
richViewer: null,
@@ -242,6 +247,18 @@ export default {
:needs-to-fork="showForkSuggestion"
@edit="editBlob"
/>
+
+ <gl-button
+ v-if="blobInfo.pipelineEditorPath"
+ class="gl-mr-3"
+ category="secondary"
+ variant="confirm"
+ data-testid="pipeline-editor"
+ :href="blobInfo.pipelineEditorPath"
+ >
+ {{ $options.i18n.pipelineEditor }}
+ </gl-button>
+
<blob-button-group
v-if="isLoggedIn"
:path="path"
diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue
index a307b7c0b8a..4a8cedb60b4 100644
--- a/app/assets/javascripts/repository/components/delete_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue
@@ -116,15 +116,14 @@ export default {
],
};
},
- /* eslint-disable dot-notation */
showCreateNewMrToggle() {
- return this.canPushCode && this.form.fields['branch_name'].value !== this.originalBranch;
+ return this.canPushCode && this.form.fields.branch_name.value !== this.originalBranch;
},
formCompleted() {
- return this.form.fields['commit_message'].value && this.form.fields['branch_name'].value;
+ return this.form.fields.commit_message.value && this.form.fields.branch_name.value;
},
showHint() {
- const splitCommitMessageByLineBreak = this.form.fields['commit_message'].value
+ const splitCommitMessageByLineBreak = this.form.fields.commit_message.value
.trim()
.split('\n');
const [firstLine, ...otherLines] = splitCommitMessageByLineBreak;
@@ -136,7 +135,7 @@ export default {
otherLines.some((text) => text.length > COMMIT_MESSAGE_BODY_MAX_LENGTH);
return (
- !this.form.fields['commit_message'].feedback &&
+ !this.form.fields.commit_message.feedback &&
(hasFirstLineExceedMaxLength || hasOtherLineExceedMaxLength)
);
},
@@ -173,9 +172,7 @@ export default {
<input type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<template v-if="emptyRepo">
- <!-- Once "empty_repo_upload_experiment" is made available, will need to add class 'js-branch-name'
- Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335721 -->
- <input type="hidden" name="branch_name" :value="originalBranch" />
+ <input type="hidden" name="branch_name" :value="originalBranch" class="js-branch-name" />
</template>
<template v-else>
<input type="hidden" name="original_branch" :value="originalBranch" />
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 5c713796bd6..62066973ee6 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,5 +1,12 @@
<script>
-import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlTooltipDirective,
+ GlLink,
+ GlButton,
+ GlButtonGroup,
+ GlLoadingIcon,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
import { sprintf, s__ } from '~/locale';
@@ -23,6 +30,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
mixins: [getRefMixin],
apollo: {
@@ -96,6 +104,9 @@ export default {
},
},
defaultAvatarUrl,
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
};
</script>
@@ -121,10 +132,10 @@ export default {
<div class="commit-detail flex-list">
<div class="commit-content qa-commit-content">
<gl-link
+ v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml"
:href="commit.webPath"
:class="{ 'font-italic': !commit.message }"
class="commit-row-message item-title"
- v-html="commit.titleHtml /* eslint-disable-line vue/no-v-html */"
/>
<gl-button
v-if="commit.descriptionHtml"
@@ -150,15 +161,15 @@ export default {
</div>
<pre
v-if="commitDescription"
+ v-safe-html:[$options.safeHtmlConfig]="commitDescription"
:class="{ 'd-block': showDescription }"
class="commit-row-description gl-mb-3"
- v-html="commitDescription /* eslint-disable-line vue/no-v-html */"
></pre>
</div>
<div class="commit-actions flex-row">
<div
v-if="commit.signatureHtml"
- v-html="commit.signatureHtml /* eslint-disable-line vue/no-v-html */"
+ v-safe-html:[$options.safeHtmlConfig]="commit.signatureHtml"
></div>
<div v-if="commit.pipeline" class="ci-status-link">
<gl-link
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 5010d60f374..bd06c064ab7 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -11,7 +11,6 @@ import {
GlIntersectionObserver,
} from '@gitlab/ui';
import { escapeRegExp } from 'lodash';
-import filesQuery from 'shared_queries/repository/files.query.graphql';
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import { TREE_PAGE_SIZE } from '~/repository/constants';
@@ -178,8 +177,7 @@ export default {
return this.isFolder ? this.loadFolder() : this.loadBlob();
},
loadFolder() {
- const query = this.glFeatures.paginatedTreeGraphqlQuery ? paginatedTreeQuery : filesQuery;
- this.apolloQuery(query, {
+ this.apolloQuery(paginatedTreeQuery, {
projectPath: this.projectPath,
ref: this.ref,
path: this.path,
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 16dfe3cfb14..ffe8d5531f8 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -1,5 +1,4 @@
<script>
-import filesQuery from 'shared_queries/repository/files.query.graphql';
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import createFlash from '~/flash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -72,9 +71,6 @@ export default {
hasShowMore() {
return !this.clickedShowMore && this.pageLimitReached;
},
- paginatedTreeEnabled() {
- return this.glFeatures.paginatedTreeGraphqlQuery;
- },
},
watch: {
@@ -101,7 +97,7 @@ export default {
return this.$apollo
.query({
- query: this.paginatedTreeEnabled ? paginatedTreeQuery : filesQuery,
+ query: paginatedTreeQuery,
variables: {
projectPath: this.projectPath,
ref: this.ref,
@@ -114,20 +110,19 @@ export default {
if (data.errors) throw data.errors;
if (!data?.project?.repository || originalPath !== (this.path || '/')) return;
- const pageInfo = this.paginatedTreeEnabled
- ? data.project.repository.paginatedTree.pageInfo
- : this.hasNextPage(data.project.repository.tree);
+ const {
+ project: {
+ repository: {
+ paginatedTree: { pageInfo },
+ },
+ },
+ } = data;
this.isLoadingFiles = false;
this.entries = Object.keys(this.entries).reduce(
(acc, key) => ({
...acc,
- [key]: this.normalizeData(
- key,
- this.paginatedTreeEnabled
- ? data.project.repository.paginatedTree.nodes[0][key]
- : data.project.repository.tree[key].edges,
- ),
+ [key]: this.normalizeData(key, data.project.repository.paginatedTree.nodes[0][key]),
}),
{},
);
@@ -149,9 +144,7 @@ export default {
});
},
normalizeData(key, data) {
- return this.entries[key].concat(
- this.paginatedTreeEnabled ? data.nodes : data.map(({ node }) => node),
- );
+ return this.entries[key].concat(data.nodes);
},
hasNextPage(data) {
return []
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index 0199b893453..11e5b5608cb 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -15,7 +15,6 @@ import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import {
SECONDARY_OPTIONS_TEXT,
@@ -165,9 +164,6 @@ export default {
},
})
.then((response) => {
- if (!this.replacePath) {
- trackFileUploadEvent('click_upload_modal_form_submit');
- }
visitUrl(response.data.filePath);
})
.catch(() => {
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 4892e54ebef..96d712ce9b4 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -64,7 +64,6 @@ const defaultClient = createDefaultClient(
/* eslint-enable @gitlab/require-i18n-strings */
},
},
- assumeImmutableResults: true,
},
);
diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js
index a2ddcbf0e4c..30c36dee48f 100644
--- a/app/assets/javascripts/repository/mixins/preload.js
+++ b/app/assets/javascripts/repository/mixins/preload.js
@@ -1,4 +1,3 @@
-import filesQuery from 'shared_queries/repository/files.query.graphql';
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import projectPathQuery from '../queries/project_path.query.graphql';
import getRefMixin from './get_ref';
@@ -22,7 +21,7 @@ export default {
return this.$apollo
.query({
- query: gon.features.paginatedTreeGraphqlQuery ? paginatedTreeQuery : filesQuery,
+ query: paginatedTreeQuery,
variables: {
projectPath: this.projectPath,
ref: this.ref,
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index 8e0b5e21ca3..cf3892802fd 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -31,6 +31,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
storedExternally
rawPath
replacePath
+ pipelineEditorPath
simpleViewer {
fileType
tooLarge
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 29642b6633f..48a15954035 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -3,6 +3,7 @@ export * from './api/projects_api';
export * from './api/user_api';
export * from './api/markdown_api';
export * from './api/bulk_imports_api';
+export * from './api/namespaces_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 381421cdc23..3c8533dd06d 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
import { hide, fixTitle } from '~/tooltips';
+import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale';
@@ -130,8 +131,10 @@ Sidebar.prototype.openDropdown = function (blockOrName) {
// Wait for the sidebar to trigger('click') open
// so it doesn't cause our dropdown to close preemptively
setTimeout(() => {
- $block.find('.js-sidebar-dropdown-toggle').trigger('click');
- });
+ if (!gon.features?.labelsWidget && !$block.hasClass('labels-select-wrapper')) {
+ $block.find('.js-sidebar-dropdown-toggle').trigger('click');
+ }
+ }, DEBOUNCE_DROPDOWN_DELAY);
};
Sidebar.prototype.setCollapseAfterUpdate = function ($block) {
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 c8513a0b803..3edb658eaf5 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -1,18 +1,26 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlBadge, GlLink } from '@gitlab/ui';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber, sprintf, __ } from '~/locale';
+import { sprintf, __ } from '~/locale';
+
+import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
-import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue';
+import RunnerTypeTabs from '../components/runner_type_tabs.vue';
+
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
-import { typeTokenConfig } from '../components/search_tokens/type_token_config';
-import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
+import {
+ ADMIN_FILTERED_SEARCH_NAMESPACE,
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ I18N_FETCH_ERROR,
+} from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import {
fromUrlQueryToSearch,
@@ -24,19 +32,37 @@ import { captureException } from '../sentry_utils';
export default {
name: 'AdminRunnersApp',
components: {
+ GlBadge,
GlLink,
+ RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerList,
- RunnerManualSetupHelp,
RunnerName,
RunnerPagination,
+ RunnerTypeTabs,
},
props: {
+ registrationToken: {
+ type: String,
+ required: true,
+ },
activeRunnersCount: {
- type: Number,
+ type: String,
required: true,
},
- registrationToken: {
+ allRunnersCount: {
+ type: String,
+ required: true,
+ },
+ instanceRunnersCount: {
+ type: String,
+ required: true,
+ },
+ groupRunnersCount: {
+ type: String,
+ required: true,
+ },
+ projectRunnersCount: {
type: String,
required: true,
},
@@ -86,13 +112,12 @@ export default {
},
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
- active_runners_count: formatNumber(this.activeRunnersCount),
+ active_runners_count: this.activeRunnersCount,
});
},
searchTokens() {
return [
statusTokenConfig,
- typeTokenConfig,
{
...tagTokenConfig,
recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
@@ -116,6 +141,20 @@ export default {
this.reportToSentry(error);
},
methods: {
+ tabCount({ runnerType }) {
+ switch (runnerType) {
+ case null:
+ return this.allRunnersCount;
+ case INSTANCE_TYPE:
+ return this.instanceRunnersCount;
+ case GROUP_TYPE:
+ return this.groupRunnersCount;
+ case PROJECT_TYPE:
+ return this.projectRunnersCount;
+ default:
+ return null;
+ }
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -126,10 +165,30 @@ export default {
</script>
<template>
<div>
- <runner-manual-setup-help
- :registration-token="registrationToken"
- :type="$options.INSTANCE_TYPE"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
+ >
+ <runner-type-tabs
+ v-model="search"
+ class="gl-w-full"
+ content-class="gl-display-none"
+ nav-class="gl-border-none!"
+ >
+ <template #title="{ tab }">
+ {{ tab.title }}
+ <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm">
+ {{ tabCount(tab) }}
+ </gl-badge>
+ </template>
+ </runner-type-tabs>
+
+ <registration-dropdown
+ class="gl-w-full gl-sm-w-auto gl-mr-auto"
+ :registration-token="registrationToken"
+ :type="$options.INSTANCE_TYPE"
+ right
+ />
+ </div>
<runner-filtered-search-bar
v-model="search"
diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js
index 1eec1019b73..62da6cbfa2b 100644
--- a/app/assets/javascripts/runner/admin_runners/index.js
+++ b/app/assets/javascripts/runner/admin_runners/index.js
@@ -1,8 +1,10 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import AdminRunnersApp from './admin_runners_app.vue';
+Vue.use(GlToast);
Vue.use(VueApollo);
export const initAdminRunners = (selector = '#js-admin-runners') => {
@@ -14,15 +16,19 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
// TODO `activeRunnersCount` should be implemented using a GraphQL API
// https://gitlab.com/gitlab-org/gitlab/-/issues/333806
- const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset;
+ const {
+ runnerInstallHelpPage,
+ registrationToken,
+
+ activeRunnersCount,
+ allRunnersCount,
+ instanceRunnersCount,
+ groupRunnersCount,
+ projectRunnersCount,
+ } = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
return new Vue({
@@ -34,8 +40,15 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
render(h) {
return h(AdminRunnersApp, {
props: {
- activeRunnersCount: parseInt(activeRunnersCount, 10),
registrationToken,
+
+ // All runner counts are returned as formatted
+ // strings, we do not use `parseInt`.
+ activeRunnersCount,
+ allRunnersCount,
+ instanceRunnersCount,
+ groupRunnersCount,
+ projectRunnersCount,
},
});
},
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 e26bdbf1aea..c4bddb7b398 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -3,7 +3,7 @@ import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
-import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
+import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
const i18n = {
@@ -71,7 +71,7 @@ export default {
runnerUpdate: { errors },
},
} = await this.$apollo.mutate({
- mutation: runnerUpdateMutation,
+ mutation: runnerActionsUpdateMutation,
variables: {
input: {
id: this.runner.id,
diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
new file mode 100644
index 00000000000..9ba1192bc8c
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+
+import RunnerContactedStateBadge from '../runner_contacted_state_badge.vue';
+import RunnerPausedBadge from '../runner_paused_badge.vue';
+
+import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants';
+
+export default {
+ components: {
+ RunnerContactedStateBadge,
+ RunnerPausedBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ paused() {
+ return !this.runner.active;
+ },
+ },
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_PAUSED_RUNNER_DESCRIPTION,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <runner-contacted-state-badge :runner="runner" size="sm" />
+ <runner-paused-badge v-if="paused" size="sm" />
+ </div>
+</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 886b5cb29fc..3b476997915 100644
--- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
@@ -1,11 +1,21 @@
<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import RunnerName from '../runner_name.vue';
+import RunnerTypeBadge from '../runner_type_badge.vue';
+
+import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../../constants';
export default {
components: {
+ GlIcon,
TooltipOnTruncate,
RunnerName,
+ RunnerTypeBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
runner: {
@@ -14,10 +24,19 @@ export default {
},
},
computed: {
+ runnerType() {
+ return this.runner.runnerType;
+ },
+ locked() {
+ return this.runner.locked;
+ },
description() {
return this.runner.description;
},
},
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ },
};
</script>
@@ -26,6 +45,14 @@ export default {
<slot :runner="runner" name="runner-name">
<runner-name :runner="runner" />
</slot>
+
+ <runner-type-badge :type="runnerType" size="sm" />
+ <gl-icon
+ v-if="locked"
+ v-gl-tooltip
+ :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 }}
diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
deleted file mode 100644
index c8cb0bf6088..00000000000
--- a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<script>
-import { GlTooltipDirective } from '@gitlab/ui';
-import RunnerTypeBadge from '../runner_type_badge.vue';
-import RunnerStateLockedBadge from '../runner_state_locked_badge.vue';
-import RunnerStatePausedBadge from '../runner_state_paused_badge.vue';
-import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants';
-
-export default {
- components: {
- RunnerTypeBadge,
- RunnerStateLockedBadge,
- RunnerStatePausedBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- runner: {
- type: Object,
- required: true,
- },
- },
- computed: {
- runnerType() {
- return this.runner.runnerType;
- },
- locked() {
- return this.runner.locked;
- },
- paused() {
- return !this.runner.active;
- },
- },
- i18n: {
- I18N_LOCKED_RUNNER_DESCRIPTION,
- I18N_PAUSED_RUNNER_DESCRIPTION,
- },
-};
-</script>
-
-<template>
- <div>
- <runner-type-badge :type="runnerType" size="sm" />
- <runner-state-locked-badge v-if="locked" size="sm" />
- <runner-state-paused-badge v-if="paused" size="sm" />
- </div>
-</template>
diff --git a/app/assets/javascripts/runner/components/helpers/masked_value.vue b/app/assets/javascripts/runner/components/helpers/masked_value.vue
deleted file mode 100644
index feccb37de81..00000000000
--- a/app/assets/javascripts/runner/components/helpers/masked_value.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- value: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- isMasked: true,
- };
- },
- computed: {
- label() {
- if (this.isMasked) {
- return __('Click to reveal');
- }
- return __('Click to hide');
- },
- icon() {
- if (this.isMasked) {
- return 'eye';
- }
- return 'eye-slash';
- },
- displayedValue() {
- if (this.isMasked && this.value?.length) {
- return '*'.repeat(this.value.length);
- }
- return this.value;
- },
- },
- methods: {
- toggleMasked() {
- this.isMasked = !this.isMasked;
- },
- },
-};
-</script>
-<template>
- <span
- >{{ displayedValue }}
- <gl-button
- :aria-label="label"
- :icon="icon"
- class="gl-text-body!"
- data-testid="toggle-masked"
- variant="link"
- @click="toggleMasked"
- />
- </span>
-</template>
diff --git a/app/assets/javascripts/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/runner/components/registration/registration_dropdown.vue
new file mode 100644
index 00000000000..3fbe3c1be74
--- /dev/null
+++ b/app/assets/javascripts/runner/components/registration/registration_dropdown.vue
@@ -0,0 +1,112 @@
+<script>
+import {
+ GlFormGroup,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlDropdownDivider,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
+import RegistrationToken from './registration_token.vue';
+import RegistrationTokenResetDropdownItem from './registration_token_reset_dropdown_item.vue';
+
+export default {
+ i18n: {
+ showInstallationInstructions: s__(
+ 'Runners|Show runner installation and registration instructions',
+ ),
+ registrationToken: s__('Runners|Registration token'),
+ },
+ components: {
+ GlFormGroup,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlDropdownDivider,
+ RegistrationToken,
+ RunnerInstructionsModal,
+ RegistrationTokenResetDropdownItem,
+ },
+ props: {
+ registrationToken: {
+ type: String,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ validator(type) {
+ return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type);
+ },
+ },
+ },
+ data() {
+ return {
+ currentRegistrationToken: this.registrationToken,
+ instructionsModalOpened: false,
+ };
+ },
+ computed: {
+ dropdownText() {
+ switch (this.type) {
+ case INSTANCE_TYPE:
+ return s__('Runners|Register an instance runner');
+ case GROUP_TYPE:
+ return s__('Runners|Register a group runner');
+ case PROJECT_TYPE:
+ return s__('Runners|Register a project runner');
+ default:
+ return s__('Runners|Register a runner');
+ }
+ },
+ },
+ methods: {
+ onShowInstructionsClick() {
+ // Rendering the modal on demand, to avoid
+ // loading instructions prematurely from API.
+ this.instructionsModalOpened = true;
+
+ this.$nextTick(() => {
+ // $refs.runnerInstructionsModal is defined in
+ // the tick after the modal is rendered
+ this.$refs.runnerInstructionsModal.show();
+ });
+ },
+ onTokenReset(token) {
+ this.currentRegistrationToken = token;
+
+ this.$refs.runnerRegistrationDropdown.hide(true);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="runnerRegistrationDropdown"
+ menu-class="gl-w-auto!"
+ :text="dropdownText"
+ variant="confirm"
+ v-bind="$attrs"
+ >
+ <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick">
+ {{ $options.i18n.showInstallationInstructions }}
+ <runner-instructions-modal
+ v-if="instructionsModalOpened"
+ ref="runnerInstructionsModal"
+ :registration-token="registrationToken"
+ data-testid="runner-instructions-modal"
+ />
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-form class="gl-p-4!">
+ <gl-form-group class="gl-mb-0" :label="$options.i18n.registrationToken">
+ <registration-token :value="currentRegistrationToken" />
+ </gl-form-group>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" />
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/runner/components/registration/registration_token.vue b/app/assets/javascripts/runner/components/registration/registration_token.vue
new file mode 100644
index 00000000000..d54a66ff0e4
--- /dev/null
+++ b/app/assets/javascripts/runner/components/registration/registration_token.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+
+export default {
+ components: {
+ GlButtonGroup,
+ GlButton,
+ ModalCopyButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ 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() {
+ // value already in the clipboard, simply notify the user
+ this.$toast?.show(s__('Runners|Registration token copied!'));
+ },
+ },
+ i18n: {
+ copyLabel: 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>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
index cdf14abd4f9..3bb15bff8d8 100644
--- a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue
+++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -1,17 +1,18 @@
<script>
-import { GlButton } from '@gitlab/ui';
-import createFlash, { FLASH_TYPES } from '~/flash';
+import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import createFlash from '~/flash';
import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
export default {
name: 'RunnerRegistrationTokenReset',
components: {
- GlButton,
+ GlDropdownItem,
+ GlLoadingIcon,
},
inject: {
groupId: {
@@ -95,10 +96,7 @@ export default {
this.reportToSentry(error);
},
onSuccess(token) {
- createFlash({
- message: s__('Runners|New registration token generated!'),
- type: FLASH_TYPES.SUCCESS,
- });
+ this.$toast?.show(s__('Runners|New registration token generated!'));
this.$emit('tokenReset', token);
},
reportToSentry(error) {
@@ -108,7 +106,8 @@ export default {
};
</script>
<template>
- <gl-button :loading="loading" @click="resetToken">
+ <gl-dropdown-item @click.capture.native.stop="resetToken">
{{ __('Reset registration token') }}
- </gl-button>
+ <gl-loading-icon v-if="loading" inline />
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue b/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue
new file mode 100644
index 00000000000..b4727f832f8
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import {
+ I18N_ONLINE_RUNNER_DESCRIPTION,
+ I18N_OFFLINE_RUNNER_DESCRIPTION,
+ I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
+ STATUS_ONLINE,
+ STATUS_OFFLINE,
+ STATUS_NOT_CONNECTED,
+} from '../constants';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ contactedAtTimeAgo() {
+ if (this.runner.contactedAt) {
+ return getTimeago().format(this.runner.contactedAt);
+ }
+ return null;
+ },
+ badge() {
+ switch (this.runner.status) {
+ case STATUS_ONLINE:
+ return {
+ variant: 'success',
+ label: s__('Runners|online'),
+ tooltip: sprintf(I18N_ONLINE_RUNNER_DESCRIPTION, {
+ timeAgo: this.contactedAtTimeAgo,
+ }),
+ };
+ case STATUS_OFFLINE:
+ return {
+ variant: 'muted',
+ label: s__('Runners|offline'),
+ tooltip: sprintf(I18N_OFFLINE_RUNNER_DESCRIPTION, {
+ timeAgo: this.contactedAtTimeAgo,
+ }),
+ };
+ case STATUS_NOT_CONNECTED:
+ return {
+ variant: 'muted',
+ label: s__('Runners|not connected'),
+ tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
+ };
+ default:
+ return null;
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs">
+ {{ badge.label }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
index e04ca8ddca0..a9dfec35479 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -2,6 +2,7 @@
import { cloneDeep } from 'lodash';
import { __ } from '~/locale';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import { searchValidator } from '~/runner/runner_search_utils';
import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants';
const sortOptions = [
@@ -31,9 +32,12 @@ export default {
value: {
type: Object,
required: true,
- validator(val) {
- return Array.isArray(val?.filters) && typeof val?.sort === 'string';
- },
+ validator: searchValidator,
+ },
+ tokens: {
+ type: Array,
+ required: false,
+ default: () => [],
},
namespace: {
type: String,
@@ -43,7 +47,7 @@ export default {
data() {
// filtered_search_bar_root.vue may mutate the inital
// filters. Use `cloneDeep` to prevent those mutations
- // from affecting this component
+ // from affecting this component
const { filters, sort } = cloneDeep(this.value);
return {
initialFilterValue: filters,
@@ -52,19 +56,17 @@ export default {
},
methods: {
onFilter(filters) {
- const { sort } = this.value;
-
+ // Apply new filters, from page 1
this.$emit('input', {
+ ...this.value,
filters,
- sort,
pagination: { page: 1 },
});
},
onSort(sort) {
- const { filters } = this.value;
-
+ // Apply new sort, from page 1
this.$emit('input', {
- filters,
+ ...this.value,
sort,
pagination: { page: 1 },
});
@@ -74,13 +76,16 @@ export default {
};
</script>
<template>
- <div>
+ <div
+ class="gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1"
+ >
<filtered-search
v-bind="$attrs"
:namespace="namespace"
recent-searches-storage-key="runners-search"
:sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue"
+ :tokens="tokens"
:initial-sort-by="initialSortBy"
:search-input-placeholder="__('Search or filter results...')"
data-testid="runners-filtered-search"
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 3f6ea389288..f8dbc469c22 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,12 +1,11 @@
<script>
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { formatNumber, __, s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import { RUNNER_JOB_COUNT_LIMIT } from '../constants';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
-import RunnerTypeCell from './cells/runner_type_cell.vue';
+import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
const tableField = ({ key, label = '', width = 10 }) => {
@@ -37,7 +36,7 @@ export default {
RunnerActionsCell,
RunnerSummaryCell,
RunnerTags,
- RunnerTypeCell,
+ RunnerStatusCell,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -54,18 +53,6 @@ export default {
},
},
methods: {
- formatProjectCount(projectCount) {
- if (projectCount === null) {
- return __('n/a');
- }
- return formatNumber(projectCount);
- },
- formatJobCount(jobCount) {
- if (jobCount > RUNNER_JOB_COUNT_LIMIT) {
- return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`;
- }
- return formatNumber(jobCount);
- },
runnerTrAttr(runner) {
if (runner) {
return {
@@ -76,13 +63,11 @@ export default {
},
},
fields: [
- tableField({ key: 'type', label: __('Type/State') }),
- tableField({ key: 'summary', label: s__('Runners|Runner'), width: 30 }),
+ tableField({ key: 'status', label: s__('Runners|Status') }),
+ tableField({ key: 'summary', label: s__('Runners|Runner ID'), width: 30 }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'ipAddress', label: __('IP Address') }),
- tableField({ key: 'projectCount', label: __('Projects'), width: 5 }),
- tableField({ key: 'jobCount', label: __('Jobs'), width: 5 }),
- tableField({ key: 'tagList', label: __('Tags') }),
+ tableField({ key: 'tagList', label: __('Tags'), width: 20 }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
tableField({ key: 'actions', label: '' }),
],
@@ -103,8 +88,8 @@ export default {
<gl-skeleton-loader v-for="i in 4" :key="i" />
</template>
- <template #cell(type)="{ item }">
- <runner-type-cell :runner="item" />
+ <template #cell(status)="{ item }">
+ <runner-status-cell :runner="item" />
</template>
<template #cell(summary)="{ item, index }">
@@ -123,14 +108,6 @@ export default {
{{ ipAddress }}
</template>
- <template #cell(projectCount)="{ item: { projectCount } }">
- {{ formatProjectCount(projectCount) }}
- </template>
-
- <template #cell(jobCount)="{ item: { jobCount } }">
- {{ formatJobCount(jobCount) }}
- </template>
-
<template #cell(tagList)="{ item: { tagList } }">
<runner-tags :tag-list="tagList" size="sm" />
</template>
diff --git a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
deleted file mode 100644
index 475d362bb52..00000000000
--- a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
+++ /dev/null
@@ -1,108 +0,0 @@
-<script>
-import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import MaskedValue from '~/runner/components/helpers/masked_value.vue';
-import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
-
-export default {
- components: {
- GlLink,
- GlSprintf,
- ClipboardButton,
- MaskedValue,
- RunnerInstructions,
- RunnerRegistrationTokenReset,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- inject: {
- runnerInstallHelpPage: {
- default: null,
- },
- },
- props: {
- registrationToken: {
- type: String,
- required: true,
- },
- type: {
- type: String,
- required: true,
- validator(type) {
- return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type);
- },
- },
- },
- data() {
- return {
- currentRegistrationToken: this.registrationToken,
- };
- },
- computed: {
- rootUrl() {
- return gon.gitlab_url || '';
- },
- typeName() {
- switch (this.type) {
- case INSTANCE_TYPE:
- return s__('Runners|shared');
- case GROUP_TYPE:
- return s__('Runners|group');
- case PROJECT_TYPE:
- return s__('Runners|specific');
- default:
- return '';
- }
- },
- },
- methods: {
- onTokenReset(token) {
- this.currentRegistrationToken = token;
- },
- },
-};
-</script>
-
-<template>
- <div class="bs-callout">
- <h5 data-testid="runner-help-title">
- <gl-sprintf :message="__('Set up a %{type} runner manually')">
- <template #type>
- {{ typeName }}
- </template>
- </gl-sprintf>
- </h5>
-
- <ol>
- <li>
- <gl-link :href="runnerInstallHelpPage" data-testid="runner-help-link" target="_blank">
- {{ __("Install GitLab Runner and ensure it's running.") }}
- </gl-link>
- </li>
- <li>
- {{ __('Register the runner with this URL:') }}
- <br />
-
- <code data-testid="coordinator-url">{{ rootUrl }}</code>
- <clipboard-button :title="__('Copy URL')" :text="rootUrl" />
- </li>
- <li>
- {{ __('And this registration token:') }}
- <br />
-
- <code data-testid="registration-token"
- ><masked-value :value="currentRegistrationToken"
- /></code>
- <clipboard-button :title="__('Copy token')" :text="currentRegistrationToken" />
- </li>
- </ol>
-
- <runner-registration-token-reset :type="type" @tokenReset="onTokenReset" />
-
- <runner-instructions />
- </div>
-</template>
diff --git a/app/assets/javascripts/runner/components/runner_state_paused_badge.vue b/app/assets/javascripts/runner/components/runner_paused_badge.vue
index d1e6fa05e4d..d1e6fa05e4d 100644
--- a/app/assets/javascripts/runner/components/runner_state_paused_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_paused_badge.vue
diff --git a/app/assets/javascripts/runner/components/runner_state_locked_badge.vue b/app/assets/javascripts/runner/components/runner_state_locked_badge.vue
deleted file mode 100644
index 458526010bc..00000000000
--- a/app/assets/javascripts/runner/components/runner_state_locked_badge.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<script>
-import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants';
-
-export default {
- components: {
- GlBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- i18n: {
- I18N_LOCKED_RUNNER_DESCRIPTION,
- },
-};
-</script>
-<template>
- <gl-badge
- v-gl-tooltip="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
- variant="warning"
- v-bind="$attrs"
- >
- {{ s__('Runners|locked') }}
- </gl-badge>
-</template>
diff --git a/app/assets/javascripts/runner/components/runner_tag.vue b/app/assets/javascripts/runner/components/runner_tag.vue
index 06562e618a8..6ad2023a866 100644
--- a/app/assets/javascripts/runner/components/runner_tag.vue
+++ b/app/assets/javascripts/runner/components/runner_tag.vue
@@ -1,11 +1,15 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui';
import { RUNNER_TAG_BADGE_VARIANT } from '../constants';
export default {
components: {
GlBadge,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlResizeObserver: GlResizeObserverDirective,
+ },
props: {
tag: {
type: String,
@@ -14,14 +18,39 @@ export default {
size: {
type: String,
required: false,
- default: 'md',
+ default: 'sm',
+ },
+ },
+ data() {
+ return {
+ overflowing: false,
+ };
+ },
+ computed: {
+ tooltip() {
+ if (this.overflowing) {
+ return this.tag;
+ }
+ return '';
+ },
+ },
+ methods: {
+ onResize() {
+ const { scrollWidth, offsetWidth } = this.$el;
+ this.overflowing = scrollWidth > offsetWidth;
},
},
RUNNER_TAG_BADGE_VARIANT,
};
</script>
<template>
- <gl-badge :size="size" :variant="$options.RUNNER_TAG_BADGE_VARIANT">
+ <gl-badge
+ v-gl-tooltip="tooltip"
+ v-gl-resize-observer="onResize"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ :size="size"
+ :variant="$options.RUNNER_TAG_BADGE_VARIANT"
+ >
{{ tag }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue
index aec0d8e2c66..8da5e33076f 100644
--- a/app/assets/javascripts/runner/components/runner_tags.vue
+++ b/app/assets/javascripts/runner/components/runner_tags.vue
@@ -14,13 +14,19 @@ export default {
size: {
type: String,
required: false,
- default: 'md',
+ default: 'sm',
},
},
};
</script>
<template>
<div>
- <runner-tag v-for="tag in tagList" :key="tag" :tag="tag" :size="size" />
+ <runner-tag
+ v-for="tag in tagList"
+ :key="tag"
+ class="gl-display-inline gl-mr-1"
+ :tag="tag"
+ :size="size"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_alert.vue b/app/assets/javascripts/runner/components/runner_type_alert.vue
index aa435aaa823..1400875a1d6 100644
--- a/app/assets/javascripts/runner/components/runner_type_alert.vue
+++ b/app/assets/javascripts/runner/components/runner_type_alert.vue
@@ -9,17 +9,14 @@ const ALERT_DATA = {
message: s__(
'Runners|This runner is available to all groups and projects in your GitLab instance.',
),
- variant: 'success',
anchor: 'shared-runners',
},
[GROUP_TYPE]: {
message: s__('Runners|This runner is available to all projects and subgroups in a group.'),
- variant: 'success',
anchor: 'group-runners',
},
[PROJECT_TYPE]: {
message: s__('Runners|This runner is associated with one or more projects.'),
- variant: 'info',
anchor: 'specific-runners',
},
};
@@ -50,7 +47,7 @@ export default {
};
</script>
<template>
- <gl-alert v-if="alert" :variant="alert.variant" :dismissible="false">
+ <gl-alert v-if="alert" variant="info" :dismissible="false">
{{ alert.message }}
<gl-link :href="helpHref">{{ __('Learn more.') }}</gl-link>
</gl-alert>
diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue
index 1a61b80184b..b885dcefdcb 100644
--- a/app/assets/javascripts/runner/components/runner_type_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_type_badge.vue
@@ -12,17 +12,14 @@ import {
const BADGE_DATA = {
[INSTANCE_TYPE]: {
- variant: 'success',
text: s__('Runners|shared'),
tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION,
},
[GROUP_TYPE]: {
- variant: 'success',
text: s__('Runners|group'),
tooltip: I18N_GROUP_RUNNER_DESCRIPTION,
},
[PROJECT_TYPE]: {
- variant: 'info',
text: s__('Runners|specific'),
tooltip: I18N_PROJECT_RUNNER_DESCRIPTION,
},
@@ -53,7 +50,7 @@ export default {
};
</script>
<template>
- <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs">
+ <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" variant="info" v-bind="$attrs">
{{ badge.text }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue
new file mode 100644
index 00000000000..b767dafaccf
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlTabs, GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { searchValidator } from '~/runner/runner_search_utils';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+
+const tabs = [
+ {
+ title: s__('Runners|All'),
+ runnerType: null,
+ },
+ {
+ title: s__('Runners|Instance'),
+ runnerType: INSTANCE_TYPE,
+ },
+ {
+ title: s__('Runners|Group'),
+ runnerType: GROUP_TYPE,
+ },
+ {
+ title: s__('Runners|Project'),
+ runnerType: PROJECT_TYPE,
+ },
+];
+
+export default {
+ components: {
+ GlTabs,
+ GlTab,
+ },
+ props: {
+ value: {
+ type: Object,
+ required: true,
+ validator: searchValidator,
+ },
+ },
+ methods: {
+ onTabSelected({ runnerType }) {
+ this.$emit('input', {
+ ...this.value,
+ runnerType,
+ pagination: { page: 1 },
+ });
+ },
+ isTabActive({ runnerType }) {
+ return runnerType === this.value.runnerType;
+ },
+ },
+ tabs,
+};
+</script>
+<template>
+ <gl-tabs v-bind="$attrs" data-testid="runner-type-tabs">
+ <gl-tab
+ v-for="tab in $options.tabs"
+ :key="`${tab.runnerType}`"
+ :active="isTabActive(tab)"
+ @click="onTabSelected(tab)"
+ >
+ <template #title>
+ <slot name="title" :tab="tab">{{ tab.title }}</slot>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+</template>
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 03dff5e61a5..9963048ae1d 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
@@ -10,23 +10,29 @@ import {
PARAM_KEY_STATUS,
} 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_NOT_CONNECTED, title: s__('Runners|Not connected') },
+];
+
export const statusTokenConfig = {
icon: 'status',
title: __('Status'),
type: PARAM_KEY_STATUS,
token: BaseToken,
unique: true,
- 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') },
-
- // Added extra quotes in this title to avoid splitting this value:
+ 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
- { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
- ],
- // TODO In principle we could support more complex search rules,
- // this can be added to a separate issue.
+ title: title.replace(' ', '\u00a0'),
+ })),
operators: OPERATOR_IS_ONLY,
};
diff --git a/app/assets/javascripts/runner/components/search_tokens/type_token_config.js b/app/assets/javascripts/runner/components/search_tokens/type_token_config.js
deleted file mode 100644
index 1da61c53386..00000000000
--- a/app/assets/javascripts/runner/components/search_tokens/type_token_config.js
+++ /dev/null
@@ -1,20 +0,0 @@
-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 { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, PARAM_KEY_RUNNER_TYPE } from '../../constants';
-
-export const typeTokenConfig = {
- icon: 'file-tree',
- title: __('Type'),
- type: PARAM_KEY_RUNNER_TYPE,
- token: BaseToken,
- unique: true,
- options: [
- { value: INSTANCE_TYPE, title: s__('Runners|instance') },
- { value: GROUP_TYPE, title: s__('Runners|group') },
- { value: PROJECT_TYPE, title: s__('Runners|project') },
- ],
- // TODO We should support more complex search rules,
- // search for multiple states (OR) or have NOT operators
- operators: OPERATOR_IS_ONLY,
-};
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index a2fb9d9efd8..3952e2398e0 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,21 +1,33 @@
import { s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
-export const RUNNER_JOB_COUNT_LIMIT = 1000;
export const GROUP_RUNNER_COUNT_LIMIT = 1000;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
+// Type
export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects');
export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
'Runners|Available to all projects and subgroups in the group',
);
export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
+
+// Status
+export const I18N_ONLINE_RUNNER_DESCRIPTION = s__(
+ 'Runners|Runner is online; last contact was %{timeAgo}',
+);
+export const I18N_OFFLINE_RUNNER_DESCRIPTION = s__(
+ 'Runners|No recent contact from this runner; last contact was %{timeAgo}',
+);
+export const I18N_NOT_CONNECTED_RUNNER_DESCRIPTION = s__(
+ 'Runners|This runner has never connected to this instance',
+);
+
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
-export const RUNNER_TAG_BADGE_VARIANT = 'info';
+export const RUNNER_TAG_BADGE_VARIANT = 'neutral';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
// Filtered search parameter names
diff --git a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql
new file mode 100644
index 00000000000..547cc43907c
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql
@@ -0,0 +1,14 @@
+#import "~/runner/graphql/runner_node.fragment.graphql"
+
+# Mutation for updates within the runners list via action
+# buttons (play, pause, ...), loads attributes shown in the
+# runner list.
+
+mutation runnerActionsUpdate($input: RunnerUpdateInput!) {
+ runnerUpdate(input: $input) {
+ runner {
+ ...RunnerNode
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
index 68d6f02f799..98f2dab26ca 100644
--- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
@@ -10,6 +10,5 @@ fragment RunnerNode on CiRunner {
locked
tagList
contactedAt
- jobCount
- projectCount
+ status
}
diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
index dcc7fdf24f1..ea622fd4958 100644
--- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
@@ -1,5 +1,8 @@
#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
+# Mutation for updates from the runner form, loads
+# attributes shown in the runner details.
+
mutation runnerUpdate($input: RunnerUpdateInput!) {
runnerUpdate(input: $input) {
runner {
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 4bb28796dfa..c3dfa885f27 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -5,14 +5,14 @@ import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, s__ } from '~/locale';
+import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
-import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue';
+import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
-import { typeTokenConfig } from '../components/search_tokens/type_token_config';
import {
I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE,
@@ -31,11 +31,12 @@ export default {
name: 'GroupRunnersApp',
components: {
GlLink,
+ RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerList,
- RunnerManualSetupHelp,
RunnerName,
RunnerPagination,
+ RunnerTypeTabs,
},
props: {
registrationToken: {
@@ -112,7 +113,7 @@ export default {
});
},
searchTokens() {
- return [statusTokenConfig, typeTokenConfig];
+ return [statusTokenConfig];
},
filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
@@ -144,7 +145,20 @@ export default {
<template>
<div>
- <runner-manual-setup-help :registration-token="registrationToken" :type="$options.GROUP_TYPE" />
+ <div class="gl-display-flex gl-align-items-center">
+ <runner-type-tabs
+ v-model="search"
+ content-class="gl-display-none"
+ nav-class="gl-border-none!"
+ />
+
+ <registration-dropdown
+ class="gl-ml-auto"
+ :registration-token="registrationToken"
+ :type="$options.GROUP_TYPE"
+ right
+ />
+ </div>
<runner-filtered-search-bar
v-model="search"
diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js
index 9545764c68d..60b7a7ab541 100644
--- a/app/assets/javascripts/runner/group_runners/index.js
+++ b/app/assets/javascripts/runner/group_runners/index.js
@@ -1,8 +1,10 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import GroupRunnersApp from './group_runners_app.vue';
+Vue.use(GlToast);
Vue.use(VueApollo);
export const initGroupRunners = (selector = '#js-group-runners') => {
@@ -21,12 +23,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
} = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
return new Vue({
diff --git a/app/assets/javascripts/runner/runner_details/index.js b/app/assets/javascripts/runner/runner_details/index.js
index 05e6f86869d..db8f239a3c3 100644
--- a/app/assets/javascripts/runner/runner_details/index.js
+++ b/app/assets/javascripts/runner/runner_details/index.js
@@ -15,12 +15,7 @@ export const initRunnerDetail = (selector = '#js-runner-details') => {
const { runnerId } = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
return new Vue({
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js
index 0a817ea0acf..b88023720e8 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_search_utils.js
@@ -18,6 +18,50 @@ import {
RUNNER_PAGE_SIZE,
} from './constants';
+/**
+ * The filters and sorting of the runners are built around
+ * an object called "search" that contains the current state
+ * of search in the UI. For example:
+ *
+ * ```
+ * const search = {
+ * // The current tab
+ * runnerType: 'INSTANCE_TYPE',
+ *
+ * // Filters in the search bar
+ * filters: [
+ * { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
+ * { type: 'filtered-search-term', value: { data: '' } },
+ * ],
+ *
+ * // Current sorting value
+ * sort: 'CREATED_DESC',
+ *
+ * // Pagination information
+ * pagination: { page: 1 },
+ * };
+ * ```
+ *
+ * An object in this format can be used to generate URLs
+ * with the search parameters or by runner components
+ * a input using a v-model.
+ *
+ * @module runner_search_utils
+ */
+
+/**
+ * Validates a search value
+ * @param {Object} search
+ * @returns {boolean} True if the value follows the search format.
+ */
+export const searchValidator = ({ runnerType, filters, sort }) => {
+ return (
+ (runnerType === null || typeof runnerType === 'string') &&
+ Array.isArray(filters) &&
+ typeof sort === 'string'
+ );
+};
+
const getPaginationFromParams = (params) => {
const page = parseInt(params[PARAM_KEY_PAGE], 10);
const after = params[PARAM_KEY_AFTER];
@@ -35,13 +79,20 @@ const getPaginationFromParams = (params) => {
};
};
+/**
+ * Takes a URL query and transforms it into a "search" object
+ * @param {String?} query
+ * @returns {Object} A search object
+ */
export const fromUrlQueryToSearch = (query = window.location.search) => {
const params = queryToObject(query, { gatherArrays: true });
+ const runnerType = params[PARAM_KEY_RUNNER_TYPE]?.[0] || null;
return {
+ runnerType,
filters: prepareTokens(
urlQueryToFilter(query, {
- filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG],
+ filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_TAG],
filteredSearchTermKey: PARAM_KEY_SEARCH,
}),
),
@@ -50,8 +101,15 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
};
};
+/**
+ * Takes a "search" object and transforms it into a URL.
+ *
+ * @param {Object} search
+ * @param {String} url
+ * @returns {String} New URL for the page
+ */
export const fromSearchToUrl = (
- { filters = [], sort = null, pagination = {} },
+ { runnerType = null, filters = [], sort = null, pagination = {} },
url = window.location.href,
) => {
const filterParams = {
@@ -65,6 +123,10 @@ export const fromSearchToUrl = (
}),
};
+ if (runnerType) {
+ filterParams[PARAM_KEY_RUNNER_TYPE] = [runnerType];
+ }
+
if (!filterParams[PARAM_KEY_SEARCH]) {
filterParams[PARAM_KEY_SEARCH] = null;
}
@@ -82,21 +144,31 @@ export const fromSearchToUrl = (
return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true);
};
-export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => {
+/**
+ * Takes a "search" object and transforms it into variables for runner a GraphQL query.
+ *
+ * @param {Object} search
+ * @returns {Object} Hash of filter values
+ */
+export const fromSearchToVariables = ({
+ runnerType = null,
+ filters = [],
+ sort = null,
+ pagination = {},
+} = {}) => {
const variables = {};
const queryObj = filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH,
});
- variables.search = queryObj[PARAM_KEY_SEARCH];
-
- // TODO Get more than one value when GraphQL API supports OR for "status" or "runner_type"
[variables.status] = queryObj[PARAM_KEY_STATUS] || [];
- [variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || [];
-
+ variables.search = queryObj[PARAM_KEY_SEARCH];
variables.tagList = queryObj[PARAM_KEY_TAG];
+ if (runnerType) {
+ variables.type = runnerType;
+ }
if (sort) {
variables.sort = sort;
}
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 99cf16c8350..5c7cbeac5b2 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -13,9 +13,9 @@ export default {
ConfidentialityFilter,
},
computed: {
- ...mapState(['query']),
+ ...mapState(['urlQuery', 'sidebarDirty']),
showReset() {
- return this.query.state || this.query.confidential;
+ return this.urlQuery.state || this.urlQuery.confidential;
},
},
methods: {
@@ -32,7 +32,7 @@ export default {
<status-filter />
<confidentiality-filter />
<div class="gl-display-flex gl-align-items-center gl-mt-3">
- <gl-button category="primary" variant="confirm" size="small" type="submit">
+ <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty">
{{ __('Apply') }}
</gl-button>
<gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
index 73911b9d319..aa7c26b8044 100644
--- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
@@ -1,7 +1,7 @@
<script>
import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { sprintf, s__ } from '~/locale';
+import { sprintf, __ } from '~/locale';
export default {
name: 'RadioFilter',
@@ -49,7 +49,7 @@ export default {
...mapActions(['setQuery']),
radioLabel(filter) {
return filter.value === this.ANY.value
- ? sprintf(s__('Any %{header}'), { header: this.filterData.header.toLowerCase() })
+ ? sprintf(__('Any %{header}'), { header: this.filterData.header.toLowerCase() })
: filter.label;
},
},
diff --git a/app/assets/javascripts/search/sidebar/constants/state_filter_data.js b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js
index 7c9a029ffe4..2f9f8a7cb46 100644
--- a/app/assets/javascripts/search/sidebar/constants/state_filter_data.js
+++ b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js
@@ -5,7 +5,7 @@ const header = __('Status');
const filters = {
ANY: {
label: __('Any'),
- value: 'all',
+ value: null,
},
OPEN: {
label: __('Open'),
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index be64a9278e3..a6af5644681 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -2,9 +2,9 @@ import Api from '~/api';
import createFlash from '~/flash';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
+import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
-import { loadDataFromLS, setFrequentItemToLS, mergeById } from './utils';
+import { loadDataFromLS, setFrequentItemToLS, mergeById, isSidebarDirty } from './utils';
export const fetchGroups = ({ commit }, search) => {
commit(types.REQUEST_GROUPS);
@@ -86,8 +86,12 @@ export const setFrequentProject = ({ state, commit }, item) => {
commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: frequentItems });
};
-export const setQuery = ({ commit }, { key, value }) => {
+export const setQuery = ({ state, commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
+
+ if (SIDEBAR_PARAMS.includes(key)) {
+ commit(types.SET_SIDEBAR_DIRTY, isSidebarDirty(state.query, state.urlQuery));
+ }
};
export const applyQuery = ({ state }) => {
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index 3abf7cac6ba..678bd82c7a6 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -1,3 +1,6 @@
+import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
+import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
+
export const MAX_FREQUENT_ITEMS = 5;
export const MAX_FREQUENCY = 5;
@@ -5,3 +8,5 @@ export const MAX_FREQUENCY = 5;
export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups';
export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects';
+
+export const SIDEBAR_PARAMS = [stateFilterData.filterParam, confidentialFilterData.filterParam];
diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js
index 5c1c29dc738..bf1e3e79cba 100644
--- a/app/assets/javascripts/search/store/mutation_types.js
+++ b/app/assets/javascripts/search/store/mutation_types.js
@@ -7,5 +7,6 @@ export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS';
export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR';
export const SET_QUERY = 'SET_QUERY';
+export const SET_SIDEBAR_DIRTY = 'SET_SIDEBAR_DIRTY';
export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS';
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
index 63156a89738..5d154fe3aa0 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -26,6 +26,9 @@ export default {
[types.SET_QUERY](state, { key, value }) {
state.query[key] = value;
},
+ [types.SET_SIDEBAR_DIRTY](state, value) {
+ state.sidebarDirty = value;
+ },
[types.LOAD_FREQUENT_ITEMS](state, { key, data }) {
state.frequentItems[key] = data;
},
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index 5b1429ccc97..d4005697f35 100644
--- a/app/assets/javascripts/search/store/state.js
+++ b/app/assets/javascripts/search/store/state.js
@@ -1,6 +1,8 @@
+import { cloneDeep } from 'lodash';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
const createState = ({ query }) => ({
+ urlQuery: cloneDeep(query),
query,
groups: [],
fetchingGroups: false,
@@ -10,5 +12,6 @@ const createState = ({ query }) => ({
[GROUPS_LOCAL_STORAGE_KEY]: [],
[PROJECTS_LOCAL_STORAGE_KEY]: [],
},
+ sidebarDirty: false,
});
export default createState;
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index b00b9bb0f2e..6b56ff0b5e5 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -1,5 +1,5 @@
import AccessorUtilities from '../../lib/utils/accessor';
-import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY } from './constants';
+import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY, SIDEBAR_PARAMS } from './constants';
function extractKeys(object, keyList) {
return Object.fromEntries(keyList.map((key) => [key, object[key]]));
@@ -80,3 +80,13 @@ export const mergeById = (inflatedData, storedData) => {
return { ...stored, ...data };
});
};
+
+export const isSidebarDirty = (currentQuery, urlQuery) => {
+ return SIDEBAR_PARAMS.some((param) => {
+ // userAddParam ensures we don't get a false dirty from null !== undefined
+ const userAddedParam = !urlQuery[param] && currentQuery[param];
+ const userChangedExistingParam = urlQuery[param] && urlQuery[param] !== currentQuery[param];
+
+ return userAddedParam || userChangedExistingParam;
+ });
+};
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index 6c70a8c33db..bc13150c99c 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
@@ -31,6 +31,7 @@ export default {
AutoDevOpsAlert,
AutoDevOpsEnabledAlert,
FeatureCard,
+ GlAlert,
GlLink,
GlSprintf,
GlTab,
@@ -79,6 +80,7 @@ export default {
data() {
return {
autoDevopsEnabledAlertDismissedProjects: [],
+ errorMessage: '',
};
},
computed: {
@@ -106,6 +108,12 @@ export default {
dismissedProjects.add(this.projectPath);
this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects);
},
+ onError(message) {
+ this.errorMessage = message;
+ },
+ dismissAlert() {
+ this.errorMessage = '';
+ },
},
autoDevopsEnabledAlertStorageKey: AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY,
};
@@ -113,6 +121,16 @@ export default {
<template>
<article>
+ <gl-alert
+ v-if="errorMessage"
+ sticky
+ class="gl-top-8 gl-z-index-1"
+ data-testid="manage-via-mr-error-alert"
+ variant="danger"
+ @dismiss="dismissAlert"
+ >
+ {{ errorMessage }}
+ </gl-alert>
<local-storage-sync
v-model="autoDevopsEnabledAlertDismissedProjects"
:storage-key="$options.autoDevopsEnabledAlertStorageKey"
@@ -174,6 +192,7 @@ export default {
data-testid="security-testing-card"
:feature="feature"
class="gl-mb-6"
+ @error="onError"
/>
</template>
</section-layout>
@@ -207,6 +226,7 @@ export default {
:key="feature.type"
:feature="feature"
class="gl-mb-6"
+ @error="onError"
/>
</template>
</section-layout>
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 6a282df99bf..9c80506549e 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -3,6 +3,7 @@ import { __, s__ } from '~/locale';
import {
REPORT_TYPE_SAST,
+ REPORT_TYPE_SAST_IAC,
REPORT_TYPE_DAST,
REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_SECRET_DETECTION,
@@ -16,6 +17,7 @@ import {
} from '~/vue_shared/security_reports/constants';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
+import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql';
import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
/**
@@ -30,6 +32,19 @@ export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sas
anchor: 'configuration',
});
+export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning');
+export const SAST_IAC_SHORT_NAME = s__('ciReport|IaC Scanning');
+export const SAST_IAC_DESCRIPTION = __(
+ 'Analyze your infrastructure as code configuration files for known vulnerabilities.',
+);
+export const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index');
+export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath(
+ 'user/application_security/iac_scanning/index',
+ {
+ anchor: 'configuration',
+ },
+);
+
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.');
@@ -141,6 +156,27 @@ export const securityFeatures = [
// https://gitlab.com/gitlab-org/gitlab/-/issues/331621
canEnableByMergeRequest: true,
},
+ ...(gon?.features?.configureIacScanningViaMr
+ ? [
+ {
+ name: SAST_IAC_NAME,
+ shortName: SAST_IAC_SHORT_NAME,
+ description: SAST_IAC_DESCRIPTION,
+ helpPath: SAST_IAC_HELP_PATH,
+ configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH,
+ type: REPORT_TYPE_SAST_IAC,
+
+ // This field is currently hardcoded because SAST IaC is always available.
+ // It will eventually come from the Backend, the progress is tracked in
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/331622
+ available: true,
+
+ // This field will eventually come from the backend, the progress is
+ // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
+ canEnableByMergeRequest: true,
+ },
+ ]
+ : []),
{
name: DAST_NAME,
shortName: DAST_SHORT_NAME,
@@ -242,6 +278,21 @@ export const featureToMutationMap = {
},
}),
},
+ ...(gon?.features?.configureIacScanningViaMr
+ ? {
+ [REPORT_TYPE_SAST_IAC]: {
+ mutationId: 'configureSastIac',
+ getMutationPayload: (projectPath) => ({
+ mutation: configureSastIacMutation,
+ variables: {
+ input: {
+ projectPath,
+ },
+ },
+ }),
+ },
+ }
+ : {}),
[REPORT_TYPE_SECRET_DETECTION]: {
mutationId: 'configureSecretDetection',
getMutationPayload: (projectPath) => ({
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 86afdbfeb8c..33d72b54f86 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -66,6 +66,11 @@ export default {
return Boolean(name && description && configurationText);
},
},
+ methods: {
+ onError(message) {
+ this.$emit('error', message);
+ },
+ },
i18n: {
enabled: s__('SecurityConfiguration|Enabled'),
notEnabled: s__('SecurityConfiguration|Not enabled'),
@@ -129,6 +134,7 @@ export default {
category="primary"
class="gl-mt-5"
:data-qa-selector="`${feature.type}_mr_button`"
+ @error="onError"
/>
<gl-button
diff --git a/app/assets/javascripts/security_configuration/graphql/configure_iac.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_iac.mutation.graphql
new file mode 100644
index 00000000000..26b826ef722
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/graphql/configure_iac.mutation.graphql
@@ -0,0 +1,6 @@
+mutation configureSastIac($input: ConfigureSastIacInput!) {
+ configureSastIac(input: $input) {
+ successPath
+ errors
+ }
+}
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index 60d2c0d4e5a..a8623b468f2 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -14,7 +14,7 @@ export const initSecurityConfiguration = (el) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
const {
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 b1c8f6ef22e..0021fe909e5 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
@@ -339,7 +339,7 @@ export default {
</div>
<div class="gl-display-flex">
<span class="gl-text-gray-600 gl-ml-5">
- {{ s__('SetStatusModal|A busy indicator is shown next to your name and avatar.') }}
+ {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
</span>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index e41bb41dc05..bdd014163a0 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -39,6 +39,9 @@ export default {
assignSelf() {
this.$emit('assign-self');
},
+ toggleAttentionRequested(data) {
+ this.$emit('toggle-attention-requested', data);
+ },
},
};
</script>
@@ -58,7 +61,12 @@ export default {
</template>
</span>
- <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" />
+ <uncollapsed-assignee-list
+ v-else
+ :users="sortedAssigness"
+ :issuable-type="issuableType"
+ @toggle-attention-requested="toggleAttentionRequested"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index 80caebad39d..a3379784bc1 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -32,6 +32,11 @@ export default {
return this.users.length === 0;
},
},
+ methods: {
+ toggleAttentionRequested(data) {
+ this.$emit('toggle-attention-requested', data);
+ },
+ },
};
</script>
@@ -61,6 +66,7 @@ export default {
:users="users"
:issuable-type="issuableType"
class="gl-text-gray-800 gl-mt-2 hide-collapsed"
+ @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index c6877226b7d..453dd1b0580 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -125,6 +125,9 @@ export default {
availability: this.assigneeAvailabilityStatus[username] || '',
}));
},
+ toggleAttentionRequested(data) {
+ this.mediator.toggleAttentionRequested('assignee', data);
+ },
},
};
</script>
@@ -152,6 +155,7 @@ export default {
:editable="store.editable"
:issuable-type="issuableType"
@assign-self="assignSelf"
+ @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
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 c2ca87af9ce..8d5c3b2def3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -1,6 +1,8 @@
<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issue_show/constants';
import { __, sprintf } from '~/locale';
+import AttentionRequestedToggle from '../attention_requested_toggle.vue';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -8,9 +10,11 @@ const DEFAULT_RENDER_COUNT = 5;
export default {
components: {
+ AttentionRequestedToggle,
AssigneeAvatarLink,
UserNameWithStatus,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
users: {
type: Array,
@@ -32,6 +36,10 @@ export default {
return this.users[0];
},
hasOneUser() {
+ if (this.showVerticalList) {
+ return false;
+ }
+
return this.users.length === 1;
},
hiddenAssigneesLabel() {
@@ -45,6 +53,10 @@ export default {
return this.users.length - DEFAULT_RENDER_COUNT;
},
uncollapsedUsers() {
+ if (this.showVerticalList) {
+ return this.users;
+ }
+
const uncollapsedLength = this.showLess
? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
: this.users.length;
@@ -53,6 +65,12 @@ export default {
username() {
return `@${this.firstUser.username}`;
},
+ showVerticalList() {
+ return this.glFeatures.mrAttentionRequests && this.isMergeRequest;
+ },
+ isMergeRequest() {
+ return this.issuableType === IssuableType.MergeRequest;
+ },
},
methods: {
toggleShowLess() {
@@ -64,6 +82,9 @@ export default {
}
return u?.status?.availability || '';
},
+ toggleAttentionRequested(data) {
+ this.$emit('toggle-attention-requested', data);
+ },
},
};
</script>
@@ -84,11 +105,34 @@ export default {
<div v-else>
<div class="gl-display-flex gl-flex-wrap">
<div
- v-for="user in uncollapsedUsers"
+ v-for="(user, index) in uncollapsedUsers"
:key="user.id"
- class="user-item gl-display-inline-block"
+ :class="{
+ 'user-item': !showVerticalList,
+ 'gl-mb-3': index !== users.length - 1 && showVerticalList,
+ }"
+ class="gl-display-inline-block"
>
- <assignee-avatar-link :user="user" :issuable-type="issuableType" />
+ <attention-requested-toggle
+ v-if="showVerticalList && user.can_update_merge_request"
+ :user="user"
+ type="assignee"
+ @toggle-attention-requested="toggleAttentionRequested"
+ />
+ <assignee-avatar-link
+ :user="user"
+ :issuable-type="issuableType"
+ :tooltip-has-name="!showVerticalList"
+ >
+ <div
+ v-if="showVerticalList"
+ class="gl-ml-3 gl-line-height-normal gl-display-grid"
+ data-testid="username"
+ >
+ <user-name-with-status :name="user.name" :availability="userAvailability(user)" />
+ <span>@{{ user.username }}</span>
+ </div>
+ </assignee-avatar-link>
</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
new file mode 100644
index 00000000000..38ba468d197
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
+
+export default {
+ i18n: {
+ attentionRequestedReviewer: __('Request attention to review'),
+ attentionRequestedAssignee: __('Request attention'),
+ removeAttentionRequested: __('Remove attention request'),
+ },
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ type: {
+ type: String,
+ required: true,
+ },
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ computed: {
+ tooltipTitle() {
+ if (this.user.attention_requested) {
+ return this.$options.i18n.removeAttentionRequested;
+ }
+
+ return this.type === 'reviewer'
+ ? this.$options.i18n.attentionRequestedReviewer
+ : this.$options.i18n.attentionRequestedAssignee;
+ },
+ },
+ methods: {
+ toggleAttentionRequired() {
+ if (this.loading) return;
+
+ this.$root.$emit(BV_HIDE_TOOLTIP);
+ this.loading = true;
+ this.$emit('toggle-attention-requested', {
+ user: this.user,
+ callback: this.toggleAttentionRequiredComplete,
+ });
+ },
+ toggleAttentionRequiredComplete() {
+ this.loading = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-gl-tooltip.left.viewport="tooltipTitle">
+ <gl-button
+ :loading="loading"
+ :variant="user.attention_requested ? 'warning' : 'default'"
+ :icon="user.attention_requested ? 'star' : 'star-o'"
+ :aria-label="tooltipTitle"
+ size="small"
+ category="tertiary"
+ @click="toggleAttentionRequired"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index d5647619ea3..5cd4a1a5192 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -11,6 +11,7 @@ import { toLabelGid } from '~/sidebar/utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
+import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const mutationMap = {
@@ -48,6 +49,7 @@ export default {
return {
isLabelsSelectInProgress: false,
selectedLabels: this.initiallySelectedLabels,
+ LabelType,
};
},
methods: {
@@ -154,13 +156,12 @@ export default {
:footer-manage-label-title="__('Manage project labels')"
:labels-create-title="__('Create project label')"
:labels-filter-base-path="projectIssuesPath"
- :labels-select-in-progress="isLabelsSelectInProgress"
- :selected-labels="selectedLabels"
:variant="$options.variant"
:issuable-type="issuableType"
+ workspace-type="project"
+ :attr-workspace-path="fullPath"
+ :label-create-type="LabelType.project"
data-qa-selector="labels_block"
- @onLabelRemove="handleLabelRemove"
- @updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
</labels-select-widget>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
index 5729b958b5d..b07fd944ff9 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -49,6 +49,9 @@ export default {
requestReview(data) {
this.$emit('request-review', data);
},
+ toggleAttentionRequested(data) {
+ this.$emit('toggle-attention-requested', data);
+ },
},
};
</script>
@@ -70,6 +73,7 @@ export default {
:root-path="rootPath"
:issuable-type="issuableType"
@request-review="requestReview"
+ @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index e414aaf719b..2ea63219e92 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -88,6 +88,9 @@ export default {
requestReview(data) {
this.mediator.requestReview(data);
},
+ toggleAttentionRequested(data) {
+ this.mediator.toggleAttentionRequested('reviewer', data);
+ },
},
};
</script>
@@ -106,6 +109,7 @@ export default {
:editable="store.editable"
:issuable-type="issuableType"
@request-review="requestReview"
+ @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
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 2922008cfb2..adaf1b65f3f 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,6 +1,8 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, sprintf, s__ } from '~/locale';
+import AttentionRequestedToggle from '../attention_requested_toggle.vue';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
@@ -14,10 +16,12 @@ export default {
GlButton,
GlIcon,
ReviewerAvatarLink,
+ AttentionRequestedToggle,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
users: {
type: Array,
@@ -76,6 +80,9 @@ export default {
this.loadingStates[userId] = null;
}
},
+ toggleAttentionRequested(data) {
+ this.$emit('toggle-attention-requested', data);
+ },
},
LOADING_STATE,
SUCCESS_STATE,
@@ -90,6 +97,12 @@ export default {
:class="{ 'gl-mb-3': index !== users.length - 1 }"
data-testid="reviewer"
>
+ <attention-requested-toggle
+ v-if="glFeatures.mrAttentionRequests && user.can_update_merge_request"
+ :user="user"
+ type="reviewer"
+ @toggle-attention-requested="toggleAttentionRequested"
+ />
<reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType">
<div class="gl-ml-3 gl-line-height-normal gl-display-grid">
<span>{{ user.name }}</span>
@@ -113,7 +126,9 @@ export default {
data-testid="re-request-success"
/>
<gl-button
- v-else-if="user.can_update_merge_request && user.reviewed"
+ v-else-if="
+ user.can_update_merge_request && user.reviewed && !glFeatures.mrAttentionRequests
+ "
v-gl-tooltip.left
:title="$options.i18n.reRequestReview"
:aria-label="$options.i18n.reRequestReview"
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 8f4d5406da8..0ba8c4f8907 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -370,6 +370,7 @@ export default {
:loading="loading"
class="gl-w-full"
toggle-class="gl-max-w-100"
+ block
@shown="setFocus"
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 22adbd79ef6..056b3e98a1c 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -134,7 +134,7 @@ export default {
v-if="canUpdate && !initialLoading && canEdit"
category="tertiary"
size="small"
- class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2"
+ class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2 shortcut-sidebar-dropdown-toggle"
data-testid="edit-button"
:data-track-action="tracking.event"
:data-track-label="tracking.label"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index d4a8abb81a8..5d4031ac68b 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { GlLoadingIcon, GlTableLite } from '@gitlab/ui';
import createFlash from '~/flash';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -12,7 +12,7 @@ const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
export default {
components: {
GlLoadingIcon,
- GlTable,
+ GlTableLite,
},
inject: ['issuableType'],
props: {
@@ -89,7 +89,7 @@ export default {
<template>
<div>
<div v-if="isLoading"><gl-loading-icon size="md" /></div>
- <gl-table v-else :items="report" :fields="$options.fields" foot-clone>
+ <gl-table-lite v-else :items="report" :fields="$options.fields" foot-clone>
<template #cell(spentAt)="{ item: { spentAt } }">
<div>{{ formatDate(spentAt) }}</div>
</template>
@@ -111,6 +111,6 @@ export default {
<div>{{ getSummary(summary, note) }}</div>
</template>
<template #foot(note)>&nbsp;</template>
- </gl-table>
+ </gl-table-lite>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index e593973da82..ac34a75ac5c 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,4 +1,5 @@
-import { IssuableType } from '~/issue_show/constants';
+import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
+import { IssuableType, WorkspaceType } from '~/issue_show/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
@@ -29,11 +30,14 @@ import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_conf
import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql';
import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql';
+import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql';
+import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql';
import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
+import mergeRequestLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql';
import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql';
import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
@@ -109,14 +113,30 @@ export const referenceQueries = {
},
};
-export const labelsQueries = {
+export const workspaceLabelsQueries = {
+ [WorkspaceType.project]: {
+ query: projectLabelsQuery,
+ },
+ [WorkspaceType.group]: {
+ query: groupLabelsQuery,
+ },
+};
+
+export const issuableLabelsQueries = {
[IssuableType.Issue]: {
issuableQuery: issueLabelsQuery,
- workspaceQuery: projectLabelsQuery,
+ mutation: updateIssueLabelsMutation,
+ mutationName: 'updateIssue',
+ },
+ [IssuableType.MergeRequest]: {
+ issuableQuery: mergeRequestLabelsQuery,
+ mutation: updateMergeRequestLabelsMutation,
+ mutationName: 'mergeRequestSetLabels',
},
[IssuableType.Epic]: {
issuableQuery: epicLabelsQuery,
- workspaceQuery: groupLabelsQuery,
+ mutation: updateEpicLabelsMutation,
+ mutationName: 'updateEpic',
},
};
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index 1a806a051b7..6a670db2d38 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -25,7 +25,6 @@ export const defaultClient = createDefaultClient(resolvers, {
cacheConfig: {
fragmentMatcher,
},
- assumeImmutableResults: true,
});
export const apolloProvider = new VueApollo({
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 9f5a2f4ebb0..898be4a97ce 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -260,6 +260,10 @@ export function mountSidebarLabels() {
variant: DropdownVariant.Sidebar,
canUpdate: parseBoolean(el.dataset.canEdit),
isClassicSidebar: true,
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
},
render: (createElement) => createElement(SidebarLabels),
});
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
index 2aff7da4605..dd85eb1631b 100644
--- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
@@ -1,4 +1,4 @@
-query($fullPath: ID!, $iid: String!) {
+query sidebarDetails($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
iid
diff --git a/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql b/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql
new file mode 100644
index 00000000000..a9f4af6e1b9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql
@@ -0,0 +1,7 @@
+mutation mergeRequestToggleAttentionRequested($projectPath: ID!, $iid: String!, $userId: ID!) {
+ mergeRequestToggleAttentionRequested(
+ input: { projectPath: $projectPath, iid: $iid, userId: $userId }
+ ) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql b/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql
index 28a47735143..2c6f379744e 100644
--- a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql
@@ -1,4 +1,4 @@
-mutation($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) {
+mutation updateIssueHealthStatus($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) {
updateIssue(input: { projectPath: $projectPath, iid: $iid, healthStatus: $healthStatus }) {
issuable: issue {
id
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql
index 69927ddd205..2d58843140f 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql
@@ -1,4 +1,4 @@
-mutation updateEpic($input: UpdateEpicInput!) {
+mutation updateIssuableConfidential($input: UpdateEpicInput!) {
issuableSetConfidential: updateEpic(input: $input) {
issuable: epic {
id
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
index af43766aed5..4a3090f3836 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
@@ -1,4 +1,4 @@
-mutation epicSetSubscription($fullPath: ID!, $iid: ID!, $subscribedState: Boolean!) {
+mutation boardEpicSetSubscription($fullPath: ID!, $iid: ID!, $subscribedState: Boolean!) {
updateIssuableSubscription: epicSetSubscription(
input: { groupPath: $fullPath, iid: $iid, subscribedState: $subscribedState }
) {
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
index 317b48c142d..2e6bc8c36ba 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
@@ -1,4 +1,4 @@
-mutation updateEpic($input: UpdateEpicInput!) {
+mutation updateEpicTitle($input: UpdateEpicInput!) {
updateIssuableTitle: updateEpic(input: $input) {
epic {
title
diff --git a/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql
index 81891fb601f..e1a3927e7e1 100644
--- a/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql
@@ -1,4 +1,4 @@
-mutation issueSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) {
+mutation projectIssueSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) {
updateIssuableSubscription: issueSetSubscription(
input: { projectPath: $fullPath, iid: $iid, subscribedState: $subscribedState }
) {
diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql
index 3c09daad793..016c31ea096 100644
--- a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql
@@ -2,6 +2,7 @@ mutation mergeRequestSetLabels($input: MergeRequestSetLabelsInput!) {
mergeRequestSetLabels(input: $input) {
errors
mergeRequest {
+ id
labels {
nodes {
color
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index cea26acd101..d8ab8f1c65b 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -5,6 +5,7 @@ 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 toggleAttentionRequestedMutation from '../queries/toggle_attention_requested.mutation.graphql';
const queries = {
merge_request: sidebarDetailsMRQuery,
@@ -90,4 +91,15 @@ export default class SidebarService {
},
});
}
+
+ toggleAttentionRequested(userId) {
+ return gqClient.mutate({
+ mutation: toggleAttentionRequestedMutation,
+ variables: {
+ userId: convertToGraphQLId(TYPE_USER, `${userId}`),
+ projectPath: this.fullPath,
+ iid: this.iid.toString(),
+ },
+ });
+ }
}
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 9144e3b08db..86580744ccc 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,6 +1,6 @@
import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
import createFlash from '~/flash';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service';
@@ -56,13 +56,52 @@ export default class SidebarMediator {
return this.service
.requestReview(userId)
.then(() => {
- this.store.updateReviewer(userId);
+ this.store.updateReviewer(userId, 'reviewed');
toast(__('Requested review'));
callback(userId, true);
})
.catch(() => callback(userId, false));
}
+ async toggleAttentionRequested(type, { user, callback }) {
+ try {
+ const isReviewer = type === 'reviewer';
+ const reviewerOrAssignee = isReviewer
+ ? this.store.findReviewer(user)
+ : this.store.findAssignee(user);
+
+ await this.service.toggleAttentionRequested(user.id);
+
+ if (reviewerOrAssignee.attention_requested) {
+ toast(
+ sprintf(__('Removed attention request from @%{username}'), {
+ username: user.username,
+ }),
+ );
+ } else {
+ toast(sprintf(__('Requested attention from @%{username}'), { username: user.username }));
+ }
+
+ this.store.updateReviewer(user.id, 'attention_requested');
+ this.store.updateAssignee(user.id, 'attention_requested');
+
+ callback();
+ } catch (error) {
+ callback();
+ createFlash({
+ message: sprintf(__('Updating the attention request for %{username} failed.'), {
+ username: user.username,
+ }),
+ error,
+ captureError: true,
+ actionConfig: {
+ title: __('Try again'),
+ clickHandler: () => this.toggleAttentionRequired(type, { user, callback }),
+ },
+ });
+ }
+ }
+
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 94c54fc0980..5376791469e 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -82,11 +82,19 @@ export default class SidebarStore {
}
}
- updateReviewer(id) {
+ updateAssignee(id, stateKey) {
+ const assignee = this.findAssignee({ id });
+
+ if (assignee) {
+ assignee[stateKey] = !assignee[stateKey];
+ }
+ }
+
+ updateReviewer(id, stateKey) {
const reviewer = this.findReviewer({ id });
if (reviewer) {
- reviewer.reviewed = false;
+ reviewer[stateKey] = !reviewer[stateKey];
}
}
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index 8e7368ef804..21f38c4d8c9 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -18,7 +18,6 @@ export default function appFactory(el, Component) {
{},
{
batchMax: 1,
- assumeImmutableResults: true,
},
),
});
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
index 2ae2baddbcc..53572e680e5 100644
--- a/app/assets/javascripts/static_site_editor/graphql/index.js
+++ b/app/assets/javascripts/static_site_editor/graphql/index.js
@@ -22,7 +22,6 @@ const createApolloProvider = (appData) => {
},
{
typeDefs,
- assumeImmutableResults: true,
},
);
diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue
index 70e692a0c86..eb03aa3cca3 100644
--- a/app/assets/javascripts/static_site_editor/pages/success.vue
+++ b/app/assets/javascripts/static_site_editor/pages/success.vue
@@ -30,7 +30,7 @@ export default {
updatedFileDescription() {
const { sourcePath } = this.appData;
- return sprintf(s__('Update %{sourcePath} file'), { sourcePath });
+ return sprintf(__('Update %{sourcePath} file'), { sourcePath });
},
},
created() {
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index 93353b400e5..79a30340856 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -12,6 +12,7 @@ export default class TaskList {
this.lockVersion = options.lockVersion;
this.taskListContainerSelector = `${this.selector} .js-task-list-container`;
this.updateHandler = this.update.bind(this);
+ this.onUpdate = options.onUpdate || (() => {});
this.onSuccess = options.onSuccess || (() => {});
this.onError =
options.onError ||
@@ -96,6 +97,7 @@ export default class TaskList {
},
};
+ this.onUpdate();
this.disableTaskListItems(e);
return axios
diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue
new file mode 100644
index 00000000000..aedf5b6acfe
--- /dev/null
+++ b/app/assets/javascripts/terms/components/app.vue
@@ -0,0 +1,117 @@
+<script>
+import $ from 'jquery';
+import { GlButton, GlIntersectionObserver, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+
+import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+import '~/behaviors/markdown/render_gfm';
+
+export default {
+ name: 'TermsApp',
+ i18n: {
+ accept: __('Accept terms'),
+ continue: __('Continue'),
+ decline: __('Decline and sign out'),
+ },
+ flashElements: [],
+ csrf,
+ directives: {
+ SafeHtml,
+ },
+ components: { GlButton, GlIntersectionObserver },
+ inject: ['terms', 'permissions', 'paths'],
+ data() {
+ return {
+ acceptDisabled: true,
+ };
+ },
+ computed: {
+ isLoggedIn,
+ },
+ mounted() {
+ this.renderGFM();
+ this.setScrollableViewportHeight();
+
+ this.$options.flashElements = [
+ ...document.querySelectorAll(
+ Object.values(FLASH_TYPES)
+ .map((flashType) => `.flash-${flashType}`)
+ .join(','),
+ ),
+ ];
+
+ this.$options.flashElements.forEach((flashElement) => {
+ flashElement.addEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
+ });
+ },
+ beforeDestroy() {
+ this.$options.flashElements.forEach((flashElement) => {
+ flashElement.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
+ });
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs.gfmContainer).renderGFM();
+ },
+ handleBottomReached() {
+ this.acceptDisabled = false;
+ },
+ setScrollableViewportHeight() {
+ // Reset `max-height` inline style
+ this.$refs.scrollableViewport.style.maxHeight = '';
+
+ const { scrollHeight, clientHeight } = document.documentElement;
+
+ // Set `max-height` to 100vh minus all elements that are NOT the scrollable viewport (header, footer, alerts, etc)
+ this.$refs.scrollableViewport.style.maxHeight = `calc(100vh - ${
+ scrollHeight - clientHeight
+ }px)`;
+ },
+ handleFlashClose(event) {
+ this.setScrollableViewportHeight();
+ event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-card-body gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content">
+ <div
+ class="terms-fade gl-absolute gl-left-5 gl-right-5 gl-bottom-0 gl-h-11 gl-pointer-events-none"
+ ></div>
+ <div
+ ref="scrollableViewport"
+ data-testid="scrollable-viewport"
+ class="gl-h-100vh gl-overflow-y-auto gl-pb-11 gl-px-5"
+ >
+ <div ref="gfmContainer" v-safe-html="terms"></div>
+ <gl-intersection-observer @appear="handleBottomReached">
+ <div></div>
+ </gl-intersection-observer>
+ </div>
+ </div>
+ <div v-if="isLoggedIn" class="gl-card-footer gl-display-flex gl-justify-content-end">
+ <form v-if="permissions.canDecline" method="post" :action="paths.decline">
+ <gl-button type="submit">{{ $options.i18n.decline }}</gl-button>
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ </form>
+ <form v-if="permissions.canAccept" class="gl-ml-3" method="post" :action="paths.accept">
+ <gl-button
+ type="submit"
+ variant="confirm"
+ :disabled="acceptDisabled"
+ data-qa-selector="accept_terms_button"
+ >{{ $options.i18n.accept }}</gl-button
+ >
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ </form>
+ <gl-button v-else class="gl-ml-3" :href="paths.root" variant="confirm">{{
+ $options.i18n.continue
+ }}</gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/terms/index.js b/app/assets/javascripts/terms/index.js
new file mode 100644
index 00000000000..9d60fdfb50a
--- /dev/null
+++ b/app/assets/javascripts/terms/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+
+import TermsApp from 'jh_else_ce/terms/components/app.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export const initTermsApp = () => {
+ const el = document.getElementById('js-terms-of-service');
+
+ if (!el) return false;
+
+ const { terms, permissions, paths } = convertObjectPropsToCamelCase(
+ JSON.parse(el.dataset.termsData),
+ { deep: true },
+ );
+
+ return new Vue({
+ el,
+ provide: { terms, permissions, paths },
+ render(createElement) {
+ return createElement(TermsApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js
index 8d29a65d705..6a29883290a 100644
--- a/app/assets/javascripts/token_access/index.js
+++ b/app/assets/javascripts/token_access/index.js
@@ -6,7 +6,7 @@ import TokenAccess from './components/token_access.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
diff --git a/app/assets/javascripts/user_lists/components/user_lists_table.vue b/app/assets/javascripts/user_lists/components/user_lists_table.vue
index 765f59228a6..ccc2bfabb56 100644
--- a/app/assets/javascripts/user_lists/components/user_lists_table.vue
+++ b/app/assets/javascripts/user_lists/components/user_lists_table.vue
@@ -23,7 +23,7 @@ export default {
translations: {
createdTimeagoLabel: s__('UserList|created %{timeago}'),
deleteListTitle: s__('UserList|Delete %{name}?'),
- deleteListMessage: s__('User list %{name} will be removed. Are you sure?'),
+ deleteListMessage: __('User list %{name} will be removed. Are you sure?'),
editUserListLabel: s__('FeatureFlags|Edit User List'),
},
modal: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
new file mode 100644
index 00000000000..492e68b636f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { escape } from 'lodash';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { n__, s__ } from '~/locale';
+
+const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit');
+
+export default {
+ components: {
+ GlSprintf,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ isSquashEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isFastForwardEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ commitsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ targetBranchEscaped() {
+ return escape(this.targetBranch);
+ },
+ commitsCountMessage() {
+ return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount);
+ },
+ message() {
+ return this.isFastForwardEnabled
+ ? s__('mrWidgetCommitsAdded|Adds %{commitCount} to %{targetBranch}.')
+ : s__(
+ 'mrWidgetCommitsAdded|Adds %{commitCount} and %{mergeCommitCount} to %{targetBranch}%{squashedCommits}.',
+ );
+ },
+ textDecorativeComponent() {
+ return this.glFeatures.restructuredMrWidget ? 'span' : 'strong';
+ },
+ },
+ mergeCommitCount,
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf :message="message">
+ <template #commitCount>
+ <component :is="textDecorativeComponent" class="commits-count-message">{{
+ commitsCountMessage
+ }}</component>
+ </template>
+ <template #mergeCommitCount>
+ <component :is="textDecorativeComponent">{{ $options.mergeCommitCount }}</component>
+ </template>
+ <template #targetBranch>
+ <span class="label-branch">{{ targetBranchEscaped }}</span>
+ </template>
+ <template #squashedCommits>
+ <template v-if="glFeatures.restructuredMrWidget && isSquashEnabled">
+ {{ n__('(squashes %d commit)', '(squashes %d commits)', commitsCount) }}</template
+ ></template
+ >
+ </gl-sprintf>
+ </span>
+</template>
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 0c4a5ee35d9..25dbb614c1d 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
@@ -1,7 +1,11 @@
<script>
import { toNounSeriesText } from '~/lib/utils/grammar';
import { n__, sprintf } from '~/locale';
-import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages';
+import {
+ APPROVED_BY_YOU_AND_OTHERS,
+ APPROVED_BY_YOU,
+ APPROVED_BY_OTHERS,
+} from '~/vue_merge_request_widget/components/approvals/messages';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
export default {
@@ -29,12 +33,23 @@ export default {
},
},
computed: {
- message() {
- if (this.approved) {
- return APPROVED_MESSAGE;
+ approvalLeftMessage() {
+ if (this.rulesLeft.length) {
+ return sprintf(
+ n__(
+ 'Requires %{count} approval from %{names}.',
+ 'Requires %{count} approvals from %{names}.',
+ this.approvalsLeft,
+ ),
+ {
+ names: toNounSeriesText(this.rulesLeft),
+ count: this.approvalsLeft,
+ },
+ false,
+ );
}
- if (!this.rulesLeft.length) {
+ if (!this.approved) {
return n__(
'Requires %d approval from eligible users.',
'Requires %d approvals from eligible users.',
@@ -42,32 +57,51 @@ export default {
);
}
- return sprintf(
- n__(
- 'Requires %{count} approval from %{names}.',
- 'Requires %{count} approvals from %{names}.',
- this.approvalsLeft,
- ),
- {
- names: toNounSeriesText(this.rulesLeft),
- count: this.approvalsLeft,
- },
- false,
- );
+ return '';
+ },
+ message() {
+ if (this.approvedByMe && this.approvedByOthers) {
+ return APPROVED_BY_YOU_AND_OTHERS;
+ }
+
+ if (this.approvedByMe) {
+ return APPROVED_BY_YOU;
+ }
+
+ if (this.approved) {
+ return APPROVED_BY_OTHERS;
+ }
+
+ return '';
},
hasApprovers() {
return Boolean(this.approvers.length);
},
+ approvedByMe() {
+ if (!this.currentUserId) {
+ return false;
+ }
+ return this.approvers.some((approver) => approver.id === this.currentUserId);
+ },
+ approvedByOthers() {
+ if (!this.currentUserId) {
+ return false;
+ }
+ return this.approvers.some((approver) => approver.id !== this.currentUserId);
+ },
+ currentUserId() {
+ return gon.current_user_id;
+ },
},
- APPROVED_MESSAGE,
};
</script>
<template>
<div data-qa-selector="approvals_summary_content">
- <strong>{{ message }}</strong>
+ <strong>{{ approvalLeftMessage }}</strong>
<template v-if="hasApprovers">
- <span>{{ s__('mrWidget|Approved by') }}</span>
+ <span v-if="approvalLeftMessage">{{ message }}</span>
+ <strong v-else>{{ message }}</strong>
<user-avatar-list class="d-inline-block align-middle" :items="approvers" />
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js
index 0538c38307b..fbdefa95630 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js
@@ -6,4 +6,6 @@ export const FETCH_ERROR = s__(
);
export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.');
export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.');
-export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.');
+export const APPROVED_BY_YOU_AND_OTHERS = s__('mrWidget|Approved by you and others');
+export const APPROVED_BY_YOU = s__('mrWidget|Approved by you');
+export const APPROVED_BY_OTHERS = s__('mrWidget|Approved by');
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index cbace1ad57c..f4f611dfd1b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -12,13 +12,12 @@ import {
CANCELED,
SKIPPED,
} from './constants';
-import MemoryUsage from './memory_usage.vue';
export default {
name: 'DeploymentInfo',
components: {
GlLink,
- MemoryUsage,
+ MemoryUsage: () => import('./memory_usage.vue'),
TooltipOnTruncate,
},
directives: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
index d3384903cce..655acf28253 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
@@ -2,10 +2,11 @@
import { GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale';
import MrCollapsibleExtension from '../mr_collapsible_extension.vue';
+import Deployment from './deployment.vue';
export default {
components: {
- Deployment: () => import('./deployment.vue'),
+ Deployment,
GlSprintf,
MrCollapsibleExtension,
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
index 023367a794e..33a83aef057 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
@@ -24,13 +24,20 @@ export default {
return sprintf(__('%{widget} options'), { widget: this.widget });
},
},
+ methods: {
+ onClickAction(action) {
+ if (action.onClick) {
+ action.onClick();
+ }
+ },
+ },
};
</script>
<template>
<div>
<gl-dropdown
- v-if="tertiaryButtons"
+ v-if="tertiaryButtons.length"
:text="dropdownLabel"
icon="ellipsis_v"
no-caret
@@ -47,6 +54,7 @@ export default {
:key="index"
:href="btn.href"
:target="btn.target"
+ @click="onClickAction(btn)"
>
{{ btn.text }}
</gl-dropdown-item>
@@ -57,11 +65,12 @@ export default {
:key="index"
:href="btn.href"
:target="btn.target"
- :class="{ 'gl-mr-3': index > 1 }"
+ :class="{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }"
category="tertiary"
variant="confirm"
size="small"
- class="gl-display-none gl-md-display-block"
+ class="gl-display-none gl-md-display-block gl-float-left"
+ @click="onClickAction(btn)"
>
{{ btn.text }}
</gl-button>
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 298f7c7ad8c..6f10f788952 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
@@ -8,6 +8,8 @@ import {
GlTooltipDirective,
GlIntersectionObserver,
} from '@gitlab/ui';
+import { once } from 'lodash';
+import api from '~/api';
import { sprintf, s__, __ } from '~/locale';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import { EXTENSION_ICON_CLASS } from '../../constants';
@@ -102,8 +104,15 @@ export default {
});
},
methods: {
+ triggerRedisTracking: once(function triggerRedisTracking() {
+ if (this.$options.expandEvent) {
+ api.trackRedisHllUserEvent(this.$options.expandEvent);
+ }
+ }),
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
+
+ this.triggerRedisTracking();
},
loadAllData() {
if (this.fullData) return;
@@ -143,7 +152,10 @@ export default {
:is-loading="isLoadingSummary"
:icon-name="statusIconName"
/>
- <div class="media-body gl-display-flex gl-flex-direction-row!">
+ <div
+ class="media-body gl-display-flex gl-flex-direction-row!"
+ data-testid="widget-extension-top-level"
+ >
<div class="gl-flex-grow-1">
<template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
<div v-else v-safe-html="summary(collapsedData)"></div>
@@ -194,20 +206,28 @@ export default {
class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7"
data-testid="extension-list-item"
>
- <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" />
+ <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" />
<gl-intersection-observer
:options="{ rootMargin: '100px', thresholds: 0.1 }"
- class="gl-flex-wrap gl-align-self-center gl-display-flex"
+ class="gl-flex-wrap gl-display-flex gl-w-full"
@appear="appear(index)"
@disappear="disappear(index)"
>
- <div v-safe-html="data.text" class="gl-mr-4"></div>
+ <div
+ v-safe-html="data.text"
+ class="gl-mr-4 gl-display-flex gl-align-items-center"
+ ></div>
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
+ <actions
+ :widget="$options.label || $options.name"
+ :tertiary-buttons="data.actions"
+ class="gl-ml-auto"
+ />
</gl-intersection-observer>
</li>
</smart-virtual-list>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
index 4ca0b660696..ec6e6ed2620 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -12,6 +12,7 @@ export const registerExtension = (extension) => {
name: extension.name,
props: extension.props,
i18n: extension.i18n,
+ expandEvent: extension.expandEvent,
computed: {
...Object.keys(extension.computed).reduce(
(acc, computedKey) => ({
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 5c67b9c7ab5..9070cb1fe65 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -151,7 +151,7 @@ export default {
right
data-qa-selector="download_dropdown"
>
- <gl-dropdown-section-header>{{ s__('Download as') }}</gl-dropdown-section-header>
+ <gl-dropdown-section-header>{{ __('Download as') }}</gl-dropdown-section-header>
<gl-dropdown-item
:href="mr.emailPatchesPath"
class="js-download-email-patches"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 9bb955c534f..f7c952f9ef6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -101,6 +101,9 @@ export default {
? this.pipeline.details.status
: {};
},
+ artifacts() {
+ return this.pipeline?.details?.artifacts;
+ },
hasStages() {
return this.pipeline?.details?.stages?.length > 0;
},
@@ -285,7 +288,7 @@ export default {
/>
</span>
<linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
- <pipeline-artifacts :pipeline-id="pipeline.id" class="gl-ml-3" />
+ <pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" />
</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index 306026072a3..c314261d3f5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -1,8 +1,10 @@
<script>
import { s__, n__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'MRWidgetRelatedLinks',
+ mixins: [glFeatureFlagMixin()],
props: {
relatedLinks: {
type: Object,
@@ -14,6 +16,11 @@ export default {
required: false,
default: '',
},
+ showAssignToMe: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
closesText() {
@@ -30,16 +37,25 @@ export default {
};
</script>
<template>
- <section class="mr-info-list gl-ml-7 gl-pb-5">
- <p v-if="relatedLinks.closing">
+ <section>
+ <p
+ v-if="relatedLinks.closing"
+ :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ >
{{ closesText }}
<span v-html="relatedLinks.closing /* eslint-disable-line vue/no-v-html */"></span>
</p>
- <p v-if="relatedLinks.mentioned">
+ <p
+ v-if="relatedLinks.mentioned"
+ :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ >
{{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }}
<span v-html="relatedLinks.mentioned /* eslint-disable-line vue/no-v-html */"></span>
</p>
- <p v-if="relatedLinks.assignToMe">
+ <p
+ v-if="relatedLinks.assignToMe && showAssignToMe"
+ :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ >
<span v-html="relatedLinks.assignToMe /* eslint-disable-line vue/no-v-html */"></span>
</p>
</section>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
index f3673005c45..cd5b7c3110d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
@@ -4,9 +4,7 @@ import Tracking from '~/tracking';
import DismissibleContainer from '~/vue_shared/components/dismissible_container.vue';
import {
SP_TRACK_LABEL,
- SP_LINK_TRACK_EVENT,
SP_SHOW_TRACK_EVENT,
- SP_LINK_TRACK_VALUE,
SP_SHOW_TRACK_VALUE,
SP_HELP_CONTENT,
SP_HELP_URL,
@@ -20,9 +18,7 @@ export default {
name: 'MRWidgetSuggestPipeline',
SP_ICON_NAME,
SP_TRACK_LABEL,
- SP_LINK_TRACK_EVENT,
SP_SHOW_TRACK_EVENT,
- SP_LINK_TRACK_VALUE,
SP_SHOW_TRACK_VALUE,
SP_HELP_CONTENT,
SP_HELP_URL,
@@ -81,29 +77,14 @@ export default {
<div>
<gl-sprintf
:message="
- s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd}
- %{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd}
- to create one.`)
+ s__(`mrWidget|%{boldHeaderStart}Looks like there's no pipeline here.%{boldHeaderEnd}`)
"
>
- <template #prefixToLink="{ content }">
+ <template #boldHeader="{ content }">
<strong>
{{ content }}
</strong>
</template>
- <template #addPipelineLink="{ content }">
- <gl-link
- :href="pipelinePath"
- class="gl-ml-1"
- data-testid="add-pipeline-link"
- :data-track-property="humanAccess"
- :data-track-value="$options.SP_LINK_TRACK_VALUE"
- :data-track-action="$options.SP_LINK_TRACK_EVENT"
- :data-track-label="$options.SP_TRACK_LABEL"
- >
- {{ content }}
- </gl-link>
- </template>
</gl-sprintf>
</div>
</template>
@@ -115,9 +96,6 @@ export default {
</div>
<div class="col-md-7 order-md-first col-12">
<div class="ml-6 gl-pt-5">
- <strong>
- {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }}
- </strong>
<p class="gl-mt-2">
<gl-sprintf :message="$options.SP_HELP_CONTENT">
<template #link="{ content }">
@@ -142,7 +120,7 @@ export default {
:data-track-action="$options.SP_SHOW_TRACK_EVENT"
:data-track-label="$options.SP_TRACK_LABEL"
>
- {{ __('Show me how to add a pipeline') }}
+ {{ __('Try out GitLab Pipelines') }}
</gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
index 9268e426954..caafd6b995e 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
@@ -4,7 +4,7 @@ import { __ } from '../../locale';
export default {
i18n: {
- removesBranchText: __('The source branch will be deleted'),
+ removesBranchText: __('Deletes the source branch'),
tooltipTitle: __('A user with write access to the source branch selected this option'),
},
components: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
index 44bdc4a3be8..3eda2828e97 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
@@ -1,5 +1,8 @@
<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
export default {
+ mixins: [glFeatureFlagMixin()],
props: {
value: {
type: String,
@@ -20,7 +23,10 @@ export default {
<template>
<li>
<div class="commit-message-editor">
- <div class="d-flex flex-wrap align-items-center justify-content-between">
+ <div
+ :class="{ 'gl-mb-3': glFeatures.restructuredMrWidget }"
+ class="d-flex flex-wrap align-items-center justify-content-between"
+ >
<label class="col-form-label" :for="inputId">
<strong>{{ label }}</strong>
</label>
@@ -35,7 +41,7 @@ export default {
rows="7"
@input="$emit('input', $event.target.value)"
></textarea>
- <slot name="checkbox"></slot>
+ <slot name="text-muted"></slot>
</div>
</li>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
index 3ca193514f1..5c4a526bcc3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -1,15 +1,12 @@
<script>
-import { GlButton, GlSprintf } from '@gitlab/ui';
-import { escape } from 'lodash';
-import { __, n__, s__ } from '~/locale';
-
-const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit');
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+import AddedCommitMessage from '../added_commit_message.vue';
export default {
- mergeCommitCount,
components: {
GlButton,
- GlSprintf,
+ AddedCommitMessage,
},
props: {
isSquashEnabled: {
@@ -39,9 +36,6 @@ export default {
collapseIcon() {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
- commitsCountMessage() {
- return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount);
- },
modifyLinkMessage() {
if (this.isFastForwardEnabled) return __('Modify commit message');
else if (this.isSquashEnabled) return __('Modify commit messages');
@@ -50,16 +44,6 @@ export default {
ariaLabel() {
return this.expanded ? __('Collapse') : __('Expand');
},
- targetBranchEscaped() {
- return escape(this.targetBranch);
- },
- message() {
- return this.isFastForwardEnabled
- ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
- : s__(
- 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.',
- );
- },
},
methods: {
toggle() {
@@ -86,17 +70,12 @@ export default {
<span v-if="expanded">{{ __('Collapse') }}</span>
<span v-else>
<span class="vertical-align-middle">
- <gl-sprintf :message="message">
- <template #commitCount>
- <strong class="commits-count-message">{{ commitsCountMessage }}</strong>
- </template>
- <template #mergeCommitCount>
- <strong>{{ $options.mergeCommitCount }}</strong>
- </template>
- <template #targetBranch>
- <span class="label-branch">{{ targetBranchEscaped }}</span>
- </template>
- </gl-sprintf>
+ <added-commit-message
+ :is-squash-enabled="isSquashEnabled"
+ :is-fast-forward-enabled="isFastForwardEnabled"
+ :commits-count="commitsCount"
+ :target-branch="targetBranch"
+ />
</span>
<gl-button variant="link" class="modify-message-button">
{{ modifyLinkMessage }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 0eb173edbcb..a44caf886a4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -177,10 +177,10 @@ export default {
</h4>
<section class="mr-info-list">
<p v-if="shouldRemoveSourceBranch">
- {{ s__('mrWidget|The source branch will be deleted') }}
+ {{ s__('mrWidget|Deletes the source branch') }}
</p>
<p v-else class="gl-display-flex">
- <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span>
+ <span class="gl-mr-3">{{ s__('mrWidget|Does not delete the source branch') }}</span>
<gl-button
v-if="canRemoveSourceBranch"
:loading="isRemovingSourceBranch"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index e02be6dc2f7..10b93d7849f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -1,4 +1,5 @@
<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -6,11 +7,12 @@ export default {
components: {
statusIcon,
},
+ mixins: [glFeatureFlagMixin()],
};
</script>
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="loading" />
+ <status-icon :show-disabled-button="!glFeatures.restructuredMrWidget" status="loading" />
<div class="media-body space-children">
<span class="bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index a1759b1a815..84dac95ce74 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,6 +1,7 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlLoadingIcon, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import api from '~/api';
import createFlash from '~/flash';
import { s__, __ } from '~/locale';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants';
@@ -83,6 +84,8 @@ export default {
removeSourceBranch() {
this.isMakingRequest = true;
+ api.trackRedisHllUserEvent('i_code_review_post_merge_delete_branch');
+
this.service
.removeSourceBranch()
.then((res) => res.data)
@@ -103,9 +106,13 @@ export default {
});
},
openRevertModal() {
+ api.trackRedisHllUserEvent('i_code_review_post_merge_click_revert');
+
modalEventHub.$emit(OPEN_REVERT_MODAL);
},
openCherryPickModal() {
+ api.trackRedisHllUserEvent('i_code_review_post_merge_click_cherry_pick');
+
modalEventHub.$emit(OPEN_CHERRY_PICK_MODAL);
},
},
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 1c245b584ea..247877a8235 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
@@ -32,7 +32,7 @@ export default {
</h4>
<section class="mr-info-list">
<p>
- {{ s__('mrWidget|The changes will be merged into') }}
+ {{ s__('mrWidget|Merges changes into') }}
<span class="label-branch">
<a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
</span>
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 1976d3639a6..9f2870d8d69 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
@@ -1,8 +1,7 @@
<script>
import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
-import { escape } from 'lodash';
import createFlash from '~/flash';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub';
@@ -85,13 +84,7 @@ export default {
return ['failed', 'loading'].includes(this.status);
},
fastForwardMergeText() {
- return sprintf(
- __('Merge blocked: the source branch must be rebased onto the target branch.'),
- {
- targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`,
- },
- false,
- );
+ return __('Merge blocked: the source branch must be rebased onto the target branch.');
},
},
methods: {
@@ -170,8 +163,8 @@ export default {
v-if="!rebaseInProgress && !canPushToSourceBranch"
class="gl-font-weight-bold gl-ml-0!"
data-testid="rebase-message"
- v-html="fastForwardMergeText /* eslint-disable-line vue/no-v-html */"
- ></span>
+ >{{ fastForwardMergeText }}</span
+ >
<div
v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue
index 9a7743348ff..0b6aa104181 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue
@@ -37,7 +37,7 @@ export default {
<template>
<div class="mr-widget-body media">
<status-icon status="success" />
- <p class="media-body gl-m-0! gl-font-weight-bold">
+ <p class="media-body gl-m-0! gl-font-weight-bold gl-text-gray-900!">
<template v-if="canMerge">
{{ __('Ready to merge!') }}
</template>
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 7827c79cd31..2d704d3b07a 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
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+import api from '~/api';
import { helpPagePath } from '~/helpers/help_page_helper';
export default {
@@ -22,6 +23,11 @@ export default {
data() {
return { emptyStateSVG };
},
+ methods: {
+ onClickNewFile() {
+ api.trackRedisHllUserEvent('i_code_review_widget_nothing_merge_click_new_file');
+ },
+ },
ciHelpPage: helpPagePath('/ci/quick_start/index.html'),
safeHtmlConfig: { ADD_TAGS: ['use'] },
};
@@ -59,6 +65,7 @@ export default {
category="secondary"
variant="success"
data-testid="createFileButton"
+ @click="onClickNewFile"
>
{{ __('Create file') }}
</gl-button>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 7d4bd4cf1bf..d2cc99302a9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -18,9 +18,10 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests
import createFlash from '~/flash';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import SmartInterval from '~/smart_interval';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { helpPagePath } from '~/helpers/help_page_helper';
import MergeRequest from '../../../merge_request';
import {
AUTO_MERGE_STRATEGIES,
@@ -35,6 +36,8 @@ import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import MergeRequestStore from '../../stores/mr_widget_store';
import statusIcon from '../mr_widget_status_icon.vue';
+import AddedCommitMessage from '../added_commit_message.vue';
+import RelatedLinks from '../mr_widget_related_links.vue';
import CommitEdit from './commit_edit.vue';
import CommitMessageDropdown from './commit_message_dropdown.vue';
import CommitsHeader from './commits_header.vue';
@@ -113,6 +116,8 @@ export default {
import(
'ee_component/vue_merge_request_widget/components/merge_train_failed_pipeline_confirmation_dialog.vue'
),
+ AddedCommitMessage,
+ RelatedLinks,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -134,6 +139,7 @@ export default {
isSquashReadOnly: this.mr.squashIsReadonly,
squashCommitMessage: this.mr.squashCommitMessage,
isPipelineFailedModalVisible: false,
+ editCommitMessage: false,
};
},
computed: {
@@ -162,7 +168,7 @@ export default {
},
isMergeAllowed() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.mergeable || false;
+ return this.state.mergeable;
}
return this.mr.isMergeAllowed;
@@ -174,6 +180,11 @@ export default {
return this.mr.canRemoveSourceBranch;
},
+ commitTemplateHelpPage() {
+ return helpPagePath('user/project/merge_requests/commit_templates.md', {
+ anchor: 'merge-commit-message-template',
+ });
+ },
commits() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
return this.state.commitsWithoutMergeCommits.nodes;
@@ -279,6 +290,10 @@ export default {
return enableSquashBeforeMerge && this.commitsCount > 1;
},
shouldShowMergeControls() {
+ if (this.glFeatures.restructuredMrWidget) {
+ return this.restructuredWidgetShowMergeButtons;
+ }
+
return this.isMergeAllowed || this.isAutoMergeAvailable;
},
shouldShowSquashEdit() {
@@ -297,15 +312,26 @@ export default {
showDangerMessageForMergeTrain() {
return this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY && this.isPipelineFailed;
},
+ restructuredWidgetShowMergeButtons() {
+ if (this.glFeatures.restructuredMrWidget) {
+ return this.isMergeAllowed && this.state.userPermissions.canMerge;
+ }
+
+ return true;
+ },
},
mounted() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
eventHub.$on('ApprovalUpdated', this.updateGraphqlState);
+ eventHub.$on('MRWidgetUpdateRequested', this.updateGraphqlState);
+ eventHub.$on('mr.discussion.updated', this.updateGraphqlState);
}
},
beforeDestroy() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
eventHub.$off('ApprovalUpdated', this.updateGraphqlState);
+ eventHub.$off('MRWidgetUpdateRequested', this.updateGraphqlState);
+ eventHub.$off('mr.discussion.updated', this.updateGraphqlState);
}
if (this.pollingInterval) {
@@ -327,15 +353,6 @@ export default {
updateGraphqlState() {
return this.$apollo.queries.state.refetch();
},
- updateMergeCommitMessage(includeDescription) {
- const commitMessage = this.glFeatures.mergeRequestWidgetGraphql
- ? this.state.defaultMergeCommitMessage
- : this.mr.commitMessage;
- const commitMessageWithDescription = this.glFeatures.mergeRequestWidgetGraphql
- ? this.state.defaultMergeCommitMessageWithDescription
- : this.mr.commitMessageWithDescription;
- this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage;
- },
handleMergeButtonClick(useAutoMerge, mergeImmediately = false, confirmationClicked = false) {
if (this.showFailedPipelineModal && !confirmationClicked) {
this.isPipelineFailedModalVisible = true;
@@ -488,11 +505,21 @@ export default {
});
},
},
+ i18n: {
+ mergeCommitTemplateHintText: s__(
+ 'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more.%{linkEnd}',
+ ),
+ },
};
</script>
<template>
- <div>
+ <div
+ :class="{
+ 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7':
+ glFeatures.restructuredMrWidget,
+ }"
+ >
<div v-if="loading" class="mr-widget-body">
<div class="gl-w-full mr-ready-to-merge-loader">
<gl-skeleton-loader :width="418" :height="30">
@@ -504,11 +531,16 @@ export default {
</div>
</div>
<template v-else>
- <div class="mr-widget-body media">
- <status-icon :status="iconClass" />
+ <div
+ class="mr-widget-body media"
+ :class="{
+ 'mr-widget-body-line-height-1': glFeatures.restructuredMrWidget,
+ }"
+ >
+ <status-icon v-if="!glFeatures.restructuredMrWidget" :status="iconClass" />
<div class="media-body">
- <div class="mr-widget-body-controls gl-display-flex gl-align-items-center">
- <gl-button-group class="gl-align-self-start">
+ <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap">
+ <gl-button-group v-if="restructuredWidgetShowMergeButtons" class="gl-align-self-start">
<gl-button
size="medium"
category="primary"
@@ -555,14 +587,27 @@ export default {
</gl-button-group>
<div
v-if="shouldShowMergeControls"
+ :class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }"
class="gl-display-flex gl-align-items-center gl-flex-wrap"
>
+ <merge-train-helper-icon
+ v-if="shouldRenderMergeTrainHelperIcon"
+ :merge-train-when-pipeline-succeeds-docs-path="
+ mr.mergeTrainWhenPipelineSucceedsDocsPath
+ "
+ class="gl-mx-3"
+ />
+
<gl-form-checkbox
v-if="canRemoveSourceBranch"
id="remove-source-branch-input"
v-model="removeSourceBranch"
:disabled="isRemoveSourceBranchButtonDisabled"
- class="js-remove-source-branch-checkbox gl-mx-3 gl-display-flex gl-align-items-center"
+ :class="{
+ 'gl-mx-3': !glFeatures.restructuredMrWidget,
+ 'gl-mr-5': glFeatures.restructuredMrWidget,
+ }"
+ class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center"
>
{{ __('Delete source branch') }}
</gl-form-checkbox>
@@ -573,38 +618,146 @@ export default {
v-model="squashBeforeMerge"
:help-path="mr.squashBeforeMergeHelpPath"
:is-disabled="isSquashReadOnly"
- class="gl-mx-3"
+ :class="{
+ 'gl-mx-3': !glFeatures.restructuredMrWidget,
+ 'gl-mr-5': glFeatures.restructuredMrWidget,
+ }"
/>
- <merge-train-helper-icon
- v-if="shouldRenderMergeTrainHelperIcon"
- :merge-train-when-pipeline-succeeds-docs-path="
- mr.mergeTrainWhenPipelineSucceedsDocsPath
+ <gl-form-checkbox
+ v-if="
+ glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit)
"
- />
+ v-model="editCommitMessage"
+ class="gl-display-flex gl-align-items-center"
+ >
+ {{ __('Edit commit message') }}
+ </gl-form-checkbox>
+ </div>
+ <div
+ v-else-if="!glFeatures.restructuredMrWidget"
+ class="bold js-resolve-mr-widget-items-message gl-ml-3"
+ >
+ <div
+ v-if="hasPipelineMustSucceedConflict"
+ class="gl-display-flex gl-align-items-center"
+ data-testid="pipeline-succeed-conflict"
+ >
+ <gl-sprintf :message="pipelineMustSucceedConflictText" />
+ <gl-link
+ :href="mr.pipelineMustSucceedDocsPath"
+ target="_blank"
+ class="gl-display-flex gl-ml-2"
+ >
+ <gl-icon name="question" />
+ </gl-link>
+ </div>
+ <gl-sprintf v-else :message="mergeDisabledText" />
</div>
- <template v-else>
- <div class="bold js-resolve-mr-widget-items-message gl-ml-3">
- <div
- v-if="hasPipelineMustSucceedConflict"
- class="gl-display-flex gl-align-items-center"
- data-testid="pipeline-succeed-conflict"
+ <template v-if="glFeatures.restructuredMrWidget">
+ <div v-show="editCommitMessage" class="gl-w-full gl-order-n1">
+ <ul
+ :class="{
+ 'content-list': !glFeatures.restructuredMrWidget,
+ 'gl-list-style-none gl-p-0 gl-pt-4': glFeatures.restructuredMrWidget,
+ }"
+ class="border-top commits-list flex-list"
>
- <gl-sprintf :message="pipelineMustSucceedConflictText" />
- <gl-link
- :href="mr.pipelineMustSucceedDocsPath"
- target="_blank"
- class="gl-display-flex gl-ml-2"
+ <commit-edit
+ v-if="shouldShowSquashEdit"
+ v-model="squashCommitMessage"
+ :label="__('Squash commit message')"
+ input-id="squash-message-edit"
+ class="gl-m-0! gl-p-0!"
+ >
+ <template #header>
+ <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" />
+ </template>
+ </commit-edit>
+ <commit-edit
+ v-if="shouldShowMergeEdit"
+ v-model="commitMessage"
+ :label="__('Merge commit message')"
+ input-id="merge-message-edit"
+ class="gl-m-0! gl-p-0!"
>
- <gl-icon name="question" />
- </gl-link>
- </div>
- <gl-sprintf v-else :message="mergeDisabledText" />
+ <template #text-muted>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText">
+ <template #link="{ content }">
+ <gl-link
+ :href="commitTemplateHelpPage"
+ class="inline-link"
+ target="_blank"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </commit-edit>
+ </ul>
+ </div>
+ <div
+ v-if="!restructuredWidgetShowMergeButtons"
+ class="gl-w-full gl-order-n1 gl-text-gray-500"
+ >
+ <strong>
+ {{ __('Merge details') }}
+ </strong>
+ <ul class="gl-pl-4 gl-m-0">
+ <li class="gl-line-height-normal">
+ <added-commit-message
+ :is-squash-enabled="squashBeforeMerge"
+ :is-fast-forward-enabled="!shouldShowMergeEdit"
+ :commits-count="commitsCount"
+ :target-branch="stateData.targetBranch"
+ />
+ </li>
+ <li class="gl-line-height-normal">
+ <template v-if="removeSourceBranch">
+ {{ __('Deletes the source branch.') }}
+ </template>
+ <template v-else>
+ {{ __('Does not delete the source branch.') }}
+ </template>
+ </li>
+ <li v-if="mr.relatedLinks" class="gl-line-height-normal">
+ <related-links
+ :state="mr.state"
+ :related-links="mr.relatedLinks"
+ :show-assign-to-me="false"
+ class="mr-ready-merge-related-links gl-display-inline"
+ />
+ </li>
+ </ul>
+ </div>
+ <div
+ v-else
+ :class="{ 'gl-mb-5': restructuredWidgetShowMergeButtons }"
+ class="gl-w-full gl-order-n1 gl-text-gray-500"
+ >
+ <added-commit-message
+ :is-squash-enabled="squashBeforeMerge"
+ :is-fast-forward-enabled="!shouldShowMergeEdit"
+ :commits-count="commitsCount"
+ :target-branch="stateData.targetBranch"
+ />
+ <template v-if="mr.relatedLinks">
+ &middot;
+ <related-links
+ :state="mr.state"
+ :related-links="mr.relatedLinks"
+ :show-assign-to-me="false"
+ class="mr-ready-merge-related-links gl-display-inline"
+ />
+ </template>
</div>
</template>
</div>
<div
- v-if="showDangerMessageForMergeTrain"
+ v-if="showDangerMessageForMergeTrain && !glFeatures.restructuredMrWidget"
class="gl-mt-5 gl-text-gray-500"
data-testid="failed-pipeline-merge-train-text"
>
@@ -612,7 +765,7 @@ export default {
</div>
</div>
</div>
- <template v-if="shouldShowMergeControls">
+ <template v-if="shouldShowMergeControls && !glFeatures.restructuredMrWidget">
<div
v-if="!shouldShowMergeEdit"
class="mr-fast-forward-message"
@@ -621,7 +774,7 @@ export default {
{{ __('Fast-forward merge without a merge commit') }}
</div>
<commits-header
- v-if="shouldShowSquashEdit || shouldShowMergeEdit"
+ v-if="!glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit)"
:is-squash-enabled="squashBeforeMerge"
:commits-count="commitsCount"
:target-branch="stateData.targetBranch"
@@ -646,15 +799,16 @@ export default {
:label="__('Merge commit message')"
input-id="merge-message-edit"
>
- <template #checkbox>
- <label>
- <input
- id="include-description"
- type="checkbox"
- @change="updateMergeCommitMessage($event.target.checked)"
- />
- {{ __('Include merge request description') }}
- </label>
+ <template #text-muted>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText">
+ <template #link="{ content }">
+ <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</template>
</commit-edit>
</ul>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 41b5983ae0c..c6227c4394d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -1,15 +1,18 @@
<script>
-import { GlIcon, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective, GlFormCheckbox, GlLink } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SQUASH_BEFORE_MERGE } from '../../i18n';
export default {
components: {
GlIcon,
GlFormCheckbox,
+ GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
i18n: {
...SQUASH_BEFORE_MERGE,
},
@@ -33,6 +36,9 @@ export default {
tooltipTitle() {
return this.isDisabled ? this.$options.i18n.tooltipTitle : null;
},
+ helpIconName() {
+ return this.glFeatures.restructuredMrWidget ? 'question-o' : 'question';
+ },
},
};
</script>
@@ -51,18 +57,18 @@ export default {
>
{{ $options.i18n.checkboxLabel }}
</gl-form-checkbox>
- <a
+ <gl-link
v-if="helpPath"
v-gl-tooltip
:href="helpPath"
:title="$options.i18n.helpLabel"
+ :class="{ 'gl-text-blue-600': glFeatures.restructuredMrWidget }"
target="_blank"
- rel="noopener noreferrer nofollow"
>
- <gl-icon name="question" />
+ <gl-icon :name="helpIconName" />
<span class="sr-only">
{{ $options.i18n.helpLabel }}
</span>
- </a>
+ </gl-link>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 790870ee4c6..fa4f8b76cb9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -10,8 +10,8 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import getStateQuery from '../../queries/get_state.query.graphql';
-import workInProgressQuery from '../../queries/states/work_in_progress.query.graphql';
-import removeWipMutation from '../../queries/toggle_wip.mutation.graphql';
+import draftQuery from '../../queries/states/draft.query.graphql';
+import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql';
import StatusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -23,7 +23,7 @@ export default {
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
userPermissions: {
- query: workInProgressQuery,
+ query: draftQuery,
skip() {
return !this.glFeatures.mergeRequestWidgetGraphql;
},
@@ -53,25 +53,25 @@ export default {
},
},
methods: {
- removeWipMutation() {
+ removeDraftMutation() {
const { mergeRequestQueryVariables } = this;
this.isMakingRequest = true;
this.$apollo
.mutate({
- mutation: removeWipMutation,
+ mutation: removeDraftMutation,
variables: {
...mergeRequestQueryVariables,
- wip: false,
+ draft: false,
},
update(
store,
{
data: {
- mergeRequestSetWip: {
+ mergeRequestSetDraft: {
errors,
- mergeRequest: { mergeableDiscussionsState, workInProgress, title },
+ mergeRequest: { mergeableDiscussionsState, draft, title },
},
},
},
@@ -91,7 +91,7 @@ export default {
const data = produce(sourceData, (draftState) => {
draftState.project.mergeRequest.mergeableDiscussionsState = mergeableDiscussionsState;
- draftState.project.mergeRequest.workInProgress = workInProgress;
+ draftState.project.mergeRequest.draft = draft;
draftState.project.mergeRequest.title = title;
});
@@ -104,14 +104,14 @@ export default {
optimisticResponse: {
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
- mergeRequestSetWip: {
+ mergeRequestSetDraft: {
__typename: 'MergeRequestSetWipPayload',
errors: [],
mergeRequest: {
__typename: 'MergeRequest',
mergeableDiscussionsState: true,
title: this.mr.title,
- workInProgress: false,
+ draft: false,
},
},
},
@@ -119,7 +119,7 @@ export default {
.then(
({
data: {
- mergeRequestSetWip: {
+ mergeRequestSetDraft: {
mergeRequest: { title },
},
},
@@ -137,9 +137,9 @@ export default {
this.isMakingRequest = false;
});
},
- handleRemoveWIP() {
+ handleRemoveDraft() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
- this.removeWipMutation();
+ this.removeDraftMutation();
} else {
this.isMakingRequest = true;
this.service
@@ -178,8 +178,8 @@ export default {
size="small"
:disabled="isMakingRequest"
:loading="isMakingRequest"
- class="js-remove-wip gl-ml-3"
- @click="handleRemoveWIP"
+ class="js-remove-draft gl-ml-3"
+ @click="handleRemoveDraft"
>
{{ s__('mrWidget|Mark as ready') }}
</gl-button>
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index b88e83ccb0f..d0c6cf12e25 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -17,14 +17,12 @@ export const AUTO_MERGE_STRATEGIES = [MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY,
// SP - "Suggest Pipelines"
export const SP_TRACK_LABEL = 'no_pipeline_noticed';
-export const SP_LINK_TRACK_EVENT = 'click_link';
export const SP_SHOW_TRACK_EVENT = 'click_button';
-export const SP_LINK_TRACK_VALUE = 30;
export const SP_SHOW_TRACK_VALUE = 10;
export const SP_HELP_CONTENT = s__(
- `mrWidget|Use %{linkStart}CI pipelines to test your code%{linkEnd} by simply adding a GitLab CI configuration file to your project. It only takes a minute to make your code more secure and robust.`,
+ `mrWidget|GitLab %{linkStart}CI/CD can automatically build, test, and deploy your application.%{linkEnd} It only takes a few minutes to get started, and we can help you create a pipeline configuration file.`,
);
-export const SP_HELP_URL = 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/';
+export const SP_HELP_URL = 'https://docs.gitlab.com/ee/ci/quick_start/';
export const SP_ICON_NAME = 'status_notfound';
export const MERGE_ACTIVE_STATUS_PHRASES = [
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 349e9d29355..9cbc0b0e5d1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -11,6 +11,7 @@ export default {
label: 'Issues',
loading: 'Loading issues...',
},
+ expandEvent: 'i_testing_load_performance_widget_total',
// Add an array of props
// These then get mapped to values stored in the MR Widget store
props: ['targetProjectFullPath', 'conflictsDocsPath'],
@@ -29,7 +30,15 @@ export default {
// Tertiary action buttons that will take the user elsewhere
// in the GitLab app
tertiaryButtons() {
- return [{ text: 'Full report', href: this.conflictsDocsPath, target: '_blank' }];
+ return [
+ {
+ text: 'Click me',
+ onClick() {
+ console.log('Hello world');
+ },
+ },
+ { text: 'Full report', href: this.conflictsDocsPath, target: '_blank' },
+ ];
},
},
methods: {
@@ -66,6 +75,7 @@ export default {
// href: 'https://google.com', // Required: href for the link
// text: 'Link text', // Required: Text to be used inside the link
// },
+ actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
}));
});
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql
index 389a81e0a61..da1cace4598 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql
@@ -1,4 +1,4 @@
-query getIssues($projectPath: ID!) {
+query getProjectIssues($projectPath: ID!) {
project(fullPath: $projectPath) {
issues {
count
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index f5dbcec7dbe..8d596465970 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -13,12 +13,7 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
export default () => {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index cf6472f2c8c..83789f10285 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,9 +1,13 @@
import { __ } from '~/locale';
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
+export const MERGE_DISABLED_SKIPPED_PIPELINE_TEXT = __(
+ "Merge blocked: pipeline must succeed. It's waiting for a manual job to continue.",
+);
export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __(
'A CI/CD pipeline must run and be successful before merge.',
);
+export const PIPELINE_SKIPPED_STATUS = 'SKIPPED';
export default {
computed: {
@@ -17,6 +21,10 @@ export default {
);
},
mergeDisabledText() {
+ if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) {
+ return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT;
+ }
+
return MERGE_DISABLED_TEXT;
},
pipelineMustSucceedConflictText() {
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 3ac1e881658..c98dc426224 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
@@ -91,6 +91,7 @@ export default {
MrWidgetApprovals,
SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'),
MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'),
+ ReadyToMerge: ReadyToMergeState,
},
apollo: {
state: {
@@ -213,6 +214,9 @@ export default {
window.gon?.features?.refactorMrWidgetsExtensionsUser
);
},
+ isRestructuredMrWidgetEnabled() {
+ return window.gon?.features?.restructuredMrWidget;
+ },
},
watch: {
'mr.machineValue': {
@@ -547,12 +551,17 @@ export default {
<div class="mr-widget-section">
<component :is="componentName" :mr="mr" :service="service" />
-
- <div class="mr-widget-info">
+ <ready-to-merge
+ v-if="isRestructuredMrWidgetEnabled && mr.commitsCount"
+ :mr="mr"
+ :service="service"
+ />
+ <div v-else class="mr-widget-info">
<mr-widget-related-links
v-if="shouldRenderRelatedLinks"
:state="mr.state"
:related-links="mr.relatedLinks"
+ class="mr-info-list gl-ml-7 gl-pb-5"
/>
<source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
index 871aa880b36..bfb1517be81 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -23,7 +23,7 @@ query getState($projectPath: ID!, $iid: String!) {
userPermissions {
canMerge
}
- workInProgress
+ draft
}
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
index daf21e75b3b..e0215fbd969 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
@@ -1,6 +1,6 @@
#import "./auto_merge_enabled.fragment.graphql"
-query autoMergeEnabledQuery($projectPath: ID!, $iid: String!) {
+query autoMergeEnabled($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
...autoMergeEnabled
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql
index 186c0e64561..e66ac01ab12 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql
@@ -1,4 +1,4 @@
-query workInProgressQuery($projectPath: ID!, $iid: String!) {
+query workInProgress($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
shouldBeRebased
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql
index 73e205ebf2b..0983c28448e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql
@@ -1,4 +1,4 @@
-query workInProgressQuery($projectPath: ID!, $iid: String!) {
+query mrUserPermission($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
userPermissions {
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql
index 3b34be73c15..21c3ffd8321 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql
@@ -1,4 +1,4 @@
-query readyToMergeQuery($projectPath: ID!, $iid: String!) {
+query getReadyToMergeStatus($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
userPermissions {
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
index 367b9ad1cdf..b2a1be5c5a9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
@@ -18,7 +18,9 @@ fragment ReadyToMerge on Project {
commitCount
diffHeadSha
userPermissions {
+ canMerge
removeSourceBranch
+ updateMergeRequest
}
targetBranch
mergeError
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
index 78259e1f553..f713739f65a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
@@ -1,6 +1,6 @@
#import "./ready_to_merge.fragment.graphql"
-query readyToMergeQuery($projectPath: ID!, $iid: String!) {
+query readyToMerge($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
...ReadyToMerge
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql
new file mode 100644
index 00000000000..200fb1b7ca5
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql
@@ -0,0 +1,10 @@
+mutation toggleDraftStatus($projectPath: ID!, $iid: String!, $draft: Boolean!) {
+ mergeRequestSetDraft(input: { projectPath: $projectPath, iid: $iid, draft: $draft }) {
+ mergeRequest {
+ mergeableDiscussionsState
+ title
+ draft
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
deleted file mode 100644
index cfaa198d516..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-mutation toggleWIPStatus($projectPath: ID!, $iid: String!, $wip: Boolean!) {
- mergeRequestSetWip(input: { projectPath: $projectPath, iid: $iid, wip: $wip }) {
- mergeRequest {
- mergeableDiscussionsState
- title
- workInProgress
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 65d78fc283c..2ae4f4da2f3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -1,14 +1,14 @@
import { stateKey } from './state_maps';
export default function deviseState() {
- if (this.hasMergeChecksFailed) {
+ if (!this.commitsCount) {
+ return stateKey.nothingToMerge;
+ } else if (this.hasMergeChecksFailed && !this.autoMergeEnabled) {
return stateKey.mergeChecksFailed;
} else if (this.projectArchived) {
return stateKey.archived;
} else if (this.branchMissing) {
return stateKey.missingBranch;
- } else if (!this.commitsCount) {
- return stateKey.nothingToMerge;
} else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') {
return stateKey.checking;
} else if (this.hasConflicts) {
@@ -17,8 +17,8 @@ export default function deviseState() {
return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed;
- } else if (this.workInProgress) {
- return stateKey.workInProgress;
+ } else if (this.draft) {
+ return stateKey.draft;
} else if (this.hasMergeableDiscussionsState && !this.autoMergeEnabled) {
return stateKey.unresolvedDiscussions;
} else if (this.isPipelineBlocked) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 6628225cd46..10a2907c81a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -164,7 +164,7 @@ export default class MergeRequestStore {
this.projectArchived = data.project_archived;
this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.shouldBeRebased = Boolean(data.should_be_rebased);
- this.workInProgress = data.work_in_progress;
+ this.draft = data.draft;
}
const currentUser = data.current_user;
@@ -207,7 +207,7 @@ export default class MergeRequestStore {
this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled';
this.isSHAMismatch = this.sha !== mergeRequest.diffHeadSha;
this.shouldBeRebased = mergeRequest.shouldBeRebased;
- this.workInProgress = mergeRequest.workInProgress;
+ this.draft = mergeRequest.draft;
this.mergeRequestState = mergeRequest.state;
this.setState();
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
index 4cb23407a74..9dfeaee905c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -4,7 +4,7 @@ export const stateToComponentMap = {
merging: 'mr-widget-merging',
conflicts: 'mr-widget-conflicts',
missingBranch: 'mr-widget-missing-branch',
- workInProgress: 'mr-widget-wip',
+ draft: 'mr-widget-wip',
readyToMerge: 'mr-widget-ready-to-merge',
nothingToMerge: 'mr-widget-nothing-to-merge',
notAllowedToMerge: 'mr-widget-not-allowed',
@@ -24,7 +24,7 @@ export const stateToComponentMap = {
export const statesToShowHelpWidget = [
'merging',
'conflicts',
- 'workInProgress',
+ 'draft',
'readyToMerge',
'checking',
'unresolvedDiscussions',
@@ -40,7 +40,7 @@ export const stateKey = {
nothingToMerge: 'nothingToMerge',
checking: 'checking',
conflicts: 'conflicts',
- workInProgress: 'workInProgress',
+ draft: 'draft',
pipelineFailed: 'pipelineFailed',
unresolvedDiscussions: 'unresolvedDiscussions',
pipelineBlocked: 'pipelineBlocked',
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
index f8f1613879f..6b774b2a734 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import NoteHeader from '~/notes/components/note_header.vue';
export default {
@@ -7,6 +7,9 @@ export default {
NoteHeader,
GlIcon,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
note: {
type: Object,
@@ -39,7 +42,7 @@ export default {
<div class="note-header">
<note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
- <span v-html="note.bodyHtml /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html="note.bodyHtml"></span>
</note-header>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
index da5f1a00e11..0c26fcc0ab2 100644
--- a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
@@ -1,7 +1,7 @@
#import "~/graphql_shared/fragments/alert_detail_item.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
-query alertDetails($fullPath: ID!, $alertId: String) {
+query alertDetailsAssignees($fullPath: ID!, $alertId: String) {
project(fullPath: $fullPath) {
alertManagementAlerts(iid: $alertId) {
nodes {
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index fda405c0fa5..9f1da9ae173 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -38,7 +38,6 @@ export default (selector) => {
return defaultDataIdFromObject(object);
},
},
- assumeImmutableResults: true,
}),
});
diff --git a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
deleted file mode 100644
index 16ca2df02c0..00000000000
--- a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
+++ /dev/null
@@ -1,46 +0,0 @@
-<script>
-import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlAlert,
- GlLink,
- GlSprintf,
- },
- inject: ['hasManagedPrometheus'],
- i18n: {
- alertsDeprecationText: s__(
- 'Metrics|GitLab-managed Prometheus is deprecated and %{linkStart}scheduled for removal%{linkEnd}. Following this removal, your existing alerts will continue to function as part of the new cluster integration. However, you will no longer be able to add new alerts or edit existing alerts from the metrics dashboard.',
- ),
- },
- methods: {
- helpPagePath,
- },
-};
-</script>
-
-<template>
- <gl-alert
- v-if="hasManagedPrometheus"
- variant="warning"
- class="my-2"
- data-testid="alerts-deprecation-warning"
- >
- <gl-sprintf :message="$options.i18n.alertsDeprecationText">
- <template #link="{ content }">
- <gl-link
- :href="
- helpPagePath('operations/metrics/alerts.html', {
- anchor: 'managed-prometheus-instances',
- })
- "
- target="_blank"
- >
- <span>{{ content }}</span>
- </gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
-</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 40044e518c3..2c74d56f617 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,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
@@ -9,6 +9,9 @@ export default {
components: {
GlIcon,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
mixins: [ViewerMixin, glFeatureFlagsMixin()],
inject: ['blobHash'],
data() {
@@ -65,7 +68,7 @@ export default {
<div class="blob-content">
<pre
class="code highlight"
- ><code :data-blob-hash="blobHash" v-html="content /* eslint-disable-line vue/no-v-html */"></code></pre>
+ ><code v-safe-html="content" :data-blob-hash="blobHash"></code></pre>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index f388a468fd2..5de71c35be9 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -21,6 +21,7 @@ import CiIcon from './ci_icon.vue';
* - Job show view - header
* - MR widget
* - Terraform table
+ * - On-demand scans list
*/
export default {
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
new file mode 100644
index 00000000000..4c07cf44fed
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { CONFIRM_DANGER_MODAL_ID } from './constants';
+import ConfirmDangerModal from './confirm_danger_modal.vue';
+
+export default {
+ name: 'ConfirmDanger',
+ components: {
+ GlButton,
+ ConfirmDangerModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ phrase: {
+ type: String,
+ required: true,
+ },
+ buttonText: {
+ type: String,
+ required: true,
+ },
+ buttonTestid: {
+ type: String,
+ required: false,
+ default: 'confirm-danger-button',
+ },
+ },
+ modalId: CONFIRM_DANGER_MODAL_ID,
+};
+</script>
+<template>
+ <div>
+ <gl-button
+ v-gl-modal="$options.modalId"
+ class="gl-button"
+ variant="danger"
+ :disabled="disabled"
+ :data-testid="buttonTestid"
+ >{{ buttonText }}</gl-button
+ >
+ <confirm-danger-modal
+ :modal-id="$options.modalId"
+ :phrase="phrase"
+ @confirm="$emit('confirm')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
new file mode 100644
index 00000000000..18fa297da87
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
@@ -0,0 +1,28 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import ConfirmDanger from './confirm_danger.vue';
+
+export default {
+ component: ConfirmDanger,
+ title: 'vue_shared/components/modals/confirm_danger_modal',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { ConfirmDanger },
+ props: Object.keys(argTypes),
+ template: '<confirm-danger v-bind="$props" />',
+ provide: {
+ confirmDangerMessage: 'You require more Vespene Gas',
+ },
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ phrase: 'You must construct additional pylons',
+ buttonText: 'Confirm button text',
+};
+
+export const Disabled = Template.bind({});
+Disabled.args = {
+ ...Default.args,
+ disabled: true,
+};
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
new file mode 100644
index 00000000000..30c96daf7e3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlAlert, GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
+import {
+ CONFIRM_DANGER_MODAL_BUTTON,
+ CONFIRM_DANGER_MODAL_TITLE,
+ CONFIRM_DANGER_PHRASE_TEXT,
+ CONFIRM_DANGER_WARNING,
+ CONFIRM_DANGER_MODAL_ERROR,
+} from './constants';
+
+export default {
+ name: 'ConfirmDangerModal',
+ components: {
+ GlAlert,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlSprintf,
+ },
+ inject: {
+ confirmDangerMessage: {
+ default: '',
+ },
+ confirmButtonText: {
+ default: CONFIRM_DANGER_MODAL_BUTTON,
+ },
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ phrase: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return { confirmationPhrase: '' };
+ },
+ computed: {
+ isValid() {
+ return Boolean(
+ this.confirmationPhrase.length && this.equalString(this.confirmationPhrase, this.phrase),
+ );
+ },
+ actionPrimary() {
+ return {
+ text: this.confirmButtonText,
+ attributes: [{ variant: 'danger', disabled: !this.isValid }],
+ };
+ },
+ },
+ methods: {
+ equalString(a, b) {
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
+ },
+ },
+ i18n: {
+ CONFIRM_DANGER_MODAL_BUTTON,
+ CONFIRM_DANGER_MODAL_TITLE,
+ CONFIRM_DANGER_WARNING,
+ CONFIRM_DANGER_PHRASE_TEXT,
+ CONFIRM_DANGER_MODAL_ERROR,
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ :modal-id="modalId"
+ :data-testid="modalId"
+ :title="$options.i18n.CONFIRM_DANGER_MODAL_TITLE"
+ :action-primary="actionPrimary"
+ @primary="$emit('confirm')"
+ >
+ <gl-alert
+ v-if="confirmDangerMessage"
+ variant="danger"
+ data-testid="confirm-danger-message"
+ :dismissible="false"
+ class="gl-mb-4"
+ >
+ {{ confirmDangerMessage }}
+ </gl-alert>
+ <p data-testid="confirm-danger-warning">{{ $options.i18n.CONFIRM_DANGER_WARNING }}</p>
+ <p data-testid="confirm-danger-phrase">
+ <gl-sprintf :message="$options.i18n.CONFIRM_DANGER_PHRASE_TEXT">
+ <template #phrase_code>
+ <code>{{ phrase }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-group :state="isValid" :invalid-feedback="$options.i18n.CONFIRM_DANGER_MODAL_ERROR">
+ <gl-form-input
+ id="confirm_name_input"
+ v-model="confirmationPhrase"
+ class="form-control"
+ data-testid="confirm-danger-input"
+ type="text"
+ />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js
new file mode 100644
index 00000000000..fa44a9be411
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js
@@ -0,0 +1,12 @@
+import { __ } from '~/locale';
+
+export const CONFIRM_DANGER_MODAL_ID = 'confirm-danger-modal';
+export const CONFIRM_DANGER_MODAL_TITLE = __('Confirmation required');
+export const CONFIRM_DANGER_MODAL_ERROR = __('Confirmation required');
+export const CONFIRM_DANGER_MODAL_BUTTON = __('Confirm');
+export const CONFIRM_DANGER_WARNING = __(
+ 'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.',
+);
+export const CONFIRM_DANGER_PHRASE_TEXT = __(
+ 'Please type %{phrase_code} to proceed or close this modal to cancel.',
+);
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index ea507017caa..9cf8638f3cb 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -1,5 +1,8 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import {
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { forEach, escape } from 'lodash';
@@ -13,6 +16,9 @@ export default {
components: {
GlSkeletonLoading,
},
+ directives: {
+ SafeHtml,
+ },
props: {
content: {
type: String,
@@ -103,6 +109,7 @@ export default {
}
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'use'] },
};
</script>
@@ -111,8 +118,8 @@ export default {
<gl-skeleton-loading v-if="isLoading" />
<div
v-else
+ v-safe-html:[$options.safeHtmlConfig]="previewContent"
class="md gl-ml-auto gl-mr-auto"
- v-html="previewContent /* eslint-disable-line vue/no-v-html */"
></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
index 7859ef85dd8..153b0981813 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
@@ -45,7 +45,7 @@ export default {
default: false,
},
selected: {
- type: Object,
+ type: [Object, Array],
required: false,
default: () => {},
},
@@ -54,6 +54,11 @@ export default {
required: false,
default: '',
},
+ allowMultiselect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
isSearchEmpty() {
@@ -66,8 +71,14 @@ export default {
methods: {
selectOption(option) {
this.$emit('set-option', option || null);
+ if (!this.allowMultiselect) {
+ this.$refs.dropdown.hide();
+ }
},
isSelected(option) {
+ if (Array.isArray(this.selected)) {
+ return this.selected.some((label) => label.title === option.title);
+ }
return (
this.selected &&
((option.name && this.selected.name === option.name) ||
@@ -78,7 +89,7 @@ export default {
this.$refs.dropdown.show();
},
setFocus() {
- this.$refs.search.focusInput();
+ this.$refs.search?.focusInput();
},
setSearchTerm(search) {
this.$emit('set-search', search);
@@ -108,56 +119,60 @@ export default {
@shown="setFocus"
>
<template #header>
- <gl-search-box-by-type
- ref="search"
- :value="searchTerm"
- :placeholder="searchText"
- class="js-dropdown-input-field"
- @input="setSearchTerm"
- />
+ <slot name="header">
+ <gl-search-box-by-type
+ ref="search"
+ :value="searchTerm"
+ :placeholder="searchText"
+ class="js-dropdown-input-field"
+ @input="setSearchTerm"
+ />
+ </slot>
</template>
- <gl-dropdown-form class="gl-relative gl-min-h-7">
- <gl-loading-icon
- v-if="isLoading"
- size="md"
- class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
- />
- <template v-else>
- <template v-if="isSearchEmpty && presetOptions.length > 0">
+ <slot name="default">
+ <gl-dropdown-form class="gl-relative gl-min-h-7" data-qa-selector="labels_dropdown_content">
+ <gl-loading-icon
+ v-if="isLoading"
+ size="md"
+ class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
+ />
+ <template v-else>
+ <template v-if="isSearchEmpty && presetOptions.length > 0">
+ <gl-dropdown-item
+ v-for="option in presetOptions"
+ :key="option.id"
+ :is-checked="isSelected(option)"
+ :is-check-centered="true"
+ :is-check-item="true"
+ @click.native.capture.stop="selectOption(option)"
+ >
+ <slot name="preset-item" :item="option">
+ {{ option.title }}
+ </slot>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
<gl-dropdown-item
- v-for="option in presetOptions"
+ v-for="option in options"
:key="option.id"
:is-checked="isSelected(option)"
:is-check-centered="true"
:is-check-item="true"
- @click="selectOption(option)"
+ :avatar-url="avatarUrl(option)"
+ :secondary-text="secondaryText(option)"
+ data-testid="unselected-option"
+ @click.native.capture.stop="selectOption(option)"
>
- <slot name="preset-item" :item="option">
+ <slot name="item" :item="option">
{{ option.title }}
</slot>
</gl-dropdown-item>
- <gl-dropdown-divider />
+ <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!">
+ {{ $options.i18n.noMatchingResults }}
+ </gl-dropdown-item>
</template>
- <gl-dropdown-item
- v-for="option in options"
- :key="option.id"
- :is-checked="isSelected(option)"
- :is-check-centered="true"
- :is-check-item="true"
- :avatar-url="avatarUrl(option)"
- :secondary-text="secondaryText(option)"
- data-testid="unselected-option"
- @click="selectOption(option)"
- >
- <slot name="item" :item="option">
- {{ option.title }}
- </slot>
- </gl-dropdown-item>
- <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!">
- {{ $options.i18n.noMatchingResults }}
- </gl-dropdown-item>
- </template>
- </gl-dropdown-form>
+ </gl-dropdown-form>
+ </slot>
<template #footer>
<slot name="footer"></slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index 276fb35b51f..adf34f822ed 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -86,7 +86,7 @@ export default {
<template>
<span>
<gl-loading-icon v-if="loading" size="sm" :inline="true" />
- <gl-icon v-else-if="isSymlink" name="symlink" :size="size" use-deprecated-sizes />
+ <gl-icon v-else-if="isSymlink" name="symlink" :size="size" />
<svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 1df65d0a666..d9290e86bca 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -53,6 +53,7 @@ export const TOKEN_TITLE_ASSIGNEE = __('Assignee');
export const TOKEN_TITLE_MILESTONE = __('Milestone');
export const TOKEN_TITLE_LABEL = __('Label');
export const TOKEN_TITLE_TYPE = __('Type');
+export const TOKEN_TITLE_RELEASE = __('Release');
export const TOKEN_TITLE_MY_REACTION = __('My-Reaction');
export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
export const TOKEN_TITLE_ITERATION = __('Iteration');
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 9dc5c5db276..7c1828f2294 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
@@ -171,15 +171,17 @@ export default {
* This watcher listens for updates to `filterValue` on
* such instances. :(
*/
- filterValue(value) {
- const [firstVal] = value;
+ filterValue(newValue, oldValue) {
+ const [firstVal] = newValue;
if (
!this.initialRender &&
- value.length === 1 &&
+ newValue.length === 1 &&
firstVal.type === 'filtered-search-term' &&
!firstVal.value.data
) {
- this.$emit('onFilter', []);
+ const filtersCleared =
+ oldValue[0].type !== 'filtered-search-term' || oldValue[0].value.data !== '';
+ this.$emit('onFilter', [], filtersCleared);
}
// Set initial render flag to false
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 ae5d3965de1..b3b3d5c88c6 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
@@ -1,6 +1,6 @@
<script>
import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui';
-
+import { compact } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
@@ -59,8 +59,10 @@ export default {
.then((res) => {
// We'd want to avoid doing this check but
// users.json and /groups/:id/members & /projects/:id/users
- // return response differently.
- this.authors = Array.isArray(res) ? res : res.data;
+ // return response differently
+
+ // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756
+ this.authors = Array.isArray(res) ? compact(res) : compact(res.data);
})
.catch(() =>
createFlash({
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
index c1d1bc7da91..aff93ebc9c0 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
@@ -1,16 +1,21 @@
<script>
-import { GlFilteredSearchSuggestion } from '@gitlab/ui';
+import { GlDropdownDivider, GlDropdownSectionHeader, GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT_ITERATIONS } from '../constants';
export default {
components: {
BaseToken,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
GlFilteredSearchSuggestion,
},
+ mixins: [glFeatureFlagMixin()],
props: {
active: {
type: Boolean,
@@ -40,6 +45,27 @@ export default {
getActiveIteration(iterations, data) {
return iterations.find((iteration) => this.getValue(iteration) === data);
},
+ groupIterationsByCadence(iterations) {
+ const cadences = [];
+ iterations.forEach((iteration) => {
+ if (!iteration.iterationCadence) {
+ return;
+ }
+ const { title } = iteration.iterationCadence;
+ const cadenceIteration = {
+ id: iteration.id,
+ title: iteration.title,
+ period: this.getIterationPeriod(iteration),
+ };
+ const cadence = cadences.find((cad) => cad.title === title);
+ if (cadence) {
+ cadence.iterations.push(cadenceIteration);
+ } else {
+ cadences.push({ title, iterations: [cadenceIteration] });
+ }
+ });
+ return cadences;
+ },
fetchIterations(searchTerm) {
this.loading = true;
this.config
@@ -57,6 +83,16 @@ export default {
getValue(iteration) {
return String(getIdFromGraphQLId(iteration.id));
},
+ /**
+ * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619
+ * This method also exists as a utility function in ee/../iterations/utils.js
+ * Remove the duplication when iteration token is moved to EE.
+ */
+ getIterationPeriod({ startDate, dueDate }) {
+ const start = formatDate(startDate, 'mmm d, yyyy', true);
+ const due = formatDate(dueDate, 'mmm d, yyyy', true);
+ return `${start} - ${due}`;
+ },
},
};
</script>
@@ -77,13 +113,26 @@ export default {
{{ activeTokenValue ? activeTokenValue.title : inputValue }}
</template>
<template #suggestions-list="{ suggestions }">
- <gl-filtered-search-suggestion
- v-for="iteration in suggestions"
- :key="iteration.id"
- :value="getValue(iteration)"
- >
- {{ iteration.title }}
- </gl-filtered-search-suggestion>
+ <template v-for="(cadence, index) in groupIterationsByCadence(suggestions)">
+ <gl-dropdown-divider v-if="index !== 0" :key="index" />
+ <gl-dropdown-section-header
+ :key="cadence.title"
+ class="gl-overflow-hidden"
+ :title="cadence.title"
+ >
+ {{ cadence.title }}
+ </gl-dropdown-section-header>
+ <gl-filtered-search-suggestion
+ v-for="iteration in cadence.iterations"
+ :key="iteration.id"
+ :value="getValue(iteration)"
+ >
+ {{ iteration.title }}
+ <div v-if="glFeatures.iterationCadences" class="gl-text-gray-400">
+ {{ iteration.period }}
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
</template>
</base-token>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue
new file mode 100644
index 00000000000..f353cc3a765
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlFilteredSearchSuggestion } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import { DEFAULT_NONE_ANY } from '../constants';
+
+export default {
+ components: {
+ BaseToken,
+ GlFilteredSearchSuggestion,
+ },
+ props: {
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ releases: this.config.initialReleases || [],
+ loading: false,
+ };
+ },
+ computed: {
+ defaultReleases() {
+ return this.config.defaultReleases || DEFAULT_NONE_ANY;
+ },
+ },
+ methods: {
+ getActiveRelease(releases, data) {
+ return releases.find((release) => release.tag.toLowerCase() === data.toLowerCase());
+ },
+ fetchReleases(searchTerm) {
+ this.loading = true;
+ this.config
+ .fetchReleases(searchTerm)
+ .then((response) => {
+ this.releases = response;
+ })
+ .catch(() => {
+ createFlash({ message: __('There was a problem fetching releases.') });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <base-token
+ :active="active"
+ :config="config"
+ :value="value"
+ :default-suggestions="defaultReleases"
+ :suggestions="releases"
+ :suggestions-loading="loading"
+ :get-active-token-value="getActiveRelease"
+ @fetch-suggestions="fetchReleases"
+ v-on="$listeners"
+ >
+ <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
+ {{ activeTokenValue ? activeTokenValue.tag : inputValue }}
+ </template>
+ <template #suggestions-list="{ suggestions }">
+ <gl-filtered-search-suggestion
+ v-for="release in suggestions"
+ :key="release.id"
+ :value="release.tag"
+ >
+ {{ release.tag }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </base-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 41613bb3307..6ace0bd88f8 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,10 +1,16 @@
<script>
-import { GlTooltipDirective, GlLink, GlButton, GlTooltip, GlSafeHtmlDirective } from '@gitlab/ui';
+import {
+ GlTooltipDirective,
+ GlButton,
+ GlSafeHtmlDirective,
+ GlAvatarLink,
+ GlAvatarLabeled,
+} from '@gitlab/ui';
+import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '../../locale';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
-import UserAvatarImage from './user_avatar/user_avatar_image.vue';
/**
* Renders header component for job and pipeline page based on UI mockups
@@ -17,10 +23,9 @@ export default {
components: {
CiIconBadge,
TimeagoTooltip,
- UserAvatarImage,
- GlLink,
GlButton,
- GlTooltip,
+ GlAvatarLink,
+ GlAvatarLabeled,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -94,6 +99,9 @@ export default {
return this.itemName;
},
+ userId() {
+ return isGid(this.user?.id) ? getIdFromGraphQLId(this.user?.id) : this.user?.id;
+ },
},
methods: {
@@ -124,24 +132,32 @@ export default {
{{ __('by') }}
<template v-if="user">
- <gl-link
- v-gl-tooltip
- :href="userPath"
- :title="user.email"
- class="js-user-link commit-committer-link"
+ <gl-avatar-link
+ :data-user-id="userId"
+ :data-username="user.username"
+ :data-name="user.name"
+ :href="user.webUrl"
+ target="_blank"
+ class="js-user-link gl-vertical-align-middle gl-mx-2 gl-align-items-center"
>
- <user-avatar-image :img-src="avatarUrl" :img-alt="userAvatarAltText" :size="24" />
- {{ user.name }}
- </gl-link>
- <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
- {{ message }}
- </gl-tooltip>
- <span
- v-if="statusTooltipHTML"
- :ref="$options.EMOJI_REF"
- v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML"
- :data-testid="message"
- ></span>
+ <gl-avatar-labeled
+ :size="24"
+ :src="avatarUrl"
+ :label="user.name"
+ class="gl-display-none gl-sm-display-inline-flex gl-mx-1"
+ />
+ <strong class="author gl-display-inline gl-sm-display-none!">@{{ user.username }}</strong>
+ <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
+ {{ message }}
+ </gl-tooltip>
+ <span
+ v-if="statusTooltipHTML"
+ :ref="$options.EMOJI_REF"
+ v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML"
+ class="gl-ml-2"
+ :data-testid="message"
+ ></span>
+ </gl-avatar-link>
</template>
</section>
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 f9ae59567b2..648e9c9462f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -1,11 +1,11 @@
<script>
-import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlBadge, GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import ApplySuggestion from './apply_suggestion.vue';
export default {
- components: { GlIcon, GlButton, GlLoadingIcon, ApplySuggestion },
+ components: { GlBadge, GlIcon, GlButton, GlLoadingIcon, ApplySuggestion },
directives: { 'gl-tooltip': GlTooltipDirective },
props: {
batchSuggestionsCount: {
@@ -134,8 +134,14 @@ export default {
<gl-icon name="question-o" css-classes="link-highlight" />
</a>
</div>
- <div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div>
- <div v-else-if="isApplying" class="d-flex align-items-center text-secondary">
+ <gl-badge v-if="isApplied" variant="success" data-qa-selector="applied_badge">
+ {{ __('Applied') }}
+ </gl-badge>
+ <div
+ v-else-if="isApplying"
+ class="d-flex align-items-center text-secondary"
+ data-qa-selector="applying_badge"
+ >
<gl-loading-icon size="sm" class="d-flex-center mr-2" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 755e6f1f224..8877cfa39fb 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -26,6 +26,7 @@ import {
import $ from 'jquery';
import { mapGetters, mapActions, mapState } from 'vuex';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
+import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import initMRPopovers from '~/mr_popover/';
import noteHeader from '~/notes/components/note_header.vue';
@@ -61,6 +62,9 @@ export default {
data() {
return {
expanded: false,
+ lines: [],
+ showLines: false,
+ loadingDiff: false,
};
},
computed: {
@@ -94,10 +98,25 @@ export default {
},
methods: {
...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']),
+ async toggleDiff() {
+ this.showLines = !this.showLines;
+
+ if (!this.lines.length) {
+ this.loadingDiff = true;
+ const { data } = await axios.get(this.note.outdated_line_change_path);
+
+ this.lines = data.map((l) => ({
+ ...l,
+ rich_text: l.rich_text.replace(/^[+ -]/, ''),
+ }));
+ this.loadingDiff = false;
+ }
+ },
},
safeHtmlConfig: {
ADD_TAGS: ['use'], // to support icon SVGs
},
+ userColorSchemeClass: window.gon.user_color_scheme,
};
</script>
@@ -112,15 +131,28 @@ export default {
<div class="note-header">
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
<span v-safe-html="actionTextHtml"></span>
- <template v-if="canSeeDescriptionVersion" #extra-controls>
+ <template
+ v-if="canSeeDescriptionVersion || note.outdated_line_change_path"
+ #extra-controls
+ >
&middot;
<gl-button
+ v-if="canSeeDescriptionVersion"
variant="link"
:icon="descriptionVersionToggleIcon"
data-testid="compare-btn"
@click="toggleDescriptionVersion"
>{{ __('Compare with previous version') }}</gl-button
>
+ <gl-button
+ v-if="note.outdated_line_change_path"
+ :icon="showLines ? 'chevron-up' : 'chevron-down'"
+ variant="link"
+ data-testid="outdated-lines-change-btn"
+ @click="toggleDiff"
+ >
+ {{ __('Compare changes') }}
+ </gl-button>
</template>
</note-header>
</div>
@@ -154,6 +186,37 @@ export default {
@click="deleteDescriptionVersion"
/>
</div>
+ <div
+ v-if="lines.length && showLines"
+ class="diff-content gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden"
+ >
+ <table
+ :class="$options.userColorSchemeClass"
+ class="code js-syntax-highlight"
+ data-testid="outdated-lines"
+ >
+ <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder">
+ <td
+ :class="line.type"
+ class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0!"
+ >
+ {{ line.old_line }}
+ </td>
+ <td
+ :class="line.type"
+ class="diff-line-num new_line gl-border-bottom-0! gl-border-top-0!"
+ >
+ {{ line.new_line }}
+ </td>
+ <td
+ :class="line.type"
+ class="line_content gl-display-table-cell!"
+ v-html="line.rich_text /* eslint-disable-line vue/no-v-html */"
+ ></td>
+ </tr>
+ </table>
+ </div>
+ <gl-skeleton-loading v-else-if="showLines" class="gl-mt-4" />
</div>
</div>
</timeline-entry-item>
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 4b21ec0330a..d108d8d689d 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -69,20 +69,23 @@ export default {
/>
<div class="gl-display-flex gl-flex-direction-column">
- <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title">
+ <h2 class="gl-font-size-h1 gl-mt-3 gl-mb-0" data-testid="title">
<slot name="title">{{ title }}</slot>
- </h1>
+ </h2>
<div
v-if="$slots['sub-header']"
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3"
>
<slot name="sub-header"></slot>
</div>
</div>
</div>
- <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3">
+ <div
+ v-if="metadataSlots.length > 0"
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
+ >
<template v-if="!metadataLoading">
<div
v-for="(row, metadataIndex) in metadataSlots"
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
index facace0d809..34845e3d9e4 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
@@ -2,6 +2,8 @@ import { s__ } from '~/locale';
export const PLATFORMS_WITHOUT_ARCHITECTURES = ['docker', 'kubernetes'];
+export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN';
+
export const INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES = {
docker: {
instructions: s__(
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index d55c93fd146..d5493aa5a66 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -16,8 +16,9 @@ import { isEmpty } from 'lodash';
import { __, s__ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import {
- PLATFORMS_WITHOUT_ARCHITECTURES,
INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
+ PLATFORMS_WITHOUT_ARCHITECTURES,
+ REGISTRATION_TOKEN_PLACEHOLDER,
} from './constants';
import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql';
import getRunnerSetupInstructionsQuery from './graphql/queries/get_runner_setup.query.graphql';
@@ -41,7 +42,13 @@ export default {
props: {
modalId: {
type: String,
- required: true,
+ required: false,
+ default: 'runner-instructions-modal',
+ },
+ registrationToken: {
+ type: String,
+ required: false,
+ default: null,
},
},
apollo: {
@@ -117,8 +124,20 @@ export default {
runnerInstallationLink() {
return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.link;
},
+ registerInstructionsWithToken() {
+ const { registerInstructions } = this.instructions || {};
+
+ if (this.registrationToken) {
+ return registerInstructions.replace(REGISTRATION_TOKEN_PLACEHOLDER, this.registrationToken);
+ }
+
+ return registerInstructions;
+ },
},
methods: {
+ show() {
+ this.$refs.modal.show();
+ },
selectPlatform(platform) {
this.selectedPlatform = platform;
@@ -158,9 +177,11 @@ export default {
</script>
<template>
<gl-modal
+ ref="modal"
:modal-id="modalId"
:title="$options.i18n.installARunner"
:action-secondary="$options.closeButton"
+ v-bind="$attrs"
>
<gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
{{ $options.i18n.fetchError }}
@@ -243,11 +264,11 @@ export default {
<pre
class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line"
data-testid="register-command"
- >{{ instructions.registerInstructions }}</pre
+ >{{ registerInstructionsWithToken }}</pre
>
<modal-copy-button
:title="$options.i18n.copyInstructions"
- :text="instructions.registerInstructions"
+ :text="registerInstructionsWithToken"
:modal-id="$options.modalId"
css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
category="tertiary"
diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
index e75fedbb1d7..e68f0f31c13 100644
--- a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
+++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
@@ -24,10 +24,13 @@ export default {
},
},
data() {
+ const forceOpen = !this.collapsible || this.defaultExpanded;
return {
// Non-collapsible sections should always be expanded.
// For collapsible sections, fall back to defaultExpanded.
- sectionExpanded: !this.collapsible || this.defaultExpanded,
+ sectionExpanded: forceOpen,
+ initialised: forceOpen,
+ animating: false,
};
},
computed: {
@@ -53,7 +56,12 @@ export default {
toggleSectionExpanded() {
this.sectionExpanded = !this.sectionExpanded;
+ if (!this.initialised) {
+ this.initialised = true;
+ }
+
if (this.sectionExpanded) {
+ this.animating = true;
this.$refs.settingsContent.focus();
}
},
@@ -68,7 +76,10 @@ export default {
</script>
<template>
- <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded: sectionExpanded }">
+ <section
+ class="settings"
+ :class="{ 'no-animate': !slideAnimated, expanded: sectionExpanded, animating }"
+ >
<div class="settings-header">
<h4>
<span
@@ -103,12 +114,14 @@ export default {
</p>
</div>
<div
+ v-show="initialised"
:id="settingsContentId"
ref="settingsContent"
:aria-labelledby="settingsLabelId"
tabindex="-1"
role="region"
class="settings-content"
+ @animationend="animating = false"
>
<slot></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
index 6511c8d8c31..460a10e08ed 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
@@ -40,7 +40,7 @@ export default {
</script>
<template>
- <div v-gl-tooltip.left.viewport :class="containerClass" :title="tooltipText" @click="click">
+ <div v-gl-tooltip.left.viewport="tooltipText" :class="containerClass" @click="click">
<gl-icon v-if="showIcon" name="calendar" />
<slot>
<span> {{ text }} </span>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
index d80b66fd9be..399db978b60 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
@@ -36,6 +36,7 @@ export default {
<template>
<div
class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute"
+ data-testid="labels-select-dropdown-contents"
data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
index 122250d1ce7..8a26c4a6618 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
@@ -43,12 +43,7 @@ export default {
</script>
<template>
- <div
- v-gl-tooltip.left.viewport
- :title="labelsList"
- class="sidebar-collapsed-icon"
- @click="handleClick"
- >
+ <div v-gl-tooltip.left.viewport="labelsList" class="sidebar-collapsed-icon" @click="handleClick">
<gl-icon name="labels" />
<span>{{ labels.length }}</span>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
index 0ea22eb7aea..9e64f03fe84 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -1,5 +1,4 @@
import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
-import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import { DropdownVariant } from '../constants';
import * as types from './mutation_types';
@@ -67,9 +66,11 @@ export default {
}
if (isScopedLabel(candidateLabel)) {
- const scopedKeyWithDelimiter = `${scopedLabelKey(candidateLabel)}${SCOPED_LABEL_DELIMITER}`;
const currentActiveScopedLabel = state.labels.find(
- ({ title }) => title.startsWith(scopedKeyWithDelimiter) && title !== candidateLabel.title,
+ ({ set, title }) =>
+ set &&
+ title !== candidateLabel.title &&
+ scopedLabelKey({ title }) === scopedLabelKey(candidateLabel),
);
if (currentActiveScopedLabel) {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
index 389eb174c0e..cd671b4d8f5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
@@ -1,7 +1,13 @@
export const SCOPED_LABEL_DELIMITER = '::';
+export const DEBOUNCE_DROPDOWN_DELAY = 200;
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
Embedded: 'embedded',
};
+
+export const LabelType = {
+ group: 'group',
+ project: 'project',
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index 3ee0baf8812..f7485de0342 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -1,20 +1,25 @@
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __, s__, sprintf } from '~/locale';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
+import DropdownFooter from './dropdown_footer.vue';
+import DropdownHeader from './dropdown_header.vue';
import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils';
export default {
components: {
DropdownContentsLabelsView,
DropdownContentsCreateView,
+ DropdownHeader,
+ DropdownFooter,
GlButton,
GlDropdown,
GlDropdownItem,
GlLink,
},
- inject: ['allowLabelCreate', 'labelsManagePath'],
props: {
labelsCreateTitle: {
type: String,
@@ -48,10 +53,6 @@ export default {
type: String,
required: true,
},
- issuableType: {
- type: String,
- required: true,
- },
isVisible: {
type: Boolean,
required: false,
@@ -61,10 +62,17 @@ export default {
type: String,
required: true,
},
+ workspaceType: {
+ type: String,
+ required: true,
+ },
attrWorkspacePath: {
type: String,
- required: false,
- default: undefined,
+ required: true,
+ },
+ labelCreateType: {
+ type: String,
+ required: true,
},
},
data() {
@@ -72,6 +80,7 @@ export default {
showDropdownContentsCreateView: false,
localSelectedLabels: [...this.selectedLabels],
isDirty: false,
+ searchKey: '',
};
},
computed: {
@@ -113,15 +122,24 @@ export default {
if (newVal) {
this.$refs.dropdown.show();
this.isDirty = false;
+ this.localSelectedLabels = this.selectedLabels;
} else {
this.$refs.dropdown.hide();
this.setLabels();
}
},
selectedLabels(newVal) {
- this.localSelectedLabels = newVal;
+ if (!this.isDirty) {
+ this.localSelectedLabels = newVal;
+ }
},
},
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ beforeDestroy() {
+ this.debouncedSearchKeyUpdate.cancel();
+ },
methods: {
toggleDropdownContentsCreateView() {
this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView;
@@ -140,10 +158,20 @@ export default {
this.$emit('setLabels', this.localSelectedLabels);
},
handleDropdownHide() {
+ this.$emit('closeDropdown');
if (!isDropdownVariantSidebar(this.variant)) {
this.setLabels();
}
},
+ setSearchKey(value) {
+ this.searchKey = value;
+ },
+ setFocus() {
+ this.$refs.header.focusInput();
+ },
+ showDropdown() {
+ this.$refs.dropdown.show();
+ },
},
};
</script>
@@ -153,62 +181,44 @@ export default {
ref="dropdown"
:text="buttonText"
class="gl-w-full gl-mt-2"
+ data-testid="labels-select-dropdown-contents"
data-qa-selector="labels_dropdown_content"
@hide="handleDropdownHide"
+ @shown="setFocus"
>
<template #header>
- <div
+ <dropdown-header
v-if="!isStandalone"
- class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
- data-testid="dropdown-header"
- >
- <gl-button
- v-if="showDropdownContentsCreateView"
- :aria-label="__('Go back')"
- variant="link"
- size="small"
- class="js-btn-back dropdown-header-button gl-p-0"
- icon="arrow-left"
- data-testid="go-back-button"
- @click.stop="toggleDropdownContent"
- />
- <span class="gl-flex-grow-1">{{ dropdownTitle }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- size="small"
- class="dropdown-header-button gl-p-0!"
- icon="close"
- data-testid="close-button"
- @click="$emit('closeDropdown')"
- />
- </div>
+ ref="header"
+ v-model="searchKey"
+ :labels-create-title="labelsCreateTitle"
+ :labels-list-title="labelsListTitle"
+ :show-dropdown-contents-create-view="showDropdownContentsCreateView"
+ @toggleDropdownContentsCreateView="toggleDropdownContent"
+ @closeDropdown="$emit('closeDropdown')"
+ @input="debouncedSearchKeyUpdate"
+ />
</template>
<template #default>
<component
:is="dropdownContentsView"
v-model="localSelectedLabels"
- :selected-labels="selectedLabels"
+ :search-key="searchKey"
:allow-multiselect="allowMultiselect"
- :issuable-type="issuableType"
:full-path="fullPath"
+ :workspace-type="workspaceType"
:attr-workspace-path="attrWorkspacePath"
- @hideCreateView="toggleDropdownContentsCreateView"
+ :label-create-type="labelCreateType"
+ @hideCreateView="toggleDropdownContent"
/>
</template>
<template #footer>
- <div v-if="showDropdownFooter" data-testid="dropdown-footer">
- <gl-dropdown-item
- v-if="allowLabelCreate"
- data-testid="create-label-button"
- @click.capture.native.stop="toggleDropdownContent"
- >
- {{ footerCreateLabelTitle }}
- </gl-dropdown-item>
- <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop>
- {{ footerManageLabelTitle }}
- </gl-dropdown-item>
- </div>
+ <dropdown-footer
+ v-if="showDropdownFooter"
+ :footer-create-label-title="footerCreateLabelTitle"
+ :footer-manage-label-title="footerManageLabelTitle"
+ @toggleDropdownContentsCreateView="toggleDropdownContent"
+ />
</template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index a2ed08e6b28..da626a21b14 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -2,10 +2,10 @@
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
-import { labelsQueries } from '~/sidebar/constants';
+import { workspaceLabelsQueries } from '~/sidebar/constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
+import { LabelType } from './constants';
const errorMessage = __('Error creating label.');
@@ -20,18 +20,21 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- issuableType: {
+ fullPath: {
type: String,
required: true,
},
- fullPath: {
+ attrWorkspacePath: {
type: String,
required: true,
},
- attrWorkspacePath: {
+ labelCreateType: {
+ type: String,
+ required: true,
+ },
+ workspaceType: {
type: String,
- required: false,
- default: undefined,
+ required: true,
},
},
data() {
@@ -50,25 +53,13 @@ export default {
return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
},
mutationVariables() {
- if (this.issuableType === IssuableType.Epic) {
- return {
- title: this.labelTitle,
- color: this.selectedColor,
- groupPath: this.fullPath,
- };
- }
+ const attributePath = this.labelCreateType === LabelType.group ? 'groupPath' : 'projectPath';
- return this.attrWorkspacePath !== undefined
- ? {
- title: this.labelTitle,
- color: this.selectedColor,
- groupPath: this.attrWorkspacePath,
- }
- : {
- title: this.labelTitle,
- color: this.selectedColor,
- projectPath: this.fullPath,
- };
+ return {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ [attributePath]: this.attrWorkspacePath,
+ };
},
},
methods: {
@@ -82,8 +73,10 @@ export default {
this.selectedColor = this.getColorCode(color);
},
updateLabelsInCache(store, label) {
+ const { query } = workspaceLabelsQueries[this.workspaceType];
+
const sourceData = store.readQuery({
- query: labelsQueries[this.issuableType].workspaceQuery,
+ query,
variables: { fullPath: this.fullPath, searchTerm: '' },
});
@@ -95,7 +88,7 @@ export default {
});
store.writeQuery({
- query: labelsQueries[this.issuableType].workspaceQuery,
+ query,
variables: { fullPath: this.fullPath, searchTerm: '' },
data,
});
@@ -180,7 +173,7 @@ export default {
<gl-button
class="js-btn-cancel-create"
data-testid="cancel-button"
- @click="$emit('hideCreateView')"
+ @click.stop="$emit('hideCreateView')"
>
{{ __('Cancel') }}
</gl-button>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
index e6a25362ff0..e9a2d7747e2 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,18 +1,10 @@
<script>
-import {
- GlDropdownForm,
- GlDropdownItem,
- GlLoadingIcon,
- GlSearchBoxByType,
- GlIntersectionObserver,
-} from '@gitlab/ui';
+import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { debounce } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
-import { labelsQueries } from '~/sidebar/constants';
+import { workspaceLabelsQueries } from '~/sidebar/constants';
import LabelItem from './label_item.vue';
export default {
@@ -20,7 +12,6 @@ export default {
GlDropdownForm,
GlDropdownItem,
GlLoadingIcon,
- GlSearchBoxByType,
GlIntersectionObserver,
LabelItem,
},
@@ -28,18 +19,10 @@ export default {
prop: 'localSelectedLabels',
},
props: {
- selectedLabels: {
- type: Array,
- required: true,
- },
allowMultiselect: {
type: Boolean,
required: true,
},
- issuableType: {
- type: String,
- required: true,
- },
localSelectedLabels: {
type: Array,
required: true,
@@ -48,10 +31,17 @@ export default {
type: String,
required: true,
},
+ searchKey: {
+ type: String,
+ required: true,
+ },
+ workspaceType: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
- searchKey: '',
labels: [],
isVisible: false,
};
@@ -59,7 +49,7 @@ export default {
apollo: {
labels: {
query() {
- return labelsQueries[this.issuableType].workspaceQuery;
+ return workspaceLabelsQueries[this.workspaceType].query;
},
variables() {
return {
@@ -71,12 +61,6 @@ export default {
return this.searchKey.length === 1 || !this.isVisible;
},
update: (data) => data.workspace?.labels?.nodes || [],
- async result() {
- if (this.$refs.searchInput) {
- await this.$nextTick;
- this.$refs.searchInput.focusInput();
- }
- },
error() {
createFlash({ message: __('Error fetching labels.') });
},
@@ -101,12 +85,6 @@ export default {
return Boolean(this.searchKey) && this.visibleLabels.length === 0;
},
},
- created() {
- this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
- },
- beforeDestroy() {
- this.debouncedSearchKeyUpdate.cancel();
- },
methods: {
isLabelSelected(label) {
return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id));
@@ -137,13 +115,7 @@ export default {
({ id }) => id !== getIdFromGraphQLId(label.id) && id !== label.id,
);
} else {
- labels = [
- ...this.localSelectedLabels,
- {
- ...label,
- id: getIdFromGraphQLId(label.id),
- },
- ];
+ labels = [...this.localSelectedLabels, label];
}
this.$emit('input', labels);
},
@@ -153,12 +125,8 @@ export default {
this.$emit('closeDropdown', this.localSelectedLabels);
}
},
- setSearchKey(value) {
- this.searchKey = value;
- },
onDropdownAppear() {
this.isVisible = true;
- this.$refs.searchInput.focusInput();
},
},
};
@@ -167,14 +135,6 @@ export default {
<template>
<gl-intersection-observer @appear="onDropdownAppear">
<gl-dropdown-form class="labels-select-contents-list js-labels-list">
- <gl-search-box-by-type
- ref="searchInput"
- :value="searchKey"
- :disabled="labelsFetchInProgress"
- data-qa-selector="dropdown_input_field"
- data-testid="dropdown-input-field"
- @input="debouncedSearchKeyUpdate"
- />
<div ref="labelsListContainer" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue
new file mode 100644
index 00000000000..e67e704ffb8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdownItem,
+ },
+ inject: ['allowLabelCreate', 'labelsManagePath'],
+ props: {
+ footerCreateLabelTitle: {
+ type: String,
+ required: true,
+ },
+ footerManageLabelTitle: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="dropdown-footer">
+ <gl-dropdown-item
+ v-if="allowLabelCreate"
+ data-testid="create-label-button"
+ @click.capture.native.stop="$emit('toggleDropdownContentsCreateView')"
+ >
+ {{ footerCreateLabelTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop>
+ {{ footerManageLabelTitle }}
+ </gl-dropdown-item>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
new file mode 100644
index 00000000000..10064b01648
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlButton, GlSearchBoxByType } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ GlSearchBoxByType,
+ },
+ model: {
+ prop: 'searchKey',
+ },
+ props: {
+ labelsCreateTitle: {
+ type: String,
+ required: true,
+ },
+ labelsListTitle: {
+ type: String,
+ required: true,
+ },
+ showDropdownContentsCreateView: {
+ type: Boolean,
+ required: true,
+ },
+ labelsFetchInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ searchKey: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ dropdownTitle() {
+ return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
+ },
+ },
+ methods: {
+ focusInput() {
+ this.$refs.searchInput?.focusInput();
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="dropdown-header">
+ <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!">
+ <gl-button
+ v-if="showDropdownContentsCreateView"
+ :aria-label="__('Go back')"
+ variant="link"
+ size="small"
+ class="js-btn-back dropdown-header-button gl-p-0"
+ icon="arrow-left"
+ data-testid="go-back-button"
+ @click.stop="$emit('toggleDropdownContentsCreateView')"
+ />
+ <span class="gl-flex-grow-1">{{ dropdownTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ data-testid="close-button"
+ @click="$emit('closeDropdown')"
+ />
+ </div>
+ <gl-search-box-by-type
+ v-if="!showDropdownContentsCreateView"
+ ref="searchInput"
+ :value="searchKey"
+ :disabled="labelsFetchInProgress"
+ data-qa-selector="dropdown_input_field"
+ data-testid="dropdown-input-field"
+ @input="$emit('input', $event)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
index 71d3d87cce5..aed5bc303ee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
@@ -1,7 +1,6 @@
<script>
import { GlLabel } from '@gitlab/ui';
import { sortBy } from 'lodash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
@@ -47,7 +46,7 @@ export default {
return this.allowScopedLabels && isScopedLabel(label);
},
removeLabel(labelId) {
- this.$emit('onLabelRemove', getIdFromGraphQLId(labelId));
+ this.$emit('onLabelRemove', labelId);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
index eb478645a03..a9c791091fc 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
@@ -1,12 +1,11 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPath: ID) {
labelCreate(
input: { title: $title, color: $color, projectPath: $projectPath, groupPath: $groupPath }
) {
label {
- id
- color
- description
- title
+ ...Label
}
errors
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
index a2e8579486f..c130cc426dc 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
@@ -1,13 +1,12 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
query epicLabels($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
issuable: epic(iid: $iid) {
id
labels {
nodes {
- id
- title
- color
- description
+ ...Label
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql
new file mode 100644
index 00000000000..45fcb50732e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql
@@ -0,0 +1,15 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+mutation updateEpicLabels($input: UpdateEpicInput!) {
+ updateEpic(input: $input) {
+ epic {
+ id
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql
index acc9bcd2015..ce1a69f84c0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql
@@ -1,11 +1,11 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
query groupLabels($fullPath: ID!, $searchTerm: String) {
workspace: group(fullPath: $fullPath) {
- labels(searchTerm: $searchTerm, onlyGroupLabels: true) {
+ id
+ labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) {
nodes {
- id
- title
- color
- description
+ ...Label
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
index 1c2fd3bb7c0..e471d279b24 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
@@ -1,13 +1,12 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
query issueLabels($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
issuable: issue(iid: $iid) {
id
labels {
nodes {
- id
- title
- color
- description
+ ...Label
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql
new file mode 100644
index 00000000000..dd80e89c8a7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql
@@ -0,0 +1,14 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+query mergeRequestLabels($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ issuable: mergeRequest(iid: $iid) {
+ id
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql
index dc39220487d..a7c24620aad 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql
@@ -1,11 +1,11 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
query projectLabels($fullPath: ID!, $searchTerm: String) {
workspace: project(fullPath: $fullPath) {
+ id
labels(searchTerm: $searchTerm, includeAncestorGroups: true) {
nodes {
- id
- title
- color
- description
+ ...Label
}
}
}
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 6bd43da2203..97a65c13933 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,9 +1,12 @@
<script>
+import { debounce } from 'lodash';
+import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
-import { labelsQueries } from '~/sidebar/constants';
-import { DropdownVariant } from './constants';
+import { issuableLabelsQueries } from '~/sidebar/constants';
+import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants';
import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
@@ -50,16 +53,6 @@ export default {
required: false,
default: DropdownVariant.Sidebar,
},
- selectedLabels: {
- type: Array,
- required: false,
- default: () => [],
- },
- labelsSelectInProgress: {
- type: Boolean,
- required: false,
- default: false,
- },
labelsFilterBasePath: {
type: String,
required: false,
@@ -95,36 +88,44 @@ export default {
required: false,
default: __('Manage group labels'),
},
- isEditing: {
- type: Boolean,
- required: false,
- default: false,
- },
issuableType: {
type: String,
required: true,
},
+ workspaceType: {
+ type: String,
+ required: true,
+ },
attrWorkspacePath: {
type: String,
- required: false,
- default: undefined,
+ required: true,
+ },
+ labelCreateType: {
+ type: String,
+ required: true,
},
},
data() {
return {
contentIsOnViewport: true,
issuableLabels: [],
+ labelsSelectInProgress: false,
+ oldIid: null,
+ sidebarExpandedOnClick: false,
};
},
computed: {
isLoading() {
return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading;
},
+ issuableLabelIds() {
+ return this.issuableLabels.map((label) => label.id);
+ },
},
apollo: {
issuableLabels: {
query() {
- return labelsQueries[this.issuableType].issuableQuery;
+ return issuableLabelsQueries[this.issuableType].issuableQuery;
},
skip() {
return !isDropdownVariantSidebar(this.variant);
@@ -143,16 +144,140 @@ export default {
},
},
},
+ watch: {
+ iid(_, oldVal) {
+ this.oldIid = oldVal;
+ },
+ },
+ mounted() {
+ document.addEventListener('toggleSidebarRevealLabelsDropdown', this.handleCollapsedValueClick);
+ },
+ beforeDestroy() {
+ document.removeEventListener(
+ 'toggleSidebarRevealLabelsDropdown',
+ this.handleCollapsedValueClick,
+ );
+ },
methods: {
handleDropdownClose(labels) {
- this.$emit('updateSelectedLabels', labels);
+ if (this.iid !== '') {
+ this.updateSelectedLabels(this.getUpdateVariables(labels));
+ } else {
+ this.$emit('updateSelectedLabels', { labels });
+ }
+
this.collapseEditableItem();
},
collapseEditableItem() {
this.$refs.editable?.collapse();
+ if (this.sidebarExpandedOnClick) {
+ this.sidebarExpandedOnClick = false;
+ this.$emit('toggleCollapse');
+ }
},
handleCollapsedValueClick() {
+ this.sidebarExpandedOnClick = true;
this.$emit('toggleCollapse');
+ debounce(() => {
+ this.$refs.editable.toggle();
+ this.$refs.dropdownContents.showDropdown();
+ }, DEBOUNCE_DROPDOWN_DELAY)();
+ },
+ getUpdateVariables(labels) {
+ let labelIds = [];
+
+ labelIds = labels.map(({ id }) => id);
+ const currentIid = this.oldIid || this.iid;
+
+ const updateVariables = {
+ iid: currentIid,
+ projectPath: this.fullPath,
+ labelIds,
+ };
+
+ switch (this.issuableType) {
+ case IssuableType.Issue:
+ return updateVariables;
+ case IssuableType.MergeRequest:
+ return {
+ ...updateVariables,
+ operationMode: MutationOperationMode.Replace,
+ };
+ case IssuableType.Epic:
+ return {
+ iid: currentIid,
+ groupPath: this.fullPath,
+ addLabelIds: labelIds.map((id) => getIdFromGraphQLId(id)),
+ removeLabelIds: this.issuableLabelIds
+ .filter((id) => !labelIds.includes(id))
+ .map((id) => getIdFromGraphQLId(id)),
+ };
+ default:
+ return {};
+ }
+ },
+ updateSelectedLabels(inputVariables) {
+ this.labelsSelectInProgress = true;
+
+ this.$apollo
+ .mutate({
+ mutation: issuableLabelsQueries[this.issuableType].mutation,
+ variables: { input: inputVariables },
+ })
+ .then(({ data }) => {
+ const { mutationName } = issuableLabelsQueries[this.issuableType];
+
+ if (data[mutationName]?.errors?.length) {
+ throw new Error();
+ }
+
+ this.$emit('updateSelectedLabels', {
+ id: data[mutationName]?.[this.issuableType]?.id,
+ labels: data[mutationName]?.[this.issuableType]?.labels?.nodes,
+ });
+ })
+ .catch((error) =>
+ createFlash({
+ message: __('An error occurred while updating labels.'),
+ captureError: true,
+ error,
+ }),
+ )
+ .finally(() => {
+ this.labelsSelectInProgress = false;
+ });
+ },
+ getRemoveVariables(labelId) {
+ const removeVariables = {
+ iid: this.iid,
+ projectPath: this.fullPath,
+ };
+
+ switch (this.issuableType) {
+ case IssuableType.Issue:
+ return {
+ ...removeVariables,
+ removeLabelIds: [labelId],
+ };
+ case IssuableType.MergeRequest:
+ return {
+ ...removeVariables,
+ labelIds: [labelId],
+ operationMode: MutationOperationMode.Remove,
+ };
+ case IssuableType.Epic:
+ return {
+ iid: this.iid,
+ removeLabelIds: [getIdFromGraphQLId(labelId)],
+ groupPath: this.fullPath,
+ };
+ default:
+ return {};
+ }
+ },
+ handleLabelRemove(labelId) {
+ this.updateSelectedLabels(this.getRemoveVariables(labelId));
+ this.$emit('onLabelRemove', labelId);
},
isDropdownVariantSidebar,
isDropdownVariantStandalone,
@@ -180,6 +305,7 @@ export default {
:title="__('Labels')"
:loading="isLoading"
:can-edit="allowLabelEdit"
+ @open="oldIid = null"
>
<template #collapsed>
<dropdown-value
@@ -188,7 +314,7 @@ export default {
:allow-label-remove="allowLabelRemove"
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
- @onLabelRemove="$emit('onLabelRemove', $event)"
+ @onLabelRemove="handleLabelRemove"
>
<slot></slot>
</dropdown-value>
@@ -201,23 +327,25 @@ export default {
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
class="gl-mb-2"
- @onLabelRemove="$emit('onLabelRemove', $event)"
+ @onLabelRemove="handleLabelRemove"
>
<slot></slot>
</dropdown-value>
<dropdown-contents
+ ref="dropdownContents"
:dropdown-button-text="dropdownButtonText"
:allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle"
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
:labels-create-title="labelsCreateTitle"
- :selected-labels="selectedLabels"
+ :selected-labels="issuableLabels"
:variant="variant"
- :issuable-type="issuableType"
:is-visible="edit"
:full-path="fullPath"
+ :workspace-type="workspaceType"
:attr-workspace-path="attrWorkspacePath"
+ :label-create-type="labelCreateType"
@setLabels="handleDropdownClose"
@closeDropdown="collapseEditableItem"
/>
@@ -233,10 +361,12 @@ export default {
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
:labels-create-title="labelsCreateTitle"
- :selected-labels="selectedLabels"
+ :selected-labels="issuableLabels"
:variant="variant"
- :issuable-type="issuableType"
:full-path="fullPath"
+ :workspace-type="workspaceType"
+ :attr-workspace-path="attrWorkspacePath"
+ :label-create-type="labelCreateType"
@setLabels="handleDropdownClose"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
index a2990d7171b..dffcc053fac 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
@@ -1,6 +1,6 @@
#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
-query timeTrackingReport($id: IssueID!) {
+query issueTimeTrackingReport($id: IssueID!) {
issuable: issue(id: $id) {
__typename
id
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
index 753f1b345e3..ede9b75d765 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
@@ -1,6 +1,6 @@
#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
-query timeTrackingReport($id: MergeRequestID!) {
+query mrTimeTrackingReport($id: MergeRequestID!) {
issuable: mergeRequest(id: $id) {
__typename
id
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue
index 1eea660d527..a16dcb6d893 100644
--- a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue
@@ -5,10 +5,10 @@ import { OBSTACLE_TYPES } from './constants';
const OBSTACLE_TEXT = {
[OBSTACLE_TYPES.oncallSchedules]: s__(
- 'OnCallSchedules|On-call schedule %{obstacle} in Project %{project}',
+ 'OnCallSchedules|On-call schedule %{obstacle} in project %{project}',
),
[OBSTACLE_TYPES.escalationPolicies]: s__(
- 'EscalationPolicies|Escalation policy %{obstacle} in Project %{project}',
+ 'EscalationPolicies|Escalation policy %{obstacle} in project %{project}',
),
};
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 33fac5ebdbb..9cb66f6e65f 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -63,3 +63,6 @@ export const timeRanges = [
export const defaultTimeRange = timeRanges.find((tr) => tr.default);
export const getTimeWindow = (timeWindowName) =>
timeRanges.find((tr) => tr.name === timeWindowName);
+
+export const AVATAR_SHAPE_OPTION_CIRCLE = 'circle';
+export const AVATAR_SHAPE_OPTION_RECT = 'rect';
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
index 779b04dc2bd..fc0ff78e7b4 100644
--- a/app/assets/javascripts/vue_shared/directives/validation.js
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { __ } from '~/locale';
/**
* Validation messages will take priority based on the property order.
@@ -12,11 +12,11 @@ import { s__ } from '~/locale';
const defaultFeedbackMap = {
valueMissing: {
isInvalid: (el) => el.validity?.valueMissing,
- message: s__('Please fill out this field.'),
+ message: __('Please fill out this field.'),
},
urlTypeMismatch: {
isInvalid: (el) => el.type === 'url' && el.validity?.typeMismatch,
- message: s__('Please enter a valid URL format, ex: http://www.example.com/home'),
+ message: __('Please enter a valid URL format, ex: http://www.example.com/home'),
},
};
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index c1e8376d656..114f60c96ee 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -82,7 +82,7 @@ export default {
});
this.$root.$on('clicked::link', (e) => {
- window.location = e.target.href;
+ window.location = e.currentTarget.href;
});
},
diff --git a/app/assets/javascripts/vue_shared/security_configuration/provider.js b/app/assets/javascripts/vue_shared/security_configuration/provider.js
index fa23669b615..ef96b443da8 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/provider.js
+++ b/app/assets/javascripts/vue_shared/security_configuration/provider.js
@@ -5,5 +5,5 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
index 3a4453bc7ae..e0669b3ed27 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
@@ -26,6 +26,11 @@ export default {
type: Number,
required: true,
},
+ injectedArtifacts: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -56,6 +61,9 @@ export default {
isLoadingReportArtifacts() {
return this.$apollo.queries.reportArtifacts.loading;
},
+ mergedReportArtifacts() {
+ return [...this.reportArtifacts, ...this.injectedArtifacts];
+ },
},
methods: {
showError(error) {
@@ -77,7 +85,7 @@ export default {
<template>
<security-report-download-dropdown
:title="s__('SecurityReports|Download results')"
- :artifacts="reportArtifacts"
+ :artifacts="mergedReportArtifacts"
:loading="isLoadingReportArtifacts"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index b024e92bd0e..fafbd02634f 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -17,6 +17,7 @@ export const REPORT_FILE_TYPES = {
* Security scan report types, as provided by the backend.
*/
export const REPORT_TYPE_SAST = 'sast';
+export const REPORT_TYPE_SAST_IAC = 'sast_iac';
export const REPORT_TYPE_DAST = 'dast';
export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
index b5858ab012b..e1f3c55a886 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
@@ -1,6 +1,6 @@
#import "../fragments/job_artifacts.fragment.graphql"
-query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) {
+query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) {
project(fullPath: $projectPath) {
pipeline(iid: $iid) {
id
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
index 62a51abe038..8aefc13a5fa 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
@@ -18,6 +18,7 @@ export const fetchDiff = ({ state, rootState, dispatch }) => {
return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SAST)
.then((data) => {
dispatch('receiveDiffSuccess', data);
+ return data;
})
.catch(() => {
dispatch('receiveDiffError');
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
index 722dcce3075..13ca154bfa7 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
@@ -18,6 +18,7 @@ export const fetchDiff = ({ state, rootState, dispatch }) => {
return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SECRET_DETECTION)
.then((data) => {
dispatch('receiveDiffSuccess', data);
+ return data;
})
.catch(() => {
dispatch('receiveDiffError');
diff --git a/app/assets/javascripts/work_items/components/app.vue b/app/assets/javascripts/work_items/components/app.vue
index 93de17d1e43..a14d0c32cbe 100644
--- a/app/assets/javascripts/work_items/components/app.vue
+++ b/app/assets/javascripts/work_items/components/app.vue
@@ -1,9 +1,5 @@
-<script>
-export default {
- name: 'WorkItemRoot',
-};
-</script>
-
<template>
- <div></div>
+ <div>
+ <router-view />
+ </div>
</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
new file mode 100644
index 00000000000..b39f68abf74
--- /dev/null
+++ b/app/assets/javascripts/work_items/constants.js
@@ -0,0 +1,3 @@
+export const widgetTypes = {
+ title: 'TITLE',
+};
diff --git a/app/assets/javascripts/work_items/graphql/fragmentTypes.json b/app/assets/javascripts/work_items/graphql/fragmentTypes.json
new file mode 100644
index 00000000000..c048ac34ac0
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/fragmentTypes.json
@@ -0,0 +1 @@
+{"__schema":{"types":[{"kind":"INTERFACE","name":"WorkItemWidget","possibleTypes":[{"name":"TitleWidget"}]}]}}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
new file mode 100644
index 00000000000..083735336ce
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
+import createDefaultClient from '~/lib/graphql';
+import workItemQuery from './work_item.query.graphql';
+import introspectionQueryResultData from './fragmentTypes.json';
+import typeDefs from './typedefs.graphql';
+
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+ introspectionQueryResultData,
+});
+
+export function createApolloProvider() {
+ Vue.use(VueApollo);
+
+ const defaultClient = createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ fragmentMatcher,
+ },
+ typeDefs,
+ },
+ );
+
+ defaultClient.cache.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id: '1',
+ },
+ data: {
+ workItem: {
+ __typename: 'WorkItem',
+ id: '1',
+ type: 'FEATURE',
+ widgets: {
+ __typename: 'WorkItemWidgetConnection',
+ nodes: [
+ {
+ __typename: 'TitleWidget',
+ type: 'TITLE',
+ enabled: true,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ contentText: 'Test Work Item Title',
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ return new VueApollo({
+ defaultClient,
+ });
+}
diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/resolvers.js
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index e69de29bb2d..4a6e4aeed60 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -0,0 +1,38 @@
+enum WorkItemType {
+ FEATURE
+}
+
+enum WidgetType {
+ TITLE
+}
+
+interface WorkItemWidget {
+ type: WidgetType!
+}
+
+# Replicating Relay connection type for client schema
+type WorkItemWidgetEdge {
+ cursor: String!
+ node: WorkItemWidget
+}
+
+type WorkItemWidgetConnection {
+ edges: [WorkItemWidgetEdge]
+ nodes: [WorkItemWidget]
+ pageInfo: PageInfo!
+}
+
+type TitleWidget implements WorkItemWidget {
+ type: WidgetType!
+ contentText: String!
+}
+
+type WorkItem {
+ id: ID!
+ type: WorkItemType!
+ widgets: [WorkItemWidgetConnection]
+}
+
+extend type Query {
+ workItem(id: ID!): WorkItem!
+}
diff --git a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql
new file mode 100644
index 00000000000..d7608c26052
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql
@@ -0,0 +1,3 @@
+fragment WidgetBase on WorkItemWidget {
+ type
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
new file mode 100644
index 00000000000..549e4f8c65a
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -0,0 +1,16 @@
+#import './widget.fragment.graphql'
+
+query WorkItem($id: ID!) {
+ workItem(id: $id) @client {
+ id
+ type
+ widgets {
+ nodes {
+ ...WidgetBase
+ ... on TitleWidget {
+ contentText
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index a635d43776d..7cc8a23b7b1 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -1,11 +1,15 @@
import Vue from 'vue';
import App from './components/app.vue';
+import { createRouter } from './router';
+import { createApolloProvider } from './graphql/provider';
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
return new Vue({
el,
+ router: createRouter(el.dataset.fullPath),
+ apolloProvider: createApolloProvider(),
render(createElement) {
return createElement(App);
},
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
new file mode 100644
index 00000000000..493ee0aba01
--- /dev/null
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -0,0 +1,48 @@
+<script>
+import workItemQuery from '../graphql/work_item.query.graphql';
+import { widgetTypes } from '../constants';
+
+export default {
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ workItem: null,
+ };
+ },
+ apollo: {
+ workItem: {
+ query: workItemQuery,
+ variables() {
+ return {
+ id: this.id,
+ };
+ },
+ },
+ },
+ computed: {
+ titleWidgetData() {
+ return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title);
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <!-- Title widget placeholder -->
+ <div>
+ <h2
+ v-if="titleWidgetData"
+ class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5"
+ data-testid="title"
+ >
+ {{ titleWidgetData.contentText }}
+ </h2>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/work_items/router/index.js b/app/assets/javascripts/work_items/router/index.js
new file mode 100644
index 00000000000..142fab8cfa6
--- /dev/null
+++ b/app/assets/javascripts/work_items/router/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { routes } from './routes';
+
+Vue.use(VueRouter);
+
+export function createRouter(fullPath) {
+ return new VueRouter({
+ routes,
+ mode: 'history',
+ base: joinPaths(fullPath, '-', 'work_items'),
+ });
+}
diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js
new file mode 100644
index 00000000000..a3cf44ad4ca
--- /dev/null
+++ b/app/assets/javascripts/work_items/router/routes.js
@@ -0,0 +1,8 @@
+export const routes = [
+ {
+ path: '/:id',
+ name: 'work_item',
+ component: () => import('../pages/work_item_root.vue'),
+ props: true,
+ },
+];